OpenJDK修订Java内存模型

现有的Java内存模型涵盖了很多Java语言的语义保证。在这篇文章中,我们将深入这些语义,并体会对现有Java内存模型更新的动机。



传统的Java内存模型涵盖了很多Java语言的语义保证。在这篇文章中,我们将重点介绍其中的几个语义,以更深入地了解他们。对于本文中描述的语义,我们还将尝试体会对现有Java内存模型更新的动机。本文中与与JMM未来更新相关的讨论,将被称为JMM9。

1. Java内存模型

现有的Java内存模型,如JSR133(以下称为JMM-JSR133)中所定义的,为共享内存指定了一致性模型,并且有助于为开发者提供与JMM-JSR133表述一致的定义。JMM-JSR133规范的目标是确保线程通过内存交互语义的精确定义,以便允许优化并提供清晰的编程模型。JMM-JSR133旨在提供定义和语义,使多线程程序不仅是正确的,而且是高性能的,并对现有代码库的影响微乎其微。

考虑到这一点,我们来过一下JMM-JSR133中,过分指定或者指定不足的语义保证,同时重点放到社区广泛讨论的,关于我们如何在JMM9对其改进的话题上。

2. JMM9 - 顺序一致性 - 数据竞态自由问题

JMM-JSR133谈到了相对于操作的程序执行。结合有序操作的执行,描述了这些操作之间的关系。在这篇文章中,我们将扩展一些这样的顺序和关系,进而讨论一下什么是顺序一致的执行。让我们先从“程序顺序”开始。每个线程的程序顺序是一个总体顺序,表示通过该线程执行的所有操作的顺序。有时候,并不是所有操作都需要按序执行的。因此,有一些关系仅是部分有序的关系。例如,“happens-before”和“synchronized-with”两个就是部分有序关系。当一个操作发生在另一个操作之前;第一个操作不仅对第二个操作是可见的,而且其顺序在第二个操作之前。这两个操作之间的关系被称为是happens-before关系。有时,有些特殊操作需要指定顺序,他们被称为“同步操作”。volatile的读取和写入、monitor的锁定和解锁等都是同步操作的例子。一个同步操作会引起该操作的“synchronized-with”关系。synchronized-with关系是偏序的,这意味着并非所有两两的同步操作都包含这个关系之内。所有同步操作的总体顺序被称为“同步顺序”,每个执行都有一个同步顺序。

现在让我们谈谈顺序一致的执行。当所有的读写操作是总体有序执行时,被认为是顺序一致的(SC)。在SC执行中,读操作总是能看到最后一次写入特定变量的值。当SC执行表现为没有“数据竞态”时,该程序被认为是数据竞态自由(DRF)的。当程序中有两个不具备happens-before关系顺序的访问,他们访问的变量相同且至少其中之一是一个写访问时,就会发生数据竞态。数据竞态自由的顺序一致(SC for DRF)意味着DRF程序的行为是顺序一致的。但是严格支持顺序一致是以牺牲性能为代价的,大多数系统会对内存中的操作重新排序,以提高执行速度,并“隐藏”昂贵操作的延迟。同时,编译器也会对代码重新排序以优化执行。在保证严格顺序的一致性的场景中,不能进行这些内存操作重新排序或代码优化,因此性能会受到影响。JMM-JSR133已经使用底层编译器、高速缓冲存储器的相互作用和对程序不可见的JIT,合并了松散排序限制和任何重新排序。

注:昂贵操作是那些占用大量的CPU周期来完成、阻止执行流水线

对于JMM9来说,性能是一个重要的考虑因素,而且任何一门编程语言的内存模型,理论上,都应该让开发者可以利用内存模型架构上弱有序(weakly-ordered)的优势。成功的实现和示例是放松严格的顺序,尤其是在弱有序的架构上。

注:弱序是指可以对读取和写入重新排序,并且需要显式的内存屏障遏制这种重新排序的架构

3. JMM9 - 无中生有问题

JMM-JSR133另一个主要的语义是对“无中生有”(Out-of Thin Air,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旨在发现和纠正正式的定义,以便允许一些常见的优化。

4. JMM9 非Volatile变量上的Volatile操作

首先,关键字'Volatile'是什么意思呢?Java的‘volatile’保证了线程间的交互,使得当一个线程写入一个volatile变量,不仅这次写入对其他线程可见,而且其他线程可以看到该线程所有的对volatile变量的写入。

那么对于non-volatile变量又发生了什么呢?非volatile变量没有‘volatile’关键字保证交互的好处。因此,编译器可以使用non-volatile变量的缓存值而不是‘volatile’保证,‘volatile’变量将总是从内存中读取。happens-before模型可以用来绑定同步访问到非volatile变量上。

注:声明的任何字段为‘volatile’并不意味着有锁参与。因此volatile比使用锁来同步更便宜。但是着重要注意的是,当方法中有多个volatile字段时,可能比使用锁更昂贵。

5. JMM9 - 读写原子性问题和字分裂问题

JMM-JSR133也有为共享内存并行算法提供的读取和写入的原子性保证(使用异常)。异常是为non-volatile的长整型和双精度浮点型的写入被视为两个独立的写入而定义的。因此,一个64位的值可以分别写入两个32位,一个线程正在执行读的时候,如果其中的一个写入仍未完成,该线程可能会看到只有一半正确的值,从而失去原子性。这是原子性保证依赖于底层硬件和内存子系统的一个例子。例如,底层汇编指令应该能够处理的操作数的大小,以便保证原子性,否则如果读或写操作必须被分成多于一个的操作,最终将破坏原子性(正如例子中的non-volatile的长整型和双精度浮点型的值)。类似地,如果因为实现产生一个以上的内存子系统事务,那么也将破坏原子性。

注:volatile的长整型和双精度浮点型字段和引用始终保证读取和写入的原子性

基于位的设计不是一个理想的解决方案,因为如果64位的异常被删除,那么在32位的体系结构中就会受损。如果在64位架构上行不通,如果期望原子性,那么不得不为长整型和双精度浮点型引入“volatile”,即使底层硬件可以保证原子操作。例如:volatile类型的字段不需要定义为双精度浮点型,因为基础架构,或者ISA、浮点单元会处理好64位宽字段的原子性需求。JMM9的目的是确定硬件提供原子性的保证。

JMM-JSR133写于十多年前;此后处理器位数发生了演变,64位已经成为主流的处理位数。当即强调的是,JMM-JSR133提出了针对64位读写的妥协,尽管64位的值可以由任何架构原子生成,一些架构仍然有必要请求锁。现在,这使得在这些架构上的64位读写操作非常昂贵。在32位x86架构上,如果不能找到一个合理的原子64位操作实现,则原子性将不会改变。

注:在语言设计中潜在一个问题,关键字“volatile”被赋予了过分的含义。运行时很难弄清楚,用户使用“volatile”是为了恢复原子性(因此它可以在64位平台被剥离出来),还是为了内存排序的目的。

当谈论访问原子性,读写操作的独立性是要着重考虑的。写入一个特定的字段不应该与读取或者写入其他字段有交互。JMM-JSR133的保证意味着,同步不应需要提供顺序一致性。因此,JMM-JSR133保证禁止被称为“字分裂”的问题。基本上,当更新一个操作数希望在比基础架构为所有操作数生成的更低的粒度上操作时,我们将遇到“字撕裂”问题。需要记住的重要一点是,字撕裂问题的原因之一是,64位长整型和双精度浮点型都没有给出原子性保证。字撕裂在JMM-JSR133中是禁止的,在JMM9中继续保持这种方式。

6. JMM9 - final字段问题

与其他字段相比,final字段是不同的。例如,一个线程用final字段'x'读取一个“完全初始化”的对象;在对象“完全初始化”后,能保证读取了final字段'y'的初始值值,但不能保证“正常”的非final字段'nonX'。

注:“完全初始化”是指对象的构造函数完成

鉴于上述情况,有一些简单的事情可以在JMM9中修复。例如:volatile类型字段,volatile字段在构造函数中初始化是不保证可见性的,即使对实例本身是可见的。因此,问题来了,是否final字段应该保证扩大到所有字段,包括初始化volatile字段?此外,如果一个完全初始化对象的“正常”非final字段的值不发生变化,我们是否可以将final字段保证到这个“正常”的字段。

参考文献

我从如下这些网站学到了很多,他们提供了大量的示例编码。本文是一篇介绍性的文章,以下文章更适合深入掌握Java内存模型。

  1. JSR 133: JavaTM Memory Model and Thread Specification Revision
  2. The Java Memory Model
  3. JAVA CONCURRENCY (&C)
  4. The jmm-dev Archives
  5. Threads and Locks
  6. Synchronization and the Java Memory Model
  7. All Accesses Are Atomic
  8. Java Memory Model Pragmatics (transcript)
  9. Memory Barriers: a Hardware View for Software Hackers

特别感谢

感谢Jeremy Manson,帮助我纠正了很多误解,并为我更清楚地解释了那些对于我来说很新的术语。还要感谢Aleksey Shipilev,帮助我减少了本文草稿版本中出现的概念的复杂性。Aleksey还指导我们去他的JMM,语用学文章更深层次的理解,澄清和例子。

关于作者

Monica Beckwith是Java性能顾问。她过去曾经与Oracle/Sun和AMD一起工作,对JVM服务器级系统进行优化。Monica被评为JavaOne 2013的明星演讲者,并且是First Garbage Collector(G1 GC)性能团队的领导者。她的Twitter是@mon_beck。

The OpenJDK Revised Java Memory Model

时间: 2024-07-28 14:18:17

OpenJDK修订Java内存模型的相关文章

深入理解Java内存模型系列篇

[本文转载于深入理解Java内存模型,可点击每个章节标题查看原文] 深入理解Java内存模型(一)--基础 并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模型里,线程之间没有公共状态,线程之间必

Java内存模型

原文地址  作者:Jakob Jenkov 译者:张坤 Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的.Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型--又称为Java内存模型. 如果你想设计表现良好的并发程序,理解Java内存模型是非常重要的.Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量. 原始的Java内存模型存在一些不足,因此Java内存模型在Java1.5时被重新修订.这个版

Java内存模型FAQ(十一)新的内存模型是否修复了双重锁检查问题?

原文:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#dcl 译者:Alex 臭名昭著的双重锁检查(也叫多线程单例模式)是一个骗人的把戏,它用来支持lazy初始化,同时避免过度使用同步.在非常早的JVM中,同步非常慢,开发人员非常希望删掉它.双重锁检查代码如下: // double-checked-locking - don't do this! private static Something instance = n

深入理解Java内存模型(六) final

与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问.对于final域,编译 器和处理器要遵守两个重排序规则: 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操 作之间不能重排序. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序. 下面,我们通过一些示例性的代码来分别说明这两个规则: public class FinalExample { int i; //普通变量 fin

深入理解Java内存模型(五) 锁

锁的释放-获取建立的happens before 关系 锁是java并发编程中最重要的同步机制.锁除了让 临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息. 下面是锁释放-获取 的示例代码: class MonitorExample { int a = 0; public synchronized void writer() { //1 a++; //2 } //3 public synchronized void reader() { //4 int i = a; //5 -

深入理解Java内存模型(四) volatile

volatile的特性 当我们声明共享变量为volatile后,对这个变量的读/写将会很特别.理解 volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个 读/写操作做了同步.下面我们通过具体的示例来说明,请看下面的示例代码: class VolatileFeaturesExample { volatile long vl = 0L; //使用volatile声明64位的long型变量 public void set(long l) { vl

深入理解Java内存模型(三) 顺序一致性

数据竞争与顺序一致性保证 当程序未正确同步时,就会存在数据竞争.java内存模型规范对数 据竞争的定义如下: 在一个线程中写一个变量, 在另一个线程读同一个变量, 而且写和读没有通过同步来排序. 当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此).如果一 个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序. JMM对正确同步的多线程程序 的内存一致性做了如下保证: 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consisten

深入理解Java内存模型(二) 重排序

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依 赖性.数据依赖分下列三种类型: 上 面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变. 前面提到过,编译 器和处理器可能会对操作做重排序.编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不 会改变存在数据依赖关系的两个操作的执行顺序. 注意,这里所说的数据依赖性仅针对单个处理 器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器 和处理器考

深入理解Java内存模型(一) 基础

并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之 间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令 式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程 之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模 型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信. 同步是指程 序用于控制不同线程之间操作发生相对顺序的机制.在共