JSR(Java内存模型)常见问题解答

 2019-12-10 16:17  阅读(1063)
文章分类:Java Core

原文地址http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html

内容目录

1.什么是内存模型?

2.像其他语言,比如c++,有内存模型吗?

3.java的内存模型是什么样的?

4.重排序是什么?

5.旧的记忆模型有什么问题?

6.没有正确同步是什么意思?

7.同步是做什么的?

8.怎么改变final字段的值?

9.final字段在新的JMM下是怎么工作的?

10.volatile的作用?

11.新的内存模型是否解决了”双重检查锁定”问题?

12.如果要写虚拟机,需要怎么做?

13.我为什么要关心?

一、什么是内存模型?

在多处理器系统中,处理器一般有一个或多个层的内存缓存,以加快对数据的访问,提高了性能(因为数据更接近处理器)和减少对共享内存总线的流量(因为很多内存的操作可以通过本地缓存来满足)内存缓存可以极大地提高性能,但它们带来了许多新的挑战。例如,当两个处理器同时检查同一内存位置时,会发生什么?在什么条件下,他们会看到相同的值?
在处理器级,内存模型定义了必要的充分条件,知道其他处理器对内存的写入对当前处理器是可见的,当前处理器的写入对其他处理器可见。有些处理器表现出强大的内存模型,所有的处理器在任何时候都能看到任何给定内存位置的精确值。其他处理器表现出较弱的内存模型,在这里,特殊的指令被称为内存障碍,需要刷新或使本地处理器缓存失效,以便看到其他处理器写的字,或者由这个处理器写给其他人看。这些内存障碍通常是在进行锁定和解锁操作时执行的,它们在高级语言中对程序员来说是不可见的。
由于内存需求减少,编写强内存模型的程序有时更容易一些。然而,即使在一些最强大的记忆模型中,记忆障碍常常是必要的。处理器设计的最新趋势鼓励了较弱的内存模型,因为它们对缓存一致性的放宽允许跨多个处理器和更大内存量的更大的可伸缩性。
编译器对代码的重新排序使编译器对另一个线程可见时出现了问题。例如,编译器可能会决定在程序后面移动一个写操作更有效;只要这个代码运动不改变程序的语义,它是可以自由地这样做的。如果编译器推迟操作,则在执行它之前,其他线程将无法看到它;这反映了缓存的效果
此外,在程序中可以较早地写入内存;在这种情况下,其他线程可能在程序“实际发生”之前看到写入。所有这些灵活性都是通过设计实现的——通过给编译器、运行时或硬件提供灵活性,以最佳的顺序执行操作,在内存模型的范围内,我们可以获得更高的性能。
下面的代码可以看到一个简单的例子:

Class Reordering { int x = 0, y = 0; public void writer() { x = 1; y = 2; } public void reader() { int r1 = y; int r2 = x; } }

假设这段代码同时被两个线程执行,看到y的值为2。由于对y的赋值是在对x的赋值后面,所以程序员就会认为x的值一定是1. (个人备注:按顺序执行,先赋值x在赋值y)。然而赋值的顺序可能被重排了。如果发生重排,则可能是先赋值y=2,然后在读取两个变量的值(备注:即r1和r2),接着在对x赋值。最后的结果就是r1的值是2,而r2的值是0,(备注:因为读取r2的时候,对x尚未赋值,其值仍为0)
java的内存模型描述了那些行为是合法的在多线程中,以及线程之间通过内存是如何交互的。它描述了程序中变量与存储在真实计算机系统中的内存或寄存器之间的低级别细节之间的关系。它通过用各种各样的硬件和各种各样的编译器优化来实现这一点。
java包含多种语言结构,包括,volatile,final,和snychronized 这些旨在帮助旨在帮助程序员向编译器描述程序的并发性需求。java内存模型定义了volatile和synchronized的行为,更重要的是,确保正确同步java程序能够正确的运行在所有的处理器上。

二、像其他语言,比如c++,有内存模型吗?

大多数其他的编程语言,如C,C++,设计并没有直接支持多线程。这些语言提供对重排序发生在编译器和体系结构的类型的保护是严重依赖于使用的线程库提供的担保(如pthreads),编译器使用,和平台上运行的代码。

三、java的内存模型是什么样的?

自从1997年以来,在Java的内存模型发现了一些严重的缺陷,正如java语言规范17章中定义的。这些缺陷允许混乱的行为(例如观察到的最终字段改变它们的值),削弱编译器执行公共优化的能力。
Java内存模型是一个有野心的事业,它是第一次,一种编程语言规范试图一个内存模型具体化,该模型可以在各种体系结构中为并发性提供一致的语义。不幸的是,定义一个既一致又直观的内存模型比预期的要困难得多。JSR133为Java语言定义了一种新的内存模型,改模型修复了早期记忆模型的缺陷。为了做到这一点,需要改变final和volatil的语义。
完整的语义可以参照http://www.cs.umd.edu/users/pugh/java/memoryModel, 令人惊讶的发现像同步这样看似简单的概念是多么复杂。幸运的是,你不需要懂得的形式语义的具体细节——JSR 133的目标是要建立一套正式的语义,为volatile, synchronized, 和 final是如何工作的提供了一个直观的框架 。
JSR 133的目标包括:
1.保持现有安全保障,如类型安全,加强其他。例如,变量值可能不是凭空产生的:每个线程所观察到的变量的每个值必须是一个可以由某些线程合理放置的值。
2.正确同步程序的语义应该尽可能简单直观。
3.应定义不完全或不正确同步程序的语义,以尽量减少潜在的安全风险。
4.程序员应该能够自信地推理多线程程序如何与内存交互
5.应该在广泛流行的硬件架构中实现设计正确的、高性能的JVM。

  1. 应提供初始化安全的新保证。如果一个对象被正确构建(这意味着在构建过程中对它的引用不会逃逸)然后,看到该对象引用的所有线程也将看到构造函数中设置的最终字段的值,而无需同步。
    7.应该对现有代码的影响最小。

四、什么是重新排序?

有许多情况下,程序变量(对象实例字段、类静态字段和数组元素)的访问可能会显示出与程序指定的顺序不同的顺序。编译器可以自由地以优化的名称随意使用指令的顺序。处理器可能在某些情况下可能不按照顺序执行指令。数据可以在寄存器、处理器缓存和主存储器之间按照不同的顺序移动而不是按照程序所指定的顺序。例如,如果一个线程先写入字段a,再写入字段b,而且b的值不依赖于a,那么编译器可以自由重新排序这些操作,并且缓存可以在刷新a之前将B刷新到主存。有许多潜在的重新排序原因,如编译器、JIT和缓存。
编译器、运行时和硬件都注定会产生类似串行语义的错觉,这意味着在单线程的程序,程序不应该能够观察到重排序的影响。然而,重排序在正确同步的多线程程序来发挥作用,其中一个线程能够观察其他线程的影响,并且可以检测到变量访问的顺序与程序中执行或指定的顺序不同。
大多数时候,一个线程不关心其他线程在做什么。但当关心其他线程的时候,这就是同步。

五、旧的内存模型有什么问题?

旧内存模型有几个严重的问题。例如,在许多情况下,旧模型在每个JVM类中没有允许重排序发生。正视这种混乱的旧模型概念迫使JSR-133的形成。
例如,一个普遍的观点是,如果使用final字段,就没有必要使用同步在线程之间,来保证线程可以看到该字段的值。在旧的内存模式中,虽然这是一个合理的假设,也是一种明智的行为,而且确实是我们所希望的,但这并不是真的。
在旧内存模型中,final字段与其他字段的处理方式没什么不同–意思是同步是确保所有线程看到构造函数所写的最后字段值的唯一方法。因此,线程可以看到字段的默认值,然后在稍后时间看到它的构造值。
例如,这意味着,像字符串(String)这样的不可变对象可能会改变它们的值——确实令人不安。
在旧的内存模型中允许volatile 写入和和nonvolatile 写和读一起重排序,这和开发人员对volatile的直观感受不一致,因此引起混乱。
最后,正如我们将看到的,程序员对程序错误同步时可能发生的错误的直观感受常常是错误的。JSR-133一个目标就是呼吁人们关注这一事实。

六、没有正确同步是什么意思?
错误同步的代码对不同的人意味着不同的东西。当我们谈到“没有正确同步的java内存模型的背景下,我们的意思是代码出现下面的情况:
1.一个线程写数据
2.另外一个线程,读取这个变量(备注:即上一步写的变量)
3.读写是没有下令同步,来保证顺序
当这些规则被违反时,我们说我们对这个变量进行了数据竞争。带有数据竞争的程序是不正确同步的程序

七、同步是做什么的?

同步有几个方面。最常见的是相互排斥–只有一个线程可以同时保持监视器,因此,在监视器上同步意味着,一旦一个线程进入由监视器保护的同步块,则在第一个线程退出同步块之前,没有其他线程可以进入由该监视器保护的块。
但是同步的含义比互相排斥更广。同步确保线程在同步块之前或期间写入的内存以可预见的方式显示到其他线程,这些线程在同一监视器上同步。在退出同步块之后,我们释放监视器,该监视器具有将缓存刷新到主存的效果,因此该线程所做的写入可以对其他线程可见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。
从缓存的角度来讨论这个问题,听起来好像这些问题只影响多处理器系统。然而,在单个处理器上可以很容易地看到重新排序的效果。例如,编译器不可能在获取前或释放后移动你的代码。当我们谈论到在缓存区获取和释放的行为时,我们使用了一些简述的方式来描述大量可能的影响
新的内存模型语义在内存操作(读字段、写域、锁、解锁)和其他线程操作(启动和连接)中创建了部分排序,其中一些操作在其他操作之前发生。当一个动作发生在另一个动作时,能够保证第一个动作在前,且对第二个动作是可见的。这种排序的规则如下:
1.线程中的每一动作都发生程序中排在后面那个线程的每个动作之前。
2.监视器释放锁发生在该监视器在后续获得锁的动作之前。(个人理解 就是该监视器有锁的时候,必须先释放锁才能再次获取锁)
3.对volatile 字段的写入动作都会发生在后续对该volatile 变量读取之前。
4.对线程start()的调用发生在线程任何动作之前(只有调用start之后,才会有其他动作)
5.一个线程中所有的操作都发生在从这个线程join()方法成功返回的任何其他线程之前。(如果该线程调用join 其他线程就要等套这个线程结束才能结束)
这意味着,任何内存操作对一个线程退出同步块之前,和任何其他进入相同的监视器保护同步块之后是可见的,因为所有内存操作都发生在释放之前,并且释放发生在获取之前。
另一个概念如下模式,被一些人用来强迫实现一个内存屏障的,不会生效:

synchronized (new Object()) {}

这实际上是一个非运算,编译器可以完全删除它,因为编译器知道其他线程不会在同一个监视器上同步。一个线程要看到其他线程的结果,你必须为这个线程建立happens before关系。

重要提示:注意两个线程在同一个监视器上同步,以便正确设置关系之前发生的事情是很重要的。

下观点是错误的:当线程A在对象X上面同步的时候,所有东西对线程A可见,线程B在对象Y上面进行同步的时候,所有东西对线程B也是可见的。释放监视器和获取监视器必须匹配(也就是说要在相同的监视器上面完成这两个操作),否则,代码就会存在“数据竞争”。

八、怎么改变final字段的值?

一个final变量是如何改变的,最好例子之一是字符串类的一种特殊实现。
String对象包含了三个字段:一个字符数组,一个数组的偏移量和长度。实现String类的基本原理为:它不仅仅拥有字符数组,为了避免额外的对象分配和复制,让多个字符串和StringBuffer对象共享相同的字符数组String.substring()方法能够通过创建一个和初始string共享一个字符数组,只是改变了长度和偏移量的新string来实现。
对于字符串,这些字段都是最终字段。

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

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

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

九、final字段在新的JMM下是怎么工作的?

一个对象的final字段值是在它的构造方法里面设置的。假设对象被成功的构造了,一旦对象被构造,在构造方法里面设置给final字段的的值在没有同步的情况下对所有其他的线程都会可见。此外,引用这些final字段的对象或数组都将会看到final字段的最新值。

对一个对象来说,被正确的构造是什么意思呢?简单来说,它意味着这个正在构造的对象的引用在构造期间没有被允许逸出。换句话说,不要让其他线程在其他地方能够看见一个构造期间的对象引用。不要指派给一个静态字段,不要作为一个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;
          int j = 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指针指向一个数组,你不需要担心线程能够看到引用的最新值却看不到引用所指向的数组的最新值。重复一下,这儿的“正确的”的意思是“对象构造方法结尾的最新的值”而不是“最新可用的值”。

现在,在讲了如上的这段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含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。对变量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't do this!

    private static Something instance = null;

    public Something getInstance() {
      if (instance == null) {
        synchronized (this) {
          if (instance == null)
            instance = new Something();
        }
      }
      return instance;
    }

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

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

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

private static class LazySomethingHolder {
      public static Something something = new Something();
    }

    public static Something getInstance() {
      return LazySomethingHolder.something;
    }

此代码绝对正确的,因为初始化是采用静态字段;如果在静态初始化器中设置字段,则保证对访问该类的任何线程都可见。

十二、如果要写虚拟机,需要怎么做

You should look at http://gee.cs.oswego.edu/dl/jmm/cookbook.html .

十三、我为什么要关心?
你为什么要在乎?并发错误很难调试。它们通常不会出现在测试中,而是等待,直到您的程序在重载下运行,并且很难复制和捕获。你最好提前花费额外的精力来确保你的程序是正确同步的;虽然这并不容易,但是比起调试一个严重同步的应用程序来说要容易得多

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> JSR(Java内存模型)常见问题解答

相关推荐