分享

JAVA内存的可见性和指令的reorde

 dtl乐学馆 2014-11-11
 原文地址:
中文译版
英文原版
http://www.ibm.com/developerworks/library/j-jtp03304/index.html
一、前言    
   Java 平台把多线程和多处理器技术集成到了语言中,这种集成程度比以前的大多数编程语言都要强很多。该语言针对多种异构平台的平台独立性而使用的多线程技术支持具有开拓性的意义。但它在同步和线程安全方面产生了内存的不一致性和无序性问题。通常我们之所以受困于线程同步和线程安全,是因为我们没有对JAVA的内存模型(Java Memory Model,简称JMM)进行深入的了解。JMM最开始出现在Java Language Specification的第17章中,而后经过修订最终被确定为JSR133规范。
      例如,并不是所有的多处理器系统都表现出缓存一致性(cache coherency);假如有一个处理器有一个更新了的变量值位于其缓存中,但还没有被存入主存,这样别的处理器就可能会看不到这个更新的值。在缓存缺乏一致性的情况下,两个不同的处理器可以看到在内存中同一位置处有两种不同的值。这听起来不太可能,但是这却是故意的 —— 这是一种获得较高的性能和可伸缩性的方法 —— 但是这加重了开发者和编译器为解决这些问题而编写代码的负担。
二、JAVA内存模型简介
     JAVA内存的不一致性和无序性是和Java 内存模型 (Java Memory Model,JMM)相关联的。
      JAVA内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及如何在实际计算机系统中从内存读取变量值和把变量值写到内存的底层细节。对象最终是存储在内存里面的,这点没有错,但是编译器、运行库、处理器或者系统缓存对于何时从内存读取变量的值何时把变量值写入内存却拥有很大的自由。例如,编译器为了优化一个循环索引变量,可能会选择把它存储到一个寄存器中,或者缓存中被修改了的变量值并不是立即更新到主存(内存)中,而是一个更合适时候才更新。所有的这些优化是为了帮助实现更高的性能,通常这对于用户来说是透明的,但是对多处理系统来说,这些复杂的事情可能影响我们的程序的正确运行。
        JAVA内存模型允许编译器缓存有自主安排数据在处理器特定的缓存(或寄存器)和主存之间如何进行移动的自由,除非程序员使用了finalsynchronized明确要求保证数据对其他线程的实时可见性。这样的话,在没有使用synchronized的情况下,从不同的线程的角度来看,对内存的操作的顺序可能就不太一样。
        与之相对应地,像 C 和 C++ 这些语言就没有显示的内存模型 —— 但 C 语言程序继承了执行程序处理器的内存模型(尽管一个给定体系结构的编译器可能知道有关底层处理器的内存模型的一些情况,并且保持一致性的一部分责任也落到了该编译器的头上)。这意味着并发的 C 语言程序可以在一个,而不能在另一个,处理器体系结构上正确地运行。虽然最初的JMM 引起一些麻烦,但是它的确为我们带来了很大的好处 —— 只要依照JMM 规范对程序正确的使用同步,程序就能正确地运行在任何支持 Java 的平台上。
三、旧版JAVA内存模型的缺陷
虽然最初出现在Java Language Specification的第17章中的JMM是一个雄心勃勃的尝试计划,它尝试定义一个一致的跨平台的内存模型,但它却拥有有一些看似小而实际很严重的缺陷。 synchronized  volatile 的语义很容易让人困惑,以致于许多比较资深的开发者有时也选择忽略JMM的规则,因为在旧的JMM下编写正确同步的代码非常困难。
     旧的JMM 允许匪夷所思的事情发生。比如,final变量并不是在构造函数里所设定的值(原本被认为不可变的final变量变成可变)。内存操作代码的“reordering”也可能产生意想不到的结果。这也阻止了其他一些有效的编译器优化。如果您阅读了关于双重检查锁定问题(double-checked locking problem)的任何文章(参阅 单例模式及JMM的无序性),您将会记得内存操作重新排序是多么的混乱,以及当您没有正确地同步(或者没有积极地试图避免同步)时,看似小而实际很严重的隐患就深埋进在了你的代码中。更糟糕的是,许多没有正确同步的程序在某些情况下似乎工作得很好,例如在轻微的负载下、在单处理器系统上,或者在具有比 JMM 所要求的更强的内存模型的处理器上。
      JMM规范在“reordering”方面约定对内存操作的顺序可以在以下几种情况下进行调整:
1、编译器,在不会改变程序语义的情况下,为了优化,可以对某些内存操作指令进行重新排序。
2、处理器,在某些情况下,可以以不同于指令本身的次序执行内存操作指令。
3、缓存,通常允许以与源码中的变量们的操作顺序不一致的顺序来把缓存变量回写入主存。比如对于源码"a=1;b=2",缓存中虽然可能是a先变为1,b后为2;但是缓存可能先把b从缓存的值回写到主存中,而后才把a的值从缓存回写到主存中。这样对于别的线程是先看到b被赋值为2,而后a才被赋值为1.
虽然上面提及的JMM规范关于reordering这些约定可能引发代码指令以与程序源码中不同的次序被执行这个事情对本线程来说是透明的,而且JMM也认为reordering”并不影响程序执行结果但是从其他线程的来说却非透明的,reordering实际上可能会影响其执行结果
3.1、同步和可见性
大多数程序员都知道, synchronized关键字强制实施一个互斥锁(互相排斥),这个互斥锁防止每次有多个线程进入一个给定监控器所保护的同步语句块。但是同步还有另一个方面:正如JMM所指定,它强制实施某些内存可见性规则。它确保了当进入一个同步块时,同步块中变量的缓存值将被更新,即缓存值将重新从主存读取,以便得到更新的值;当离开一个同步块时,同步块中变量的缓存值将回写到主存中。这样的话,在一个由给定监控器保护的同步块期间,一个线程所写入的值对于其余所有的执行由同一监控器所保护的同步块的线程来说是立即可见的。 synchronized 关键字也确保了编译器不会把指令从一个同步块的内部移到外部(虽然在某些情况下它会把指令从同步块的外部移到内部)。JMM 在不使用synchronized关键字的情况下,不会做这种保证 —— 这就是只要有多个线程访问相同的变量时必须使用synchronization(或者它的兄弟,volatile)的原因。
3.2、问题 1(不可变对象不是不可变的
JMM 的其中一个最惊人的缺陷是,不可变对象似乎是可变的。不可变对象是指它的所有成员变量都是final的,而且所有成员变量必须是基本数据类型对不可变对象的引用。不可变对象(如 String )通常被认为是不需要同步的。但是因为在对内存写方面的更改从一个线程传播到另一个线程时存在一定延迟,即对内存的更改对别的线程来说并不能保证立即可见这样的话,就可能出现一个线程首先看到不可变对象的一个值,一段时间之后看到一个与首次不同的值。
    这是怎么发生的呢?考虑到 Sun 1.4 JDK 中 String 的实现,这儿基本上有三个重要的决定性字段:对字符数组的引用、长度和描述字符串开始的字符数组的偏移量。 String 是以这种方式实现的,而不是只有字符数组,因此字符数组可以在多个 String 和 StringBuffer 对象之间共享,而不需要在每次创建一个 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 或平台这也许是根本不会发生的,但是旧的内存模型规范允许这样做。
3.3、问题2(volatile and nonvolatile变量存储的Reordering
JMM 的另一个比较严重的缺陷是由于对volatile变量的内存操作的Reordering引起的,它给JMM带来了一些混乱。现有JMM表明对volatile的变量的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者处理器特定的缓存。这使得多个线程一般能看见一个给定变量的最新的值。可是,结果是这种由volatile修饰的变量并没有最初所想像的那样有用,volatile的实际意义还是比较混乱的。
       为了在缺乏同步的情况下提供较好的性能,编译器、运行库和缓存通常被允许重新排序普通的内存操作,只要当前执行的线程分辨不出它们的区别。(这就是所谓的 线程内似乎是串行的语义(within-thread as-if-serial semantics)。)但是,对volatile变量的读和写往往是完全跨线程的,Reordering是只考虑当前线程的,编译器或缓存并不能在多线程之间对volatile变量的读和写进行很好Reordering。遗憾的是,旧版JMM 允许volatile变量和普通变量一样,都可以对其读和写被重新排序Reordering只要当前执行的线程分辨不出它们的区别。这意味着我们不能使用易失性标志作为操作已完成的指示。考虑下面的代码,其意图是假定volatile字段 initialized 用于表明初始化已经完成了。
清单1. Using a volatile field as a "guard" variable.
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变量 initialized 担任守卫来表明一套别的操作已经完成了。这是一个很好的思想,但是它不能在旧的 JMM 下工作,因为旧的JMM 允许非易失性的写(比如写到 configOptions 字段,以及写到由 configOptions 引用 Map 的字段中)与volatile的写一起重新排序,因此另一个线程可能会看到 initialized 为 true,但是对于 configOptions 字段或它所引用的对象的初始化操作并还没有完成。volatile 的旧语义只承诺正在读和写的变量的可见性,而不承诺其他的变量。虽然使用volatile这种方法更容易有效地实现,但结果是没有原来所想的那么有用。
四、新版JAVA内存模型(JSR 133) 的改进
虽然许多Java 平台提供了比 JMM 所要求的更强的保证,但是老版JMM 中的漏洞使得无法容易地编写可以在任何平台上运行的并发 Java 程序
新版JMM(JSR 133) 被授权来修复 旧版的JMM,这新版JMM的一些更改已经集成在 JDK 1.4 中;而另一些则在JDK 1.5中才被完全集成。
新版JMM(JSR 133有几个目标:
1、保留现有的安全保证,包括类型安全。 
2、提供无中生有安全性(out-of-thin-air safety)。这意味着变量值并不是“无中生有”地创建的 —— 所以对于一个线程来说,要观察到一个变量具有变量值 X,必须有某个线程以前已经真正把变量值 X 写入了那个变量。 
3、“correctly synchronized”程序的语义应该尽可能简单直观。这样,“正确同步的”应该被正式而直观地定义(这两种定义应该相互一致)。 
4、应该让程序员有信心去创建多线程程序。虽然我们没有魔法使得编写并发程序变得很容易,但是我们减轻程序员理解内存模型所有细节的负担。 
5、一定要让跨大范围的流行硬件体系结构上的高性能 JVM 实现变得可能。现代的处理器在它们的内存模型上有着很大的不同;JMM 应该能够适合于实际的尽可能多的体系结构,而且不能以牺牲性能为代价。 
6、提供一个新的安全保证,让我们发布一个对象时,即使对它不使用同步,它对其他线程也立即可见。这种新的安全保证叫做初始化安全(initialization safety)。
7、对现有代码的影响应该尽量的小。
   值得注意的是,有漏洞的技术(如双重检查锁定)在新的内存模型下仍然有漏洞,并且新内存模型并没把“修复”双重检查锁定技作为目标之一。(但是, volatile 的新语义可以让我们经常提及的双重检查锁定的可替换方法中的一个方法正确地工作,尽管这种技术并不被鼓励使用。)
     从 JSR 133 process 变得活跃的三年来,人们发现它其实比大家想象的更难以捉摸。这就是作为一个开拓者的代价!最终正式的语义比原来所预料的要复杂得多,实际上它采用了一种与原先预想的完全不同的形式,但非正式的语义是清晰直观的,将在本文的下半部分概要地说明。
五、新版JMM的可见性
     理解JMM 所需要的一个关键概念是 可见性(visibility)——你怎么知道当线程A执行someVariable=3 时,其他线程就可以看到线程A的someVariable变量的值就是 3呢?有一些原因使其他线程不能立即看到 someVariable 的值 3:可能是因为编译器为了执行效率更高而重新排序了指令,也可能是 someVariable 缓存在寄存器中,或者它的值写到写处理器的缓存中、但是还没有刷新到主存中,或者在读处理器的缓存中有一个老的(或者无效的)值。JAVA内存模型决定一个线程什么时候可以“看到”由其他线程对变量的写入值。
JAVA内存模型特别定义volatile 、 synchronized 和 final 的语义以保证内存操作跨线程的可见性。但是在旧版中他们的语义并不能完全满足我们的需求,所有在新版中对它们的语义进行了增强。。
    当线程为释放相关监视器而退出一个同步块(synchronized)时,JMM 要求本地处理器缓冲刷新到主存中。(实际上,内存模型并不关心缓存——它所关心的是包括缓存和寄存器的抽象的local内存, 以及其他硬件和编译的优化。)与此类似,作为获得监视的一部分,当进入一个同步块时,本地缓存失效,使之后的读操作直接从主内存而不是本地缓存来进行。这样的话,在一个由给定监控器保护的同步块期间,一个线程所写入的值对于其余所有的执行由同一监控器所保护的同步块的线程来说是立即可见的。如果没有同步,则JMM 不提供这种保证——这就是为什么在多个线程访问同一个变量时,必须使用同步(或者它的兄弟,volatile)。
5.1、volatile 对可见性的保障
    volatile原来的语义只保证volatile变量的读写直接在主存而不是寄存器或者本地处理器缓存中进行,并且线程对所有volatile 变量进行的这些操作是按代码中对这些volatile变量操作的顺序进行的。换句话说,这意味着老的内存模型只对volatile 变量读或写的变量的可见性,不保证写入其他变量的可见性。虽然旧版中volatile的语义很容易实现,但是它没有像最初设想的那么有用。
    另外,在老版JMM中,虽然对volatile 变量的读和写不能与对其他 volatile 变量的读和写操作一起Reordering,但是它们仍然可以与对 nonvolatile 变量的读写操作一起Reordering。在上文 中,我已经介绍了清单 1 的代码(在旧的内存模型中)是如何不足以保证线程 B 看到 configOptions 及通过 configOptions 间接可及的所有变量(如 Map 元素)的正确值,因为 configOptions 的初始化可能已经随 volatile initialized 变量进行重新排序。
不幸地是清单1的代码其实 volatile 的一常见用例——用一个 volatile 字段作为“守护”表明已经初始化了一组共享变量。JSR 133决定让 volatile 读写不能与其他内存操作一起重新排序(Reordering),这是有意义的——这样就可以完美的解决这种和其他类似的用例。在新的内存模型下,如果当线程 A 写入 volatile 变量 V 而线程 B 读取 V 时,那么在写入 V 时,A 可见的所有变量值现在都可以保证对 B 是立即可见的。结果就是为volatile定义更强大的语义,代价是访问 volatile 字段时会对性能产生更大的影响。
5.2、volatilehappens before的保障
   像对变量的读写这样的操作,在线程中是根据所谓的“程序顺序”—程序的语义声明它们应当发生的顺序——排序的。(编译器实际上对在线程中使用程序顺序是可以有一些自由的——只要保留了 as-if-serial 语义。)对于在不同线程中的操作,完全不一定要彼此排序——如果启动两个线程并且它们对任何公共监视器都不用同步执行、或者涉及任何公共 volatile 变量,则完全 无法准确地预言一个线程中的操作(或者对第三个线程可见)相对于另一个线程中操作的顺序。
   此外,不同线程中操作的顺序是可以通过线程启动、一个线程参与另一个线程的join操作、一个线程获得或者释放一个监视器(进入或者退出一个同步块)、或者一个线程访问一个 volatile 变量来保证的。JMM 描述了程序使用同步或者 volatile 变量以协调多个线程中的活动时所进行的的顺序保证。新的JMM 非正式地定义了一个名为 happens-before ordering语义,它是程序中所有操作的部分顺序,如下所示:
1、在程序顺序中,每一个线程中的每一个操作 happens-before该操作后面出现的每一个操作 。
2、对监视器的解锁 happens-before同一监视器上的所有后续需要锁定的所有操作 
3、volatile变量操作得程序顺序前的操作,实际执行时也一定happens-beforevolatile变量操作volatile变量操作实际执行时也一定happens-beforevolatile变量操作的每一个程序顺序后续操作。当然,当我们读到一个已写的volatile变量的值的时候这时其对应的对volatile变量的写操作程序顺序之前的所有操作已经happens-before于该volatile变量读操作
注:这里原文是“A write to a volatile field happens-before every subsequent read of that same volatile”,感觉阐述得不明不白。
难道仅仅是想表达“对一个volatile变量的写操作happens-before于我们读取到volatile变量该写操作所写入的值“
4、对一个线程的 Thread.start() 调用 happens-before这个被启动的线程的其他所有操作 
5、一个线程中的所有操作happens-before从这个线程的Thread.join() 成功返回的其他线程中的后续操作
  这些规则中的第三个——控制对volatile变量的读写,是新的并且修正了清单 1 中的例子的问题。因为对volatile 变量 initialized 的写是在初始化configOptions 之后发生的, configOptions 的使用是在initialized的读后发生的,而initialized当为"true"时,对initialized的读是在对 initialized 的写后发生的,因此可以得出结论,线程 A 对 configOptions 的初始化是在线程 B 使用 configOptions 之前发生的。因而 configOptions 和通过它可及的变量对于线程 B 是可见的。

5.3、数据争用
当有一个变量被多个线程读、被至少一个线程写、并且读和写不是按 hanppens-before 关系排序的时,程序就称为有数据争取(data race),因而不是一个“正确同步”的程序。
5.4、使用volatile能否解决双重检查锁定的问题
   双重检查锁定问题提出的一种修复是使包含迟缓初始化的实例的字段为一个volatile字段。(有关双重检查锁定的问题和对为什么所建议的算法修复不能解决问题的说明请参阅 《单例模式及JMM的无序性》)
     首先,旧版内存模型,这不能使双重检查锁定成为线程安全的,因为对volatile字段的写仍然会与对其他 nonvolatile 字段的写(如新构造的对象的字段)一起重新排序,因而 volatile 实例引用仍然可能包含对一个未构造完的对象的引用。
      其次、在新版内存模型中,volatile的确可以对双重检查锁定变得线程安全。但是仍然不意味着应当使用这个volatile来解决!双重检查锁定的目的是实现性能优化的,设计用于消除公共代码路径的同步,因为对于早期的 JDK来说,同步是很昂贵的。虽然非竞争的同步(volatile)已经便宜 多 了,但是对 volatile 语义的新改变也使它在某些平台上比旧的语义昂贵得多。(实际上,对 volatile 字段的每一次读或者写都像是“半个”同步——对 volatile 的读有与监视器所获得的同样的内存语义,对 volatile 的写有与监视器所释放的同样的语义。)所以如果双重检查锁定的目标是提供比更直观的同步方式更好的性能,那么这个“修复的”版本也没有多大帮助。
不使用双重检查锁定,而使用 Initialize-on-demand Holder Class idiom,它提供了迟缓初始化,是线程安全的,而且比双重检查锁定更快且没那么混乱:
Listing 2. The 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的初始化安全性
  新的JMM 还寻求提供一种新的初始化安全性保证——只要对象是正确构造的(意即不会在构造函数完成之前发布对这个对象的引用),然后所有线程都会看到在构造函数中设置的final字段的值,不管是否使用同步在线程之间传递这个引用。而且,所有通过正确构造的对象final 成员变量所对应的对象引用成员变量也保证对其他线程是可见的。这意味着如果一个对象有一个final成员变量,比如说对一个 LinkedList 的引用,除了引用本身的值对于其他线程是可见的外,这个 LinkedList 在构造时的内容在不同步的情况下,对于其他线程也是可见的。这样显著增强了 final 的意义——可以不用同步安全地访问这个 final 字段,编译器可以假定 final 字段将不会改变,因而可以优化多次提取。
6.1、Final的意义
      在 前 部分描述了在旧的内存模型中,final 字段的值似乎可以改变的一种机制——在不使用同步时,另一个线程会首先看到 final 字段的默认值,然后看到正确的值。
   在新的内存模型中,在构造函数的 final 字段的写与在另一个线程中对这个对象的共享引用的初次装载之间有一个类似于 happens-before 的关系。当构造函数完成任务时,对 final 字段的所有写(以及通过这些 final 字段间接可及的变量)变为“冻结”,所有在冻结之后获得对这个对象的引用的线程都会保证看到所有冻结字段的冻结值。初始化 final 字段的写将不会与构造函数相关的冻结后面的操作一起重新排序。
七、结束语
  新的JAVA内存模型(JSR 133) 显著增强了volatile的语义,这样就可以可靠地使用 volatile 修饰的变量作为标志来判断它否已经被另一个线程改变成某一值。因为对 volatile的语义进行了加强,使得在某些平台上使用volatile 的性能成本更接近于某些情况下同步的性能成本,但是在大多数平台上性能成本仍然相当低。JSR 133 还显著地增强了final的语义。如果一个对象的引用在构造阶段不允许逸出(escape),那么一旦构造函数完成,并且线程发布了对另一个对象的引用,那么在不使用同步的条件下,这个对象的 final 字段就保证对所有其他线程是可见的、正确的并且是不变的。
     这些改变极大地加强了并发程序中不变对象的效用,不变对象最终成为固有的线程安全(就像它们所要成为的那样),即使使用数据争用在线程之间将引用传递给不变对象。
初始化安全性的一个告诫是对象的引用不许“逸出”其构造函数——构造函数不应直接或者间接发布对正在构造的对象的引用。这包括发布对 nonstatic 内部类的引用,并一般要避免在构造函数中启动线程。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多