并发编程笔记(二):Java 并发机制的底层实现原理

时间:2021-06-29 18:00:01

并发系列的文章都是根据阅读《Java 并发编程的艺术》这本书总结而来,想更深入学习的同学可以自行购买此书进行学习。

Java 代码在编译后会变成 Java 字节码,通过类加载器加载到 JVM 里,JVM 执行字节码,最终需要转化为汇编指令在 CPU 上执行,Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。

volatile

volatile 在并发编程中扮演着重要的角色。volatile 是轻量级的 synchronized。它在多处理器开发中保证了共享变量的「可见性」

「可见性」的意思是当一个线程修改一个共享变量的时候,另一个线程可以读取这个修改后的值。volatile 如果使用的恰当的话,对比 synchronized,它的使用和执行成本更低,也不会有上下文切换。

1. volatile 的定义与实现原理

Java 语言规范中对 volatile 的定义是这样的:「Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。」如果一个字段被声明成了 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。

那么 volatile 是如何保证可见性呢?让我们看以下代码:
Java 代码如下:

instance = new Singleton() ; //instance 是 volatile 变量

转变为汇编代码,如下:

0x01a3deld: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有 volatile 修饰的共享变量在进行写操作的时候会多出两行汇编代码。Lock 前缀的指令在多核处理器下回引发两件事:

  1. 将当前处理器缓存行的数据写回到系统内存中。
  2. 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

缓存行是 CPU 高速缓存中可以分配的最小存储单位。处理器填写缓存行的时候会加载整个缓存行,现代 CPU 需要执行几百次 CPU 指令。

正常情况下,为了提高处理速度,处理器不直接和内存通信,而是先将系统内存中的数据读到内部缓存中再进行后续操作,但操作完之后将数据写到内存的时间不确定。但如果是对声明了 volatile 的变量进行写操作,JVM 就会向处理器发出 Lock 前缀指令,然后将数据写回到系统内存。在多处理器情况下,会实现缓存一致性协议,通过嗅探在总线上传播的数据来检查自己的缓存值是否过期,如果发现自己缓存行对应的内存地址被修改了,就会将它当前的缓存行设置成无效状态,当处理器对这个数据进行操作的时候,才会重新从系统内存中读取这个新数据到处理器缓存里。

Lock 前缀的指令导致在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言改信号期间,处理器可以独占任何共享内存。不过最近的处理器里,一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。通过锁定内存区域的缓存并回写到内存,这个操作被称为「缓存锁定」,缓存一致性机制确保了修改的原子性,并组织同时修改由两个处理器缓存的内存区域数据。

2. volatile 的使用优化

Java 并发大师 Doug lea 在 JDK 7 中新增了一个队列集合类 LinkedTransferQueue,它在使用 volatile 变量时,用一种追加字节的方式来优化队列出队和入队的性能。代码如下:

/** 队列中的头部节点 */
private transient final PaddedAtomicReference<QNode> head;
/** 队列中的尾部节点 */
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference <T> extends AtomicReference <T> {
//使用很多 4 个字节的引用追加到 64 个字节
Object p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;
PaddedAtomicReference(T r) {
super(r);
}
}
public class AtomicReference <V> implements java.io.Serializable {
private volatile V value;
//省略其他代码
}

LinkedTransferQueue这个类使用一个内部类对象来定义队列的头尾节点,这个内部类相对于父类 AtomicReference 只做了一件事,就是把共享变量追加到 64 字节。一个对象引用占用 4 个字节,这个内部类追加了 15 个变量(p0-pe),加上父类的 value 变量,一共占了 64 字节。

那么,为什么追加到 64 字节能提高并发编程效率呢?对于很多比如酷睿 i7 这样的处理器来说,他们的 L1、L2或L3缓存的高速缓存行是 64 字节宽的,不支持部分填充缓存行。如果头尾节点都不足 64 字节的话,处理器会把它们都读取到一个高速缓存行中,那么每个处理器都会缓存同样的头尾节点,当处理器试图修改头节点时,会将整个缓存行锁定,在缓存一致性机制的作用下,会导致其他处理无法访问该处理器高速缓存中的尾节点。但队列的入队出队操作需要不断的修改头尾节点,多处理器在这种情况下队列的效率就会受到很大的影响。

而通过把头尾节点都追加到 64 字节,这样头尾节点就不会加载到同一个缓存行中,头尾节点在修改的时候就不会相互锁定了。

但在使用 volatile 变量的时候也不是都需要追加到 64 字节的。在下面两种情况下不应该使用这种方式:

  1. 缓存行非 64 字节宽的处理器
  2. 共享变量不会被频繁的写。本身追加的方式就会带来一定的性能损耗,如果共享变量的写操作不是很频繁的话,锁的几率也不会很高,那么久没必要通过追加字节的方式来避免锁

这种追加字节的方式在 Java 7 下可能不会生效,因为 Java 7 更智能,它会淘汰或者重排序无用字段,需要使用其他追加字节的方式。

synchronized 的实现原理与应用

synchronized 一直是多线程并发编程中的元老级角色,很多人称呼它为重量级锁。随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况它也不一定那么重量级了。

synchronized 实现同步的基础: Java 中的每一个对象都可以作为锁。具体表现为以下三种形式:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的 Class 对象。
  • 对于同步方法块,锁是 Synchronized 括号里配置的对象。

JVM 是基于进入和退出 Monitor 对象来实现方法同步和代码块同步的。两者实现细节有所区别,但都可以通过两个指令:monitorentermonitorexit 来实现。

monitorenter 指令是在编译后插入到同步代码块的开始位置,monitorexit 是插入到方法结束处和异常处,JVM保证每个 monitorenter 都有一个 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象锁对应的 monitorenter 指令,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

Java 对象头

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,JVM 用 3 个字宽 (Word) 存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。32 位虚拟机里,1 字宽等于 4 字节,即 32 bit
Java 对象头分为三个部分:

  • Mark Word:存储对象的 hashCode 或 锁信息。
  • Class Metadata Address:存储到对象类型数据的指针。
  • Array length:数组的长度(如果当前对象是数组)

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 里存储的数据会随着标志位的变化而变化:

  • 轻量级锁
  • 重量级锁
  • GC 标记
  • 偏向锁

Java SE 1.6 为了减少获得释放锁带来的性能消耗,引入了「偏向锁」和「轻量级锁」,锁有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁状态会随着竞争情况逐渐升级,但只能升级不能降级,目的是为了提高获得和释放锁的效率

1. 偏向锁

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

1.1 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

撤销偏向锁,需要等待全局安全点,在这个时间点上没有正在执行的字节码。它首先暂停拥有偏向锁的线程,然后检查该线程是否还活着,如果没有,就将对象头设置成无锁状态,如果还活着,则拥有偏向锁的栈就会执行,并遍历偏向对象的锁记录,Mark Word 要么重新偏向于其他线程,要么就恢复到无锁或者标记对象不合适作为偏向锁,最后唤醒暂停的线程。

1.2 关闭偏向锁

Java 6 和 Java 7 中偏向锁是默认启动的,但它在应用启动几秒之后才会激活,可以通过设置 JVM 参数来关闭延迟:

-XX:BiasedLockingStartupDelay = 0

如果你的应用程序里所有锁通常都处于竞争状态,那么可以通过 JVM 参数关闭偏向锁:

-XX:-UseBiasedLocking = false

设置后,程序默认会进入轻量级锁状态。

2. 轻量级锁

线程在执行同步之前,JVM 会在当前线程栈帧中创建用于存储记录的空间,并将对象头中的 Mark Word 复制到锁记录中,称为 Displaced Mark Word 。线程尝试使用 CAS 将 Mark Work 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,说明其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,若成功,说明没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

因为自旋会消耗 CPU,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦锁升级成重量级锁,就不会再回到轻量级状态。锁处于这种状态下,其他线程试图竞争锁时,都会被阻塞,当该锁被释放之后才会唤醒这些线程,被唤醒的线程就会进行新一轮的竞争。

1.3 锁的优缺点对比。
  • 偏向锁:加锁解锁不需要额外的消耗,但如果线程间存在锁竞争,将会带来额外的锁撤销消耗。偏向锁适用于只有一个线程访问同步块的场景
  • * 轻量级锁*:竞争的线程不会被阻塞,提高了程序的响应速度。但如果线程始终得不到锁竞争,使用自旋会消耗 CPU。适用于追求响应时间或者同步块的执行速度非常快的情况。
  • 重量级锁:线程竞争不使用自旋,不会消耗 CPU,但线程会阻塞,相应时间比较慢。适用于追求吞吐量或者同步块执行速度较长的情况。

原子操作的实现原理

原子操作意为「不可被中断的一个或一系列操作」。多处理器上实现原子操作有些复杂。这里我们需要先了解一下几个术语:

  • CAS:Compare and Swap,比较并交换。CAS操作的时候,会输入两个值,一个新值一个旧值,在操作的时候先比较旧值有没有变化,如果没有变化,就替换成新值,如果变化了就不交换。
  • CPU pipeline:CPU流水线。CPU中有 5-6 个不同功能的电路单元组成一条指令处理流水线,然后将 X86 指令分成 5-6 步后再由这些电路单元分别执行,这样就能实现在一个 CPU 时钟周期完成一条指令,提高 CPU 运算速度。
  • Memory order violation:内存顺序冲突。内存顺序冲突一般是由假共享引起的。假共享是指多个 CPU 同时修改同一个缓存行的不同部分而引起其中一个 CPU 的操作无效,出现这种情况的时候,CPU必须清空流水线。

处理器提供总线锁定缓存锁定两个机制来实现多处理器之间的原子操作。

总线锁就是使用处理器提供一个 LOCK # 信号,当处理器在总线上输出此信号时,其他处理器的请求将被阻塞,那么发出信号的处理器就会独占共享内存。

总线锁定把 CPU 和内存之间的通信锁住了,这个期间其他处理器不能操作其他内存地址的数据,所以总线锁定的开销较大,这时候就可以用缓存锁定来进行优化。缓存锁定是指内存区域如果被缓存在处理器的缓存行中,在 Lock 期间被锁定,那么当它执行锁操作回写内存的时候,通过修改内部的内存地址,并允许它的缓存一致性机制来保证操作原子性,该机制阻止同时修改由两个以上处理器缓存的内存区域数据。

注意,有两种情况处理器不能用缓存锁定
第一个情况是:如果被操作数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行的时候,处理器会调用总线锁定。
第二种情况是:有些处理器不支持缓存锁定,比如 Inter 486 和 Pentium 处理器。

Java中可以通过循环 CAS 的方式来实现原子操作。以下代码实现了基于 CAS 线程安全的计数方法 safeCount和一个非线程安全的计数方法 count

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 = ArrayList<Thread>(600);
long start = System.currentTimeMillis();
forint 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.printIn(cas.i);
System.out.printIn(cas.atomicI.get());
System.out.printIn(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++;
}
}

JDK 从 Java 1.5 开始,并发包中提供了一些类来支持原子操作,如 AtomicBooleanAtomicIntegerAtomicLong,这些原子包装类还提供了很多有用的工具方法,例如通过原子的方式进行自增和自减。

使用 CAS 实现原子操作有三个问题:
  • ABA 问题:CAS 操作的时候,检查的是值有没有变化,比如原来是 A,变成 B,又变回 A,那么 CAS 检查的时候就会认为它的值没有变化,实际上却变化过了。解决方式是通过添加版本号,比如刚刚的例子,加上版本号后就会变成 1A -> 2B - >3A。Java 1.5 中的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
  • 循环时间长开销大:自旋 CAS 如果长时间不成功,CPU 开销将会非常大。JVM 如果能支持处理器提供的pause指令,效率将会有一定提升。pause指令可以延迟流水线执行指令,还可以避免退出循环时候因内存顺序冲突而导致的流水线清空,从而提高 CPU 效率。
  • 只能保证一个共享变量的原子操作:对多个共享变量操作的时候,循环 CAS 无法保证操作的原子性,这时候可以用锁。不过可以通过把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i = 2, j = a,合并一下 ij = 2a,然后再用 CAS 来操作 ij。Java 1.5 提供了 AtomicReference类来保证引用对象的原子性,就可以把多个变量放在一个对象里进行 CAS 操作。
使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能操作锁定的内存区域。JVM 内部有很多锁,偏向、轻量级和互斥锁。除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即进入同步块时候使用循环 CAS 获取锁,退出时候使用循环 CAS 释放锁。

总结

Java 中大部分容器和框架依赖于 volatile 和原子操作的实现原理,了解这些原理对我们进行并发编程会很有帮助。