你真的理解JMM(Java内存模型)吗?

37 阅读6分钟

你是如何理解JMM的?你是否真的明白计算机世界的原子性、可见性、有序性?

或许你早就知道volatile、MESI、JMM,但是不知道他们的关系,那欢迎阅读本文。

前置知识

JMM的核心就是如何处理原子性、可见性、有序性。

原子性

这个概念大家应该都比较熟悉了,基本数据类型的访问、读写一般都是具备原子性的(但是long等类型可能非原子,例如32位系统,读取long这个64位的值可能会有非原子的情况)。

对于其他的更大范围的原子操作,应用层上我们可以使用synchronized等,对应到字节码就是monitorenter这种指令。

可见性

对于多核心CPU,每个CPU都有自己的缓存。对应到Java线程,每个线程都有自己的工作内存,多线程对同一个变量进行操作,就会涉及缓存一致性的内容,这里简单介绍一下MESI协议

  • Modify: 被修改的。处于这一状态的数据只在本CPU中有缓存,且其数据已被修改,没有更新到内存中
  • Exclusive: 独占的。处于这一状态的数据只在本CPU中有缓存,且其数据没有被修改,与内存一致
  • S: 共享的。处于这一状态的数据在多个CPU中有缓存
  • Invaild: 无效的。本CPU中的这份缓存已经无效了

简单来说,如果遵循MESI,线程A要改变变量的值前要通知其他线程,把自己的修改刷新回内存,并把自己手上的副本失效(变成I),然后线程A就可以进行修改。可以看见,MESI实际上是一个强一致性协议,如果计算机完全遵循MESI是不会发生缓存不一致的,但是严格按照MESI来,效率就会很低,所以现代CPU会采取异步的优化手段,保证最终一致性,这就是可见性产生的原因(例如volatile变量就可以理解为强制让CPU对该变量的修改遵循MESI)。

有序性

我们都知道cpu执行的时候,可能会让指令重排来优化。这里有一个核心的点是,一个线程进行指令重排之后,他的最终结果是一样的,例如:

public class ReorderExample {
    int a = 0;
    boolean flag = false;

    // 线程 A 执行此方法
    public void writer() {
        a = 1;           // 步骤 1
        flag = true;     // 步骤 2

        if(a==1){		// 判断点A
            flag=false;
        }
    }

    // 线程 B 执行此方法
    public void reader() {
        if (flag) {      // 步骤 3
            int i = a * a; // 步骤 4
            System.out.println(i);
        }
    }
}

对于线程A来说,就算发生指令重排,步骤1不可能在判断点A之后发生,最终的结果一定是a=1,flag=false,也就是说对于同一个线程,不存在有序性的问题。产生有序性的问题来源于其他线程对该线程的观察,例如对于线程B,多次执行的结果可能就不一样。

保证有序性也很简单,上个锁让线程按顺序访问,或者使用volatile关键字防止指令的重排。

JMM具体规范

JMM原子操作规范与Volatile

首先JMM定义了8个原子操作,理解这些指令的关键是区分他们的作用对象。

  • 作用于主内存变量的:lock、unlock、read、write
  • 作用于工作内存变量的:load、use(使用,传递工作变量给执行引擎)、assign(赋值,接收执行引擎的返回值赋给工作变量)、store

例如把一个变量从主内存读取到工作内存,要先read,然后load;写回要先store,然后write。同时定义了这些操作的使用规范,这些规范有些乱、麻烦,就不全展示了,例如:

1)线程不允许丢弃assign操作,工作内存发生改变就一定要同步回主内存

2)不允许在工作内存初始化变量,也就是说对一个变量执行use、store操作之前,必须先执行assign和load操作

3)同理对于volatile变量V,assign和store、write动作相关联(保证修改V一定要立即更新到主内存)

4)线程 T 对 volatile 变量 V 执行的 use/assign 动作 A,若先于该线程对volatile变量W 执行的 use/assign 动作 B,则与 A 关联的 V 的 read/write 动作 P,必须先于与 B 关联的 W 的 read/write 动作 Q。(也就是要求volatile变量不会被指令重排)

这里还要特别说明的是,JMM是规范,上述的这些“操作”并不一定存在具体的命令,只是概念模型而不是实现,例如我们上面提到的read、load这种,其实对应的可能是缓存一致性协议;lock/unlock可能对应的是Lock前缀的指令。这里顺便说一下volatile的实现原理:

就以双锁检测单例模式为例吧,代码如下:

public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

翻译过来的字节码中,在赋值操作的后面多了一个命令lock addl$0x0,(%esp),这个操作是一个空的操作,不会改变任何值,但是lock前缀的作用是将该核心的缓存写回内存,让其他的核心的缓存失效(也就是刚刚提到的MESI协议,保证可见性

同时我们之前说过,有序性实际上是防止其他线程观测到打乱后的指令,这里的lock addl$0x0,(%esp)也能够意味着“前面的修改都已经完成”,就像之前说的“步骤1不可能在判断点A之后发生”,这里的lock前缀让赋值操作成为了“判断点A,保证有序性

happen before

happen before原则是上述操作原则的等效规范,而且其实我们已经提前看到过这个案例的,还记得刚刚的那个案例中,我们说过“步骤1一定在判断点A之前发生”,这就是happen before中最重要的原则程序次序规则

也就是说,happen before实际上定义的是天然的有序性,不管指令如何打乱,Java语言一定保证在这些规则下的有序性,例如:

  • 管程锁定规则:lock操作发生于unlock操作之前
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

可以看到,这些规则实际上都非常普通,非常符合常理,毕竟指令重排并不是一个”符合常理的事情“。

参考

《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》,非常推荐大家阅读这本书。