浅析 Java 内存模型
文章转载于 飞天小牛肉 的 《「跬步千里」详解 Java 内存模型与原子性、可见性、有序性》、《JMM 最最最核心的概念:Happens-before 原则》
1. 为什么要学习并发编程
对于 “我们为什么要学习并发编程?” 这个问题,就好比 “我们为什么要学习政治?” 一样,也许你平常很少接触到,然后背了一堆 “正确且伟大无比的废话”,最终沦为八股被快速遗忘。
直到我开始去深入了解这块知识而不是盲目背诵的时候,我才明白,它正确且伟大无比,但不是废话。
尽管并发编程的各种底层原理以及其庞大的知识体系容易让人心生畏惧,但是 Java 语言和 Java 虚拟机都提供了相当多的并发工具,替我们隐藏了很多的线程并发细节,使得我们在编码时能更关注业务逻辑,把并发编程的门槛降低了不少。
但是无论语言、中间件和框架再如何先进,我们都不应该完全依赖于它们完成并发处理的所有事情,了解并发的内幕并学习其中的思想,仍然是成为一个高级程序员的必经之路。
我想,上面这段话大概可以回答 “我们为什么要学习并发编程?” 这个问题了。
2. 为什么需要并发编程
不知道各位有没有听说过被誉为计算机第一定律的摩尔定律,它是英特尔创始人之一戈登 · 摩尔长期观察总结出来的经验,虽然不是严格推导出来的真理,但最起码迄今为止仍然是令人深信不疑的。其核心内容通俗来说就是 处理器的性能每隔两年就会翻一倍。看起来像个废话🐶。
而事实上,当今多核 CPU 的发展速度也确实正在支撑着摩尔定律的有效性。在时代的大背景下,并发编程已成燎原之势,通过并发编程的形式将多核 CPU 的计算能力发挥到极致,性能得到提升。
举个例子,在当今诸神黄昏的图像处理领域,很多图像处理算法,在代码初步编写完毕并调试正确后,其实仍然需要进行一个漫长的优化过程。因为尽管有些算法的处理效果很棒,但是如果运算太过耗时,还是无法集成进产品给用户使用的。
对于一副 1000 x 800 分辨率的图像,我们最原始的处理思路就是从第 1 个像素开始,一直遍历计算到最后一个像素。那么面对如此庞大且复杂的计算量,为了提高算法的性能,最直接也最容易实现的想法就是基于多线程充分利用多核 CPU 的计算能力。
可以将整个图像分成若干块,比如我们的 CPU 是 8 核的,那么可以分成 8 块,每块图像大小为 1000 * 100 像素,我们可以创建 8 个线程,每个线程处理一个图像块,每个 CPU 分配执行一个线程。这样,运算速度将得到明显的提升。
当然了,这样操作后,运算速度并不会恐怖的提升 4 倍,因为线程创建和释放以及上下文切换都有一定的损耗。
这里摘录《Java 并发编程的艺术》书中的一段话来回答这个问题,我们为什么需要并发线程?
多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。多个 CPU 意味着每个线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。
而至于多核 CPU 盛行的原因,《深入理解 Java 虚拟机 - 第 3 版》一书中也有所涉及,这里我略作修改摘录如下:
多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,更重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大,这样 CPU 不得不花费大量的时间等待其他资源,比如磁盘 I/O、网络通信或者数据库访问等。
为此,我们就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。
另外,除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景。
3. 从物理机中得到启发
事实上,物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义,因此,我们有必要学习下物理机中处理问题的方法。
上文说过可以使用并发编程来充分利用 CPU 的资源,其中一个主要原因就是计算机的存储设备与 CPU 的运算速度有着几个数量级的差距,这样 CPU 不得不花费大量的时间去等待其他资源。
这是软件层面,而在硬件层面上,现代计算机系统都会在内存与 CPU 之间加入一层或多层读写速度尽可能接近 CPU 运算速度的高速缓存来作为缓冲。
将运算需要使用的数据复制到缓存中,让运算能快速进行,在运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
为此,这不可避免的带来了一个新的问题:缓存一致性(Cache Coherence)。
就是说当多个 CPU 的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?
为了解决一致性的问题,需要各个 CPU 访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。于是,我们引出了内存模型的概念。
在物理机层面,内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
显然,不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也拥有自己的内存模型,称为 Java 内存模型(Java Memory Model,JMM),其目的就是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
当然了,JMM 与这里我们介绍的物理机的内存模型具有高度的可类比性。
4. Java 内存模型
JMM 规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。
线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
此处的主内存可以与前面所说的物理机的主内存类比,当然,实际上它仅是虚拟机内存的一部分,工作内存可与前面讲的高速缓存类比。
《Java 并发编程的艺术》中把 “工作内存” 称为 “本地内存”(Local Memory)。“工作内存” 是《深入理解 Java 虚拟机 - 第 3 版》这本书中的写法。
多提一嘴,这里的变量其实和我们日常编程中所说的变量不一样,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后面这俩是线程私有的,不会被共享,自然就不会存在竞争问题。各位知道就好,不必太过深究。
4.1 原子性
什么是原子性
类比物理机,拥有缓存一致性协议来规定主内存和高速缓存之间的操作逻辑,那么 JMM 中主内存与工作内存之间有没有具体的交互协议呢?
Of Course!JMM 中定义了以下 8 种操作规范来完成一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现细节。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
暂时放下到底是哪 8 种操作,我们先谈何为原子?
原子(atomic)本意是 “不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为 “不可被中断的一个或一系列操作”。
举个经典的简单例子,银行转账,A 像 B 转账 100 元。转账这个操作其实包含两个离散的步骤:
- 步骤 1:A 账户减去 100
- 步骤 2:B 账户增加 100
我们要求转账这个操作是原子性的,也就是说步骤 1 和步骤 2 是顺续执行且不可被打断的,要么全部执行成功、要么执行失败。
试想一下,如果转账操作不具备原子性会导致什么问题呢?
比如说步骤 1 执行成功了,但是步骤 2 没有执行或者执行失败,就会导致 A 账户少了 100 但是 B 账户并没有相应的多出 100。
对于上述这种情况,符合原子性的转账操作应该是如果步骤 2 执行失败,那么整个转账操作就会失败,步骤 1 就会回滚,并不会将 A 账户减少 100。
OK,了解了原子性的概念后,我们再来看 JMM 定义的 8 种原子操作具体是啥,以下了解即可,没必要死记:
lock
(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。unlock
(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。read
(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。load
(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。use
(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign
(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store
(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。write
(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量
事实上,对于
double
和long
类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外,称为 “long 和 double 的非原子性协定”,不过一般不需要我们特别注意,这里就不再过多赘述了。
这 8 种操作当然不是可以随便用的,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行上述 8 种基本操作时必须满足的一系列规则。
这我就不一一列举了,多提这么一嘴的原因就是下文会涉及一些这其中的规则,为了防止大家看的时候云里雾里,所以先前说明白比较好。
上面我们举了一个转账的例子,那么,在具体的代码中,非原子性操作可能会导致什么问题呢?
看下面这段代码,各位不妨考虑一个的问题,如果两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果一定是 0 吗?
耳熟能详的问题,我们无法保证这段代码执行结果的一定性(正确性),可能是正数、也可能是负数、当然也可能是 0。
那么,我们就把这段代码称为线程不安全的,就是说在单线程环境下正常运行的一段代码,在多线程环境中可能发生各种意外情况,导致无法得到正确的结果。
从线程安全的角度来反向理解线程不安全的概念可能更容易点,这里参考《Java 并发编程实践》上面的一句话:
一段代码在被多个线程访问后,它仍然能够进行正确的行为,那这段代码就是线程安全的。
至于这段代码线程不安全的原因,就是 Java 中对静态变量自增和自减操作并不是原子操作,它俩其实都包含三个离散的操作:
- 步骤 1:读取当前 i 的值
- 步骤 2:将 i 的值加 1(减 1)
- 步骤 3:写回新值
可以看出来这是一个 读 - 改 - 写 的操作。
以 i++
操作为例,我们来看看它对应的字节码指令:
上方这段代码对应的字节码是这样的:
简单解释下这些字节码指令的含义:
getstatic i
:获取静态变量 i 的值iconst_1
:准备常量 1iadd
:自增(自减操作对应 isub)putstatic i
:将修改后的值存入静态变量 i
如果是在单线程的环境下,先自增 5000 次,然后再自减 5000 次,那当然不会发生任何问题。
但是在多线程的环境下,由于 CPU 时间片调度的原因,可能 Thread1 正在执行自增操作着呢,CPU 剥夺了它的资源占用,转而分配给了 Thread2,也就是发生了线程上下文切换。这样,就可能导致本该是一个连续的读改写动作(连续执行的三个步骤)被打断了。
下图出现的就是结果最终是负数的情况:
总结来说,如果多个 CPU 同时对某个共享变量进行读-改-写操作,那么这个共享变量就会被多个 CPU 同时处理,由于 CPU 时间片调度等原因,某个线程的读-改-写操作可能会被其他线程打断,导致操作完后共享变量的值和我们期望的不一致。
另外,多说一嘴,除了自增自减,我们常见的 i = j
这个操作也是非原子性的,它分为两个离散的步骤:
- 步骤 1:读取 j 的值
- 步骤 2:将 j 的值赋给 i
如何保证原子性
那么,如何实现原子操作,也就是如何保证原子性呢?
对于这个问题,其实在处理器和 Java 编程语言层面,它们都提供了一些有效的措施,比如处理器提供了总线锁和缓存锁,Java 提供了锁和循环 CAS 的方式,这里我们简单解释下 Java 保证原子性的措施。
由 Java 内存模型来直接保证的原子性变量操作包括read
、load
、assign
、use
、store
和 write
这 6 个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,各位只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。
如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock
和unlock
操作来满足这种需求。
尽管 JVM 并没有把 lock
和 unlock
操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter
和 monitorexit
来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 synchronized
关键字,因此在 synchronized
块之间的操作也具备原子性。
而除了 synchronized
关键字这种 Java 语言层面的锁,juc 并发包中的 java.util.concurrent.locks.Lock 接口也提供了一些类库层面的锁,比如ReentrantLock
。
另外,随着硬件指令集的发展,在 JDK 5 之后,Java 类库中开始使用基于 cmpxchg 指令的 CAS 操作(又来一个重点),该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()
和 compareAndSwapLong()
等几个方法包装提供。不过在 JDK 9 之前 Unsafe
类是不开放给用户使用的,只有 Java 类库可以使用,譬如 juc 包里面的整数原子类,其中的 compareAndSet()
和 getAndIncrement()
等方法都使用了 Unsafe
类的 CAS 操作来实现。
使用这种 CAS 措施的代码也常被称为无锁编程(Lock-Free)。
4.2 可见性
什么是可见性
回到物理机,前文说过,由于引入了高速缓存,不可避免的带来了一个新的问题:缓存一致性。而同样的,这个问题在 Java 虚拟机中同样存在,表现为工作内存与主内存的同步延迟,也就是内存可见性问题。
何为可见性?就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
回顾下 Java 内存模型:
从上图来看,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:
- 1)线程 A 把工作内存 A 中更新过的共享变量刷新到主内存中去
- 2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
也就是说,线程 A 在向线程 B 的通信过程必须要经过主内存。
那么,这就可能出现一个问题,举个简单的例子,看下面这段代码:
// 线程 1 执行的代码
int i = 0;
i = 1;
// 线程 2 执行的代码
j = i;
当线程 1 执行 i = 1 这句时,会先去主内存中读取 i 的初始值,然后加载到线程 1 的的工作内存中,再赋值为1,至此,线程 1 的工作内存当中 i 的值变为 1 了,不过还没有写入到主内存当中。
如果在线程 1 准备把新的 i 值写回主内存的时候,线程 2 执行了 j = i 这条语句,它会去主存读取 i 的值并加载到线程 2 的工作内存当中,而此时主内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 1。
这就是内存可见性问题,线程 1 修改了共享变量 i 的值,线程 2 并没有立即得知这个修改。
如何保证可见性
各位可能脱口而出使用 volatile
关键字修饰共享变量,但除了这个,容易被大家忽略的是,其实 synchronized
和 final
这俩关键字也能保证可见性。
上面我提过一嘴,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行 8 种基本原子操作时必须满足的一系列规则,这其中有一条规则正是 sychronized
能够保证原子性的理论支撑,如下:
- 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)
也就是说 synchronized
在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。
至于 final
关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final
修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final
字段的值。
4.3 有序性
什么是有序性
OK,说完了可见性,我们再回到物理机,其实除了增加高速缓存之外,为了使 CPU 内部的运算单元能尽量被充分利用,CPU 可能会对输入代码进行乱序执行优化,CPU 会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
与之类似的,Java 的编译器也有这样的一种优化手段:指令重排序(Instruction Reorder)。
那么,既然能够优化性能,重排序可以没有限制的被使用吗?
当然不,在重排序的时候,CPU 和编译器都需要遵守一个规矩,这个规矩就是 as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。
为了遵守 as-if-serial 语义,CPU 和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
那么这里,我们又引出了 “数据依赖性” 的概念。
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖性分为三种类型:写后读、写后写、读后写,看下图:
上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
其实考虑数据依赖关系的时候,各位可以通过画图来直观的判断。举个例子:
int a = 1; // A
int b = 2; // B
int sum = a + b; // C
上面 3 个操作的数据依赖关系如下图所示:
可以看出,A 和 C、B 和 C 之间存在数据依赖关系,因此在最终执行的指令序列中,C 不能被重排序到 A 或 B 的前面。但 A 和 B 之间没有数据依赖关系,所以 CPU 和处理器可以重排序 A 和 B 之间的执行顺序。如下是程序的两种执行顺序:
看起来好像没啥问题,重排序之后程序的结果并没有发生改变,还提升了性能。
然而,很不幸的是,我们这里所说的数据依赖性仅针对单个 CPU 中执行的指令序列和单个线程中执行的操作,不同 CPU 之间和不同线程之间的数据依赖性是不被 CPU 和编译器考虑的。
这就是为啥我在写 as-if-serial 语义的时候把 “单线程” 加粗的目的了。
看下面这段代码:
假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 把共享变量 a 修改成了 1 呢?
答案是不一定。
由于操作 1 和操作 2 没有数据依赖关系,CPU 和编译器可以对这两个操作重排序;同样的,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
以操作 1 和操作 2 重排序为例,可能会产生什么效果呢?
如上图右边所示,程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,因此线程 B 读到的 a 值仍然是 0。也就是说在这里多线程程序的语义被重排序破坏了。
这样,我们可以得出结论:CPU 和 Java 编译器为了优化程序性能,会自发地对指令序列进行重新排序。在多线程的环境下,由于重排序的存在,就可能导致程序运行结果出现错误。
了解了重排序的概念,我们可以这样总结下 Java 程序天然的有序性:
- 如果在本线程内观察,所有的操作都是有序的(简单来说就是线程内表现为串行)
- 如果在一个线程中观察另一个线程,所有的操作都是无序的(这个无序主要就是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象)
如何保证有序性
Java 语言提供了 volatile
和 synchronized
两个关键字来保证线程之间操作的有序性。
volatile
本身除了保证可见性的语义外,还包含了禁止指令重排序的语义,所以天生就具有保证有序性的功能。
而 synchronized
保证有序性的理论支撑,仍然是 JMM 规定在执行 8 种基本原子操作时必须满足的一系列规则中的某一个提供的:
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作
这个规则决定了持有同一个锁的两个 synchronized
同步块只能串行地进入。
不是很难理解吧,通俗来说,synchronized
通过排他锁的方式保证了同一时间内,被 synchronized
修饰的代码是单线程执行的。所以,这就满足了 as-if-serial 语义的一个关键前提,那就是单线程,这样,有了 as-if-serial 语义的保证,单线程的有序性也就得到保障了。
5. Happens-before 原则
Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。
如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写 Java 并发代码的时候并没有察觉到这一点,这就归功于 “先行发生”(Happens-Before)原则。依赖这个原则,我们可以通过几条简单规则快速解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。
关于 Happens-before,《Java 并发编程的艺术》书中是这样介绍的:
Happens-before 是 JMM 最核心的概念。对应 Java 程序员来说,理解 Happens-before 是理解 JMM 的关键。
《深入理解 Java 虚拟机 - 第 3 版》书中是这样介绍的:
Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。
我想,这两句话就已经足够表明 Happens-before 原则的重要性。那为什么 Happens-before 被不约而同的称为 JMM 的核心和灵魂呢?生来如此。
5.1 JMM 设计者的难题与完美的解决方案
前面我们学习了 JMM 及其三大性质,事实上,从 JMM 设计者的角度来看,可见性和有序性其实是互相矛盾的两点:
- 一方面,对于程序员来说,我们希望内存模型易于理解、易于编程,为此 JMM 的设计者要为程序员提供足够强的内存可见性保证,专业术语称之为 “强内存模型”
- 而另一方面,编译器和处理器则希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(比如重排序)来提高性能,因此 JMM 的设计者对编译器和处理器的限制要尽可能地放松,专业术语称之为 “弱内存模型”
对于这个问题,从 JDK 5 开始,也就是在 JSR-133 内存模型中,终于给出了一套完美的解决方案,那就是 Happens-before 原则,Happens-before 直译为 “先行发生”,《JSR-133:Java Memory Model and Thread Specification》对 Happens-before 关系的定义如下:
1)如果一个操作 Happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在 Happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 Happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)
并不难理解,第 1 条定义是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证!
需要注意的是,不同于 as-if-serial 语义只能作用在单线程,这里提到的两个操作 A 和 B 既可以是在一个线程之内,也可以是在不同线程之间。也就是说,Happens-before 提供跨线程的内存可见性保证。
针对这个第 1 条定义,我来举个例子:
// 以下操作在线程 A 中执行
i = 1; // a
// 以下操作在线程 B 中执行
j = i; // b
// 以下操作在线程 C 中执行
i = 2; // c
假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。
得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。
现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。
再来看 Happens-before 的第 2 条定义,这是 JMM 对编译器和处理器弱内存模型的保证,在给予充分的可操作空间下,对编译器和处理器的重排序进行一定的约束。也就是说,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是执行结果不能被改变。
文字可能不是很好理解,我们举个例子,来解释下第 2 条定义:虽然两个操作之间存在 Happens-before 关系,但不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。
int a = 1; // A
int b = 2; // B
int c = a + b; // C
根据 Happens-before 规则(下文会讲),上述代码存在 3 个 Happens-before 关系:
1)A Happens-before B
2)B Happens-before C
3)A Happens-before C
可以看出来,在 3 个 Happens-before 关系中,第 2 个和第 3 个是必需的,但第 1 个是不必要的。
也就是说,虽然 A Happens-before B,但是 A 和 B 之间的重排序完全不会改变程序的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。
看下面这张 JMM 的设计图更直观:
图片来源《Java 并发编程的艺术》
其实,可以这么简单的理解,为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则,这样,我们只需要弄明白 Happens-before 就行了。
图片来源《Java 并发编程的艺术》
5.2 八条 Happens-before 规则
《JSR-133:Java Memory Model and Thread Specification》定义了如下 Happens-before 规则, 这些就是 JMM 中“天然的”Happens-before 关系,这些 Happens-before 关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,JVM 可以对它们随意地进行重排序。
1)程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
这个很好理解,符合我们的逻辑思维。比如我们上面举的例子:
int a = 1; // A
int b = 2; // B
int c = a + b; // C
根据程序次序规则,上述代码存在 3 个 Happens-before 关系:
- A Happens-before B
- B Happens-before C
- A Happens-before C
2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。
这个规则其实就是针对 synchronized 的。JVM 并没有把 lock
和 unlock
操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter
和 monitorexit
来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized
。
举个例子:
synchronized (this) { // 此处自动加锁
if (x < 1) {
x = 1;
}
} // 此处自动解锁
根据管程锁定规则,假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 1,执行完自动释放锁,线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x == 1。
3)volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后。
这个规则就是 JDK 1.5 版本对 volatile 语义的增强,其意义之重大,靠着这个规则搞定可见性易如反掌。
举个例子:
假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。
根据根据程序次序规则:1 Happens-before 2;3 Happens-before 4。
根据 volatile 变量规则:2 Happens-before 3。
根据传递性规则:1 Happens-before 3;1 Happens-before 4。
也就是说,如果线程 B 读到了 “flag==true” 或者 “int i = a” 那么线程 A 设置的“a=42”对线程 B 是可见的。
看下图:
4)线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
比如说主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的所有操作。
5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法检测是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。
6)线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupted() 方法检测到是否有中断发生。
7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
8)传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
5.3“时间上的先发生” 与 “先行发生”
上述 8 种规则中,还不断提到了时间上的先后,那么,“时间上的先发生” 与 “先行发生(Happens-before)” 到底有啥区别?
一个操作 “时间上的先发生” 是否就代表这个操作会是“先行发生” 呢?一个操作 “先行发生” 是否就能推导出这个操作必定是“时间上的先发生”呢?
很遗憾,这两个推论都是不成立的。
举两个例子论证一下:
private int value = 0;
// 线程 A 调用
pubilc void setValue(int value){
this.value = value;
}
// 线程 B 调用
public int getValue(){
return value;
}
假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么?
我们根据上述 Happens-before 的 8 大规则依次分析一下:
由于两个方法分别由线程 A 和 B 调用,不在同一个线程中,所以程序次序规则在这里不适用;
由于没有 synchronized
同步块,自然就不会发生 lock 和 unlock 操作,所以管程锁定规则在这里不适用;
同样的,volatile
变量规则,线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。
因为没有一个适用的 Happens-before 规则,所以第 8 条规则传递性也无从谈起。
因此我们可以判定,尽管线程 A 在操作时间上来看是先于线程 B 的,但是并不能说 A Happens-before B,也就是 A 线程操作的结果 B 不一定能看到。所以,这段代码是线程不安全的。
想要修复这个问题也很简单?既然不满足 Happens-before 原则,那我修改下让它满足不就行了。比如说把 Getter/Setter 方法都用 synchronized
修饰,这样就可以套用管程锁定规则;再比如把 value 定义为 volatile
变量,这样就可以套用 volatile 变量规则等。
这个例子,就论证了一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生(Happens-before)”。
再来看一个例子:
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
假设这段代码中的两条赋值语句在同一个线程之中,那么根据程序次序规则,“int i = 1” 的操作先行发生(Happens-before)于 “int j = 2”,但是,还记得 Happens-before 的第 2 条定义吗?还记得上文说过 JMM 实际上是遵守这样的一条原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
所以,“int j=2” 这句代码完全可能优先被处理器执行,因为这并不影响程序的最终运行结果。
那么,这个例子,就论证了一个操作 “先行发生(Happens-before)” 不代表这个操作一定是“时间上的先发生”。
这样,综上两例,我们可以得出这样一个结论:Happens-before 原则与时间先后顺序之间基本没有因果关系,所以我们在衡量并发安全问题的时候,尽量不要受时间顺序的干扰,一切必须以 Happens-before 原则为准。
6. Happens-before 与 as-if-serial
综上,我觉得其实读懂了下面这句话也就读懂了 Happens-before 了,这句话上文也出现过几次:JMM 其实是在遵循一个基本原则,即只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
再回顾下 as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。
各位发现没有?本质上来说 Happens-before 关系和 as-if-serial 语义是一回事,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。只不过后者只能作用在单线程,而前者可以作用在正确同步的多线程环境下:
- as-if-serial 语义保证单线程内程序的执行结果不被改变,Happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。Happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 Happens-before 指定的顺序来执行的。