聊聊计算机内存模型和 volatile

532 阅读8分钟

在了解这些内容前,我们可以根据这张图,将之分为这几个层面去了解。

内存模型

现代计算机的内存模型

其实早期计算机中 CPU 和内存的速度是差不多的,但在现代计算机中,CPU 的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。

JMM

Java 内存模型描述了Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量,存储到内存和从内存中读取变量这样的底层细节。

  • 所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

本地内存和主内存的关系:

正是因为这样的机制,才导致了可见性问题的存在,那我们就讨论下可见性的解决方案。

Volatile 修饰共享变量

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且回会了,其他已经读取的线程的变量副本就会失效,需要对数据进行操作时又要再次去主内存中读取了。volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

我从计算机层面的缓存一致性协议解释一下这些名词的意义。之前我们说过当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致(例如:变量在多个 CPU 之间的共享)。

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly 及 DragonProtocol 等。聊聊 Intel 的 MESI 吧。

MESI(缓存一致性协议)

当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

怎么发现数据是否失效呢?

嗅探

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

嗅探的缺点不知道大家发现了没有?

总线风暴

由于 volatile 的 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 不断循环,无效交互会导致总线带宽达到峰值。所以不要大量使用 volatile,至于什么时候去使用 volatile什么时候使用锁,根据场景区分。

禁止指令重排序

什么是重排序?

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。

一般重排序可以分为如下三种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

  • 内存系统的重排序。由于处理器使用缓存和读、写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

这里还得提一个概念,as-if-serial。

as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变。

编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

那 volatile 是怎么保证不会被执行重排序的呢?

内存屏障

通过内存屏障。

为了实现 volatile 的内存语义,会限制特定类型的编译器和处理器重排序,针对编译器制定 volatile 重排序规则表:

需要注意的是:volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障(如下图:辅助理解)

上面的我提过重排序原则,为了提高处理速度,JVM 会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。从 JDK5 开始,提出了 happens-before 的概念。

happens-before

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。了解下 happens-before 的四个基本规则:

  • 单线程中的每个操作,happen-before 于该线程中任意后续操作
  • 对volatile变量的写,happen-before于后续对这个变量的读。
  • 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  • 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的
    读。

无法保证原子性

就是一次操作,要么完全成功,要么完全失败。假设现在有 N 个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。要解决也简单,要么用原子类,比如 AtomicInteger,要么加锁(记得关注 Atomic 的底层)。

如果每个变量都加上 Volatile 关键字会有什么隐患?

上面有提到缓存一致性协议,应该可以明白这个问题了:

当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

由于这个 MESI 缓存一致性协议,需要不断的从主内存嗅探和 CAS 不断循环,无限交互会导致总线带宽达到峰值。所以不要大量使用 volatile。