Java并发编程摘记

本文对是本人在阅读 Java并发编程实战 时对其部分章节做的摘要合集,滤过了很多个人觉得无需记录的知识点。因此,本文仅适合作为本人快速查阅使用。

全书脉络

第2-3章介绍了并发相关的基本概念。

第4-5章介绍了Java基础同步工具类,以及如何正确封装使用它们。

第6-9章介绍了任务处理相关的内容,包括线程池的使用,任务的设计等。

第10-12章介绍了如何编码以正确提升同步代码的活跃性、响应性。

第13-16章介绍了高级同步工具(显式锁、原子变量等)以及如何自定义同步工具类。

全书的主旨就是如何正确的编写正确高效的并发代码。(正确性优先于高效)

线程安全性

竞态条件 & 临界区

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。

  • 当执行较长的计算或响应慢的操作(网络IO)等操作的时候不要持有锁。

  • 避免过多的锁与同步块的使用。

对象的共享

可见性
  • 线程A修改共享数据a后,线程B去读取a可能只能读到修改前的值。
  • volatile可以解决可见性的问题。(数据的更新会被及时同步)
  • volatile的常用场景,作为状态变量标记死循环的线程是否可以退出循环。
  • 将对共享数据的操作加锁,也可以解决可见性的问题。(包含占有锁与同步两个功能)
最低安全性

线程在没有同步的情况下,读取共享数据a,要么读到旧数据,要么读到最新的数据。这种情况对大多数变量都成立,除了非volatile类型的64位数值变量(double和long),对这两种变量的读写操作会被分解为两个32位的原子操作。因此如果对该变量的读写操作在不同线程中,则可能会出现,读取到该变量旧的高32位值,和新的低32位的值,最终组合出了一个不曾存在过的值。

线程封闭

将对数据的所有操作都放到同一个线程中,保证数据的线程安全。

  • Ad-hoc线程封闭:将维护线程封闭性的职责完全由程序实现来承担。
  • 栈封闭:多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到该线程的栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。
  • ThreadLocal:ThreadLocal类为每一个线程都维护了自己独有的变量拷贝。由于每个线程在访问该变量时,读取和修改的,都是自己独有的那一份变量拷贝,变量被彻底封闭在每个访问的线程中,并发错误出现的可能也完全消除了。
不变性

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。因为一个线程在访问一个不可变对象时,可以确定该对象绝对不会被其他线程修改(因为其本身就是不可修改的)。

安全发布
  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

对象的组合

设计安全的类

步骤:

  • 找出构成对象状态的所有变量
  • 找出约束状态变量的不变性条件
  • 建立对象状态的并发访问管理策略

具体实现策略:

  • 实例封闭:将实例私有化,加锁访问,同时注意不要将对象发布出去
  • 组合一个/多个线程安全的类,让其代理线程安全的实现
  • 交给客户端做同步

基础构建模块

迭代器

同步容器在迭代期间也不保证线程安全,因此需要客户端通过加锁同步或者复制一份容器再迭代。

并发容器

同步容器:实现线程安全的方式将它们的状态封装起来,并对每个公有方法同步(使所有操作串行化),使得每次只有一个线程能够访问容器的状态。同步容器类在单个方法被使用时可以保证线程安全。复合操作则需要额外的客户端加锁来保护。

并发容器:针对于同步容器的巨大缺陷。java.util.concurrent中提供了并发容器。并发容器包注重以下特性:

  • 根据具体场景进行设计,尽量避免使用锁,提高容器的并发访问性。
  • 并发容器定义了一些线程安全的复合操作。
  • 并发容器在迭代时,可以不封闭在synchronized中。但是未必每次看到的都是”最新的、当前的”数据。如果说将迭代操作包装在synchronized中,可以达到”串行”的并发安全性,那么并发容器的迭代达到了”脏读”。

ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue、BlockingDeque

中断处理

interrupt()方法本质上不会进行线程的终止操作的,它不过是改变了线程的中断状态。而改变了此状态带来的影响是,部分可中断阻塞线程方法(比如Object.wait, Thread.sleep,Thread.join,我们也可以自定义方法,调用isInterrupted()检测然后抛出异常实现同样的效果)会定期执行isInterrupted方法,检测到此变化,随后会停止阻塞并抛出InterruptedException异常。

当捕获到InterruptException的时候,表示当前线程的interrupt()被调用过了,当前线程的执行已被中断(代码的执行转向了catch中,同时由于抛出异常的类其实会重置interrupt标记位,所以此时线程的中断标记是处于清空状态的)。接下来我们需要:

  • 如果我们已经自己实现了应对中断的处理逻辑,则此时我们可以直接消化这个InterruptException。
  • 恢复中断,即调用Thread.currentThread().interrupt(),将中断向上继续传递。
同步工具类

闭锁(Latch):延迟进程进度直到其达到终止状态

FutureTask:

信号量(Semaphore):控制同时访问某个资源的操作数量,或者同时执行某个指定操作的数量。

栅栏(Barrier):等待一定数量的线程执行到栅栏后执行在栅栏中注册的任务,并且可以多次使用,并不是一次性对象。

关闭与取消

Interrupt

参见上文的处理

通过Furure来取消

直接调用相关方法即可

不可中断的阻塞
  • Java.io包中的同步Socket I/O
  • Java.io包中的同步 I/O
  • Selector的异步I/O
  • 线程因为等待锁而阻塞

线程池的使用

合理设置线程池的大小
  • 对于CPU密集型任务,在拥有N的CPU的系统上,线程池大小应为N+1
  • 对于IO密集型任务,在拥有N的CPU的系统上,线程池可以设置为2N+1,较为精确的值应该是(线程等待时间与线程CPU时间之比 + 1)* CPU数目
  • 注意当任务之间有相互依赖关系的时候,设置线程池固定大小可能会导致饥饿死锁问题

GUI中的并发

GUI的实现基本都是单线程的

Android GUI 单线程消息队列机制 —— 多线程GUI工具箱:一个破碎的梦

避免活跃性危险

死锁
  • 锁顺序死锁,多个线程以不同顺序先后获取锁A与锁B,则可能导致死锁,解决办法是让所有线程都以固定顺序(比如先获取锁A,再获取锁B)去获取锁。
  • 动态锁顺序死锁,详情见书P171
  • 在协作对象之间发生的死锁,在A中的synchronize方法中调用B的synchronize方法时,如果同一个B对象也在它的synchronize方法中调用了A的synchronize方法,那么就有可能发生死锁。所以在某个synchronize调用外部的synchronize方法时,需要警惕这种情况的发生。解决办法是尽可能避免这种情况的发生,不要写这样的代码。
  • 资源死锁,多个线程以不同顺序访问相同的多个资源。有界线程池与相互依赖的任务不能一起使用
避免死锁

程序每次只获取一个锁,或者按顺序获取多个锁,或者使用定时的锁。

检查死锁

通过log记录,,每个线程的锁状态,可以帮助分析定位死锁。

其他活跃性危险
  • 饥饿,如果线程A长时间持有锁(资源)x,而线程B也需要获取锁(资源)x,则B会处于饥饿状态。
  • 糟糕的响应性,线程A执行CPU密集型任务,可能会导致线程B分到的CPU时间变少,解决办法是提高B的优先级或者降低A的优先级。
  • 活锁,线程A在获取资源a后又要获取资源b,但是A发现b已被B获取,则A选择释放a,再重新获取a与b,而线程B在获取资源b后又要获取资源a,因为发现a已被A获取,则选择释放b,然后重新获取b和a,这个过程不断发生,导致活锁。

性能与可伸缩性

可伸缩性

定义:当增加计算资源时(如CPU\内存、存储容量、或I/O带宽),程序的吞吐量或者处理能力能相应地增加。

性能的影响因素

多线程引入的开销:

  • 上下文切换:因为上下文切换需要占用CPU时间,上下文切换多了,则应用程序可用的CPU时间就会变少。
  • 内存同步
  • 阻塞
提高性能的方法

减少锁的竞争:锁的请求频率以及每次持有该锁的时间的乘积如果很小,那么大多数获取锁的竞争不会对可伸缩性造成严重影响。

  • 减小锁的范围
  • 减小所得粒度(适当地将一个锁拆成多个锁),如果一个锁需要保护多个相互独立的状态变量,就可以将这个锁分解为多个锁
  • 锁分段,对于一个状态变量,他可能含有多个域,设置多个锁对域分别加锁,这样每次只需要加解锁所需要操作的域即可。
  • 不适用独占锁:使用ReadWriteLock、原子变量
  • 拒绝使用对象池(因为对象分配与实例回收的操作开销要小于同步)

显式锁

内置锁这么好用,为什么还需多出一个显式锁呢?因为有些事情内置锁是做不了的,比如:

  1. 我们想给锁加个等待时间超时时间,超时还未获得锁就放弃,不至于无限等下去;
  2. 我们想以可中断的方式获取锁,这样外部线程给我们发一个中断信号就能唤起等待锁的线程;
  3. 我们想为锁维持多个等待队列,比如一个生产者队列,一个消费者队列,一边提高锁的效率。

显式锁(ReentrantLock)正式为了解决这些灵活需求而生。ReentrantLock的字面意思是可重入锁,可重入的意思是线程可以同时多次请求同一把锁,而不会自己导致自己死锁。下面是内置锁和显式锁的区别:

  • 可定时:

    1
    RenentrantLock.tryLock(long timeout, TimeUnit unit)

    提供了一种以定时结束等待的方式,如果线程在指定的时间内没有获得锁,该方法就会返回false并结束线程等待。

  • 可中断:你一定见过InterruptedException,很多跟多线程相关的方法会抛出该异常,这个异常并不是一个缺陷导致的负担,而是一种必须,或者说是一件好事。可中断性给我们提供了一种让线程提前结束的方式(而不是非得等到线程执行结束),这对于要取消耗时的任务非常有用。对于内置锁,线程拿不到内置锁就会一直等待,除了获取锁没有其他办法能够让其结束等待。RenentrantLock.lockInterruptibly()给我们提供了一种以中断结束等待的方式。

  • 条件队列(condition queue):线程在获取锁之后,可能会由于等待某个条件发生而进入等待状态(内置锁通过Object.wait()方法,显式锁通过Condition.await()方法),进入等待状态的线程会挂起并自动释放锁,这些线程会被放入到条件队列当中。synchronized对应的只有一个条件队列,而ReentrantLock可以有多个条件队列,多个队列有什么好处呢?请往下看。

  • 条件谓词:线程在获取锁之后,有时候还需要等待某个条件满足才能做事情,比如生产者需要等到“缓存不满”才能往队列里放入消息,而消费者需要等到“缓存非空”才能从队列里取出消息。这些条件被称作条件谓词,线程需要先获取锁,然后判断条件谓词是否满足,如果不满足就不往下执行,相应的线程就会放弃执行权并自动释放锁。使用同一把锁的不同的线程可能有不同的条件谓词,如果只有一个条件队列,当某个条件谓词满足时就无法判断该唤醒条件队列里的哪一个线程;但是如果每个条件谓词都有一个单独的条件队列,当某个条件满足时我们就知道应该唤醒对应队列上的线程(内置锁通过Object.notify()或者Object.notifyAll()方法唤醒,显式锁通过Condition.signal()或者Condition.signalAll()方法唤醒)。这就是多个条件队列的好处。

Java内存模型

深入理解Java内存模型