目录
第八章 volatile
8.1 几个基本概念
在介绍volatile之前,我们先回顾及介绍几个基本的概念。
8.1.1 内存可见性
在Java内存模型那一章我们介绍了JMM有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。
内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
8.1.2 重排序
为优化程序性能,对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。
8.1.3 happens-before规则
是一个给程序员使用的规则,只要程序员在写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。
8.2 volatile的内存语义
在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:
- 保证变量的内存可见性
- 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”)
8.2.1 内存可见性
以一段示例代码开始:
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
在这段代码里,我们使用volatile
关键字修饰了一个boolean
类型的变量flag
。
所谓内存可见性,指的是当一个线程对volatile
修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile
修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。
假设在时间线上,线程A先自行方法writer
方法,线程B后执行reader
方法。那必然会有下图:
而如果flag
变量没有用volatile
修饰,在step 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false
。
8.2.1 禁止重排序
在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。那上面的案例中,可能就会被重排序成下列时序来执行:
- 线程A写volatile变量,step 2,设置flag为true;
- 线程B读同一个volatile,step 3,读取到flag为true;
- 线程B读普通变量,step 4,读取到 a = 0;
- 线程A修改普通变量,step 1,设置 a = 1;
可见,如果volatile变量与普通变量发生了重排序,虽然volatile变量能保证内存可见性,也可能导致普通变量读取错误。
所以在旧的内存模型中,volatile的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供一种比锁更轻量级的线程间的通信机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。
编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。
什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是CPU缓存,如L1,L2等
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:
- 在每个volatile写操作前插入一个StoreStore屏障;
- 在每个volatile写操作后插入一个StoreLoad屏障;
- 在每个volatile读操作后插入一个LoadLoad屏障;
- 在每个volatile读操作后再插入一个LoadStore屏障。
大概示意图是这个样子:
再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作
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变量写,编译器做了一定的优化来提高性能,比如:
第一个volatile读;
LoadLoad屏障;
第二个volatile读;
LoadStore屏障
再介绍一下volatile与普通变量的重排序规则:
-
如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
-
如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
-
如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序。
举个例子,我们在案例中step 1,是普通变量的写,step 2是volatile变量的写,那符合第2个规则,这两个steps不能重排序。而step 3是volatile变量读,step 4是普通变量读,符合第1个规则,同样不能重排序。
但如果是下列情况:第一个操作是普通变量读,第二个操作是volatile变量读,那是可以重排序的:
// 声明变量
int a = 0; // 声明普通变量
volatile boolean flag = false; // 声明volatile变量
// 以下两个变量的读操作是可以重排序的
int i = a; // 普通变量读
boolean j = flag; // volatile变量读
8.3 volatile的用途
从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序。
在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。
在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:
public class Singleton {
private static Singleton instance; // 不使用volatile关键字
// 双重锁检验
public static Singleton getInstance() {
if (instance == null) { // 第7行
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 第10行
}
}
}
return instance;
}
}
如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:
instance = new Singleton(); // 第10行
// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址
// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象
而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候线程A执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
所以JSR-133对volatile做了增强后,volatile的禁止重排序功能还是非常有用的。
第九章 synchronized与锁
这篇文章我们来聊一聊Java多线程里面的“锁”。
首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。
还有一点需要注意的是,我们常听到的类锁其实也是对象锁。
Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。
9.1 Synchronized关键字
说到锁,我们通常会谈到synchronized
这个关键字。它翻译成中文就是“同步”的意思。
我们通常使用synchronized
关键字来给一段代码或一个方法上锁。它通常有以下三种形式:
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized
关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。
通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this) {
// code
}
}
同理,下面这两个方法也应该是等价的:
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this.getClass()) {
// code
}
}
9.2 几种锁
Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
关于锁降级有两点说明:
1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。
2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。
下面分别介绍这几种锁以及它们之间的升级。
9.2.1 Java对象头
前面我们提到,Java的锁都是基于对象的。首先我们来看看一个对象的“锁”的信息是存放在什么地方的。
每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的内容如下表:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
我们主要来看看Mark Word的格式:
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时,Mark Word
存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word
存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁时,Mark Word
为指向堆中的monitor对象的指针。
9.2.2 偏向锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。
大白话就是对锁置个变量,如果发现为true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为false,代表存在其他线程竞争资源,那么就会走后面的流程。
一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。
如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:
- 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
- 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
CAS: Compare and Swap
比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。
比较是否和给定的数值一致,如果一致则修改,不一致则不修改。
线程竞争偏向锁的过程如下:
图中涉及到了lock record指针指向当前堆栈中的最近一个lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:
- 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
- 唤醒被停止的线程,将当前锁升级成轻量级锁。
所以,如果应用程序里所有的锁通常出于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
-XX:UseBiasedLocking=false。
下面这个经典的图总结了偏向锁的获得和撤销:
9.2.3 轻量级锁
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。
JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。
然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
自旋:不断尝试去获取锁,一般用循环来实现。
自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
轻量级锁的释放:
在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
一张图说明加锁和释放锁的过程:
9.2.4 重量级锁
重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。
前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到Contention List的队列的队首,然后调用park
函数挂起当前线程。
当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive
即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定能获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。
果线程获得锁后调用Object.wait
方法,则会将线程加入到WaitSet中,当被Object.notify
唤醒后,会将线程从WaitSet移动到Contention List或EntryList中去。需要注意的是,当调用一个锁对象的wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
9.2.5 总结锁的升级流程
每一个线程在准备获取共享资源时:
第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,
把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
9.2.6 各种锁的优缺点对比
下表来自《Java并发编程的艺术》:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |
第十章 乐观锁和悲观锁
10.1 乐观锁与悲观锁的概念
锁可以从不同的角度分类。其中,乐观锁和悲观锁是一种分类方式。
悲观锁:
悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
乐观锁:
乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。
由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁。
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
10.2 CAS的概念
CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
- V:要更新的变量(var)
- E:预期值(expected)
- N:新值(new)
比较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。
所以这里的预期值E本质上指的是“旧值”。
我们以一个简单的例子来解释这个过程:
- 如果有一个多个线程共享的变量
i
原本等于5,我现在在线程A中,想把它设置为新的值6; - 我们使用CAS来做这个事情;
- 首先我们用i去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,
i
的值被设置成了6; - 如果不等于5,说明
i
被其它线程改过了(比如现在i
的值为2),那么我就什么也不做,此次CAS失败,i
的值仍然为2。
在这个例子中,i
就是V,5就是E,6就是N。
那有没有可能我在判断了i
为5之后,正准备更新它的新值的时候,被其它线程更改了i
的值呢?
不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
10.3 Java实现CAS的原理 - Unsafe类
前面提到,CAS是一种原子操作。那么Java是怎样来使用CAS的呢?我们知道,在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现。
在Java中,有一个Unsafe
类,它在sun.misc
包中。它里面是一些native
方法,其中就有几个关于CAS的:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
当然,他们都是public native
的。
Unsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。
Linux的X86下主要是通过cmpxchgl
这个指令在CPU级完成CAS操作的,但在多处理器情况下必须使用lock
指令加锁来完成。当然不同的操作系统和处理器的实现会有所不同,大家可以自行了解。
当然,Unsafe类里面还有其它方法用于不同的用途。比如支持线程挂起和恢复的park
和unpark
, LockSupport类底层就是调用了这两个方法。还有支持反射操作的allocateInstance()
方法。
10.4 原子操作-AtomicInteger类源码简析
上面介绍了Unsafe类的几个支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢?
JDK提供了一些用于原子操作的类,在java.util.concurrent.atomic
包下面。在JDK 11中,有如下17个类:
从名字就可以看得出来这些类大概的用途:
- 原子更新基本类型
- 原子更新数组
- 原子更新引用
- 原子更新字段(属性)
这里我们以AtomicInteger
类的getAndAdd(int delta)
方法为例,来看看Java是如何实现原子操作的。
先看看这个方法的源码:
public final int getAndAdd(int delta) {
return U.getAndAddInt(this, VALUE, delta);
}
这里的U其实就是一个Unsafe
对象:
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
所以其实AtomicInteger
类的getAndAdd(int delta)
方法是调用Unsafe
类的方法来实现的:
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
注:这个方法是在JDK 1.8才新增的。在JDK1.8之前,
AtomicInteger
源码实现有所不同,是基于for死循环的,有兴趣的读者可以自行了解一下。
我们来一步步解析这段源码。首先,对象o
是this
,也就是一个AtomicInteger
对象。然后offset
是一个常量VALUE
。这个常量是在AtomicInteger
类中声明的:
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
同样是调用的Unsafe
的方法。从方法名字上来看,是得到了一个对象字段偏移量。
用于获取某个字段相对Java对象的“起始地址”的偏移量。
一个java对象可以看成是一段内存,各个字段都得按照一定的顺序放在这段内存里,同时考虑到对齐要求,可能这些字段不是连续放置的,
用这个方法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象又没什么太大关系,跟class的定义和虚拟机的内存模型的实现细节更相关。
继续看源码。前面我们讲到,CAS是“无锁”的基础,它允许更新失败。所以经常会与while循环搭配,在失败后不断去重试。
这里声明了一个v,也就是要返回的值。从getAndAddInt
来看,它返回的应该是原来的值,而新的值的v + delta
。
这里使用的是do-while循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证return 的值v
是我们期望的值。
循环体的条件是一个CAS方法:
public final boolean weakCompareAndSetInt(Object o, long offset,
int expected,
int x) {
return compareAndSetInt(o, offset, expected, x);
}
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
可以看到,最终其实是调用的我们之前说到了CAS native
方法。那为什么要经过一层weakCompareAndSetInt
呢?从JDK源码上看不出来什么。在JDK 8及之前的版本,这两个方法是一样的。
而在JDK 9开始,这两个方法上面增加了@HotSpotIntrinsicCandidate注解。这个注解允许HotSpot VM自己来写汇编或IR编译器来实现该方法以提供性能。也就是说虽然外面看到的在JDK9中weakCompareAndSet和compareAndSet底层依旧是调用了一样的代码,但是不排除HotSpot VM会手动来实现weakCompareAndSet真正含义的功能的可能性。
根据本文第一篇参考文章(文末链接),它跟volitile
有关。
简单来说,weakCompareAndSet
操作仅保留了volatile
自身变量的特性,而出去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet
无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。这在一定程度上可以提高性能。
再回到循环条件上来,可以看到它是在不断尝试去用CAS更新。如果更新失败,就继续重试。那为什么要把获取“旧值”v的操作放到循环体内呢?其实这也很好理解。前面我们说了,CAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内。
10.5 CAS实现原子操作的三大问题
这里介绍一下CAS实现原子操作的三大问题及其解决方案。
10.5.1 ABA问题
所谓ABA问题,就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。
ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference
类来解决ABA问题。
这个类的compareAndSet
方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
10.5.2 循环时间长开销大
CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。
解决思路是让JVM支持处理器提供的pause指令。
pause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。
10.5.3 只能保证一个共享变量的原子操作
这个问题你可能已经知道怎么解决了。有两种解决方案:
- 使用JDK 1.5开始就提供的
AtomicReference
类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作; - 使用锁。锁内的临界区代码可以保证只有当前线程能操作。
第十一章 AQS
11.1 AQS简介
AQS是AbstractQueuedSynchronizer
的简称,即抽象队列同步器
,从字面意思上理解:
- 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
- 队列:使用先进先出(FIFO)队列存储数据;
- 同步:实现了同步的功能。
那AQS有什么用呢?AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的同步器,比如我们提到的ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器,只要之类实现它的几个protected
方法就可以了,在下文会有详细的介绍。
11.2 AQS的数据结构
AQS内部使用了一个volatile的变量state来作为资源的标识。同时定义了几个获取和改版state的protected方法,子类可以覆盖这些方法来实现自己的逻辑:
getState()
setState()
compareAndSetState()
这三种叫做均是原子操作,其中compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法。
而AQS类本身实现的是一些排队和阻塞的机制,比如具体线程等待队列的维护(如获取资源失败入队/唤醒出队等)。它内部使用了一个先进先出(FIFO)的双端队列,并使用了两个指针head和tail用于标识队列的头部和尾部。其数据结构如图:
但它并不是直接储存线程,而是储存拥有线程的Node节点。
11.3 资源共享模式
资源有两种共享模式,或者说两种同步方式:
- 独占模式(Exclusive):资源是独占的,一次只能一个线程获取。如ReentrantLock。
- 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch。
一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock
。
AQS中关于这两种资源共享模式的定义源码(均在内部类Node中)。我们来看看Node的结构:
static final class Node {
// 标记一个结点(对应的线程)在共享模式下等待
static final Node SHARED = new Node();
// 标记一个结点(对应的线程)在独占模式下等待
static final Node EXCLUSIVE = null;
// waitStatus的值,表示该结点(对应的线程)已被取消
static final int CANCELLED = 1;
// waitStatus的值,表示后继结点(对应的线程)需要被唤醒
static final int SIGNAL = -1;
// waitStatus的值,表示该结点(对应的线程)在等待某一条件
static final int CONDITION = -2;
/*waitStatus的值,表示有资源可用,新head结点需要继续唤醒后继结点(共享模式下,多线程并发释放资源,而head唤醒其后继结点后,需要把多出来的资源留给后面的结点;设置新的head结点时,会继续唤醒其后继结点)*/
static final int PROPAGATE = -3;
// 等待状态,取值范围,-3,-2,-1,0,1
volatile int waitStatus;
volatile Node prev; // 前驱结点
volatile Node next; // 后继结点
volatile Thread thread; // 结点对应的线程
Node nextWaiter; // 等待队列里下一个等待条件的结点
// 判断共享模式的方法
final boolean isShared() {
return nextWaiter == SHARED;
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
// 其它方法忽略,可以参考具体的源码
}
// AQS里面的addWaiter私有方法
private Node addWaiter(Node mode) {
// 使用了Node的这个构造函数
Node node = new Node(Thread.currentThread(), mode);
// 其它代码省略
}
注意:通过Node我们可以实现两个队列,一是通过prev和next实现CLH队列(线程同步队列,双向队列),二是nextWaiter实现Condition条件上的等待线程队列(单向队列),这个Condition主要用在ReentrantLock类中。
11.4 AQS的主要方法源码解析
AQS的设计是基于模板方法模式的,它有一些方法必须要子类去实现的,它们主要有:
-
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
-
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
-
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
-
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
-
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
这些方法虽然都是protected
方法,但是它们并没有在AQS具体实现,而是直接抛出异常(虽然不知道这里为什么不使用抽象方法的实现方式):
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
而AQS实现了一系列主要的逻辑。下面我们从源码来分析一下获取和释放资源的主要逻辑:
11.4.1 获取资源
获取资源的入口是acquire(int arg)方法。arg是要获取的资源的个数,在独占模式下始终为1。我们先来看看这个方法的逻辑:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先调用tryAcquire(arg)尝试去获取资源。前面提到了这个方法是在子类具体实现的。
如果获取资源失败,就通过addWaiter(Node.EXCLUSIVE)方法把这个线程插入到等待队列中。其中传入的参数代表要插入的Node是独占式的。这个方法的具体实现:
private Node addWaiter(Node mode) {
// 生成该线程对应的Node节点
Node node = new Node(Thread.currentThread(), mode);
// 将Node插入队列中
Node pred = tail;
if (pred != null) {
node.prev = pred;
// 使用CAS尝试,如果成功就返回
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果等待队列为空或者上述CAS失败,再自旋CAS插入
enq(node);
return node;
}
// 自旋CAS插入等待队列
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
上面的两个函数比较好理解,就是在队列的尾部插入新的Node节点,但是需要注意的是由于AQS中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过CAS自旋的方式保证了操作的线程安全性。
OK,现在回到最开始的aquire(int arg)方法。现在通过addWaiter方法,已经把一个Node放到等待队列尾部了。而处于等待队列的结点是从头结点一个一个去获取资源的。具体的实现我们来看看acquireQueued方法
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for (;;) {
final Node p = node.predecessor();
// 如果node的前驱结点p是head,表示node是第二个结点,就可以尝试去获取资源了
if (p == head && tryAcquire(arg)) {
// 拿到资源后,将head指向该结点。
// 所以head所指的结点,就是当前获取到资源的那个结点或null。
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 如果自己可以休息了,就进入waiting状态,直到被unpark()
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这里parkAndCheckInterrupt方法内部使用到了LockSupport.park(this),顺便简单介绍一下park。
LockSupport类是Java 6 引入的一个类,提供了基本的线程同步原语。LockSupport实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数:
- park(boolean isAbsolute, long time):阻塞当前线程
- unpark(Thread jthread):使给定的线程停止阻塞
所以结点进入等待队列后,是调用park使它进入阻塞状态的。只有头结点的线程是处于活跃状态的。
当然,获取资源的方法除了acquire外,还有以下三个:
- acquireInterruptibly:申请可中断的资源(独占模式)
- acquireShared:申请共享模式的资源
- acquireSharedInterruptibly:申请可中断的资源(共享模式)
可中断的意思是,在线程中断时可能会抛出
InterruptedException
总结起来的一个流程图:
11.4.2 释放资源
释放资源相比于获取资源来说,会简单许多。在AQS中只有一小段实现。源码:
复制代码
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
// 如果状态是负数,尝试把它设置为0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 得到头结点的后继结点head.next
Node s = node.next;
// 如果这个后继结点为空或者状态大于0
// 通过前面的定义我们知道,大于0只有一种可能,就是这个结点已被取消
if (s == null || s.waitStatus > 0) {
s = null;
// 等待队列中所有还有用的结点,都向前移动
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 如果后继结点不为空,
if (s != null)
LockSupport.unpark(s.thread);
}