【Java】Java Memory Model FAQ

Java的内存模型中某些“奇技淫巧”,或者说是“巫术”,对于某些特定场景是非常关键的。

从某种角度而言,我们可以直接称之为“缺陷”;“让语言的使用者无需关心底层实现”是编程语言发展的重要方向之一。“程序语义简单直观”也是 JSR 133 的目标之一。

如,JDK CopyOnWriteArrayList.set() 方法中对 volatile 字段的处理方式就涉及了非常晦涩的内存模型特性。(详情

原文:《JSR 133 (Java Memory Model) FAQ

Jeremy Manson and Brian Goetz,2004年2月

目录:

  • 什么是内存模型(Memory Model)?

  • 其它语言有内存模型吗(如,C++)?

  • JSR 133 是什么?

  • 重排序(reordering)是什么?

  • 老的内存模型哪里错了?

  • 不正确的同步(incorrectly synchronized)是什么意思?

  • “同步(synchronization)”有什么作用?

  • final 字段的值是如何被更改的?

  • 在新的Java内存模型中,final 字段是如何工作的?

  • volatile 有什么作用?

  • 新的内存模型修复了“双检锁”问题吗?

  • 如果编写一个VM,我该注意什么?

  • 为什么我需要关心这些?

什么是内存模型(Memory Model)?

在多处理器系统中,通常每个处理器都有一层或多层内存缓存。这种方式可以从两方面提高性能:

  • 加快数据访问速度。因为数据更靠近处理器。

  • 减少共享内存总线(memory bus)的流量。因为本地缓存可以满足许多内存操作。

内存缓存可以极大地提升性能,但也带来了一系列新的挑战。如,两个处理器同时检查相同内存位置时会发生什么?在什么条件下它们会得到相同的值?

对处理器而言,内存模型定义了充分必要的条件,在这些条件下,其它处理器写内存的操作可被当前处理器看到,当前处理器写内存的操作可以被其它处理器看到。

某些处理器可展现出强大的内存模型:所有处理器,在任何时候,从任何内存位置,获得的值完全相同。

某些处理器的内存模型较弱:为了查看其它处理器对内存的改动或使得其它处理器能看到当前处理器对内存的改动,需要特殊的指令,称为“内存屏障(Memory Barriers)”,来刷新本地处理器缓存,或将其置为“无效”。

因为不需要“内存屏障”,某些情况下,针对“强内存模型”编程会更容易。然而,即使是一些最强的内存模型,内存屏障通常也是必要的;它们出现的位置经常违反直觉。鼓励较弱的内存模型是当前处理器设计的趋势。因为对内存一致性的宽松限制可以使跨多处理器的伸缩性更好,以及实现更大的内存。

(内存)写操作何时对另一个线程可见的问题与编译器的代码重排序有关。

如,编译器可能会判断出“将写操作往后移”可使得效率更高;只要这个代码改动不会影响程序的语义,就可以这么做。

如果编译器推迟了一个操作,另一个线程直到该操作被执行后才能看到更改;这反映了缓存的效果。

此外,内存写操作也可以被往前移。这种情况下,其它线程可能会在提前看到此写操作。

所有这些灵活性都是通过设计实现的:在内存模型的范围内,给 编译器、运行时 或 硬件 一定的灵活性,我们可以获得更高的性能。

例:

Java代码

 

  1. Class Reordering {  

  2.   int x = 0, y = 0;  

  3.   public void writer() {  

  4.     x = 1;  

  5.     y = 2;  

  6.   }  

  7.   

  8.   public void reader() {  

  9.     int r1 = y;  

  10.     int r2 = x;  

  11.   }  

  12. }  

假设这段代码由两个线程并发执行(一个线程执行 writer(),另一个线程执行 reader()),且“读y”获得的值为2。那么程序员可能会认为“读x”肯定获得 1,因为(在代码中) “写y”位于“写x”之后。但是这两个写操作可能会被重排序。如果发生重排序,操作顺序可能是“写y -> 两个读操作 -> 写x”。所得结果可能为 “r1 是 2,r2 是 0”。

Java内存模型 描述了 在多线程代码中什么行为是合法的,以及线程间如何通过内存进行交互。它描述了程序中变量间的关系,以及在真实计算机系统中内存或寄存器变量存取的底层细节。可以用各种硬件和编译器优化来正确地实现这规则。

Java有多种语言结构,包括 volatile、final 和 synchronized,用于帮助程序员向编译器描述程序的并发需求。Java内存模型定义了 volatile 和 synchronized 的行为,更重要的是确保一个正确同步的Java程序在所有处理器架构上都能正确运行。

其它语言有内存模型吗(如,C++)?

绝大多数其它语言,如 C 和 C++,并不是直接为多线程设计的。这些语言中针对编译器与处理器架构重排序的保护 在很大程度上依赖于线程库(如 pthreads)提供的保证、所使用的编译器 及 代码运行的平台。

JSR 133 是什么?

从1997年开始,Java语言规范 第17章 中定义的 Java内存模型 已被发现有多处严重缺陷。这些缺陷会引发混乱的程序行为(如 final字段的值被改变),并破坏编译器的常规优化能力。

Java内存模型是一项雄心勃勃的壮举。这是首次由一个编程语言规范试图囊括一个内存模型,为多种架构系统的并发性提供一致的语义。不幸的是,定义一个一致且直观的内存模型远比想象的要困难得多。JSR 133 为Java定义了一种新的内存模型,修复了早期内存模型的缺陷。为了做到这一点,需要改变 final 和 volatile 的语义。

完整的语义在这:《The Java Memory Model》。但是正式的语义是比较“吓人”的。你会惊讶并警醒地发现即使像“同步”这样看似简单的概念背后也是非常复杂的。幸运的是,你不需要了解正式语义的细节。JSR 133 的目标是构建一个正式语义的集合,提供一个直观的框架,来说明 volatile、synchronized 和 final 是如何工作的。

JSR 133 的目标包括:

  • 保留现有的安全保障,如类型安全,并加强其它部分。如,变量值不能凭空创建(out of thin air):对于一个被某线程观察到的变量,它的每个值必须都能被该线程合理地放置。

  • 正确同步的程序,其语义应尽可能的简单直观。

  • 定义 同步不完整或不正确的程序 的语义,以尽量减少潜在的安全隐患。

  • 程序员应能合理自信地推断出多线程程序如何与内存交互。

  • 可以跨众多流行硬件架构,设计实现正确的高性能JVM

  • 提供新的“初始化安全(initialization safety)”保证。如果一个对象被正确地构建(这意味着其引用在实例构建过程中不会逃逸),那么所有保持有该对象引用的线程不需要同步,就能看到对象的 final 字段在构造方法中被赋值。

  • 最小化对现有代码的影响

重排序(reordering)是什么?

在许多案例中,访问程序变量时,程序执行(语句)的顺序可能与指定的顺序不同。这些变量包括 对象实例字段、类静态字段 和 数组元素。编译器可以优化的名义自由地随意更改指令顺序。在某些情况下,处理器可能不按顺序执行指令。数据在寄存器、处理器缓存 和 主存 之间被移动顺序与程序中指定的顺序不同。

例如,一个线程先写了字段a,然后写了字段b,且字段b的值不依赖于a的值,那么编译器可以自由地对这两个操作进行重排序,缓存也可以在a之前就将b的值刷新到主存。有很多重排序的来源,如 编译器、JIT 和 缓存。

编译器、运行时 和 硬件 被认定 一起制造了“类似串行语义”的假象。这意味着在单线程程序中,程序不应该观察到重排序的影响。但在未正确同步的多线程程序中,重排序会产生影响 —— 一个线程可以观察到其它线程的影响,其它线程对变量访问可见性的顺序与程序指定顺序不同。

绝大多数时候,一个线程不会关心其它线程做了什么。但当它关心的时候,“同步”就开始起作用。

老的内存模型哪里错了?

老的内存模型有多个严重的问题。这些问题很难理解,因此经常会违反相关使用规范。如,老的内存模型并不允许每个JVM都发生所有类型的重排序。这种对老模型含义的混淆是导致 JSR-133 形成的原因。

如,有一个广为接受的信念:“如果使用了final字段,那么不需要线程间同步就能保证另一个线程看到字段的值”。虽然这是个显而易见的合理假设,也是我们所希望的运行方式,但在老的内存模型中,这是错误的。在老的内存模型中,final字段的处理方式与其它字段没有任何不同。这意味着“同步”是唯一能保证所有线程可见到构造方法所写final字段值的方案。因此可能发生 一个线程先看到某个字段的默认值,过段时间后又看到了构造方法所赋的值。这意味着类似 String 这样的不可变对象也会发生值被改变的现象 —— 这是一个令人不安的现象。

老的内存模型允许 volatile 字段的写操作 与 非volatile 字段的读写操作进行重排序。这与大多数开发人员对 volatile 的直觉不同,因此导致了混淆。

最终,我们会发现,当程序未正确同步时,程序员对将发生之事的直觉经常是错误的。JSR-133的目标之一就是引起人们对此事实的注意。

不正确的同步(incorrectly synchronized)是什么意思?

“不正确同步的代码”的含义因人而异。当我们在Java内存模型这个上下文中说“不正确同步的代码”时表示:

某个线程存在写变量的操作;

另一个线程存在对同一个变量的读操作;

上述 写操作 和 读操作 未通过“同步”限定先后顺序。

当违反这些规则时,我们就说这个变量上存在“数据竞用(data race)”。存在数据竞用的程序就是不正确同步的程序。

“同步(synchronization)”有什么作用?

“同步”有多方面的影响。最容易理解的就是“互斥”——任意时刻只有一个线程可以拥有 monitor(好像没有特别信雅达的译名)。所以用一个monitor进行同步意味着,一旦一个线程进入了由该monitor保护的同步(代码)块,其它线程就只能在这第一个线程退出该同步块后,才能进入由该monitor保护的代码块。

但是除了互斥,还有更多同步的方式。同步保证了在同一个monitor上进行同步的其它线程,能以一种可预测的方式,见到当前线程在同步块之前或之内对内存的写操作。

当线程退出同步块时会“释放(release)”相应的monitor。该操作会引发缓存数据刷新到主内存,从而当前线程的写操作可以被其它线程见到。

在一个线程可以进入同步块之前,它会先“获取(acquire)”相应的monitor。该操作会引发本地处理器缓存被置为“无效”,从而变量会被重新从主内存加载。这样该线程就可以见到之前“释放monitor”产生的所有写操作。

从缓存的角度讨论这个问题,听起来好像这些问题只会影响多处理器机器。但是在单处理器上也很容易看到重排序的效果。例如,编译器不可能将代码移到 “获取”(monitor)之前 或 “释放”(monitor)之后。当我们说“获取/释放缓存”时,我们是对很多可能的效果进行简述。

新的内存模型语义规定了内存操作的部分排序(读字段、写字段、加锁、释放锁)及其它线程操作(start、join)。其中部分操作被规定为在其它操作之前发生——happen before。当一个操作在另一个之前发生时,可以保证第一个操作的顺序先于第二个,且其效果可给第二个操作见到。该排序的规则如下:

  • 线程中每个操作的(执行)顺序都先于程序中指定的后续操作。

  • 释放monitor锁的操作先于后续对个同一个monitor的锁操作。

  • 对 volatile 字段的写操作先于后续对同一个字段的读操作。

  • 对线程 start() 方法的调用先于该线程的其它操作。

  • 线程中所有操作都先于其它线程对其 join() 方法调用后成功返回。

这意味着,一个线程在同步块内对内存所有操作,可被其它线程在进入由同一个monitor保护的同步块后见到。因为所有的内存操作都发生在“释放”monitor之前,而“释放”monitor又发生在(其它线程)“获取”monitor之前。

另一个影响是下述模式不起作用。有些人会用该方式强制制造内存屏障。

Java代码

 

  1. synchronized (new Object()) {}  

这其实是一个空操作。编译器会将其整个移除。因为它知道没有其它线程会在同一个monitor上进行同步。你必须为线程设置一个 happen-before 关系,才能确保它能见到另一个线程的改动。

重要提示:为了正确设置 happen-before 关系,必须让两个线程都在同一个monitor上进行同步。如果线程A在对象X上同步,线程B在对象Y上进行同步,就无法保证可见性。对同一个monitor的“获取”和“释放”操作必须匹配,才能具备正确的语义。否则代码会存在数据竞用(data race)。

final 字段的值是如何被更改的?

可看到 final字段值如何被更改 的最佳示例之一涉及到String类的一种特定实现。

一个String对象可通过三个字段来实现 —— 一个字符数组、一个关于该数组的偏移量(offset) 和 一个长度(length)。以这种方式,而不是仅靠一个字符数组,来实现String的基本原理在于它允许 多个 String 和 StringBuffer 对象共享同一个字符数组,避免额外的对象分配和拷贝。这样,某些特性,如 String.substring() 方法,就可以通过创建一个与原String共享字符数组的新String对象,它们仅是 offset 和 length 不同。对一个String而言,这些字段都是final字段。

Java代码

 

  1. String s1 = "usr/tmp";  

  2. String s2 = s1.substring(4);  

字符串 s2 的偏移量(offset)是4,长度(length)也是4。在老的模型中,另一个线程可能会先看到offset的默认值0,然后再看到正确值4。对它来说s2好像从"/usr/tmp"变为"/tmp"。

原Java内存模型允许这种行为,一些JVM表现出了这种行为。新的Java内存模型将其规定为非法行为。

在新的Java内存模型中,final 字段是如何工作的?

对象中 final字段 的值是其构造方法设置的。假设对象被正确构建,那么一旦对象被构建完成,在构造方法中赋给final字段的值可被所有其它线程看到,而不需要同步。而且这些final字段所引用的其它对象或数组也同这些final字段一样是最新的。

“对象被正确构建”是什么意思?简单来说,对象被构建期间,不允许指向该对象的引用“逃逸(escape)”。换句话说:

  • 不要将正在被构造对象的引用放到其它线程可见的地方;

  • 不要将其引用赋值给一个静态字段;

  • 不要将其注册为其它对象的监听器(listener);

  • ...

这些任务应该在构造方法完成后再进行,而不是在构造方法中。

Java代码

 

  1. class FinalFieldExample {  

  2.   final int x;  

  3.   int y;  

  4.   static FinalFieldExample f;  

  5.   public FinalFieldExample() {  

  6.     x = 3;  

  7.     y = 4;  

  8.   }  

  9.   

  10.   static void writer() {  

  11.     f = new FinalFieldExample();  

  12.   }  

  13.   

  14.   static void reader() {  

  15.     if (f != null) {  

  16.       int i = f.x;  

  17.       int j = f.y;  

  18.     }  

  19.   }  

  20. }  

上述类是如何使用 final字段 的样例。这可以保证执行 reader 的线程看到 f.x 的值是3,因为它是 final;不保证该线程能看到 y 的值是4,因为它不是 final。如果 FinalFieldExample 的构造方法如下:

Java代码

 

  1. public FinalFieldExample() { // bad!  

  2.   x = 3;  

  3.   y = 4;  

  4.   // bad construction - allowing this to escape  

  5.   global.obj = this;  

  6. }  

那么不能保证“通过 global.obj 读到 this 的线程看到x的值是3”。

能看到字段正确构造的值是很好的。如果字段本身是一个引用,那么你也希望你的代码能看到其指向的对象(或数组)的最新值。如果你的字段是一个 final 字段,那么也可以保证这点。所以你可以用一个 final 指针指向一个数组而无需担心其它线程会 “看到该数组的正确引用,却看到数组中不正确的内容”。同样,这里的“正确”是指 “到对象的构造方法结束时是最新的”,而不是 “可用的最新值”。

说了这么多,现在如果在线程创建了一个“不可变对象(immutable object)”(只包含final字段的对象)后,你想保证其它线程都能看到其正确值,你仍然需要使用典型的同步。没有任何其它方法可以保证其它线程能看到指向 不可变对象 的引用。程序从final字段获得的保证,应基于对代码如何管理并发的深入细致理解,仔细调整。

没有规范可供你通过JNI(Java Native Interface)改变 final 字段的值。

volatile 有什么作用?

volatile 字段是特殊的字段,对于表示线程间通信状态非常有用。任何线程中的每一个 volatile(变量) 读操作都能看到该变量最近一次的写操作。实际上,程序员指定这些字段就是为了使它们的值不会因缓存或重排序而过时。编译器和运行时被禁止在寄存器中为这些字段分配(缓存)空间。这些字段也会确保其值被改变后从缓存刷新到主内存,让其它线程看到修改。类似的,在读取这些字段前,其相应的缓存必须被标记为无效,从而读到主内存中的值,而非本地处理器缓存。对 volatile 字段访问(操作)的重排序也有额外的限制。

在老的内存模型中,volatile 变量的访问(操作)之间不能重排序,但是它们可以与 非volatile 变量的访问(操作)重排序。这破坏了 volatile 字段作为线程间信号条件的有效性。

在新的内存模型中,volatile 字段(访问操作)之间仍然不能重排序。不同之处是,现在对它们周围正常字段访问(操作)的重排序没那么容易了。volatile字段写操作 与 monitor释放 有相同的内存效果,volatile字段读操作 与 获取monitor 有相同的内存效果。实际上,因为新内存模型对 volatile字段访问(操作)与其它字段访问(操作)的重排序有更严格的约束。无论是不是volatile字段,当线程A写 volatile字段 f 时,任何对其可见的变量,会在线程B读字段 f 时对线程B可见。

这时一个如何使用 volatile 字段的简例:

Java代码

 

  1. class VolatileExample {  

  2.   int x = 0;  

  3.   volatile boolean v = false;  

  4.   

  5.   public void writer() {  

  6.     x = 42;  

  7.     v = true;  

  8.   }  

  9.   

  10.   public void reader() {  

  11.     if (v == true) {  

  12.       // uses x - guaranteed to see 42.  

  13.     }  

  14.   }  

  15. }  

假设一个线程调用正在调用 writer,另一个线程正在调用 reader。对 v 的写操作会将对 x 的写操作释放(刷新)到内存,对 v 的读操作会从内存获得 x 的值。这样,如果 reader 看到 v 的值为 true,就能保证“将 x 写为42”已在这之前发生。老的内存模型不能提供这样的保证。如果 v 不是 volatile,那么编译器可以对 writer 中的写操作进行重排序,reader 中的读操作就可能读到 x 的值为 0。

实际上,volatile 的语义被大大增强了,几乎达到了同步的级别。每个对 volatile 字段的 读操作 或 写操作 表现得像为了实现“(变量)可见性”而执行的“半个”同步。

重要提示:为了正确设置一个 happen-before 关系,必须让两个线程访问同一个 volatile 变量。不能是线程A写 volatile 字段 f,而线程B读 volatile 字段 g。对同一个 volatile 字段的“释放(release)”和“获取(acquire)”必须匹配,才能形成正确的语义。

新的内存模型修复了“双检锁”问题吗?

臭名昭著的“双检锁”用法(也被称为多线程单例模式)是一种旨在支持延迟初始化同时避免同步开销的伎俩。在非常早期的JVM中,同步的速度很慢,程序员希望删除它——可能太过渴望了。双检锁用法类似如下:

Java代码

 

  1. // double-checked-locking - don't do this!  

  2. private static Something instance = null;  

  3.   

  4. public Something getInstance() {  

  5.   if (instance == null) {  

  6.     synchronized (this) {  

  7.       if (instance == null) {  

  8.         instance = new Something();  

  9.       }  

  10.     }  

  11.   }  

  12.   return instance;  

  13. }  

这看起来非常聪明——在公共代码路径上避免了同步。它只有一个问题——没用。为什么?最明显的原因是 “初始化instance” 和 “写instance字段” 可能被编译器或缓存重排序。这会导致返回一个只完成了部分构建的 Something。也就是可能会读到一个未初始化的对象。还有很多错误原因,对其进行算法修正也是错误的。不可能用老的内存模型来修复它。有关更深入的信息请访问《Double-checked locking: Clever, but broken》和《The "Double Checked Locking is broken" declaration》。

许多人假定使用 volatile 关键字可以消除双检锁模式的问题。1.5版本之前的JVM中,volatile 无法保证此作法可行(不同JVM可能不同)。在新的内存模型中,将 instance 字段设置为 volatile 可以修复双检锁问题。因为 Something 的初始化构建线程 和 读取该值的另一个线程 之间可以形成一个 happen-before 关系。

但是对双检锁的粉丝来说(我们真的希望没有粉丝),情况仍然不容乐观。双检锁是为了避免同步造成的性能开销。自从Java 1.0 以来,同步的开销已经变得很小;而且新内存模型中 volatile 的开销增加,甚至达到了同步的开销。所以仍然没有好的理由使用双检锁。修订 —— 在大多数平台中 volatile 的开销是比较小的。

相反,(内部私有类)Holder 初始化的用法是线程安全的,且更容易理解:

Java代码

 

  1. private static class LazySomethingHolder {  

  2.   public static Something something = new Something();  

  3. }  

  4.   

  5. public static Something getInstance() {  

  6.   return LazySomethingHolder.something;  

  7. }  

这段代码可以保证正确,因为它是初始化静态字段。如果一个字段是通过静态方式初始化的,那么可以保证任何其所在类的线程都能正确地看到它。

如果编写一个VM,我该注意什么?

你应该看看 http://gee.cs.oswego.edu/dl/jmm/cookbook.html

为什么我需要关心这些?

为什么你需要关心?并发bug是非常难调试的。它们往往不会在测试时出现,而是等你的程序在高负载运行的时候才出现,并且很难复现或捕获。你更应该提前多花些精力来确保你的程序已经被正确同步。虽然这并不容易,但比尝试调试一个糟糕同步的程序容易得多。

发布了219 篇原创文章 · 获赞 3 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/hchaoh/article/details/103904863