我们都知道导致可见性的原因是缓存,导致有序性的原因是编译优化。那解决可见性和有序性的办法就是禁用缓存和禁用编译优化了,不过我们都知道缓存和编译优化的目的是为了提高 CPU 的效率从而提高程序的性能,所以不能完全禁止,折中的方案就是按需禁用缓存和编译优化。

那么,如何进行按需禁用呢?

对于这个问题,Java 提供了 Java 内存模型(JMM)这个方案来解决。

Java 内存模型的抽象结构

在 Java 中,所有实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。方法定义参数和异常处理器参数以及方法内定义的变量这些局部变量则存在调用栈里。由于每个线程与自己的调用栈是一一对应的,所以局部变量不会在线程之间共享,它们不会有内存可见性问题。

Java 线程间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系,其示意图如下:

即线程间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程所读写的共享变量的副本。

本地内存是 JMM 的一个抽象概念,并不是真实的存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

从上图看,如果线程 A 与线程B之间要通信的话,必须要经历下面2个步骤。

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

示例过程如下所示:

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要 经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。

volatile、synchronized 关键字的语义以及 Happens-Before 规则

有了 JMM 后,如何让程序员在编程中根据 Java 给出的“约定”来实现遵循 JMM 机制的程序呢?就是通过 volatile、synchronized 和 final 这三个关键字原语来实现,以及与之配套的 Happens-Before 规则。

volatile 的内存语义

当一个变量被设为 volatile 后,线程对该变量的写会及时的刷新到主内存中,而当另一个线程读取该变量的时候,JMM 会把该线程对应的本地内存置为无效而从主内存中读取最新的变量正确值。从而实现线程间的通信。(注:线程写 volatile 变量后刷新到内存的操作是对线程自身整个本地内存的刷新,不仅仅是只对 volatile 变量进行刷新,所以如果该线程在写 volatile 变量前也对其他变量做了修改,则其他变量也会一并被刷新到主内存中,这个体现在 Happens-Before 规则里)

synchronized 的内存语义

synchronized 即锁,锁是 Java 并发中最重要的同步机制。其内存语义其实与 volatile 是有点类似的,当线程释放锁的时候,JMM 会把该线程对应的本地内存中的所有共享变量刷新到主内存中(与线程写 volatile 变量类似)。当线程获取锁时,JMM 会把该线程对应的本地内存置为无效,并从主内存中获取所需共享变量的值(与读 volatile 变量类似),从而实现线程间通信。

Happens-Before 规则

  • 程序顺序规则

一个线程中的每个操作,happens-before 于该线程中的任意后续操作。这是是遵循顺序一致性规则,比较好理解。

  • 监视器锁规则

对一个锁的解锁,happens-before 于随后对这个锁的加锁。由上面 synchronized 的内存语义可知,当对一个锁解锁的时候,该线程会将本地内存里的共享变量刷新到主内存里,随后加锁时,线程会从主内存读取最新的数据,所以解锁后的共享变量是对后面进行加锁的线程是可见的。

  • volatile变量规则

对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。这个根据上面 volatile 的内存语义也是很好理解。

  • 传递性:

如果A happens-before B,且B happens-before C,那么A happens-before C。

  • start()规则

如果线程 A 执行操作 ThreadB.start()(启动线程B),那么A线程的 ThreadB.start() 操作 happens-before 于线程B中的任意操作。
这条规则怎么理解呢?我们可以参考上面 volatile 和 synchronized 内存语义的本质,就是及时的将本地内存的共享变量刷新到主内存里,而其他线程从主内存进行读数据。

所以本条规则的本质我的理解是当线程调用 ThreadB.start()去启动别的线程时,线程会先将本地内存的共享变量刷新到主内存里,被 start 的线程则会将自己本地内存置为无效,从主内存里获取最新共享变量。

  • join()规则

如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的所有操作完成后,线程 A 都能看到。

与上一条类似,这条规则可以这么理解:当主线程调用了子线程的 join 后,子线程在执行完成后,会将自己本地内存刷新到主内存里,而主线程则会将自己本地内存置为无效,从主内存里获取最新的共享变量。从而实现了子线程的所有操作对主线程可见。

总结

对于如何解决可见性和有序性的问题上,Java 使用内存模型(JMM)这个解决方案,通过内存模型来进行按需的禁用缓存和编译优化,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为程序员呈现了一个一致的内存模型。同时,Java 也提供了相应的原语和 Happens-Before 规则来让程序员可以按照它的规定进行编写满足要求的程序。