谈谈Java Volatile的设计

java volatile为什么要这么设计?

最近在知乎上看见一个有意思的问题:

java volatile为什么要这么设计?
如图所示,这种指令重排规则背后设计的是出于什么原因考虑?我想知道why? 而不是What/How

这个问题看似简单,但是后面其实隐藏着计算机架构的演变:

CPU的多核心时代

说到这儿,就要开始聊一下多线程/多处理器的发展史了。初期计算机性能的提升,主要靠的是主频的提升,后来主频提升遇到了困难,然后就开始了多核处理器的时代。

这里面有一个问题,一个程序可不是天然就能同时跑在多个CPU核心上的,如何让程序员利用多核心处理器提升效率,是一个难题。

这个问题乍一看很简单,我们已经有了线程模型了,直接套用到多核架构上不就可以了么?

但是不行,在原来的单核心架构中,我们的内存模型是 顺序一致性(Sequential consistency)

  1. 每个线程内部的指令都是按照程序规定的顺序(program order)执行的(单个线程的视角)
  2. 线程执行的交错顺序可以是任意的,但是所有线程所看见的整个程序的总体执行顺序都是一样的(整个程序的视角)

换句话说,我在这儿写入了一个变量,那么接下来我就可以读取到新的值了。即使有CPU缓存,写入和读取操作还是会经过缓存,是能够保证上面的效果的。

多核心时代,可能就不是这样了:
我在A核心写入了a变量,A核心缓存更新了,但是主内存没有更新;在B核心读取的时候,读取到的还是旧的值。

这个问题实际上是非常严重的,会导致很多现有的软件出现问题。(打个比方:你升级了下CPU,结果原来的操作系统+软件都不能工作了)

后面提出来的缓存一致性、Java的内存模型,本质上都是为了解决这个核心问题。

对于这个问题,我们有两种解决办法:

保证主内存顺序一致性

  1. 我们在主内存保证顺序一致性,所有CPU核的写入操作都直接写到主内存当中去,就能保证可见性了。

这种解决问题的思路简单粗暴:最大程度了保证了兼容性,而且看起来也能够利用多核心的计算性能。
但实际上不行:这种方案下,所有的写入、读取操作都穿透到主内存了,缓存相当于没有任何用处了。

让我们看一下数据,Jeff Dean 提到过各个计算机操作的典型耗时

是2012年的数据,2020年的数据可以在这儿查到。

另外一个比较重要的数据是:现代CPU的缓存命中率大概在90%

让我们做一个简单的算术:

  • 之前,单核心CPU访问一次内存需要 7*90%+100*10%=16.3ns
  • 采用了我们的蹩脚架构后呢?访问一次内存需要 100ns

换句话说,我们采用了这个架构后,单线程程序性能降低了83.7%

显然这个解决方案牺牲了太多性能,然后我们有第二种方案:

保证单线程顺序一致性,跨线程默认不保证一致性

硬件层保证单线程程序的顺序一致性,但是跨线程的内存默认不保证一致性,除非你声明了操作是同步的。

前半句比较容易理解,单线程程序在同一个核心上运行的时候,都会通过同一片缓存,对于读写操作是一致的;如果调度到其他核心上的时候(这种情况操作系统应当尽可能避免),缓存也会刷到主内存上的。
后半句话的意思是,A线程写入内存的数据,B线程读取的时候,硬件不能保证能实时读取到。

至于同步原语,X86下可以使用内存屏障指令Lock前缀来保证数据同步写到内存,底层是MESI协议来实现。
RISC-V架构下,就简化到只有内存屏障指令了。

这样做有什么好处呢?

  1. 首先,它比较好地解决了多核带来的问题,单线程性能几乎没有降低。
  2. 多线程情况下,这种设计也能通过同步原语的内存操作来利用多核(即避免了全同步操作内存的低效,也提供了机制能够利用多核)。
  3. 这种解决方案不仅能够很好的利用多核性能,而且利用了单核心情况下的乱序执行、多发射等特性。

额外提一下,这种选择也影响了后来处理器的设计,

比如ARM架构、RISCV架构,都不约而同的采用了比较弱的内存一致性模型,把这个问题交给了程序员/编译器,以实现更好的性能。比如RISC-V Instruction Set Manual

Java volatile的语义为什么这样设计

好,我们回到最初的问题,在这种背景下,Java提供了volatile语义:可见性保证+禁止指令重排。

我们回过头看这个表格:

说一下why:

  1. Volatile的读写之间,不能重新排序。开发者都声明了volatile,就是想要写的东西要让其他线程看见,所以不能指令重排。
  2. 普通读/写后面跟着一个volatile读,这个没关系的,volatile已经写到主内存了,这时候的指令重排不会影响单线程的顺序一致性保证。
  3. 普通读/写后面跟着一个volatile写,那么这个volatile写就必须要在普通读/写之后。举个例子,A线程计算完结果,写到内存/缓存中,然后volatile写通知B线程;结果指令重排了,先volatile写通知了B线程,那么B线程读取计算结果的时候,就会出现问题。
  4. Volatile读后面跟着一个普通读/写,不能重排。还是上面的例子。B线程volatile读取信号,然后通过普通读/写来获取计算结果;但是如果重排了,先读取计算结果,然后再读是否完成的信号,显然会出问题。
  5. Volatile写后面跟着一个普通读/写,还是类似上面的例子,不赘述。
  6. 普通读/写操作后面跟着一个普通读写,原生就保证了这个一致性,所以也不用禁止指令重排。

以上。

作者

Robert Lu

发布于

2024-03-17

更新于

2024-03-21

许可协议

评论