基本概念
线程
竞争条件、临界区
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。
同步
线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。简而言之就是多个线程需要按照某一个特定的顺序执行。
互斥
线程互斥是线程之间的间接制约关系。当一个线程进入临界区使用临界资源时,另一个线程必须等待。只有当使用临界资源的线程退出临界区后,这个线程才会解除阻塞状态。
概述
根据上面几个概念,概括多线程编程:在多线程编程中,会遇到资源竞争的问题,通过一些手段我们可以实现线程互斥以使得程序正常运行;同时也会遇到线程顺序执行的需求,那也可以通过一些同步访问技术(如信号量)实现线程间的同步,而这些手段就是多线程编程的主要工作内容。
内存模型
内存模型
由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
JVM在设计内存模型的时候也是参照了上述的设计,因此在线程与主存之间增加了当前线程私有的本地内存,作用相当于高速缓存(cache)。在实际运行的时候,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
在之前的博文中,我们将JVM的内存区域划分为了堆、栈等区域,而现在的内存划分,则是从不同维度上进行了分层,如果要将两种划分方式对应起来。那么本地内存对应的即是虚拟机栈,而主存,则对应着堆。

共享变量
如上所述,在Java中,所有实例域、静态域和数组元素存放在堆内存中,线程之间共享,称之为“共享变量”。局部变量、方法参数等不会在线程之间共享,不存在内存可见性问题,也不受内存模型的影响。
基本操作
由于上面本地内存的设计,那么必然会引出本地内存与主存的数据同步的问题,为了解决这一问题,JVM约定了一系列这两者之间的基本操作(协议),以保证数据同步正常。
Java内存模型中定义了以下8中操作来完成主内存与工作内存之间交互的实现细节,并且JVM确保以下的操作都是原子操作。
- lock(锁定):作用于主内存的变量,它把一个变量标示为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
可见性
可见性指的是一个线程对共享变量值的修改,能否及时地被其他线程看到。回顾上面JVM内存模型的设计,我们会发现线程修改变量值并同步到主存需要经过以下几个原子操作assign->store->write。显而易见,这几个操作之间是有可能插入其他线程的读取操作的,那么就会出现一种情况,即线程A已经修改了变量x,但是并没有同步到主存中,而线程b此时读取了变量x,那么就会导致线程b读到了一个过期的x值。
因为可见性的问题,下面的代码的执行结果可能是一致循环或者输出的number不等于43。
1 | public class NoVisibility { |
重排序
重排序
重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。因为多CPU、多核架构的存在,为了提升程序性能,JVM允许将一些单线程逻辑上无先后关系的指令按照新的顺序执行,但是JVM需要保证在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致。
举例来说
1 | int a = 0; |
上面的代码执行后,i的值可能为1,也可能为0。因为JVM只保证单线程上逻辑无关的指令顺序执行,换言之,因为在线程1中a与flag的赋值指令是无逻辑关系的,因此在实际执行的时候,可能先执行flag的赋值,再执行a的赋值。
先行发生原则(happens-before)
因为重排序的存在,导致Java代码在多线程的情况下变得非常复杂,为了限制这种复杂度,Java语言规范约定了重排序的一些规则,被称为先行发生原则,这些规则主要从可见性的角度进行了约束。
核心思想:Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。
程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。注意这里的动作A与B需要有逻辑上的先后关系,参考文档原文的这一段:
It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.
监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C
实现线程安全
互斥同步
互斥同步是常见的一种并发正确性保障手段。最基本的互斥手段是 synchronized 关键字,也是最简单的一种方式。 此关键字在经过编译之后,会在同步块前后形成monitorenter和monitorexit这两个字节码的指令,这两个字节码都需要一个reference来指定对象参数,来指明要锁定和解锁的对象。除此之外,还有并发库中提供的其他锁以及各种并发工具类。
非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(如加锁),那就肯定会出现问题,无论共享数据是否会发生竞争,都要进行加锁等操作。
而非阻塞同步则基于CAS实现了在数据进行提交更新的时候,才正式对数据的冲突与否进行检测,再根据检测结果决定是否更新数据。关于CAS,可以查看 CAS维基百科
无同步方案
- 可重入代码:即纯函数,因为无状态,所以在多线程下可以保证绝对的线程安全。
- 线程本地存储:即通过编码保证对状态值的修改都在同一个线程中发生,这样也就保证了线程安全。
锁
注意以下介绍的锁的分类并不是彼此独立的,可能会有一种锁的实现同时属于多种类型
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有。 共享锁是指该锁可被多个线程所持有。典型的共享锁如读写锁,即允许多个线程进行读操作的锁。
悲观锁/乐观锁
悲观锁:悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁:乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
1
2
3
4
5
6for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。 我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。 但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
重量级锁/轻量级锁/偏向锁
在介绍这几个锁之前先了解下Java对象头的作用。Java对象头被称为“Mark Word”,在锁的实现中用于存储锁相关的信息,具体存放形式如下表:

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
tip:注意下面一开始获取锁的时候都使用了CAS,是因为存在两个线程同时去竞争一个锁的场景,因此需要用CAS保证在这一场景下的线程安全。
重量级锁:重量级锁(Synchronized关键字)是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么重量级锁效率低的原因。
轻量级锁:自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
加锁过程:(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。
(2)拷贝对象头中的Mark Word复制到锁记录中。(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
偏向锁:在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
加锁过程:(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
(5)执行同步代码。
解锁过程:偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
更多的细节可以参考 Java并发之彻底搞懂偏向锁升级为轻量级锁

全局安全点
从线程角度看,safepoint可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停。
volatile、final、synchronized与内存屏障
先看一个例子,经典的DCL实现单例,考虑下这样的写法会导致什么问题
1 | public class Singleton { |
存在的问题
第一眼可能会觉得是因为可见性的问题,线程A singleton = new Singleton(); 的赋值对线程B不可见,但是实际上由于synchronized 关键字的作用(细节下面会说),不会发生这种情况。
这样的实现真正的问题在于singleton = new Singleton();这个语句包含了两个指令(实际上更多,这里仅取关键指令):初始化与返回对象地址。因为指令重排序的问题,可能会导致在线程A中先执行返回指令,再执行对象初始化。如果在此时线程B执行到了A,那么它就会取到一个初始化未完成的singleton,进而引发错误。
至于如何规避这种情况,下面再说。
更多的细节可以参考文章:The “Double-Checked Locking is Broken” Declaration
对可见性的影响
- synchronized:会创建一个内存屏障,内存屏障指令保证了所有CPU操作结果都会直接刷到主存中,从而保证了操作的内存可见性,同时也使得先获得这个锁的线程的所有操作,都happens-before于随后获得这个锁的线程的操作。
- volatile:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来 将从主内存中读取共享变量。
- final:在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。初次读一个包含final域的对象的引用,与诉后初次读这个final域,这两个操作之间不能重排序。
内存屏障
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型:
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
实现原理
以volatile为例。

举例来说,第三行最后一个单元格的意思是,当第一个操作是普通变量的读/写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
JMM通过插入内存屏障来实现以上语义,实质上有四种内存屏障策略:
- volatile写操作前插入StoreStore屏障
- volatile写操作后插入StoreLoad屏障
- volatile读操作前插入LoadLoad屏障
- volatile读操作后插入LoadStore屏障