Java理论与实践: 修复Java内存模子,第2部门
副标题#e#
活泼了快要三年的 JSR 133,近期宣布了关于如何修复 Java 内存模子 (Java Memory Model, JMM)的果真发起。在本系列文章的 第 1 部门,专栏作 者 Brian Goetz 主要先容最初的 JMM 中的几个严重缺陷,这些缺陷导致了一些 难度高得惊人的观念语义,这些观念本来被认为很简朴。这个月,他先容在新 JMM 中 volatile 和 final 的语义是如何变革的,这些改变使它们的语义切合 大大都开拓人员的直觉。个中一些改变已经在 JDK 1.4 中呈现了,另一些改变 则要比及 JDK 1.5。请您在本文的接头论坛上与作者及其他读者交换您的想法。
开始编写并发代码是一件坚苦的工作,语言不应当增加它的难度。固然 Java 平台从一开始就包罗了对线程的支持,包罗一个打算为正确同步的措施提供“一 次编写,处处运行”担保的、跨平台的内存模子,可是本来的内存模子有一些漏 洞。固然很多 Java 平台提供了比 JMM 所要求的更强的担保,可是 JMM 中的漏 洞使得无法容易地编写可以在任何平台上运行的并发 Java 措施。所以在 2001 年 5 月,创立了以修复 Java 内存模子为目标的 JSR 133。 上个月,我接头了 个中一些裂痕,这个月,我们将接头如何堵住它们。
修复后的可见性
领略 JMM 所需要的一个要害观念是 可见性(visibility)——如何知道当 线程 A 执行 someVariable?=?3 时,其他线程是否可以看到线程 A 所写的值 3 ?有一些原因使其他线程不能当即看到 someVariable 的值 3:大概是因为编译 器为了执行效率更高而从头排序了指令,也大概是 someVariable 缓存在寄存器 中,可能它的值写到写处理惩罚器的缓存中、可是还没有刷新到主存中,可能在读处 理器的缓存中有一个老的(可能无效的)值。内存模子抉择什么时候一个线程可 以靠得住地“看到”由其他线程对变量的写入。出格是,内存模子界说了担保内存 操纵跨线程的可见性的 volatile 、 synchronized 和 final 的语义。
当线程为释放相关监督器而退出一个同步块时,JMM 要求当地处理惩罚器缓冲刷 新到主存中。(实际上,内存模子不涉及缓存——它涉及一个抽象( 当地内存 ), 它困绕了缓存、注册表和其他硬件和编译优化。)与此雷同,作为得到监督 的一部门,当进入一个同步块时,当地缓存失效,使之后的读操纵直接进入主内 存而不是当地缓存。这一进程担保当变量是由一个线程在由给定监督器掩护的同 步块中写入,并由另一个线程在由同一监督器掩护的同步块中读取时,对变量的 写可被读线程看到。假如没有同步,则 JMM 不提供这种担保——这就是为什么 在多个线程会见同一个变量时,必需利用同步(可能它的更年青的同胞 volatile )。
对 volatile 的新担保
volatile 本来的语义只担保 volatile 字段的读写直接在主存而不是寄存器 可能当地处理惩罚器缓存中举办,而且代表线程对 volatile 变量举办的这些操纵是 按线程要求的顺序举办的。换句话说,这意味着老的内存模子只担保正在读或写 的变量的可见性,不担保写入其他变量的可见性。固然可以容易实现它,可是它 没有像最初设想的那么有用。
固然对 volatile 变量的读和写不能与对其他 volatile 变量的读和写一起 从头排序,可是它们仍然可以与对 nonvolatile 变量的读写一起从头排序。在 第 1 部门 中,先容了清单 1 的代码(在旧的内存模子中)是如何不敷以担保 线程 B 看到 configOptions 及通过 configOptions 间接可及的所有变量(如 Map 元素)的正确值,因为 configOptions 的初始化大概已经随 volatile initialized 变量举办从头排序。
清单 1. 用一个 volatile 变量作为“守护”
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
不幸地,这是 volatile 常见用例——用一个 volatile 字段作为“守护” 表白已经初始化了一组共享变量。JSR 133 Expert Group 抉择让 volatile 读 写不能与其他内存操纵一起从头排序是有意义的——可以精确地支持这种和其他 雷同的用例。在新的内存模子下,假如当线程 A 写入 volatile 变量 V 而线程 B 读取 V 时,那么在写入 V 时,A 可见的所有变量值此刻都可以担保对 B 是 可见的。功效就是浸染更大的 volatile 语义,价钱是会见 volatile 字段时会 对机能发生更大的影响。
#p#副标题#e#
在这之前产生了什么?
#p#分页标题#e#
像对变量的读写这样的操纵,在线程中是按照所谓的“措施顺序”——措施 的语义声明它们该当产生的顺序——排序的。(编译器实际上对在线程中利用程 序顺序是可以有一些自由的——只要保存了 as-if-serial 语义。)在差异线程 中的操纵完全不必然要互相排序——假如启动两个线程而且它们对任何民众监督 器都不消同步执行、可能涉及任何民众 volatile 变量,则完全 无法精确地预 言一个线程中的操纵(可能对第三个线程可见)相对付另一个线程中操纵的顺序 。
另外,排序担保是在线程启动、一个线程参加另一个线程、一个线程得到或 者释放一个监督器(进入可能退出一个同步块)、可能一个线程会见一个 volatile 变量时建设的。JMM 描写了措施利用同步可能 volatile 变量以协调 多个线程中的勾那时所举办的的顺序担保。新的 JMM 非正式地界说了一个名为 happens-before 的排序,它是措施中所有操纵的部门顺序,如下所示:
线程中的每一个操纵 happens-before这个线程中在措施顺序中后头呈现的每 一个操纵
对监督器的解锁 happens-before同一监督器上的所有后续锁定
对 volatile 字段的写 happens-before同一 volatile 的每一个后续读
对一个线程的 Thread.start() 挪用 happens-before在启动的线程中的所有 操纵
线程中的所有操纵 happens-before 从这个线程的 Thread.join() 乐成返回 的所有其他线程
这些法则中的第三个——节制对 volatile 变量的读写,是新的而且批改了 清单 1 中的例子的问题。因为对 volatile 变量 initialized 的写是在初始化 configOptions 之后产生的, configOptions 的利用是在 initialized 的读后 产生的,而对 initialized 的读是在对 initialized 的写后产生的,因此可以 得出结论,线程 A 对 configOptions 的初始化是在线程 B 利用 configOptions 之前产生的。因而 configOptions 和通过它可及的变量对付线 程 B 是可见的。
图 1. 用同步担保跨线程的内存写的可见性
数据争用
当有一个变量被多个线程读、被至少一个线程写、而且读和写不是按 hanppens-before 干系排序的时,措施就称为有 数据争取(data race),因而 不是一个“正确同步”的措施。
这是否修改了双重查抄锁定的问题?
对双重查抄锁定问题提出的一种修复是使包括迟缓初始化的实例的字段为一 个 volatile 字段。(有关双重查抄锁定的问题和对为什么所发起的算法修复不 能办理问题的说明请参阅 参考资料。)在旧的内存模子中,这不能使双重查抄 锁定成为线程安详的,因为对 volatile 字段的写仍然会与对其他 nonvolatile 字段的写(如新结构的工具的字段)一起从头排序,因而 volatile 实例引用仍 然大概包括对一个未结构完的工具的引用。
在新的内存模子中,对双重查抄锁定的这个“修复”使 idiom 线程安详。但 是仍然不料味着该当利用这个 idiom!双重查抄锁定的要点是,它假定是机能优 化的,设计用于消除民众代码路径的同步,很洪流平上因为对付早期的 JDK 来 说,同步是相对昂贵的。不只非竞争的同步已经自制 多 了,并且对 volatile 语义的新改变也使它在某些平台上比旧的语义昂贵得多。(实际上,对 volatile 字段的每一次读可能写都像是“半个”同步——对 volatile 的读有 与监督器所得到的同样的内存语义,对 volatile 的写有与监督器所释放的同样 的语义。)所以假如双重查抄锁定的方针是提供比更直观的同步方法更好的机能 ,那么这个“修复的”版本也没有多大辅佐。
不利用双重查抄锁定,而利用 Initialize-on-demand Holder Class idiom ,它提供了迟缓初始化,是线程安详的,并且比双重查抄锁定更快且没那么杂乱 :
清单 2. Initialize-On-Demand Holder Class idiom
private static class LazySomethingHolder {
public static Something something = new Something();
}
...
public static Something getInstance() {
return LazySomethingHolder.something;
}
这个 idiom 由属于类初始化的操纵(如静态初始化器)担保对利用这个类的 所有线程都是可见的这一事实衍生其线程安详性,内部类直到有线程引用其字段 可能要领时才装载这一事实衍生出迟缓初始化。
初始化安详性
新的 JMM 还寻求提供一种新的 初始化安详性 担保——只要工具是正确结构 的(意即不会在结构函数完成之前宣布对这个工具的引用),然后所有线程城市 看到在结构函数中配置的 final 字段的值,不管是否利用同步在线程之间通报 这个引用。并且,所有可以通过正确结构的工具的 final 字段可及的变量,如 用一个 final 字段引用的工具的 final 字段,也担保对其他线程是可见的。这 意味着假如 final 字段包括,好比说对一个 LinkedList 的引用,除了引用的 正确的值对付其他线程是可见的外,这个 LinkedList 在结构时的内容在差异步 的环境下,对付其他线程也是可见的。功效是显著加强了 final 的意义——可 以不消同步安详地会见这个 final 字段,编译器可以假定 final 字段将不会改 变,因而可以优化多次提取。
Final 意味着最终
#p#分页标题#e#
在 第 1 部门描写了在旧的内存模子中,final 字段的值好像可以改变的一 种机制——在不利用同步时,另一个线程会首先看到 final 字段的默认值,然后 看到正确的值。
在新的内存模子中,在结构函数的 final 字段的写与在另一个线程中对这个 工具的共享引用的初次装载之间有一个雷同于 happens-before 的干系。当结构 函数完成任务时,对 final 字段的所有写(以及通过这些 final 字段间接可及 的变量)变为“冻结”,所有在冻结之后得到对这个工具的引用的线程城市担保 看到所有冻结字段的冻结值。初始化 final 字段的写将不会与结构函数关联的 冻结后头的操纵一起从头排序。
竣事语
JSR 133 显著加强了 volatile 的语义,这样就可以靠得住地利用 volatile 符号表白措施状态被另一个线程改变了。作为使 volatile 更“重量级”的功效 ,利用 volatile 的机能本钱更靠近于某些环境下同步的机能本钱,可是在大多 数平台上机能本钱仍然相当低。JSR 133 还显著地加强了 final 的语义。假如 一个工具的引用在结构阶段不答允逸出(escape),那么一旦结构函数完成,并 且线程宣布了对另一个工具的引用,那么在不利用同步的条件下,这个工具的 final 字段就担保对所有其他线程是可见的、正确的而且是稳定的。
这些改变极大地增强了并发措施中稳定工具的效用,稳定工具最终成为固有 的线程安详(就像它们所要成为的那样),纵然利用数据争用在线程之间将引用 通报给稳定工具。
初始化安详性的一个申饬是工具的引用不许“逸出”其结构函数——结构函 数不该直接可能间接宣布对正在结构的工具的引用。这包罗宣布对 nonstatic 内部类的引用,并一般要制止在结构函数中启动线程。