Java 内存模型

    2833
    最后修改于

    本文简单仅介绍 Java Memory Model,但并不会去纠结 JMM 的具体细节,也不会涉及 volatile 和 synchronized 这样的 api,而是主要讨论 JMM 对于编程者来说意味着什么。

    JMM 是什么?#

    Java 内存模型。正式一点说,内存模型描述:对于一个程序和以及一个该程序的执行路径,该执行路径对于程序是否合法。

    memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. ——JLS 8

    其原理是检查执行路径中的每个 read 和该 read 操作所能观察到的 write 操作的结果,是否符合某些特定规则。

    这...... 似乎有点太抽象了。让我们看点更抽象的。

    JMM 定义主内存和工作内存之间的交互协议#

    先让我照本宣科的来上一段。

    JMM 定义了主内存与工作内存之间的交互协议。包括如下八种操作:

    1. lock(锁定) :作用于主内存的变量,它把一个变量标识为一条 线程独占 的状态。
    2. unlock(解锁) :作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    3. read(读取) :作用于主内存的变量,它把一个变量的值 从主内存传输到线程的工作内存 中,以便 随后的 load 动作 使用。
    4. load(载入) :作用于工作内存的变量,它把 read 操作 从主内存中得到的变量值放入工作内存 的变量副本中。
    5. use(使用) :作用于工作内存的变量,它把 工作内存中一个变量的值传递给执行引擎 ,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    6. assign(赋值) :作用于工作内存的变量,它把一个 从执行引擎接收的值赋给工作内存的变量 ,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    7. store(存储) :作用于工作内存的变量,它把 工作内存中一个变量的值传送到主内存 中,以便随后的 write 操作使用。
    8. write(写入) :作用于主内存的变量,它把 store 操作 从工作内存中得到的变量的值放入主内存的变量中。

    definitely,在我多次重复浏览上面的文字时,我都是绝望的。

    这段出自 Java SE 6 的《VM Spec Threads and Locks (oracle.com)》的文字,让我再一次深刻体会到每个字都认识,但拼在一起就是读不懂的感觉。

    这里有提到主内存(Main Memory),有提到工作内存(Working Memory)。但在现版本的 JLS 中,甚至找不到这些概念。即使是在对应的 SE6 文档中,对于 部分概念的解释也(Working Memory)的解释也显得十分模糊。

    每个线程都有一个工作内存,线程在其中保存必须使用或分配的变量的副本。当线程执行程序时,它会对这些工作副本进行操作。主内存包含每个变量的主副本。关于何时允许或要求线程将其变量副本的内容传输到主副本中或从主副本传输到工作内存中,存在一些规则。

    every thread has a working memory in which it keeps its own working copy of variables that it must use or assign. As the thread executes a program, it operates on these working copies. The main memory contains the master copy of every variable. There are rules about when a thread is permitted or required to transfer the contents of its working copy of a variable into the master copy or vice versa. ——VM Spec Threads and Locks (oracle.com)

    It's so confused.

    在搜集到的其他资料中,要么是避而不谈,要么说 Working Memory 是一个逻辑上概念,与具体的实现无关。

    最终我尝试去理解这些定义,构建了一个我认为合理的说法,尽管我仍然没有找到一个权威的证明,但这是目前最符合我理解的。

    如果你知道 JVM 执行时 Stack 和 Heap 会发生 什么的话。那么上面的操作可以对应为下面的图。

    loading...
    这样看来,似乎更好理解一些。但是可以肯定的是这个图存在错误的地方。但是我们可以得出这样一个结论:对于上层应用来说,大部分底层操作是非原子的。

    没有 JMM 的 IF 线#

    我们进一步看看,在没有 JMM 的情况下会发生什么吧。
    我们用 A,B,C,D,E,F 代表对不同内存的操作。
    对于线程 1,存在这样的操作序列:A1->B1->C
    对于线程 2,存在这样的操作序列:D->B2->A2
    当进行并发时,从时间顺序上,因为线程内指令重排的存在。对于冲突的指令 (A1,A2) 与 (B1,B2)。
    可能出现下面的执行顺序

    plain
    A1 -> B1 -> B2 -> A2
    A1 -> B1 -> A2 -> B2
    A1 -> B2 -> A2 -> B1
    A1 -> B2 -> B1 -> A2
    A1 -> A2 -> B2 -> B1
    A1 -> A2 -> B1 -> B2
    
    B1 -> A1 -> B2 -> A2
    B1 -> A1 -> A2 -> B2
    B1 -> A2 -> A1 -> B2
    B1 -> A2 -> B2 -> A1
    B1 -> B2 -> A2 -> A1
    B1 -> B2 -> A1 -> A2
    
    A2 -> A1 -> B2 -> B1
    A2 -> A1 -> B1 -> B2
    A2 -> B1 -> A1 -> B2
    A2 -> B1 -> B2 -> A1
    A2 -> B2 -> B1 -> A1
    A2 -> B2 -> A1 -> B1
    
    B2 -> A1 -> B1 -> A2
    B2 -> A1 -> A2 -> B1
    B2 -> A2 -> A1 -> B1
    B2 -> A2 -> B1 -> A1
    B2 -> B1 -> A2 -> A1
    B2 -> B1 -> A1 -> A2
    

    在线程的局部视角来看,这种重排序是合理的,因为 A B 之间没有冲突 / 依赖关系。
    但是从全局视角来看,这种重排序是会导致错误的。
    由此我们可以得出第二个结论,对于上层应用来说,大部分底层操作是无序的,而无序的同时也会导致不可见的问题

    底层是混乱的#

    我们可以看到,如果将底层直接暴露给上层应用的话,其心智负担可能是无法接受的。这会导致编程者每时每刻都要考虑操作的原子性,有序性,可见性等等。

    所以需要一个中间层,JMM,对底层 JVM 实现进行规约,从而可以向上层应用承诺:某些情况一定不会发生,去屏蔽不必要的心智负担。

    也就是,JVM 需要遵循上面的内存交互协议,这样 JMM 就能对上层应用做出程序一定会按照 Happens-before 的规则去运行的保证。从而上层程序的可以通过特定的方式实现并发,同时保证的原子性,可见性,以及有序性。

    用一张图来表述大概就是这样。

    loading...

    Happens-before 原则#

    我们再来照本宣科的复制一份常见的介绍。

    • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作Happens-before 于书写在后面的操作。
    • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作Happens-before于后面对同一个锁的 lock 操作。
    • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作Happens-before于后面对这个变量的读操作。
    • 线程启动规则(Thread Start Rule):Thread 对象 start () 方法Happens-before于此线程的每一个动作。
    • 线程终止规则(Thread Termination Rule):线程中的所有操作都 Happens-before 于对此线程的终止检测,我们可以通过 Thread.join () 方法和 Thread.isAlive () 的返回值等手段检测线程是否已经终止执行。
    • 线程中断规则(Thread Interruption Rule):对线程 interrupt () 方法的调用Happens-before于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted () 方法检测到是否有中断发生。
    • 对象终结规则(Finalizer Rule) :一个对象的初始化完成(构造函数结束)Happens-before于它的 finalize () 方法的开始。
    • 传递性(Transitivity):如果操作 A Happens-before于操作 B,操作 B Happens-before于操作 C,那就可以得出操作 A Happens-before于操作 C 的结论。

    如果直接看,还是很抽象对吧,看了但又没完全看,这些糟糕的翻译对理解不但没有帮助,反而会导致产生误解。
    但是结合上面那张图,我们可以知道,Happens-before 就是内存模型对上层应用做出的 Promise
    换句话说,Happens-before 并非是对上层应用做出的规约,而是对上层应用做出的承诺 / 保证。那么就很好解释了。
    所以Happens-before不是让我们遵守什么规则,而是解释当我们写出这样的代码的时候,其底层会发生什么。
    具体一点说,
    如果 A Happens-before B,即如果时间上,先发生 A,再发生 B,那么 A 操作的结果对 B 操作的结果可见。

    Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

    应该注意的是,两个操作之间存在先行发生关系并不一定意味着它们在实现中必须按该顺序发生。如果重新排序产生的结果与合法执行一致,则它并不违法。

    It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.

    编程者不是遵循 HB 规则,而是利用 HB 规则。用来判断是否会出现底层不满足原子,有序,可见性的情况,从而影响程序的正确性。

    总结#

    总结一句话就是:JMM 对底层实现进行规约,从而可以向上层应用承诺:某些情况一定不会发生,去屏蔽不必要的心智负担。这就是我所理解的内存模型的全部。

    至于具体的 Happens-before 规则,抑或是没提到的一些底层遵循细节,这都是与具体语言实现有关的。比如 Java 中的 Happens-before 会对自己提供的 volatile, synchronized, finalizer 等 api 进行描述。而在 Golang 中,关于happens-before的规则只与 channel 有关。

    其最终目的都是在保证性能的前提下,保证底层执行的正确性,此处的正确性是 CPU 执行时与代码的语义是对应的(有序,可见,原子)。但是代码本身的正确性则是编程者需要考虑的问题。

    一些参考资料#

    research!rsc: Programming Language Memory Models (Memory Models, Part 2) (swtch.com)

    Programming Language Memory Models - 筆記 | Kenny's Blog (kennycoder.io)

    • 🥳0
    • 👍0
    • 💩0
    • 🤩0