# 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 BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; 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. 操作 1,2 重排序: flag 先被设为 true 导致操作 3 通过,从而拿到 a = 0
  2. 操作 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