Java 理论与实践: 修复 Java 内存模型,第 1 部分

什么是 Java 内存模型,最初它是怎样被破坏的?html

Brian Goetz ( brian@quiotix.com), 首席顾问, Quiotix 公司

简介: 活跃了将近三年的 JSR 133,近期发布了关于如何修复 Java 内存模型(Java Memory Model, JMM)的公开建议。原始 JMM 中有几个严重缺陷,这致使了一些难度高得惊人的概念语义,这些概念原来被认为很简单,如 volatile、final 以及 synchronized。在这一期的 Java 理论与实践 中,Brian Goetz 展现了如何增强 volatile 和 final 的语义,以修复 JMM。这些更改有些已经集成在 JDK 1.4 中;而另外一些将会包含在 JDK 1.5 中。您能够在本文对应的论坛里与做者及其余读者分享您对本文的见解(您也能够点击文章底部或顶部的 讨论 按钮来访问论坛)。java

发布日期: 2004 年 3 月 01 日
级别: 初级
访问状况 2079 次浏览
建议: 1 (查看或添加评论) 程序员

1 star 2 stars 3 stars 4 stars 5 stars 平均分 (共 3 个评分 )

Java 平台把线程和多处理技术集成到了语言中,这种集成程度比之前的大多数编程语言都要强不少。该语言对于平台独立的并发及多线程技术的支持是野心勃勃而且是具备开拓性的,或许并不奇怪,这个问题要比 Java 体系结构设计者的原始构想要稍微困难些。关于同步和线程安全的许多底层混淆是 Java 内存模型 (JMM)的一些难以直觉到的细微差异,这些差异最初是在 Java Language Specification 的第 17 章中指定的,而且由 JSR 133 从新指定。 编程

例如,并非全部的多处理器系统都表现出 缓存一致性(cache coherency);假若有一个处理器有一个更新了的变量值位于其缓存中,但尚未被存入主存,这样别的处理器就可能会看不到这个更新的值。在缓存缺少一致性的状况下,两个不一样的处理器能够看到在内存中同一位置处有两种不一样的值。这听起来不太可能,可是这倒是故意的 —— 这是一种得到较高的性能和可伸缩性的方法 —— 可是这加剧了开发者和编译器为解决这些问题而编写代码的负担。 数组

什么是内存模型,我为何须要一个内存模型?缓存

内存模型描述的是程序中各变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存取出变量这样的低层细节。对象最终存储在内存中,但编译器、运行库、处理器或缓存能够有特权定时地在变量的指定内存位置存入或取出变量值。例如,编译器为了优化一个循环索引变量,可能会选择把它存储到一个寄存器中,或者缓存会延迟到一个更适合的时间,才把一个新的变量值存入主存。全部的这些优化是为了帮助实现更高的性能,一般这对于用户来讲是透明的,可是对多处理系统来讲,这些复杂的事情可能有时会彻底显现出来。 安全

JMM 容许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员已经使用 synchronized final 明确地请求了某些可见性保证。这意味着在缺少同步的状况下,从不一样的线程角度来看,内存的操做是以不一样的次序发生的。 多线程

与之相对应地,像 C 和 C++ 这些语言就没有显示的内存模型 —— 但 C 语言程序继承了执行程序处理器的内存模型(尽管一个给定体系结构的编译器可能知道有关底层处理器的内存模型的一些状况,而且保持一致性的一部分责任也落到了该编译器的头上)。这意味着并发的 C 语言程序能够在一个,而不能在另外一个,处理器体系结构上正确地运行。虽然一开始 JMM 会有些混乱,但这有个很大的好处 —— 根据 JMM 而被正确同步的程序能正确地运行在任何支持 Java 的平台上。 并发


原始 JMM 的缺点

虽然在 Java Language Specification 的第 17 章指定的 JMM 是一个野心勃勃的尝试,它尝试定义一个一致的、跨平台的内存模型,但它有一些细微而重要的缺点。 synchronizedvolatile 的语义很让人混淆,以至于许多有见地的开发者有时选择忽略这些规则,由于在旧的存储模型下编写正确同步的代码很是困难。

旧的 JMM 容许一些奇怪而混乱的事情发生,好比 final 字段看起来没有那种设置在构造函数里的值(这样使得想像上的不可变对象并非不可变的)和内存操做从新排序的意外结果。这也防止了其余一些有效的编译器优化。若是您阅读了关于双重检查锁定问题(double-checked locking problem)的任何文章(参阅 参考资料),您将会记得内存操做从新排序是多么的混乱,以及当您没有正确地同步(或者没有积极地试图避免同步)时,细微却严重的问题会如何暗藏在您的代码中。更糟糕的是,许多没有正确同步的程序在某些状况下彷佛工做得很好,例如在轻微的负载下、在单处理器系统上,或者在具备比 JMM 所要求的更强的内存模型的处理器上。

“从新排序”这个术语用于描述几种对内存操做的真实明显的从新排序的类型:

  • 当编译器不会改变程序的语义时,做为一种优化它能够随意地从新排序某些指令。
  • 在某些状况下,能够容许处理器以颠倒的次序执行一些操做。
  • 一般容许缓存以与程序写入变量时所不相同的次序把变量存入主存。

从另外一线程的角度来看,任何这些条件都会引起一些操做以不一样于程序指定的次序发生 —— 而且忽略从新排序的源代码时,内存模型认为全部这些条件都是同等的。


JSR 133 的目标

JSR 133 被受权来修复 JMM,它有几个目标:

  • 保留现有的安全保证,包括类型安全。

  • 提供 无中生有安全性(out-of-thin-air safety)。这意味着变量值并非“无中生有”地建立的 —— 因此对于一个线程来讲,要观察到一个变量具备变量值 X,必须有某个线程之前已经真正把变量值 X 写入了那个变量。

  • “正确同步的”程序的语义应该尽量简单直观。这样,“正确同步的”应该被正式而直观地定义(这两种定义应该相互一致)。

  • 程序员应该要有信心建立多线程程序。固然,咱们没有魔法使得编写并发程序变得很容易,可是咱们的目标是为了减轻程序员理解内存模型全部细节的负担。

  • 跨大范围的流行硬件体系结构上的高性能 JVM 实现应该是可能的。现代的处理器在它们的内存模型上有着很大的不一样;JMM 应该可以适合于实际的尽量多的体系结构,而不会以牺牲性能为代价。

  • 提供一个同步习惯用法(idiom),以容许咱们发布一个对象而且使得它不用同步就可见。这是一种叫作 初始化安全(initialization safety)的新的安全保证。

  • 对现有代码应该只有最小限度的影响。

值得注意的是,有漏洞的技术(如双重检查锁定)在新的内存模型下仍然有漏洞,而且“修复”双重检查锁定技术并非新内存模型所致力的一个目标。(可是, volatile 的新语义容许一般所提出的其中一个双重检查锁定的可选方法正确地工做,尽管咱们不鼓励这种技术。)

从 JSR 133 process 变得活跃的三年来,人们发现这些问题比他们认为重要的任何问题都要微妙得多。这就是做为一个开拓者的代价!最终正式的语义比原来所预料的要复杂得多,实际上它采用了一种与原先预想的彻底不一样的形式,但非正式的语义是清晰直观的,将在本文的第 2 部分概要地说明。


同步和可见性

大多数程序员都知道, synchronized 关键字强制实施一个互斥锁(互相排斥),这个互斥锁防止每次有多个线程进入一个给定监控器所保护的同步语句块。可是同步还有另外一个方面:正如 JMM 所指定,它强制实施某些内存可见性规则。它确保了当存在一个同步块时缓存被更新,当输入一个同步块时缓存失效。所以,在一个由给定监控器保护的同步块期间,一个线程所写入的值对于其他全部的执行由同一监控器所保护的同步块的线程来讲是可见的。它也确保了编译器不会把指令从一个同步块的内部移到外部(虽然在某些状况下它会把指令从同步块的外部移到内部)。JMM 在缺少同步的状况下不会作这种保证 —— 这就是只要有多个线程访问相同的变量时必须使用同步(或者它的同胞,易失性)的缘由。


问题 1:不可变对象不是不可变的

JMM 的其中一个最惊人的缺点是,不可变对象彷佛能够改变它们的值(这种对象的不变性旨在经过使用 final 关键字来获得保证)。(Public Service Reminder:让一个对象的全部字段都为 final 并不必定使得这个对象不可变 —— 全部的字段 必须是原语类型或是对不可变对象的引用。)不可变对象(如 String )被认为不要求同步。可是,由于在将内存写方面的更改从一个线程传播到另外一个线程时存在潜在的延迟,因此有可能存在一种竞态条件,即容许一个线程首先看到不可变对象的一个值,一段时间以后看到的是一个不一样的值。

这是怎么发生的呢?考虑到 Sun 1.4 JDK 中 String 的实现,这儿基本上有三个重要的决定性字段:对字符数组的引用、长度和描述字符串开始的字符数组的偏移量。 String 是以这种方式实现的,而不是只有字符数组,所以字符数组能够在多个 StringStringBuffer 对象之间共享,而不须要在每次建立一个 String 时都将文本拷贝到一个新的数组里。例如, String.substring() 建立了一个能够与原始的 String 共享同一个字符数组的新字符串,而且这两个字符串仅仅只是在长度和偏移量上有所不一样。

假设您执行如下的代码:

String s1 = "/usr/tmp";
String s2 = s1.substring(4);   // contains "/tmp"

字符串 s2 将具备大小为 4 的长度和偏移量,可是它将同 s1 共享包含“ /usr /tmp ”的同一字符数组。在 String 构造函数运行以前, Object 的构造函数将用它们默认的值初始化全部字段,包括决定性的长度和偏移字段。当 String 构造器运行时,字符串长度和偏移量被设置成所须要的值。可是在旧的内存模型下,在缺少同步的状况下,有可能另外一个线程会临时地看到偏移量字段具备初默认值 0,然后又看到正确的值 4。结果是 s2 的值从“ /usr ”变成了“ /tmp ”。这并非咱们所想要的,并且在全部 JVM 或平台这是不可能的,可是旧的内存模型规范容许这样作。


问题 2:从新排序易失性和非易失性存储

另外一个主要领域是与 volatile 字段的内存操做从新排序有关,这个领域中现有 JMM 引发了一些很是混乱的结果。现有 JMM 代表易失性的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者绕过处理器特定的缓存。这使得多个线程通常能看见一个给定变量的最新的值。但是,结果是这种 volatile 定义并无最初所想像的那样有用,而且它致使了 volatile 实际意义上的重大混乱。

为了在缺少同步的状况下提供较好的性能,编译器、运行库和缓存一般被容许从新排序普通的内存操做,只要当前执行的线程分辨不出它们的区别。(这就是所谓的 线程内彷佛是串行的语义(within-thread as-if-serial semantics)。)可是,易失性的读和写是彻底跨线程安排的,编译器或缓存不能在彼此之间从新排序易失性的读和写。遗憾的是,经过参考普通变量的读和写,JMM 容许易失性的读和写被从新排序,这意味着咱们不能使用易失性标志做为操做已完成的指示。考虑下面的代码,其意图是假定易失性字段 initialized 用于代表初始化已经完成了。


清单 1. 使用一个易失性字段做为一个“守卫”变量
Map configOptions;
char[] configText;
volatile boolean initialized = false;
 . .
//  In thread A
        

configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 . .
// In thread B
        

while (!initialized) 
  sleep();
// use configOptions

这里的思想是使用易失性变量 initialized 担任守卫来代表一套别的操做已经完成了。这是一个很好的思想,可是它不能在旧的 JMM 下工做,由于旧的 JMM 容许非易失性的写(好比写到 configOptions 字段,以及写到由 configOptions 引用 Map 的字段中)与易失性的写一块儿从新排序,所以另外一个线程可能会看到 initialized 为 true,可是对于 configOptions 字段或它所引用的对象尚未一个一致的或者说当前的视图。 volatile 的旧语义只承诺正在读和写的变量的可见性,而不承诺其余的变量。虽然这种方法更容易有效地实现,但结果是没有原来所想的那么有用。


结束语

正如 Java Language Specification 第 17 章中所指定的,JMM 有一些严重的缺点,即容许一些看起来合理的程序发生一些非直观的或不合须要的事情。若是正确地编写并发的类太困难的话,那么咱们能够说许多并发的类不能按预期工做,而且这是平台中的一个缺点。幸运的是,咱们能够在不破坏在旧的内存模型下正确同步的任何代码的同时,建立一个与大多数开发者的直觉更加一致的内存模型,而且这一切已经由 JSR 133 process 完成。下个月,咱们将介绍新的内存模型(它的大部分功能已集成到 1.4 JDK 中)的详细信息。

相关文章
相关标签/搜索