JVM相关及其拓展(五) -- Java内存模型

Java内存模型

屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到的一致的内存访问效果。

主要目标:

定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。变量包括了实例字段、静态字段和构成对象的元素,但不包括局部变量和方法参数(他们为线程私有,不被共享)。

Java内存模型

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Work Memory)。工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)必须在工作内存中进行,不能直接读取主内存的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间传递变量均需通过主内存完成。

线程-主内存-工作内存的交互关系

主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

这里更适合把主内存/工作内存理解成一种并发访问规则模型,而不是JVM内部真实存在的两块物理内存分区:

  • 主内存强调的是共享变量最终一致性所在的位置。
  • 工作内存强调的是线程对共享变量进行读取、缓存、计算时所依赖的本地视图。
  • 它和CPU缓存、寄存器、编译器优化之间存在映射关系,但并不是简单的一一对应关系。

所以JMM关注的重点不是“内存到底长什么样”,而是“多个线程访问共享变量时,结果应该遵守什么规则”。

内存间交互操作

关于主内存与工作内存具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM中定义了8种操作来完成.

每种操作都是原子、不可再分

类型 说明
Lock(锁定) 作用于主内存的变量,把一个变量表示为一条线程独占的状态
Unlock(解锁) 作用于主内存的变量,把一个锁定状态的变量释放出来
Read(读取) 作用于主内存的变量,一个变量值从主内存传输到线程的工作内存中
Load(载入) 作用于工作内存的变量,从read操作中得到的值放入工作内存的变量副本中
Use(使用) 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行
Assign(赋值) 作用于工作内存的变量,把接收到的值赋值给工作内存中的变量,遇到需要赋值的情况会执行
Store(存储) 作用于工作内存的变量,把工作内存中的变量值传到主内存中
Write(写入) 作用于主内存的变量,把store操作中得到的工作内存中的变量的值放入主内存的变量中

如果要把一个变量从主内存复制到工作内存,就要顺序执行readload操作,如果要从工作内存同步回主内存,就要顺序的执行storewrite操作。

这 8 种操作更像是JMM用来描述共享变量同步语义的一组抽象约束,并不意味着开发者在业务代码里会直接去调用这些动作。

  • 开发者通常是通过volatilesynchronizedLockfinal等更高层的语言机制间接触发这些约束。
  • JMM关心的是这些操作之间的合法组合和先后关系,从而保证并发语义正确。

因此这一节的重点不在“背 8 个动作名字”,而在理解:线程之间对共享变量的读写为什么必须经过一套受约束的同步规则。

原子性、可见性和有序性

Java内存模型就是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。这也是并发编程的三大概念。

这三个概念分别解决的是不同层面的问题:

  • 原子性:一个操作会不会被拆开、打断。
  • 可见性:一个线程对共享变量的修改,另一个线程能不能及时看到。
  • 有序性:程序执行时,操作顺序会不会因为重排序而破坏预期。

它们之间不能相互替代:看得见,不代表操作不可分;执行顺序没乱,也不代表其他线程一定能看到最新值。

原子性(Atomicity)

对基本数据类型的读取和赋值都是原子操作,所谓原子性操作就代指这些操作是不可中断的,要么做完,要么就不执行。

Java内存模型只保证了基本读取和赋值是原子操作。如果要实现更大范围操作的原子性,就需要通过synchronizedlock实现。

Java中的原子操作包括:

  • 除long和double之外的基本数据类型赋值操作 long和double占用的字节数是8即64bit,在32位操作系统上去读写数据需要两步完成,每一步取32位数据。需要添加volatile关键字保证
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.* 包下所有类的操作

可见性(Visibility)

当一个线程修改了共享变量的值,其他线程能够立即得知这个值的修改。

Java内存模型通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性。

当一个共享变量被volatile修饰时,他会保证修改的值会立即更新到主内存,当有其他线程需要读取时,会立即从主内存中读取新值。

通过synchronizedlock也可保证可见性,保证同一时刻只有一个线程获取锁然后执行代码,并且在释放锁之前会将变量的修改刷新到主内存中,保证可见性。

拓展:final也可以实现可见性,final修饰字段一旦初始化完成,在其他线程中就可以看到fianl的值。

这里很适合再引出一个更贴近实际开发的概念:安全发布(safe publication)。所谓安全发布,就是一个对象在构造完成后,能够以正确的可见性语义被其他线程看到,而不是让其他线程读到一个“看起来已经拿到了引用,但内部状态其实还没准备好”的对象。

很多并发问题表面上看像“线程安全”,本质上其实是“对象没有被安全发布”。

final字段之所以重要,就是因为它在JMM里有一层特殊语义:如果对象在构造函数中被正确初始化,并且构造过程没有发生this逃逸,那么其他线程在看到这个对象引用之后,也能可靠地看到final字段在构造函数中写入的值。这也是为什么不可变对象在并发环境下通常更容易安全使用。

反过来说,如果在构造函数尚未执行完成前就把this暴露给其他线程,例如把自己注册到某个全局回调、启动线程、发布到共享容器,那么就可能发生this逃逸。此时其他线程即使拿到了对象引用,也可能看到一个尚未完成初始化的对象状态。

有序性(Ordering)

程序执行的顺序按照代码的先后顺序执行

Java内存模型允许编译器和处理器对指令进行重排序,但是规定了as-if-serial(不管怎么重排序,程序的执行结果不能改变)。

指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

volatile本身包含了禁止指令重排序的语义,而synchronized通过一个变量在同一个时刻只允许一条线程对其lock操作实现有序性。

补充理解:

  • volatile主要保证可见性,并通过内存屏障约束部分重排序。
  • volatile并不能保证复合操作的原子性,例如i++依然不是线程安全的。
  • synchronizedLock通常能同时提供更完整的原子性、可见性和有序性保证,只是代价和使用方式不同。

这里也可以顺手区分两个容易混淆的概念:

  • as-if-serial主要约束的是单线程语义:怎么优化、怎么重排都不能改变单线程下的执行结果。
  • happens-before主要约束的是多线程可见性与顺序推导:一个线程的结果在什么条件下对另一个线程可见。

所以前者更像“单线程世界里怎么优化都行”,后者更像“多线程世界里哪些先后关系是有保证的”。

如果把有序性问题写成更直观的代码场景,通常会更容易理解:

1
2
3
4
5
6
7
8
9
10
11
int value = 0;
boolean ready = false;

// 线程A
value = 42;
ready = true;

// 线程B
if (ready) {
System.out.println(value);
}

在单线程视角下,这段代码看起来理所当然会打印42。但如果没有任何同步手段,线程A中的写入顺序和线程B中的读取可见性都不受JMM保障,线程B即使看到ready == true,也不一定就能可靠看到value == 42

这类问题的本质,既可能来自可见性不足,也可能来自重排序与缺乏顺序约束。

要想并发程序正确的执行,必须要保证原子性、可见性和有序性,只要有一个没有被保证,就有可能导致程序运行不正确。

先行发生原则(happens-before)

JMM具备一些先天的有序性不需要通过任何手段就可以保证有序性,称之为先行发生原则。如果两个操作的执行次序无法从先行发生原则推导出来,他们之间就没有顺序性保障,就不能保证有序性。

happens-before并不单纯等于“时间上谁先执行”,它表达的是一种结果可见性 + 顺序性约束:

  • 如果A happens-before B,那么A的执行结果对B是可见的,并且A在语义上先于B。
  • 如果两个操作之间不存在happens-before关系,就不能仅凭“代码写在前面”就断定另一个线程一定能看到结果。

例如线程A先写共享变量,线程B后读共享变量,如果两者之间没有通过volatile、锁、线程启动/终止等规则建立happens-before关系,那么B就不一定能读到A写入后的最新值。

真正使用happens-before时,重点不只是记住规则本身,而是学会组合推导。例如:

  • 线程A在退出synchronized块前写入共享变量
  • 线程B之后进入同一把锁保护的synchronized

就可以通过“解锁先行于后续对同一把锁的加锁”推导出:A在释放锁前的写入,对B进入锁后的读取是可见的。

再比如:

  • 线程A先写普通变量,再写一个volatile标志位
  • 线程B先读取到这个volatile标志位,再去读取普通变量

就可以通过volatile写-读规则推导出:A在写标志位之前的结果,对B在看到标志位后的读取是可见的。

这也是为什么happens-before更像一种“推理规则”,而不仅仅是一张需要死记硬背的表。

主要有以下规则:

  • 程序次序规则:写在前面的代码先行发生于写在后面的(按照控制流顺序而不是代码顺序)
  • 管程锁定规则:一个解锁操作先行于时间后面发生的同一个线程的加锁操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于读操作
  • 线程启动规则:Thread对象的start()优先于该线程的任意操作
  • 传递性:如果操作A早于B,B又早于C,则A早于C
  • 线程中断规则:线程interrupt()调用早于该线程的中断检测。Thread.interrupted()
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。Thread.join()或者Thread.isAlive()
  • 对象终结规则:一个对象的初始化完成早于finalize()

这些规则如果继续往真实代码里落,可以顺手补一个经典案例:双重检查锁(DCL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
private static volatile Singleton instance;

static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

如果这里没有volatile,那么“分配对象内存 -> 初始化对象 -> 把引用赋给instance”这几个步骤就可能发生重排序,导致其他线程读到一个“引用已经非空,但对象还没有初始化完成”的实例。

而加上volatile之后,就为对象发布建立了更可靠的顺序约束,使得“对象初始化完成”不会被重排到“引用对外可见”之后。

所以DCL本质上并不是单纯依赖synchronized,而是同时依赖了JMM中关于volatile可见性与有序性的保证。

JMM不保证什么

为了避免把JMM理解得过于乐观,也可以反过来记住它不保证的部分:

  • 没有同步手段时,不保证一个线程写入的结果能被另一个线程及时看到
  • 没有顺序约束时,不保证跨线程按“代码书写顺序”推断执行结果
  • 不保证复合操作天然具备原子性
  • 不保证多个字段之间的一致性关系会自动成立

所以在工程实践里,一个更稳妥的思路通常是:

  • 优先使用更高层的并发工具,而不是手写共享变量协议
  • 尽量减少共享可变状态
  • 能设计成不可变对象,就尽量不要把对象设计成多线程下反复变更的共享状态

JMM相关讨论


JVM相关及其拓展(五) -- Java内存模型
https://leo-wxy.github.io/2018/05/09/JVM相关及其拓展-五/
作者
Leo-Wxy
发布于
2018年5月9日
许可协议