【并发编程的艺术】内存语义分析:volatile、锁与 CAS
系列文章:
一 volatile
几个理解下面内容的关键点:cpu 缓存结构、可见性、上一篇文章中的总线工作机制。通过系列的前面几篇文章,我们可以初步总结造成并发问题的原因,一是 cpu 本地内存(各级缓存)没有及时刷新到主存,二是指令重排序造成的执行乱序导致意料之外的结果,归根结底是对内存的使用不当导致的问题。
1.1 volatile 变量特性
1)可见性
对一个 volatile 变量的读,(对任意线程来说)总是能看到这个 volatile 变量的最后写入。
2)原子性
对任意单个 volatile 变量的读/写具有原子性(这里包括前面提到过的 64 位变量类型 long 和 double,因为分为高 32 位和低 32 位两步操作导致的可能),但类似于 volatile ++这种复合操作不具有原子性
1.2 可见性实现分析
从 JSR-133(即 JDK5)开始,volatile 变量的写-读可以实现线程之间的通信(写-读建立的 happens-before 关系)。
1.2.1 写-读内存语义
从内存语义的角度来说,volatile 的写-读,与锁的释放-获取有相同的内存效果。也就是说,volatile 写与锁的释放有相同的内存语义,volatile 读与锁的获取有相同的内存语义。
1.2.1.1 happens-before 规则
看一个代码示例:
假设有两个线程,A 调用 writer()方法后,B 执行 reader 方法,根据 happens-befores 规则,这个过程可能的 happens-before 可能有三种:
1)代码顺序,1->2, 3->4
2)根据 volatile 规则(特性),2->3
3)根据 happens-before 传递性,1->4
箭头方向表示 happens-before。
图形表示如下:
1.2.1.2 内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。这里涉及两个问题:1、写操作所在的线程要及时更新值到内存;2、其他读该共享变量的线程,要感知到本地内存中的共享变量副本失效。
共享变量的状态变化如下图所示:
1.2.1.3 volatile 的内存语义实现
在 JMM 中,为了实现 volatile 的内存语义,会分别限制在编译器和处理器中的重排序类型。规则如下表所示:
先介绍一个概念,内存屏障(memory barrier/memory fence)。内存屏障也称为内存栅栏、内存栅障、屏障指令等,是一种同步屏障指令,CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
编译器在生成字节码时,会在指令序列中插入内存屏障,来禁止某些指定类型的处理器重排序。由于几乎不可能找到一个最优设置来最小化插入内存屏障的总数,所以 JMM 采用保守策略:
1)每个 volatile 写操作的前面插入一个 StoreStore 屏障
2)每个 volatile 写操作后面插入一个 StoreLoad 屏障
3)每个 volatile 读操作的后面插入一个 LoadLoad 屏障
4)每个 volatile 读操作后面插入一个 LoadStore 屏障
保守策略考虑的基本原则:先确保正确,然后再追求效率。在这种策略下,volatile 写插入内存屏障后生成的指令序列执行的示意图如下:
1.2.1.4 JSR-133 为什么要增强 volatile 的内存语义
JSR-133 之前的 Java 内存模型中,虽然不允许 volatile 变量之间的重排序,但允许 volatile 变量与普通变量重排序。例如下面示例:
因为操作 1 和 2 没有依赖关系,所以可能被重排序(3、4 类似)。结果就是线程 B 执行 4 时不一定能看到 A 在执行 1 时对共享变量的修改。
为了提供一种比锁更轻量级的线程之间通信机制,JSR-133 决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
但还是不要忘记,volatile 仅仅保证对单个 volatile 变量的读/写具有原子性。
二 锁
锁,是 Java 并发编程中最重要的同步机制。除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
2.1 锁的释放-获取
与 volatile 的分析相同,我们还是先分析 happens-before 关系。示例代码:
两个线程 A、B,还是 A 执行 writer(),然后 B 执行 reader()。 这个过程中包含的 happens-before 关系如下:
1)根据程序次序(代码顺序)规则, 1_>2, 2->3,4->5,5->6
2)根据监视器锁规则,3->4
3)根据传递性,2->5
2.2 锁释放和获取的内存语义
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效,从而使被监视器保护的临界区代码,必须从主内存中读取共享变量。
对比 volatile 的写-读内存语义,可以看出锁释放=volatile 写,锁获取=volatile 读。锁的内存语义总结如下:
1)线程 A 释放一个锁,实际上是线程 A 向接下来要获取这个锁的某个线程发出了(线程 A 对共享变量锁做修改的)消息
2)线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
3)线程 A 释放锁,随后线程 B 获取这个锁,实质上是线程 A 通过主内存向线程 B 发送消息。
2.3 锁内存语义的实现
借助 ReentrantLock 的源码来分析具体实现机制。
ReentrantLock 的实现依赖于 AQS(AbstractQueuedSynchronizer)。AQS 使用 volatile 变量 state 来维护同步状态,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。
2.3.1 ReentrantLock 源码分析
ReentrantLock 有公平锁(FairSync)和非公平锁(NonFairSync)两种实现。这是 ReentrantLock 的两个静态 final 内部类,继承自抽象类 Sync。首先分析公平锁:
2.3.1.1 公平锁 FairSync
公平锁的加锁方法 lock,调用路径如下:
1)ReentrantLock:lock()
2)FairSync:lock()
源码是 sync.lock(); 但因为 ReentrantLock 在构造方法中会设置持有的 sync 实例为非公平锁 FairSync,所以实际上走的就是 FairSync:lock()方法。
3)AbstractQueueSynchronizer:acquire(int arg)
4)ReentrantLock: tryAcquire(int acquires)
公平锁中 tryAcquire 方法源码如下:
可见,加锁方法会先读 volatile 变量 state。
公平锁的 unlock():
ReentrantLock:unlock->AbstractQueueSynchronizer:release(int arg)->Sync:tryRelease(int releases)
释放锁的最后写 volatile 变量 state。
公平锁在释放锁的最后写 volatile 变量 state,在获取锁时先读 state。根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。
2.3.1.2 非公平锁 NonFairSync
加锁方法:
ReentrantLock:lock() -> NonfairSync:lock() -> AbstractQueuedSynchronizer: compareAndSetState(int expect, int update)
AQS 的 cas 方法源码:
通过原子操作方式更新 state 变量。
2.3.1.3 关于 CAS
概念大家都已经了解过,CAS 是通过对比当前状态值是否等于预期值来决定是否执行交换。如果当前状态值等于预期值,则以原子方式把同步状态设置为给定的更新值,最重要的是,CAS 具有 volatile 读和写的内存语义。
1)编译器角度
编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写之前的任意内存操作重排序。组合这两个条件,意味着同时实现 volatile 的读和写的内存语义,编译器不能对 CAS 于 CAS 前后的任意内存操作做重排序。
sun.misc.Unsafe 类的 compareAndSwapInt()方法是本地(native)方法,源码如下(intel X86 处理器中的):
如源码所示,会根据当前处理器类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(Lock cmpxchg);如果是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护内部的顺序一致性,不需要 lock 前缀提供的内存屏障效果)
intel 手册对 lock 前缀的说明:
1)确保对内存的读-改-写操作原子执行。在 Pentium 及以前的处理器中,带有 lock 前缀的指令会在执行期间锁住总线,使其他处理器暂时无法通过总线访问内存,但这会带来高昂的开销。所以之后的处理器使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定可以大大降低 lock 前缀指令的执行开销。
2)禁止该指令,与之前和之后的读和写指令重排序
3)把写缓冲区中的所有数据刷新到内存
这里 2) 和 3)这两点锁具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义。
2.3.2 公平锁和非公平锁的内存语义总结
1、公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state;
2、公平锁获取时,首先会读 volatile 变量
3、非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读和写的内存语义
通过对 ReentrantLock 的分析可见,锁释放-获取的内存语义的实现至少有以下两种:
1)利用 volatile 变量的写-读所具有的内存语义
2)利用 CAS 所附带的 volatile 读和写的内存语义。
三 总结
同步实现中使用的 volatile 关键字和锁,在本文中详细描述了他们的内存语义。包括 ReentrantLock 的源码分析,和 CAS 原子性实现原理。
下一篇中,将分析 final 域的内存语义、happens-before 规则,以及双重检查锁定与延迟初始化,并总结 Java 内存模型。
版权声明: 本文为 InfoQ 作者【程序员架构进阶】的原创文章。
原文链接:【http://xie.infoq.cn/article/f773070e8597991e13e942d03】。文章转载请联系作者。
评论