在阅读本文之前,需要先了解cache的基本原理和缓存一致性协议,可以参考以下文章:
[1] Memory Barriers: a Hardware View for Software Hackers
(翻译版:https://juejin.im/post/5ea7e1e6f265da7b9f07a628)
[2] Cache coherency primer
上述两篇文章给出了一致性缓存设计过程中的一些通用的技术和细节,例如MESI协议,Store Buffer,Store Forwarding,Invalidation Queue,重排序和内存屏障等概念,需要对这些概念有所理解。
实际处理器的cache系统,比上述文章中介绍的还要复杂的多,在细节上也可能有所不同(例如何时对store buffer进行刷新操作),不过基本思想都是差不多的。可以通过wikichip网站来查看常见处理器的架构,例如下面这个链接就是英特尔Skylake处理器架构处理器的基本架构:https://en.wikichip.org/wiki/intel/microarchitectures/skylake_(client)。
英特尔处理器使用基于MESI改进得到的 MESIF一致性协议,关于该协议,英特尔并没有给出详细的文档,不过其基本原理是和MESI协议差不多的,所以下文我们只能借助MESI协议和 英特尔官方的系统编程手册 来 “推测” 英特尔处理器上MESIF协议的工作原理。
需要注意的是,现代CPU虽然存在缓存一致性协议,但是并不能确保每个处理器核心每时每刻看到的数据都是一致的,这一点上述两个文章里面都有说明或体现,原因有下:
其实实现一个严格的时时刻刻都一致的cache系统并非不可能,但是要以牺牲存取操作的性能和速度为代价。store buffer、invalidation queue、重排序等技术的应用虽然带给了我们一个不是时时刻刻都保持一致的cache系统,但是最终在某个时刻也能达到一致,同时也极大提升了性能和速度,利大于弊。
正因为有了store buffer、invalidation queue、重排序等技术,所以即使有了缓存一致性协议,也不能时刻确保各个缓存的内容处于一致状态。所以,有时候为了确保一个核心上的修改能及时地被其他核心看到,或者为了防止重排序现象,就要求我们手动加入内存屏障来显式的将store buffer的内容刷新到cache或主存,以及防止重排序。
volatile
关键字下面这篇文章讲的比较好,可以先看一下下面这篇文章,再回来看本文:
一次深入骨髓的 volatile 研究
对于 volatile
关键字,其主要作用在于对可见性和有序性的影响。
对 volatile
的理解可以分以下几个层面,由表层到底层,层层深入:
这个层面涉及Java语言规范、以及JSR-133 Java内存模型规范,并不针对特定的平台,因此没有描述具体的实现细节,仅仅描述了所有平台下使用 volatile
关键字应该有的效果。
Java语言规范中对 volatile
的描述:
A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.
被volatile修饰的类字段,可由JMM确保所有线程对于该字段都能看到一致的值。(也就是说,一个线程修改了volatile字段,可以确保后续所有读取该volatile字段的线程都能看到修改后的值,即描述了volatile变量的可见性。)
关于“后续”的含义,下文会讲解
在JSR 133 (Java Memory Model) FAQ中,对JMM中 volatile
关键字的含义作出了解读:
参考 一次深入骨髓的 volatile 研究
Doug Lee在The JSR-133 Cookbook for Compiler Writers
对JVM的实现者给出了一些实现细节上的参考, 其中描述了 volatile 关键字的重排序规则以及如何实现 volatile 关键字的参考。
重排序规则
上面的表格给出了Java中的重排序状况。其中,值为NO的格子表示一定不能重排序,而值为空白的格子则不一定。
对于值为空白的格子,只要遵循JLS规范和JMM约束的前提下,是可以进行重排序的,例如:
注:volatile读写操作不会和其他读写操作发生重排序。这一点上面讲过。
如何实现volatile关键字的语义?
Doug Lea在文中引入了LoadLoad、LoadStore、StoreStore、StoreLoad 4个内存屏障,用于防止重排序、保证可见性,并给出了要正确实现volatile
的语义的话,应该何时何地加上这些内存屏障,如下表所示:
参考 一次深入骨髓的 volatile 研究
在x86平台上是借助一个lock前缀指令实现的 —— lock addl $0,0(%rsp)
。关于lock前缀指令,下文会介绍。
我们可以使用 hsdis 插件查看 Java 程序对应的机器汇编代码,具体怎么操作可以参考网上的文章。最终可以看到对 volatile 变量的写操作底层会多出一个 lock addl $0,0(%rsp)
指令。该指令具体有什么作用,请看下文。
关于LOCK前缀指令的作用网上说法不一,下面采用Intel官方的文档来做出解读。
以下内容均摘自 《Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A: System Programming Guide, Part 1》,因此,以下内容仅仅针对Intel 64 和 IA-32架构的处理器,不适用与其他处理器。
2.8.5 Controlling the Processor
…
The LOCK prefix invokes a locked (atomic) read-modify-write operation when modifying a memory operand. This mechanism is used to allow reliable communications between processors in multiprocessor systems, as described below:
• In the Pentium processor and earlier IA-32 processors, the LOCK prefix causes the processor to assert the LOCK# signal during the instruction. This always causes an explicit bus lock to occur.
• In the Pentium 4, Intel Xeon, and P6 family processors, the locking operation is handled with either a cache lock or bus lock.
- If a memory access is cacheable and affects only a single cache line, a cache lock is invoked and the system bus and the actual memory location in system memory are not locked during the operation. Here, other Pentium 4, Intel Xeon, or P6 family processors on the bus write-back any modified data and invalidate their caches as necessary to maintain system memory coherency.
- If the memory access is not cacheable and/or it crosses a cache line boundary, the processor’s LOCK# signal is asserted and the processor does not respond to requests for bus control during the locked operation.
LOCK
前缀指令时会导致该处理器生成一个LOCK #
信号,该信号在LOCK
前缀指令执行期间有效,会锁总线。LOCK
前缀指令执行时采取锁总线和锁cache结合的方式。
LOCK
前缀指令访问的内存数据在cache中命中且存在于一个cache line中(即不跨越多个cache line),则执行该指令的处理器在该指令执行期间会采取锁cache策略,在此期间系统总线和待访问数据对应的内存地址不会被锁定。此时,其他处理器会将处于modified状态的cache line写回到内存,同时将这些cache line标记为失效,从而保持内存系统的一致性(知道MESI一致性协议的话,这个操作应该不难理解)。LOCK
前缀指令的处理器会生成一个LOCK #
信号,该信号在LOCK
前缀指令执行期间有效,会锁总线。8.1.2 Bus Locking
Intel 64 and IA-32 processors provide a LOCK# signal that is asserted automatically during certain critical memory operations to lock the system bus or equivalent link. While this output signal is asserted, requests from other processors or bus agents for control of the bus are blocked. Software can specify other occasions when the LOCK semantics are to be followed by prepending the LOCK prefix to an instruction.
In the case of the Intel386, Intel486, and Pentium processors, explicitly locked instructions will result in the assertion of the LOCK# signal. It is the responsibility of the hardware designer to make the LOCK# signal available in system hardware to control memory accesses among processors.
For the P6 and more recent processor families, if the memory area being accessed is cached internally in the processor, the LOCK# signal is generally not asserted; instead, locking is only applied to the processor’s caches (see Section 8.1.4, “Effects of a LOCK Operation on Internal Processor Caches”).
当一个处理器发出LOCK #
信号时,会导致锁总线。锁住总线之后,来自其他处理器的总线访问请求都会被阻止。
对于P6家族处理器以及最近的一些处理器家族,如果LOCK
前缀指令访问的内存数据在cache中命中且存在于一个cache line中(上面讲到过),则处理器不会发出LOCK #
信号,而是会锁cache。
8.1.4 Effects of a LOCK Operation on Internal Processor Caches
For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor.
For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.
其实锁cache的大致原理在上方的2.8.5已经介绍过了,这里仅仅是稍微详细的叙述了一下:
在P6以及更新的处理器上,引入了锁cache的方式执行LOCK
前缀指令,在该过程中,处理器会修改自己cache中的数据副本,并借助缓存一致性协议来确保修改操作是原子的,且不会存在多个处理器同时修改对应副本(根据缓存一致性协议,如果其他处理器也存在该副本,那么其他处理器的副本会先置为失效状态)。
8.2.3.9 Loads and Stores Are Not Reordered with Locked Instructions
The memory-ordering model prevents loads and stores from being reordered with locked instructions that execute earlier or later.
LOCK
前缀指令具有内存屏障的功能,它可以防止其之前(之后)的load/store指令重排序到它后面(前面)。
8.2.5 Strengthening or Weakening the Memory-Ordering Model
…
Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to ensure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory.
(see Section 8.1.2, “Bus Locking”).
LOCK
前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作 (即store buffer中的写操作) 写回内存之后之后才开始执行。(⭐️ 根据缓存一致性协议,刷新store buffer的操作会导致其他cache中对应的副本失效。)
11.10 STORE BUFFER
Intel 64 and IA-32 processors temporarily store each write (store) to memory in a store buffer. The store buffer improves processor performance by allowing the processor to continue executing instructions without having to wait until a write to memory and/or to a cache is complete. It also allows writes to be delayed for more efficient use of memory-access bus cycles.
In general, the existence of the store buffer is transparent to software, even in systems that use multiple processors. The processor ensures that write operations are always carried out in program order. It also insures that the contents of the store buffer are always drained to memory in the following situations:
• When an exception or interrupt is generated.
• (P6 and more recent processor families only) When a serializing instruction is executed.
• When an I/O instruction is executed.
• When a LOCK operation is performed.
• (P6 and more recent processor families only) When a BINIT operation is performed.
• (Pentium III, and more recent processor families only) When using an SFENCE instruction to order stores.
• (Pentium 4 and more recent processor families only) When using an MFENCE instruction to order stores.
上面列举出了何时store buffer中的内容会刷新到缓存。
LOCK
前缀指令会锁总线;在最新的处理器中,LOCK
前缀指令采取锁总线和锁cache结合的方式LOCK
前缀指令具有内存屏障功能,防止load/store指令重排序LOCK
前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。volatile
关键字原理分析在x86计算机上,对 volatile
变量写操作对应的汇编代码底层会插入一个lock
前缀指令:
lock addl $0,0(%rsp)
这条指令的主体部分addl $0,0(%rsp)
并没有做什么,重点关注lock
带来的“副作用”。
一方面,它保证了 有序性, 参考上述 “总结要点2”。
另一方面,它保证了 可见性,参考上述 “总结要点3”,执行lock前缀指令会将 store buffer 中对 volatile变量的写操作(当然也包括其他写操作,这里只关注volatile变量)刷新到内存,并使得其他cache中包含该变量的副本失效。因此其他线程后续(也就是当前处理器核心 store buffer的刷新完成之后)对该volatile变量的读操作需要从内存或者当前cache中获取,取到的就是最新的值。
这两点正好是 volatile
所需要的,因此,借助 lock 前缀指令可以实现 volatile 关键字的语义。
以上的内容参考了很多网上的资料和文档,也有一部分自己的理解,毕竟硬件层面的知识相当复杂,如果有理解不对的地方欢迎指正。