二、Java并发机制的底层实现原理

1 volatile 的应用

术语 英文 解释
缓存行 Cache line 缓存的最小操作单位(例如处理器的一级缓存等)
比较并交换 Compare And Swap CAS 操作需要输入两个数值,一个旧值和一个新值,在操作期间先比较旧值发生变化没,如果没有变化,才交换成新值

1.1 volatile 的定义与实现原理

volatile 变量修饰的共享变量进行写操作的时候,会引发两件事情:

将当前处理器缓存行( cache line )的数据写回到系统内存。
这个写回内存的操作会使其它 CPU 里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其它,即一级缓存、二级缓存等)后再进行操作, 但操作完全不知道何时会真正写到内存。而声明了 volatile 的变量进行写操作, JVM 就会向处理器发送一条 Lock 前缀的指令, 将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其它处理器缓存的值还是旧的,再执行计算操作就会有问题。所以, 在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了, 当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候, 会重新从系统内存中把数据读到处理器缓存里。

1.2 volatile 的使用优化

在 JDK 1.7 中的 LinkedTransferQueue 使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能,即将共享变量追加到 64 字节。 一个对象的引用占用 4 个字节,追加 15 个变量(共占 60 个字节),一共 64 个字节。这样 CPU 中缓存行中,最多只有一个队列, 而不会导致有两个队列,操作其中一个队列的时候由于和另一个队列在同一个缓存行中锁住了另一个队列。但是有两种场景下不应该使用这种方式:

缓存行非 64 字节宽的处理。
共享变量不会被频繁的写。
但是在 java7 中,它会淘汰或重新排列无用字段,因此需要使用其它追加字节的方式。

2 synchronized 的实现原理与应用

作为一个重量级锁,它是存在 Java 对象头里的。并在 Java SE 1.6 对 synchronized 进行了各种优化。
Java 中的每一个对象都可以作为锁:

对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的 Class 对象。
对于同步方法块,锁是 Synchronized 括号里配置的对象。
JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不同。
代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步使用另外一种方法实现,细节在 JVM 规范里并没有详细说明。但是, 方法的同步同样可以使用这两个指令来实现。

2.1 锁的升级与对比

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:

无锁状态
偏向锁状态
轻量级锁状态
重量级锁状态
这几种状态随着竞争情况逐渐升级。锁可以升级但不能降级,这是为了提高获得锁和释放锁的效率,下文会详细分析。

2.1.1 偏向锁

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在当前同步块的对象头和栈帧中的锁记录里存储锁偏向的线程 ID ,以后该线程再进入和退出同步块时不需要进行 CAS 操作来加锁和解锁, 只需要简单地测试一下对象头里的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示当前线程已经获得了锁。如果测试失败,则需要再测试一下对象头中偏向锁的标识是否设置成1 (标识当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

1) 偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点 (在这个时间点上没有正在执行的字节码,GC 可以在这个点进行暂停所有线程进行暂停回收操作)。它会首先暂停拥有偏向锁的线程, 然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录, 栈中的锁记录和对象头,要么重新偏向于其它线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

2) 关闭偏向锁
偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,可以使用 -XX:BiasedLockingStartupDelay=0 。 如果你确定应用程序里所有的锁通常情况下处理竞争状态,可以通过 -XX:UseBiasedLocking=false 关闭偏向锁,那么程序默认会进入轻量级锁状态。

2.1.2 轻量级锁

1) 轻量级锁加锁
线程在执行同步块之前, JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word 复制到锁记录中,官方称为 Displaced Mark Word 。 然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其它线程竞争锁,当前线程便尝试使用自旋来获取锁。

2) 轻量级锁解锁
使用 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
当锁处于这个状态下,其它线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

3) 锁的优缺点对比

优 点 缺点 适用场景
偏向锁 加锁和解锁不需要额外消耗,和执行非同步方法相对仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗 CPU 追求响应时间,同步块内执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗 CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块内执行速度较快

3 原子操作的实现原理

3.1 处理器使用总线锁保证原子性

如果多个处理器同时对共享变量进行读改写操作(i++ 就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的, 操作完之后共享变量的值会和期望的不一致。例如:如果 i=1 ,我们进行两次 i++ 操作,期望结果是 3 ,但是有可能结果是 2 。如下图:

可能是多个处理器同时从各自的缓存中读取变量 i ,分别进行加 1 操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的, 就必须保证 CPU1 读改写共享变量的时候, CPU2 不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁来解决这个问题,即使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住, 那么该处理器就可以独占共享内存,而其它处理器会直接废弃掉该信号的值,从内存中取,再缓存。

3.2 处理器使用缓存锁保证原子性

在同一时刻,我们只需要保证对某个内存地址的操作是原子性即可,但总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其它处理器不能操作其它内存地址的户数, 所以总线锁定的开销比较大,在某些场合下可以使用缓存锁定代替总线锁定进行优化。

频繁使用的内存会缓存在处理器的 L1、L2 和 L3 高级缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁。所谓“缓存锁定” 是指内存区域如果被缓存在处理器的缓存行中,并且在 Lock 操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声明 LOCK# 信号, 而是膝盖内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改两个以上处理器缓存的内存区域数据, 当其它处理器回写已被锁定的缓存行的数据时,会使缓存行无效。如上图的例子,当 CPU1 修改缓存行中的 i 时使用了缓存锁定,那么 CPU2 就不能同时缓存 i 和缓存行。

但是有两种情况下处理器不会使用缓存锁定:
1:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,则处理器会调用总线锁定。
2:处理器本身不支持缓存锁定。

4 Java 如何实现原子操作

4.1 使用循环 CAS 实现原子操作

使用 JDK 1.5 之后的并发包,里面有一些类来支持原子操作,如 AtomicBoolean 、 AtomicInteger 、 AtomicLong ,还有一些工具了, 如以原子额方式将当前值自增 1 和自减 1 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class Counter {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override public void run() {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 使用 CAS 实现线程安全计数器
*/
private void safeCount() {
for (;;) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
i++;
}
}

4.2 CAS 实现原子操作的三大问题

4.2.1 ABA 问题

如果一个值原来是 A ,变成了 B ,又变成了 A ,那么使用 CAS 进行检查时会发现它的值没有变化,但是实际却变化了。
解决思路就是使用版本号,即 A->B->A 变成 1A->2B->3A 。 Atomic 包提供了类 AtomicStampedReference 来解决 ABA 问题。

4.2.2 循环时间开销大

自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。

4.2.3 只能保证一个共享变量的原子操作

对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作, 例如,有两个共享变量 i=2 ,j=a ,合并以下 ij=2a ,然后用 CAS 来操作 ij 。 JDK 1.5 之后提供了 AtomicReference 类来保证引用对象之间的原子性, 就可以把多个变量放在一个对象里来进行 CAS 操作。

4.3 使用锁机制实现原子操作

JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁, JVM 实现锁的方式都用了循环 CAS ,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁, 当它退出同步块的时候使用循环 CAS 释放锁。

5 本章小结

volatile 的实现原理,从处理器到 JVM 讲解。
synchronized 的实现原理以及三种锁对象以及 JDK 1.6 之后的四种状态锁。
原子操作的实现原理,即 CAS 。
Java 中的大部分容器和框架都依赖于本章介绍的 volatile 和原子操作的实现原理,了解这些原理对我们进行并发编程会更有帮助。