传统的 Java 内存模型涵盖许多 Java 语言语义上的保证。在ITJS的这篇文章中,我们将会强调其中的一些语义,并且深入的理解。我们也将尝试理解更新 Java 内存模型(JMM)的动机,这些更新都是与ITJS的这篇文章介绍的语义相关的。关于对 JMM 这次更新的讨论在ITJS的这篇文章中将被称为 JMM9。
Java 内存模型
现存的 Java 内存模型,就是 JSR133 定义的(后面称之为 JMM-JSR133),规范了共享内存的一致性模型,为开发者提供了一致的定义。JMM-JSR133 规范的目标是确保线程与内存交互的语义定义是精确的以便优化性能,也提供了一个清楚的编程模型。JMM-JSR133 致力于提供定义和语义,使得多线程程序不仅运行正确,而且性能良好,并且最小限度地影响现有代码。
知道了这些之后,我将要谈谈某些具体的语义保证,这些保证在 JMM-JSR133 或者定义的过多或者规范的不够,同时也将强调一下社区内关于如何在 JMM9 中改善的讨论。
JMM9 – 顺序一致 – 无数据竞争的问题
JMM-JSR133使用指令的概念讨论程序的执行过程。这样的执行过程结合了指令以及描述其关系的顺序。在ITJS的这篇文章中,我将会详细阐述顺序和关系,然后讨论什么构成了一个顺序一致的执行过程。让我们从“程序顺序”开始——每个线程的程序顺序是一个全序关系,指的是该线程所有指令的执行顺序。某些时候并不是所有指令都需要有顺序。因此有些关系只是半序关系。例如“happens-before”和“synchronized-with”是两个半序关系。当一条指令在另一条指令之前执行;第一个指令不仅对第二条指令是可见的,而且第一条指令也排在第二条指令前面。这两条指令之间的关系就称为happens-before关系。某些情况下,有些特殊的排序指令,这些指令被称为“同步指令”。易失性的读写、监控器加锁解锁等等都是同步指令的例子。同步指令引起“synchronized-with”关系。synchronized-with关系是半序关系,这意味着并不是所有同步指令对都包含在内。所有同步指令之间的全序关系称为“同步顺序”,每个执行过程有一个同步顺序.
现在让我们讨论一下顺序一致的执行过程——所有的读写指令都呈现全序关系的执行过程称为顺序一致(SC)。在SC执行过程中,所有的读操作总是能看到上次写操作的值。当某个SC执行过程没有“数据竞争”,那么该程序被称为无数据竞争的(DRF)。当某个程序两次访问同一数据,这两次访问至少有一次是写操作,并且这两次访问没有用happens-before关系排序的时候,那么就会产生数据竞争。DRF情况下的SC意味着DRF程序的行为类似SC。但是严格地支持SC需要牺牲一点儿性能——大部分系统将会重排内存操作指令来“隐藏”耗时操作的延迟以提高执行速度。然而即使编译器可以通过重排指令来优化执行过程,但是为了保证严格地顺序一致,所有这些内存指令重排或代码优化不能进行,因此性能受到影响。JMM-JSR133已经试图放松排序的限制,任何底层编译器、缓存-内存交互以及JIT自己的重排对程序来说都是不可见的。
注: 耗时操作指的是那些需要很多CPU周期来完成而且/或者阻塞执行流水线的操作。
对于JMM9来说,性能是一个重要的考虑事项,而且任何编程语言内存模型理想情况下都应该允许所有开发者使用弱序的架构内存模型。在弱序架构中有一些放松严格排序的成功的实现和案例。
注: 弱序架构指的是可以重排读写指令的架构,需要显式的内存屏障指令来控制这些重排。
JMM9 – 无中生有(OoTA)问题
JMM-JSR133另外一个主要的语义是禁止“无中生有”(OoTA)的值。“happens-before”模型有时候会让变量被创建,然后读到无中生有的值,这是因为该模型没有包含因果要求。 值得注意的是其原因并不是由于数据和控制依赖,就像从下述同步正确的例子代码中看到的那样,非法的写是由写操作本身引起的。
(注: x 和 y 初始化为‘0’) -
Thread a | Thread b |
r1 = x; | r2 = y; |
if (r1 != 0) | if (r2 != 0) |
y = 42; | x = 42; |
这段代码是 happens-before 一致的,但是不是顺序一致的。例如,如果 r1 看到 x=42 写操作,r2看到 y=42 写操作,那么 x 和 y 的值都是42,这是数据竞争情况下的结果。
r1 = x; |
y = 42; |
r2 = y; |
x = 42; |
两个写操作都在读操作之前执行,读操作将会看到各自的写操作,这将会导致 OoTA。
注: 数据竞争可能是由于推测,最终将会成为自我实现的预言。OoTA 保证关于遵守因果关系准则。当前考虑的是因果关系可能被推测的写操作破坏。JMM9 致力于发现 OoTA 的起因,细化避免 OoTA 的方式。
为了禁止 OoTA 值,某些写操作为了避免数据竞争需要等待读操作。因此 JMM-JSR133 将 OoTA 禁止定义形式化为不允许 OoTA 读操作。该形式化定义包含内存模型的“执行操作和因果要求”。从根本上来说,如果所有的程序指令可以被执行,那么形式合法的执行操作满足因果要求。
注: 形式合法的执行操作指的是遵守线程内语义,“happens-before”和“synchronization-order”一致执行操作指的是每个读操作可以看到相应的写操作。
你可能已经发现了,JMM-JSR133 定义不允许 OoTA 值出现。JMM9 致力于辨认和调整这个形式化定义使得允许某些常见的优化。
JMM9 – 非易失变量的易失操作
首先,‘Volatile’关键字是什么? Java ‘volatile’ 关键字保证线程间的交互,使得当某个线程对易失变量进行写操作的时候,不仅该写操作对其他线程可见,而且其他线程可以看到对易失变量的所有写操作。
那么对于非易失变量是怎样的情况呢?非易失变量没有‘volatile’关键字的线程间交互保证。因此编译器可以使用非易失变量的缓存值,然而易失变量总是读内存。happens-before 模型可以用来为非易失变量提供同步访问。
注: 将任意变量定义为‘volatile’ 并不意味着涉及到锁。因此 volatile 的成本比使用锁的成本低。但是值得注意的是在方法中使用多个易失变量将会使其成本比使用锁高。
JMM9 – 原子性读写和字分裂问题 .
JMM-JSR133 同样对共享内存的并发算法保证(也有例外)了原子性读写操作。这个例外是对非易失性长双精度数的一次写入也可以被认为是两个独立的的写操作。因此一个64位数的写入可以被认为是两个单独的32位写操作,当所有写操作还没完成的时候,如果一个线程执行一个读操作可能只会得到正确数值的一半,从而失去原子性。下面是一个例子,说明原子性是怎样依赖于底层的硬件和内存子系统从而得到确保的。例如,为了保证原子性,底层的汇编指令应该能够处理运算对象大小的位数,否则读写操作就必须被分割成多次操作,最终破坏原则性(就像非易失性长双精度数的例子)。类似的,如果内存模型的实现调用不止一个内存子系统的事务,这也会破坏原子性。
注: 易失性长双精度字段和引用一定能保证原则性读写操作。
如果这些在 64 位计算机上异常问题都解决了,那么采取有利于一种架构的方案并不是一种理想的解决方案,因为 32 位架构的计算机会受到影响。而如果采取不利于64位架构的方案,那么即使硬件可以保证操作的原子性,只要用到,你就不得不为 long 和 double 类型引入 ‘volatiles’ (易变量)。例如:因为硬件平台,ISA 或浮点浮点单元会处理 64 位长的域,因此 volatile 类型是不需要的。JMM9 旨在通过硬件来识别是否提供原子性保证。
JMM-JSR133 是在 12 年前写的;处理器的位数在这之后已经取得了很大的进展,64 位已经成为了处理器的主流。JMM-JSR133 对于 64 位的读和写采取了明显的折衷办法-虽然在任何的架构上,64 位的值可以是原子性的,但是在一些架构上需要获取锁。现在,这使得在这些架构上对 64 位值的读和写昂贵。 如果在 32 位 x86 架构上不能找到对 64 位原子性操作的合理实施,那么其原子性将不能改变。
注意:这里有个语言设计上的潜在问题,“volatile”关键字在意思上有歧义。 对于运行环境来说,它很难理解在用户输入“volatile”的时候是为了重获原子性(因此可以摆脱64位平台),或者是为了内存排序的目的。
当谈及访问的原子性,独立的读和写操作是一个重要的考虑。写操作对一个特定的区域应该不与读操作或写操作到任何其他区域相互影响。JMM-JSR133 保证禁止“字撕裂(word-tearing)”的问题。基本上,当对一个操作数做一个更新操作,它比由基础架构所提供的所有操作数在粒度上还要低,那么我们就偶遇到了“字撕裂(word-tearing)”。重点是要记住字撕裂(word-tearing)问题产生的原因是64位字长和双精度没有给出原子性操作的保证。字撕裂(word-tearing)在 JMM-JSR133 中是被禁止的,并且在 JMM9 中也持续保持了这种方式。
JMM9 – 关键的字段问题
与其他的字段相比较,关键字段是与众不同的。举例来说,一个线程读一个“完全初始化的”对象,它有一个关键性的字段‘x’。对象被“完全初始化”后,是读取这个关键性的字段值‘y’的保证。但一个“正常”的非关键性字段——‘nonX’是没有保证的。
注意: “完全初始化” 意味着对象的构造完成。
综上所述,有一些简单的东西在 JMM9 中被确定。举例来说:不稳定的字段——构造器对一个不稳定的字段初始化是没有保证的,即使实例自身是可见的。因此出现一个问题——应该从关键字段的保证扩展到所有的字段,包括不稳定的字段?因此,如果一个“正常的”完全初始化对象的非关键字段的值没有改变,我们应该扩展关键字段以保证到这个“正常”的字段。
参考文献
我从这些网站获益良多,他们还提供了大量的示例代码和运行结果。我应该考虑在文章中添加介绍文章的部分,而下面这些文章很适合深入研究 Java 内存模式。
JSR 133: JavaTM Memory Model and Thread Specification Revision The Java Memory Model JAVA CONCURRENCY (&C) The jmm-dev Archives Threads and Locks Synchronization and the Java Memory Model All Accesses Are Atomic Java Memory Model Pragmatics (transcript) Memory Barriers: a Hardware View for Software Hackers特别感谢
我要感谢 Jeremy Manson,是他纠正了我的一些错误理解,并且对我刚接触的东西给了清晰的定义。 还要感谢 Aleksey Shipilev ,在ITJS的这篇文章起草阶段得益于他的帮助减少了一些复杂的概念,Aleksey 还指导我/我们深入理解他关于 Java 内存模式编程的文章,让我们很清晰,还带有示例。