「跬步千里」详解 Java 内存模型与原子性、可见性、有序性
文题 “跬步千里” 主要是为了凸显这篇文章的基础性与重要性(狗头),并发编程这块的知识也确实主要围绕着 JMM 和三大性质来展开。
全文脉络如下:
1)为什么要学习并发编程?
2)为什么需要并发编程?
3)介绍 Java 内存模型
4)详解 Java 内存模型的三大性质(原子性、可见性、有序性),这也是判断线程安全的三个重要指标。以原子性为例,大致行文逻辑如下:
- 什么是原子性
- 不满足原子性会导致什么问题
- 如何保证原子性
为什么要学习并发编程
对于 “我们为什么要学习并发编程?” 这个问题,就好比 “我们为什么要学习政治?” 一样,我们(至少作为学生党是这样)平常很少接触到,然后背了一堆 “正确且伟大无比的废话”,最终沦为八股被快速遗忘。
直到我开始去深入了解这块知识而不是盲目背诵的时候,我才明白,它正确且伟大无比,但不是废话。
尽管并发编程的各种底层原理以及其庞大的知识体系容易让人心生畏惧,但是 Java 语言和 Java 虚拟机都提供了相当多的并发工具,替我们隐藏了很多的线程并发细节,使得我们在编码时能更关注业务逻辑,把并发编程的门槛降低了不少。
但是无论语言、中间件和框架再如何先进,我们都不应该完全依赖于它们完成并发处理的所有事情,了解并发的内幕并学习其中的思想,仍然是成为一个高级程序员的必经之路。
我想,上面这段话大概可以回答 “我们为什么要学习并发编程?” 这个问题了。
为什么需要并发编程
不知道各位有没有听说过被誉为计算机第一定律的摩尔定律,它是英特尔创始人之一戈登 · 摩尔长期观察总结出来的经验,虽然不是严格推导出来的真理,但最起码迄今为止仍然是令人深信不疑的。其核心内容通俗来说就是 处理器的性能每隔两年就会翻一倍。看起来像个废话(狗头)。
而事实上,当今多核 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、网络通信或者数据库访问等。
为此,我们就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。
另外,除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景。
从物理机中得到启发
事实上,物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义,因此,我们有必要学习下物理机中处理问题的方法。
上文说过可以使用并发编程来充分利用 CPU 的资源,其中一个主要原因就是计算机的存储设备与 CPU 的运算速度有着几个数量级的差距,这样 CPU 不得不花费大量的时间去等待其他资源。
这是软件层面,而在硬件层面上,现代计算机系统都会在内存与 CPU 之间加入一层或多层读写速度尽可能接近 CPU 运算速度的高速缓存来作为缓冲。
将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
为此,这不可避免的带来了一个新的问题:缓存一致性(Cache Coherence)。
就是说当多个 CPU 的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?
为了解决一致性的问题,需要各个 CPU 访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。于是,我们引出了内存模型的概念。
在物理机层面,内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
显然,不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也拥有自己的内存模型,称为 Java 内存模型(Java Memory Model,JMM),其目的就是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
当然了,JMM 与这里我们介绍的物理机的内存模型具有高度的可类比性。
Java 内存模型
JMM 规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。
线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
此处的主内存可以与前面所说的物理机的主内存类比,当然,实际上它仅是虚拟机内存的一部分,工作内存可与前面讲的高速缓存类比。
《Java 并发编程的艺术》中把 “工作内存” 称为 “本地内存”(Local Memory)。 “工作内存” 是《深入理解 Java 虚拟机 - 第 3 版》这本书中的写法。
多提一嘴,这里的变量其实和我们日常编程中所说的变量不一样,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后面这俩是线程私有的,不会被共享,自然就不会存在竞争问题。各位知道就好,不必太过深究。
原子性
什么是原子性
类比物理机,拥有缓存一致性协议来规定主内存和高速缓存之间的操作逻辑,那么 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)。
可见性
什么是可见性
回到物理机,前文说过,由于引入了高速缓存,不可避免的带来了一个新的问题:缓存一致性。而同样的,这个问题在 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
关键字修饰共享变量,但除了这个,容易被大家忽略的是,其实 sunchronized
和 final
这俩关键字也能保证可见性。
上面我提过一嘴,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行 8 种基本原子操作时必须满足的一系列规则,这其中有一条规则正是 sychronized
能够保证原子性的理论支撑,如下:
- 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)
也就是说 synchronized
在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。
至于 final
关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final
修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final
字段的值。
有序性
什么是有序性
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 语义的保证,单线程的有序性也就得到保障了。
Happens-before 原则
Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。为了知识体系的完整性,这里简单提一下,后续文章会详细解释的。
如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写 Java 并发代码的时候并没有察觉到这一点,这就归功于 “先行发生”(Happens-Before)原则。
依赖这个原则,我们可以通过几条简单规则快速解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。
References
- 《Java 并发编程的艺术》
- 《深入理解 Java 虚拟机 - 第 3 版》
🎉 关注公众号 | 飞天小牛肉,即时获取更新
- 博主东南大学硕士在读,携程 Java 后台开发暑期实习生,利用课余时间运营一个公众号『 飞天小牛肉 』,2020/12/29 日开通,专注分享计算机基础(数据结构 + 算法 + 计算机网络 + 数据库 + 操作系统 + Linux)、Java 技术栈等相关原创技术好文。本公众号的目的就是让大家可以快速掌握重点知识,有的放矢。关注公众号第一时间获取文章更新,成长的路上我们一起进步
- 并推荐个人维护的开源教程类项目: CS-Wiki(Gitee 推荐项目,现已累计 1.6k+ star), 致力打造完善的后端知识体系,在技术的路上少走弯路,欢迎各位小伙伴前来交流学习 ~ 😊
- 如果各位小伙伴春招秋招没有拿得出手的项目的话,可以参考我写的一个项目「开源社区系统 Echo」Gitee 官方推荐项目,目前已累计 700+ star,基于 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 并提供详细的开发文档和配套教程。公众号后台回复 Echo 可以获取配套教程,目前尚在更新中。