Java内存模型FAQ

什么是内存模型

多核系统中,处理器一般有一层或者多层的内存缓冲区,这样的内存缓冲区通过加速数据访问(因为数据距离处理器更近)和降低共享内存总线的流量(因为本地缓冲区能够满足许多内存操作)来提高CPU性能。内存缓冲区能够提升大大提升性能,但是它们也带来了许多挑战。例如,当两个CPU同时检查相同的内存地址的时候会发生什么?在什么样的条件下它们会看到相同的值?

在处理器层面,一个内存模型为:“让当前的处理器可以看到其他处理器写入到内存的数据”以及“让当前处理器写入到内存的数据为其他处理器所见”定义了充要条件。有些处理器有很强的内存模型(strongmemory model),能够让所有的处理器在任何时候任何给定的内存地址上面都可以看到完全相同的值。而另外一些处理器则有较弱的内存模型(weakermemory model),在这种处理器中,内存屏障(memorybarriers)(一种特殊的指令)被用来刷新本地处理器缓冲区以及使本地处理器缓冲区无效,目的是为了使当前处理器能够看到其他处理器的写操作或者让其他处理器看到当前处理器的写操作。这些内存屏障通常在lock和unlock操作的时候完成。内存屏障在高级语言中对程序员是可见的。

强的内存模型下,有时候编写程序可能会更容易,因为减少了对内存屏障的依赖。但是,即使在一些最强的内存模型下,内存屏障仍然是必须的。(quite frequently their placement is counterintuitive。),近来处理器设计的趋势更倾向于弱的内存模型,因为弱内存模型造成的对缓存一致性的缓和(注:即弱内存模型对缓存一致性要求不严格),同时也考虑到了多处理器之间的更大的可伸缩性和更大容量的内存。

“一个线程的写操作对其他线程可见”这个问题是来源于编译器对代码的重排序。例如,只要代码移动不会改变程序的语义,当编译器认为程序中移动一个写操作到后面会更有效的时候,编译器就会对代码进行移动。如果编译器推迟一个操作,其他线程可能在这个操作完成之前就不会看到该操作的结果,这反映了缓存的影响。(thismirrors the effect of caching.)

此外,在程序中,写入内存的操作能够被移动到更早的时候。在这种情况下,其他的线程在程序中可能看到一个比它(注:及后面的“写操作”)实际“发生”更早的写操作。所有的这些灵活性是故意的----给编译器,运行时或者硬件这些灵活性在最佳顺序的情况下来执行操作。在内存模型的限定之内,我们能够获取到更高的性能。

看下面代码展示的一个简单例子:

 ClassReordering {

  int x = 0, y = 0;

  public void writer() {

    x = 1;

    y = 2;

  }

  public void reader() {

    int r1 = y;

    int r2 = x;

  }

}

让我们看在两个并发线程中执行这段代码,读取Y变量将会得到2这个值。因为这个写入比写到X变量更晚一些,程序员可能认为读取X变量将肯定会得到1。但是,写入操作可能被重排序过。如果重排序发生了,那么,就能发生对Y变量的写入操作,读取两个变量的操作紧随其后,而且写入到X这个操作能发生。程序的结果可能是r1变量的值是2,但是r2变量的值为0。

Java内存模型描述了在多线程代码中哪些行为是合法的,以及线程在内存中如何交互。它描述了“程序中的变量“ 和 ”从内存或者暂存器(register)获取或者存储它们的底层细节”之间的关系。Java内存模型通过使用各种各样的硬件和编译器的优化的正确实现来做如上的事情。

Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面能够正确的运行。

其他语言,像C++,也有内存模型吗

大部分其他的语言,像c和c++,都没有设计直接支持多线程。发生在编译器和处理器架构中的这些重排序严重依赖线程lib提供的保障,所使用的编译器以及代码运行的平台。

JSR133是什么

从1997年以来,不断发现Java语言规范的17章定义的Java内存模型中的一些严重的缺陷。这些缺陷会导致一些使人迷惑的行为(例如final字段会被观察到值的改变)和破坏编译器常见的优化能力。

Java内存模型是一个野心勃勃的事业,它是编程语言规范第一次尝试合并能够在各种处理器架构中为并发提供一致语义的一个内存模型。不过,定义一个既一致又直观的内存模型远比想象要更难。JSR133为Java语言定义了一个新的内存模型,它修复了早期内存模型中的缺陷。为了实现JSR133,final和volatile的语义需要改变。

完整的语义见:http://www.cs.umd.edu/users/pugh/java/memoryModel,但是正式的语义不是小心翼翼的,它是令人惊讶和清醒的,目的是让人意识到一些看似简单的概念(如同步)其实有多复杂。幸运的是,你不需要懂得这些正式语义的细节 – JSR133的目的是创建一组正式语义,这些正式语义提供了volatile、synchrozied和final如何工作的直观的框架。

JSR 133的目标包含了:

  保留已经存在的安全保证(像类型安全)以及强化其他的安全保证。例如,变量值不能凭空创建:线程观察到的每个变量的值必须是被其他线程合理的设置的。

 “正确地同步了的程序”的语义应该尽量简单和直观。

 l   应该定义“未完成或者未正确同步的程序”的语义,主要是为了把潜在的安全危害降到最低。

 l   程序员应该能够自信的推断多线程程序如何同内存进行交互的。

 l   能够在现在许多流行的硬件架构中设计正确以及高性能的JVM实现。

 l   应该能提供“安全地初始化”的保证。如果一个对象正确的构建了(意思是它的引用没有在构建的时候逸出(escape)),那么所有能够看到这个对象的引用的线程,在不进行同步的情况下,也将能看到在构造方法中中设置的final字段的值。

 l   应该尽量不影响现有的代码。

 重排序意味着什么

 在 很多情况下,访问一个程序变量(对象实例字段,类静态字段和数组元素)可能会使用不同的顺序执行而不是程序制定的方式执行。编译器能够自由的以优化的名义 去对改变指令顺序。在特定的环境下,处理器可能会次序颠倒的执行指令。数据可能在寄存器,处理器缓冲区和主内存中以不同的次序移动,而不是按照程序指定的 顺序来。

 例如,如果一个线程写入值到字段a,然后写入值到字段b,而且b的值不依赖于a的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在a之前刷新b的值到主内存。有许多潜在的重排序的来源,例如编译器,JIT以及缓冲区。

 编译器,运行时和硬件被期望一起协力创建好像是顺序执行的语义的假象,这意味着在单线程的程序中,程序应该是不能够观察到重排序的影响的。但是,重排序在正确同步了的多线程程序中开始起作用,在这些多线程程序中,一个线程能够观察到其他线程的影响,而且能够以不同的顺序检测到变量访问变得对其他线程可见而不是执行或在程序中指定。(The compiler, runtime, and hardware are supposed to conspire to createthe illusion of as-if-serial semantics, which means that in a single-threadedprogram, the program should not be able to observe the effects of reorderings.However, reorderings can come into play in incorrectly synchronizedmultithreaded programs, where one thread is able to observe the effects ofother threads, and may be able to detect that variable accesses become visibleto other threads in a different order than executed or specified in theprogram.)

 大部分情况下,一个线程不会关注其他线程正在做什么,但是当它需要关注的时候,这时候就需要同步了。

旧的内存模型有什么错误

 旧的内存模型中有几个严重的问题。这些问题很难理解而且因此被广泛的违背。例如,旧的存储模型在许多情况下,在每个JVM中都不允许各种重排序发生。在实现旧模型过程中的一些迷惑造就了JSR-133的诞生。

 例如,一个被广泛认识的概念就是,如果使用final字段,那么,就没有必要在多个线程中进行同步来保证其他线程能够看到这个字段的值。这是一个合理的假设和明显的行为,实际上,在旧的内存模型中,我们想让程序正确运行起来却是不行的。在旧的内存模型中,final字段并没有同其他字段进行区别对待 -- 这意味着同步是保证所有线程看到一个在构造方法中初始化的final字段的唯一方法。结果,对一个线程来说,它可能看到一个字段的默认值,然后在稍后的时间里,又能够看到构造方法中设置的值。这意味着,一些不可变的对象,例如String,能够改变它们值 -- 这实在很让人郁闷。

 旧的内存模型允许volatile的写操作和非volaitle的读写操作一起进行重排序,这和大多数的开发人员对于volatile的直观感受是不一致的,因此会造成迷惑。

 最后,我们将看到的是,程序员关于当程序没有被正确同步的情况下将会发生什么的直观感受通常是错误的。JSR-133的目的之一就是要引起这方面的注意。

 没有正确同步的含义是什么

 “没有正确同步的代码”对于不同的人来说可能会有不同的理解。在Java内存模型这个语义环境下,我们谈到“没有正确同步”,我们的意思是:

 1.     一个线程中有一个对变量的写操作,

 2.     另外一个线程对同一个变量有读操作,而且

 3.     写操作和读操作没有通过同步来保证顺序。

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

同步会干些什么呢

同步有几个方面。最广为人知的就是互斥 -- 一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中的时候,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

但 是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同 步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要监 视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存从新加载,于是我们就能够看到所有的可见的写操作。

 依 据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的机子。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放 之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。

 新的内存模型语义在内存操作(读取字段,写入字段,锁,解锁)以及其他线程的操作(start 和 join)中创建了一个部分排序,在这些操作中,一些操作被称为“happen before”其他操作。当一个操作”happens before”另外的操作的时候,第一个操作保证能够排到前面并且对第二个操作可见。这些排序的规则如下:

 l  线程中的每个操作happens before该线程中在程序顺序上后续的每个操作。

 l  解锁一个监视器的操作happens before随后对相同监视器进行锁的操作。

 l  对volatile字段的写操作happensbefore后续对相同volatile字段的读取操作。

 l  线程上调用start()方法happensbefore这个线程启动后的任何操作。

 l  一个线程中所有的操作都happens before从这个线程join()方法成功返回的任何其他线程。(注:意思是其他线程等待一个线程的jion()方法完成,那么,这个线程中的所有操作happensbefore其他线程中的所有操作)

 这意味着:任何内存操作,这个内存操作在退出一个同步块前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块后都是可见的,因为所有内存操作happens before释放监视器以及释放监视器happensbefore获取监视器。

 其他如下模式的实现被一些人用来强迫实现一个内存屏障的,不会生效:

 synchronized(newObject()){}

 这段代码其实不会执行任何操作,你的编译器会把它完全移除掉,因为编译器知道没有其他的线程会使用相同的监视器进行同步。要看到其他线程的结果,你必须为一个线程建立happens before关系。

 重点注意:对两个线程来说,为了正确建立happensbefore关系而在相同监视器上面进行同步是非常重要的。以下观点是错误的:当线程A在对象X上面同步的时候,所有东西对线程A可见,线程B在对象Y上面进行同步的时候,所有东西对线程B也是可见的。释放监视器和获取监视器必须匹配(比如在相同的监视器上面完成这两个操作),否则,代码就会存在“数据竞争”。

 Final字段如何改变它们的值

 看到final字段的值改变的最好的例子就是String类。

 String对象包含了三个字段:一个character数组,一个数组的offset和一个length。实现String类的基本原理为:它不仅仅拥有character数组,而且为了避免多余的对象分配和拷贝,多个String和StringBuffer对象都会共享相同的character数组。因此,String.substring()方法能够通过创建一个新的String来实现,这个新的String和原来的String共享相同的character数组,只是在length和offset字段上面不同。对一个String来说,这些字段都是final型的字段。

String s1 = "/usr/tmp";

 String s2 = s1.substring(4);

 字符串s2的offset的值为4,length的值为4。但是,在旧的内存模型下,对其他线程来说,看到offset拥有默认的值0是可能的,而且,稍后一点时间会看到正确的值4,好像字符串的值从“/usr”变成了“/tmp”一样。

 旧的Java内存模型允许这些行为,部分JVM以及展现出这样的行为了。在新的Java内存模型里面,这些是非法的。

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

 一个对象的final字段值是在它的构造方法里面设置的。假设对象被正确的构造了,一旦对象被构造,在构造方法里面设置给final字段的的值在没有同步的情况下对所有其他的线程都会可见。另外,可见的值将至少是和final字段一样是最新的。被这些final字段引用的其他任何对象和数组。(Inaddition, the visible values for any other object or array referenced by thosefinal fields will be at least as up-to-date as the final fields.)

 对一个对象来说,被正确的构造是什么意思呢?简单来说,它意味着这个正在构造的对象的引用在构造期间没有被允许逸出。(参见安全构造技术)。换句话说,就是没有放置正在构建的对象的引用在其他线程可能看到的地方。不要指派给一个静态字段,不要作为一个listener注册给其他对象等等。这些操作应该在构造方法之后完成,而不是构造方法中来完成。

class FinalFieldExample {

 final int x;

 int y;

 static FinalFieldExample f;

 public FinalFieldExample() {

   x = 3;

   y = 4;

 }

 

 static void writer() {

   f = new FinalFieldExample();

 }

 static void reader() {

   if (f != null) {

      int i = f.x;

      intj = f.y;

   }

 }

}

 上面的类展示了final字段应该如何使用。一个正在执行reader方法的线程保证看到f.x的值为3,因为它是final字段。它不保证看到f.y的值为4,因为f.y不是final字段。如果FinalFieldExample的构造方法看起来像这样:

public FinalFieldExample() { //bad!

 x = 3;

 y = 4;

 // bad construction - allowing this to escape

 global.obj = this;

}

 那么,从global.obj中读取this的引用线程不会保证读取到的x的值为3。

 能够看到字段的正确的构造值固然不错,但是,如果字段本身就是一个引用,那么,你还是希望你的代码能够看到引用所指向的这个对象(或者数组)的最新值。如果你的字段是final字段,那么这是能够保证的。因此,当一个final指针指向一个数组,你不需要担心正在查看数组引用的正确的值的其他线程,而是数组内容的不正确的值。重复一下,这儿的“正确的”的意思是“对象构造方法结尾的最新的值”而不是“最新可用的值”。(So, you can have a final pointer toan array and not have to worry about other threads seeing the correct valuesfor the array reference, but incorrect values for the contents of the array.Again, by "correct" here, we mean "up to date as of the end of theobject's constructor", not "the latest value available". )

 现在,在讲了如上的这段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含final字段),你希望保证这个对象被其他线程正确的查看,你仍然需要使用同步才行。没有其他的方式可以保证,例如,不可变对象的引用将被第二个线程看到。使用final字段的程序应该仔细的调试,这需要深入而且仔细的理解并发在你的代码中是如何被管理的。

 如果你使用JNI来改变你的final字段,这方面的行为是没有定义的。

 volatile是干什么用的

 volatile字段是特殊的字段,它们被用来在各个线程间进行通讯。每次读取volatile变量都会看到volatile字 段被其他线程写入的最新值。实际上,他们被程序员指定成字段是因为由于缓存和重排序的原因而看到陈旧的值是不可接受的。编译器和运行时禁止在寄存器里面分 配它们。它们还必须保证,在它们写好之后,它们被从缓冲区刷新到主存中,因此,它们立即能够对其他线程可见。相同地,在读取一个volatile字段之前,缓冲区必须失效,因为值是存在于主存中而不是本地处理器缓冲区。在重排序访问volatile变量的时候还有其他的限制。

 在旧的内存模型下,访问volatile变量不能彼此被重排序,但是,它们可能和访问非volatile变量一起被重排序。这破坏了volatile字段从一个线程到另外一个线程作为一个信号条件的手段。

 在新的内存模型下,volatile变量仍然不能彼此重排序。和旧模型不同的时候,volatile周围的普通字段的也不再能够随便的重排序了。写入一个volatile字段和释放监视器有相同的内存影响,而且读取volatile字段和获取监视器也有相同的内存影响。事实上,因为新的内存模型在重排序volatile字段访问上面和其他字段(volatile或者非volatile)访问上面有了更严格的约束。当线程A写入一个volatile字段f的时候,如果线程B读取f的话 ,那么对线程A可见的任何东西都变得对线程B可见了。

 如下例子展示了volatile字段应该如何使用:

class VolatileExample {

 int x = 0;

 volatile boolean v = false;

 public void writer() {

   x = 42;

   v = true;

 }

 public void reader() {

   if (v == true) {

      //uses x - guaranteed to see 42.

   }

 }

}

 假设一个线程叫做“writer”,另外一个线程叫做“reader”。在writer线程中写入变量v的操作是否写入变量x的操作到内存,而且,读取变量v需要从内存中获取那个值。因此,如果reader线程看到了v的值为true,那么,它也保证能够看到在之前发生的写入42这个操作。而这在旧的内存模型中却未必是这样的。如果v不是volatile变量,那么,编译器可以在writer线程中重排序写入操作,那么reader线程中的读取x变量的操作可能会看到0。

 实际上,volatile的语义已经被加强了,已经快达到同步的级别了。为了可见性的原因,每次读取和写入一个volatile字段已经像一个半同步操作了

 注意:对两个线程来说,为了目的是正确的设置happens-before关系,访问相同的volatile变量是很重要的。以下的结论是不正确的:当线程A写volatile字段f的时候,线程A可见的所有东西,在线程B读取volatile的字段g之后,变得对线程B可见了。释放操作和获取操作必须匹配(也就是在同一个volatile字段上面完成)。

 新的内存模型是否修复了双重锁检查问题?

 臭名昭著的双重锁检查(也叫多线程单例模式)是一个巧妙的设计,它用来支持lazy初始化,同时避免过度使用同步。在非常早的JVM中,同步非常慢,开发人员非常希望删掉它。双重锁检查代码如下:

  

// double-checked-locking - don'tdo this!

private static Something instance= null;

public Something getInstance() {

 if (instance == null) {

   synchronized (this) {

      if (instance == null)

        instance = new Something();

   }

 }

 return instance;

}

这看起来好像非常聪明 – 在公用代码中避免了同步。这段代码只有一个问题 – 它 不能正常工作。为什么呢?最明显的原因是,初始化实例的写入操作和实例字段的写入操作能够被编译器或者缓冲区重排序,重排序可能会导致返回部分构造的一些 东西。(注:)结果就是我们读取到了一个没有初始化的对象。这段代码还有很多其他的错误,以及为什么对这段代码的算法修正是错误的。没有办法在旧的java内存模型下修复它。更多深入的信息可参见:Double-checkedlocking: Clever, but broken and The "DoubleChecked Locking is broken" declaration

  

 许多人认为使用volatile关键字能够消除双重锁检查模式的问题。在1.5的JVM之前,volatile并不能保证这段代码能够正常工作(因环境而定)。在新的内存模型下,实例字段使用volatile可以解决双重锁检查的问题,因为在通过构造线程来初始化一些东西和通过读取它线程返回它的值之间有happens-before关系。

  

 然后,对于喜欢使用双重锁检查的人来说(我们真的希望没有人这样做),仍然不是好消息。双重锁检查的重点是为了避免过度使用同步导致性能问题。从java1.0开始,不仅同步会有昂贵的性能开销,而且在新的内存模型下,使用volatile的性能开销也有所上升,几乎达到了和同步一样的性能开销。因此,使用双重锁检查来实现单例模式仍然不是一个好的选择。(修订—在大多数平台下,volatile性能开销还是比较低的)。

  

 使用IODH来实现多线程模式下的单例会更易读:

  

private static classLazySomethingHolder {

 public static Something something = new Something();

}

public static SomethinggetInstance() {

 return LazySomethingHolder.something;

}

 这段代码是正确的,因为初始化是由static字段来保证的。如果一个字段设置在static初始化中,对其他访问这个类的线程来说是是能正确的保证它的可见性的。

 如果我需要写一个VM,我需要做些什么

 参见:http://gee.cs.oswego.edu/dl/jmm/cookbook.html

 为什么我需要关注java内存模型

  为什么你需要关注java内存模型?并发程序的bug非常难找。它们经常不会在测试中发生,而是直到你的程序运行在高负荷的情况下才发生,非常难于重新和跟踪。你需要花费更多的努力提前保证你的程序是正确同步的。这不容易,但是它比调试一个没有正确同步的程序要容易的多。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值