# Java 内存模型
并发编程中,线程通信一般有两种方式:共享内存和消息传递。Java 并发采用的是共享内存模型,所有实例域,静态域,数组元素都存储在堆内存中,堆内存在线程之间共享。
每个线程都有一个本地内存,其实这个本地内存是一个抽象概念,它包含了缓存,写缓冲区,寄存器, cache
等,和 volatile
关键字那部分知识类似。线程之间的共享变量存储在主内存中。
下图为 JMM 抽象结构:
JMM
通过控制主内存与每个线程的本地内存之间的交互,提供内存可见性的保证。
# 重排序
编译器和处理器常常会对指令重排序:
- 编译器优化重排序:在 Java 语言层面上重新安排语句执行顺序。
- 指令级并行重排序:将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变机器指令的执行顺序。
- 内存系统重排序
重排序有时会导致严重的内存可见性问题,比如一个引用还没有初始化就被调用
A a = new A(); | |
String s = a.toSTring(); | |
// 重排序可能导致两个语句调换 | |
// 只是一个不恰当的例子,处理器不会重排序数据依赖的操作:s 依赖 a |
JMM
通过插入特定类型的内存屏障来禁止特定类型的处理器 / 编译器重排序来保证内存可见性。
还有一个经典的例子就是:
a = b = 0; | |
// processorA | |
a = 1; | |
A = b; | |
// processorB | |
b = 2; | |
B = a; |
ab 两个变量没有使用 volatile
,即使被写入也不会立即刷新到主存中,所以可能 A 和 B 变量都读到了 0,所以尽管先执行了 a=1
,但从内存操作顺序来看,是先执行的 A=b
,这表示处理器 A 内存操作顺序被重排序了。
所以 JMM
有时会插入内存屏障来保证内存可见性,对应四种内存屏障(来自《Java 并发编程的艺术》):
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。该屏障会使屏障之前所有的内存访问(存储 / 装载)指令完成后才执行屏障后的内存访问指令。 |
StoreLoad Barriers 是一个 “全能型” 的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
我们再看一个重排序对多线程的影响的例子
// 代码改自《Java 并发编程的艺术》第 3 章 | |
class ReorderExample { | |
int a = 0; | |
boolean flag = false; | |
public void writer() { | |
a = 1; //1 | |
flag = true; //2 | |
} | |
Public void reader() { | |
if (flag) { //3 | |
int i = a; //4 | |
…… | |
} | |
} | |
} |
假设有两个线程 A 和 B,A 首先执行 writer () 方法,随后 B 线程接着执行 reader () 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。我们先不考虑任何线程可能会中断的情况,操作 1,2 没有数据依赖,操作 3,4 也没有数据依赖,他们可以被重排序:
- 操作 1,2 重排序:
flag
先被设为true
导致操作 3 通过,从而拿到a = 0
。 - 操作 3,4 重排序:操作 4 先拿到
a=0
,相当于执行tmp = 0*0
,再判断flag
从而i=tmp
。
重排序会导致多线程环境下的并发问题。
计算机科学家提出了顺序一致性模型,一种理想的理论模型,所有操作都是原子的且对其他线程立即可见,且一个线程的操作必须按照程序的顺序执行。
而 JMM
并没有提供这种理想化的保证,想要不出现并发问题,就必须对临界区加入监视器(锁):
public synchronized void writer() { | |
a = 1; //1 | |
flag = true; //2 | |
} | |
//reader () 一样的 |
JMM
对于未同步的程序,只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
上述同步代码的变量不需要加上 volatile
,因为 synchronized
也可以保证可见性:线程在加锁时,先清空本地内存→在主内存拷贝最新变量的副本到本地内存→执行完代码→刷新到主内存中→解锁。
# 重排序:volatile
之前在关键字篇提到的 volatile
关键字内容只是一部分,是关于 volatile
对于可见性的实现,在我们学习 JMM 中我们还需要学习它对有序性的实现。
JMM 采取了保守的策略来禁止重排序:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,防止上面的 volatile 写与下面可能有的 volatile 读 / 写重排序。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
例子:单例模式的双重检查
public class Singleton { | |
// 使用了 volatile | |
public static volatile Singleton singleton; | |
private Singleton() {}; | |
public static Singleton getInstance() { | |
if (singleton == null) { | |
synchronized (singleton.class) { | |
if (singleton == null) { | |
singleton = new Singleton(); | |
} | |
} | |
} | |
return singleton; | |
} | |
} |
如果没有重排序,那么 signleton
可以不加 volatile
。实例化一个对象分 3 个步骤:
- 分配内存空间、
- 初始化对象
- 内存空间地址赋给引用
但是操作系统可以重排序,所以存在可能导致重排序为:
- 分配内存空间
- 内存空间地址赋给引用
- 初始化对象
尽管可能性较低,但是当这种情况发生后,就有可能将一个未初始化的对象暴露出来,所以此处需要 volaitle
来禁止重排序。
# 重排序:final
先看一段代码
class Demo { | |
int i; | |
final int j | |
public Demo() { | |
i = 1; | |
j = 2; | |
} | |
} |
Demo
如果在使用时,没有被 volatile
修饰,那么在上文内容中,我们可以知道其他线程可能会拿到一个引用不为 null
,但是还没有完全初始化的实例化对象,导致出现错误,而这本质原因在于变量被重排序到构造函数外面了。
final
也可以禁止重排序, final
域的重排序规则为:
- 构造函数中对
final
域的写入不能和被构造函数的引用赋给一个引用变量进行重排序。
public Demo() { | |
i = 1; | |
j = 2;// 其实会在 j=2 与 return 语句间插入 StoreStore 屏障 | |
} | |
Demo d = new Demo(); // 假设实例化的地址为 Ox111111 | |
// 即 d = Ox111111 不能和 j = 2 重排序 |
- 初次读包含
final
域的对象的引用不能和初次读这个final
域重排序
int t = demo.j; // 本质是先读 demo 的地址,再读 j | |
// 本质上会在读 j 的指令前加上 LoadLoad 屏障 |
对于第二点,这两个操作是由依赖性的,编译器肯定不会重排序,大部分处理器也不会,只有小部分处理器可能会重排序。
如果 final
修饰的是一个引用:
class Demo { | |
int i; | |
final Object j | |
public Demo() { | |
i = 1; | |
j = new Object(); | |
} | |
} | |
//demo 对 AB 都可见,A 线程执行 | |
demo = new Demo(); | |
// B 线程执行 | |
if(demo != null) { | |
Object o = demo.j; | |
} |
如果说 B 线程拿到了 demo
,通过了 if
语句,此时 demo
一定被初始化了,那么 j
一定也被初始化了。 final
域如果是引用,新增了约束:
构造函数内对一个 final
引用的对象的写入不能与被构造的函数引用赋给引用变量重排序。
本质上还是保证
final
域不会重排序到构造函数之外。
# 参考
《Java 并发编程的艺术》第 3 章
https://pdai.tech/md/java/jvm/java-jvm-jmm.html
https://blog.csdn.net/guyuealian/article/details/52525724
https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html
https://pdai.tech/md/java/thread/java-thread-x-key-final.html