FUNDAMENTALS
Chapter II. Thread Safety
多线程环境下,如果变量缺乏合适的同步,那么程序就可能会在某个时刻崩溃,如何去修复这个问题呢?
- don't share the state variable across threads
- make the state variable immutable
- use synchronization whenever accessing the state variable
When designing thread-safe classes, good object-oriented techniques-encapsulation, immutability, and clear specification of invariants-are your best friends.
设计线程安全的对象时,好的面向对象的技术,如封装、不变性以及清晰的不变性说明是你最得力的助手。
What is Thread Safety ?
A class is thread-safe if it behave correctly when accessed from multiple threads, regardless of the scheduling or interleaving of execution of those threads by runtime environment, and with no additional synchronization or other coordination on the part of the calling code.
如果一个类在多线程环境下,不管是调度运行或者交织运行,不需要调用方进行额外的代码同步或者协调的情况下具有正确的行为,则这个类就是线程安全的。
无状态的类总是线程安全的。
Atomicity
++count;
自增语句具有紧凑的形式,看上去像是原子的,但是其是一个符合操作。第一步,从内存中读取count的值,第二步,对count自增;第三步,将自增之后count写入到内存中。
Race condition
竞态条件,指的是多线程情况下,多个线程对共享资源进行非同步访问时,由于执行顺序不可控导致的程序结果不可预测的情况。
静态条件的两种模式:
- check then act
- read modify write
一个操作具有原子性,即当一个线程执行操作A时,要么已经执行完毕,要么没有开始执行,不存在任何的中间状态。
在实际中,最好使用原子类刻画一个类的状态。
Locking
To perserve state consistency, update related state variables in a single atomic operation.
为了保持状态的一致性,需要通过原子操作更新相关的状态变量。
Intrinsic Locks
Java内置的锁是synchronize,即同步锁。获取同步锁的唯一方法是进入一个同步代码块或者进入一个同步方法。
内置锁,或者监视器锁是一个内置锁,这意味着同一时刻只能有一个线程持有锁。
Reentrancy
Java 中的内置锁是可重入的,一个已经持有可重入锁的线程,可以再次获取该锁。可重入机制是通过一个acquisition count实现的,每次获取锁,则该计数器加1;释放锁,则该计数器减去1,计数器为0表示没有线程去获取锁。
可重入性给并发编程带来了极大的便利。如果内置锁没有可重入性,则可能会产生死锁。
Guarding State with Locks
可以使用锁来保证共享状态的一致性。
一个常识性的错误就是,只有在对共享变量进行写操作的时候,才需要同步。 当多个线程可以访问一个可变的共享变量时,对该变量所有的访问都必须持有相同的锁。在这种情况下,我们称这个变量被锁守护。
Every shared, mutable variable should be guarded by exactly one lock. Make it clear to maintainers which lock that is.
For every invariant that involves more than one varialbe, all the variables involved in that invariant must be guarded by the same lock. 对于每一个包含多个变量的不变量,所有的相关的不变量必须被同一把锁所保护。
Liveness and Performance
There is frequently a tension between simplicity and performance. When implements a synchronization policy, resist the temptation to prematurely sacrifice simplicity for the sake of performance.
简单性和性能之间存在着张力。当施行同步策略时,要抵制因为性能过早地放弃简单性的的诱惑。
所谓简单性,就是简单在所有的方法都添加同步。而性能,就是降低锁的颗粒度。
Avoid holding locks during lengthy computations or operations at risk of not completing quickly such as network or console I/O.
避免在长时间计算或者操作,例如网络或者控制台IO的过程中获取锁。
Chapter III. Sharing Object
对于同步机制,大家有一个概念混淆,简单地认为同步仅保证原子性,或者同步仅用来标定那些关键区域 的代码。其实同步还有一个重要的功能,就是保证内存的可见性。
Visibility
通常情况下,无法保证一个读线程能够及时读取到一个写线程刚刚写入的数据。为了保证内存写入的可见性,必须使用同步。
在缺乏同步的情况下,编译器、处理器以及运行时可能会对操作执行顺序产生一些稀奇古怪的影响。试图去推断在缺乏同步情况下多线程在内存中的执行的顺序几乎都是不正确的。
Stale Data
不可见系统在缺乏同步的情况下,可能会产生过时数据。过时数据可能会造成严重的错误,例如非预期的异常、数据结构损坏、不正确的数据以及无限循环等。
Non-atomic 64-bit Operations
缺乏同步的读写线程交互,可能会产生过时数据。不幸中的万幸,这个过时数据也是某个线程所修改的,而非随机的一个值,这也被称为无中生有的安全(out-of-thin-air safety)。OOTAS适用于除64位数字型之外的其他所有变量。
对于64位的long和double,写数据时,JVM会将64位写入拆分为高32位写入和低32位写入两个步骤。
Locking and Visibility
内置锁可以保证一个线程写入的数据被其他线程所感知。
Locking is not just about mutual exclusion; it is also about memory visibility. To ensure that all threads see the most up-to-date values of shared mutable variables, the reading and writing threads must synchronize on a common lock.
锁不仅和互斥相关,也和内存可见性相关。为了保证所有的线程对可变变量最新数据的可见性,读写线程必须使用锁来进行同步。
Volatile Variable
除了使用内置锁进行同步之外,Java还提供了一种较弱形式的同步形式,即volatile关键字,用来保证共享变量更新的可见性。
当一个变量被声明为volatile时,编译器和运行时会禁止对其的指令重排序的操作,并且放弃将其存储在寄存器或者缓存中。
volatile关键字可以保证系统的可见性和有序性。
visibility
Java 内存模型规定,所有的变量存储在主内存中,线程操作变量时需要将数据拷贝到工作内存中。普通变量修改之后并不会立即将值刷新到主内存中,从而导致数据的不一致问题。
volatile型变量,在写操作时,强行将工作内存的修改立即刷新到主内存,并触发其他CPU核心的缓存失效。在读操作时,每次读取前强制从主内存重新加载最新值,绕过本地缓存。
inorderity
volatile实现有序性的核心在于内存屏障(Memory barrier)。通过写屏障Storestore保证屏障前的所有写操作完成,并且结果对其他线程可见。通过读屏障Loadload保证屏障后的读操作不会重排到屏障之前。
Locking can guarantee both visibility and actomicity; volatile variables can only guarantee visibility.
Publication and Escape
一个对象不应该暴露的对象暴露给外部系统的情况,我们称之为对象逃逸。
Do not allow the this reference to escape during construction. 不要让
this引用在构造的过程中逃逸。
Some cases where the this reference may be escape
- 在构造函数中启动一个线程
- 在构造函数中引用一个重写方法
如果你想要从构造器中注册某个监听器方法或者开启一个线程,最佳实践是使用私有构造器+公有的静态工厂。
Thread Confinement
线程封闭是保证线程安全的最简单的方式,如果一个对象被限制在一个线程上不会逃逸,则其一定是线程安全的。
Java核心类库提供了局部变量和ThreadLocal类以帮助实现线程封闭,但是编程人员仍然有责任保证变量不会从线程中逃逸。
AD-hoc Thread Confinement
完全依赖编码实现来达到线程封闭的效果。Ad-hoc 线程封闭的系统是非常脆弱的,因为没有语言特性来保证对象被锁死在线程上。
通常来说,AD-hoc线程封闭用于一些特殊场景,例如GUI、单线程子系统等。
Stack confinement
栈封闭是线程封闭的一种特殊形式,在栈封闭中,只有通过局部变量的形式访问到对象。
局部变量从本质上将对象的引用限制在当前知执行的线程上,其只存在于当前线程的栈上,不能被其他线程访问。
基本数据类型永远可以保证是栈封闭的。
在线程内使用非线程安全的对象仍然可以保证是线程安全的,但是一定要保证对象不会从线程中逃逸出去。
ThreadLocal
ThreadLocal允许每一个线程拥有共享变量的副本。Thread-Local类型变量用于避免可变的单例或者全局变量的共享。
ThreadLocal变量也可以用于需要使用临时对象的操作的频繁调用,例如Integer.toString使用ThreadLocal用作缓存。
事实上,可以将ThreadLocal<T>看作是Map<Thread, T>,其底层也是这么实现的。线程特定的value被存储在Thread对象中,当线程T终止时,其局部变量T会被垃圾回收。
Immutabillity
不可变对象的状态在其初始化之后就无法被改变,其从根本上就是线程安全的。
不可变对象仅有一种状态,其在初始化的时候被确定,即使暴露给其他非信任的应用,仍旧可以保证安全。
但是,想要定义一个不可变对象,并容易。不是说将所有的字段都定义为final类型的,其就是不可变的了。因为其引用指向的对象,仍然可能是可变的。
如果一个对象是不可变的,那么:
- 状态在初始化之后不可变
- 所有的字段都是
final的 - 所有字段的引用在其创建过程中不会逃逸
Final Fields
在Java内存模型中,final具有特殊的语义,即保证初始化安全从而让不可变对象能够不加同步得被访问。
除非想要对象是可变的,对象引用都应该声明为不可变的形式。
Immutable Objects and Intialization Safety
不可变对象对于任何线程都是安全的,并且不需要额外的同步。对于不可变的引用,若其指向了可变的对象,那么其仍然需要进行同步。
Safe Publication Idioms
为了安全发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个合理构造的对象可以通过如下的方式安全发布:
- 从一个静态初始化器去初始化对象的引用
- 使用原子引用或者
volatile引用该对象 - 使用
final类型的引用 - 使用一个锁来保护该对象的应用
使用静态初始化器是实现安全发布的最简单有效的方式,静态初始化器在类加载的时候被JVM执行。
Effectively Immutable Objects
一些对象的状态,一旦发布完毕就不会再有任何的改变,那么这些对象称为事实不可变对象(Effectively Immutable Objects)。
安全发布的事实不可变对象无需任何同步即可被其他线程安全访问。
Mutable Objects
对于可变对象,必须进行安全发布,并且使用同步策略来保护其状态。
- 不可变对象可以通过任意的机制进行发布
- 事实不可变对象必须进行安全发布
- 可变对象需要进行安全发布,并且使用合适的同步策略
Sharing Objects Safely
使用和共享对象时一些有用的策略:
- 线程封闭
- 只读共享
- 线程安全共享
- 保护对象
Chapter 4. Composing Objects
Designing a Thread-safe Class
线程安全类的设计过程应当包含如下的基本元素:
- 识别构成对象状态的变量
- 识别约束状态变量的不变性
- 建立对象并发访问的管理策略
Gathering Synchroniztion Requirements
如果某些状态是无效的,必须对底层状态进行封装。如果回个操作具有后不合法的状态转移,那么该操作必须是原子的。
当一个不变性条件约束多个变量,访问这些变量的时候必须持有保护这些变量的锁。
如果你不理解对象不变性条件和后验条件,你就不能确保线程安全。合法值的约束条件以及状态变量的状态转移需要借助原子性和封装特性。
State-dependent Operations
一些方法需要依赖于状态变量的前置状态,例如从非空的集合中移除某个元素。这种需要前置条件的操作称为状态依赖操作。
让线程等到某个条件为true时执行,推荐的方法是使用Java中现存的库中的类,例如阻塞队列、信号量等。
State ownership
Instance Confinement
封装简化了实现线程安全类的过程,其提供了实例封闭(Instance Confinement) 的机制。
将数据封装在对象中以限制该数据的访问在对象的方法上,可以更加容易确保数据被访问时总能持有正确的锁。
封闭机制更加容易构建线程安全的类,因为一个类封闭其状态时,在分析其状态的线程安全时,可以不检查整个程序。
The Java Monitor Pattern
The primary advantage of the Java monitor pattern is simplicity.
使用Java内置的对象锁的时候,尽量使用私有对象锁,如果简单地使用this,那么当前对象的对象锁可能会被客户端获取,这可能会导致活性问题。
Delegating Thread Safety
所谓 委托线程安全(Delegating Thread Safety) ,就是将线程安全的责任委托给依赖的组件或者数据结构来实现线程安全的策略。其核心思想为利用已有的线程安全组件,例如线程安全类、同步工具或者不可变类等保证复合对象的线程安全 。
Independent State Variables
若多个状态是相互独立的,则可以将线程安全委托为这多个互相独立的状态变量。
when Delegation fails
如果有多个状态变量,但是这些状态变量并不是相互独立的,例如某个范围类的上下界,上界一定要大于或者等于下界。
如果一个类是由多个独立的线程安全的状态变量组成的,并且没有可以造成无效状态迁移的操作,那么这个类就可以将线程安全委托给其依赖的状态变量。
Publishing Underlying State Variables
什么条件下,才可以对依赖的状态变量进行可靠性发布呢?
如果一个状态变量是线程安全的,其值不涉及任何约束性条件,并且任何操作都不会造成无效状态的迁移,那么它就是可以进行安全发布的。
Adding Functionality to Existing Thread-safe Classes
如果我们要在一个线程安全的类添加一个新的线程安全的方法,有两种可行的方式:
- 直接在原类中添加新的方法:简单直接,方便,但是需要修改源代码,可能会没有权限
- 继承,在子类中扩展方法:不用修改源文件,但是非常脆弱,一旦父类修改了同步策略,子类可能会有线程不安全的风险
Client-side Locking
除了上面的两种策略,第三种有效的方式为使用第三方Helper.class来扩展功能。
但是,需要注意的是,对put-if-absent操作进行同步的时候,必须使用锁住正确的对象,即list而非是this,否则其他线程可能会并发对list进行修改。
Composition
还有一种方案,是组合方式。将List集合封装到集合ImproveList中,ImproveList中所有对List的操作都必须通过同步内置锁进行同步,这样可以确保操作的原子性。
Documenting Synchronization Policies
给客户端作线程安全保证的说明文档,给维护人员作同步策略的文档。
对一个一个没有被注明为线程安全的类,一定要谨慎地认为,其是非线程安全的,要在最坏的情况使用该对象。
Interpreting Vague Documentation
如果服务端并没有明确给出服务是否需要外部同步或者是否是线程安全的,我们需要从一个服务实现者的角度去猜,该服务是否是线程安全的。
举个例子,数据库连接池Datasource,很难想象这个东西会是给单线程的应用用的。所以,即使JDBC没有在文档中说明是否需要客户端进行同步,我们也能够猜到不需要。
Chapter 5. Building Blocks
Synchronized Collections
JDK中线程的同步集合有Hashtable,Vector,以及使用静态工厂方法Collections.synchronizedxxx()等。
Problems with synchronized collections
前文介绍的这些同步类都是线程安全的,组合操作下,会出现原子性的问题。常见的组合操作有,迭代、定位、条件操作等。虽然在这些组合操作中,我们的同步类仍然是线程安全的,但是在并发修改集合的时候,可能会遭遇到预期之外的事情。
例如,线程A去移除Vector的最后一个元素,线程B拿着索引去获取最后一个元素,当线程A执行完毕之后,线程B就会出现数组越界的异常。
最好用的解决方案就是,添加一个客户端锁,在进行组合操作的时候,锁住该集合。
Iterators and CME(Concurrent Modification Exception)
快速失败,意味着只要集合的迭代器检测到集合被修改,就会抛出CME的非受检异常。快速失败的原理在,有一个modification count,只要其发生变化,hasNext或者next方法就会抛出异常。
一个看起来可行的方法就是,在迭代器遍历集合的时候,锁住集合。但是不推荐这么做,遍历集合去做某事可能是个非常耗时的动作,会严重降低系统的性能,其次,有死锁的封信啊,最后,伤害系统的灵活性。
另一种方法就是,在遍历结合的时候,先整一份该集合的拷贝,遍历其拷贝即可。
隐藏迭代器
在代码中经常会出现一些隐藏的迭代器,例如集合的toString()方法。如果我们使用System.out.println()进行调试,大概率会忘记同步集合状态,从而出现CME的情况。
此外,容器的hash以及equals等操作也会有集合的隐藏迭代。
并发容器
常见的并发容器有ConcurrentHashMap以及CopyOnWriteArrayList,这些容器具有并发安全性,但是其并发度严重降低。但是相较于同步容器,还是具有较大的优势的。
ConcurrentHashMap
ConcurrentHashMap使用分段锁这种更加细粒度的锁来实现更大程度的共享。
CHM返回的迭代器具有弱一致性,即不保证其是准确的,弱一致性的迭代器可以避免在并发修改时抛出CME异常。此外,CHM返回的集合的size()或者isEmpty()也是当前快照情况下的值,并不保证下一时刻的值是否正确。
额外的原子Map操作
CHM提供了许多原子操作,不用额外加锁即可实现原子性。例如没有则添加(putIfAbsent),相等则移除(remove(K key, V value))等。
CopyOnWriteArrayList
写时复制列表的基本原理是,对list的每一次修改,都会创建并发布一个新的容器副本。
CopyOnWriteArrayList容器最大的开销在于,每次修改时都需要复制当前容器的副本。因此,其仅仅适用于迭代器数量远远大于修改操作的情况,即读多写少的场景。
阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的put和take方法。如果队列中的元素满了,则put操作会发生阻塞。如果队列中的元素为空,则take操作会发生阻塞。
在构建高可用的应用程序时,有界队列是一种强大的资源管理工具:他们能够抑制防止产生过的的工作项,使应用在过载的情况下更加健壮。
阻塞队列的实现有LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue。其中PriorityBlockingQueue是一个优先队列,可以根据业务需求对任务进行排序,优先处理排序等级高的任务。
SynchronousQueue是一个比较奇葩的阻塞“队列”,它其实并没有存储元素,而是维护一组线程,这些线程等待着将元素添加队列或者从队列中移除。
串行线程封闭
所谓串行线程封闭,是指单个对象只能由单个线程拥有,但是可以通过安全发布该对象来将此对象转移该对象的所有权。一旦转移成功之后,新的线程拥有该对象的所有权, 并且之前的线程无法访问该对象。
因此,生产者-消费者模式很好地实现了串行线程禁闭。
双端队列与工作密取
双端队列Deque和BlockingDequeue,实现了在队列头和尾进行高效插入和移除的操作。
双端队列非常适合一种工作模式,即工作密取(Work Stealing)。对于工作密取模式,所有的消费者都有一个独属于自己的阻塞队列,如果消费者完成了自己阻塞队列的所有任务,就能够去访问其他消费者的阻塞队列。并且会偷偷从该队列的尾部获取任务,避免和其他消费者产生竞争。
阻塞方法与中断方法
什么是阻塞?阻塞是指线程在某种条件下,例如等待外部计算结果、IO等情况,主动停止运行。这时,线程会放弃CPU的时间片,直到某种条件达成,通过竞争来恢复执行。
什么是中断?中断是指,在某些外部条件改变时,线程需要终止当前任务的执行,处理其他的中断程序。此时线程需要保存当前执行的上下文,并切换到中断程序。
面对中断异常InterrputedException时,有两种具体的解决方案,
其一是直接向上抛出InterruptedException异常,或者做简单处理工作之后向上抛出异常。
其二是,捕获中断异常,通过调用interrupt方法恢复中断状态。
同步工具类
常见的同步工具类有,阻塞队列、信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。
Latch
Latch专门为线程之间的协调而生。他作用就相当于一扇大门,等待所有的线程都执行完毕之后,才会开放线程继续执行。并且,这扇大门一旦开启,就永远也不会关闭了,因此他的作用是一次性的。
CountDownLatch是Latch的一个实现。其包含一个计数器,一个countDown()方法,用来完成任务之后递减计数器,还有一个await()方法,等待计数器为0。
FutureTask
FutureTask相当于是有返回值的Runnable,Future.get()方法和任务的状态有关,如果是出于运行状态,则一直阻塞到任务进入完成状态,如果任务已经完成,则可以直接通过get方法获取结果。
信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量。
Semaphore管理一组虚拟的许可(permit),线程需要通过acquire获取许可,而通过release归还许可。如果获取许可的时候没有许可了,则线程一直会阻塞,直到获取许可。
可以利用Semaphore的互斥性,来构建一个互斥锁,即将permit数量设置为1。
栅栏Barrier
Barrier和Latch比较相似,只不过,后者是等待某种条件,例如计数器为0,才会触发线程继续执行。而Barrier是等待所有的线程在某个点执行完毕,再同时开始执行。
CyclicBarrier是可以重复使用的,其在并行运算迭代算法中非常有用。
另一种常见的Barrier是Exchanger,Exchanger用于帮助两个线程做数据的传递,即一个线程在特定的同步点,将数据转递给另一个线程。依赖这个特性,使用Exchanger可以轻松实现生产者-消费者模式。