JSR 133(Java内存模型/JMM)问题解答

JMM是什么,为什么要关心JMM?Java Memory Model定义了线程如何通过内存进行交互。

并发导致的问题很难通过调试重现,一般也不会在测试中出现,而是等到程序运行在高负载下。所以,提前花费一些精力来确保程序正确同步,虽然这有一定的难度,但总比在生产环境调试一个在同步方面,千疮百孔的应用程序要容易得多。

什么是内存模型(JMM)?

在多处理器系统中,通常处理器会有多级高速缓存,减少共享内存总线上的流量,加速对数据的访问以此来提高性能。高速缓存极大地提高性能,但也带来了许多新的挑战。

当两个处理器同时查看同一个的内存地址时会发生什么?在什么条件下他们会看到相同的值?

在处理器级别,内存模型(JMM)定义了很多限制,用于保证当前处理器,可以看到其他处理器对内存的写入,并让当前处理器的写入,对其他处理器也是可见的。

有些处理器表现出的内存模型比较强大(暂时称为强内存模型),可以让所有处理器始终看到完全相同的值。

有些处理器表现出的内存模型比较弱(暂时称为弱内存模型),需要通过特殊指令(称为内存屏障)来刷新,或使处理器的高速缓存失效,以便处理器间对内存的写入相互可见。

这些内存屏障通常会在锁定和解锁操作时执行, 对于语言使用者来说,内存屏障是不可见的。由于减少了对内存屏障的依赖,所以,为强内存模型编写程序有时更容易。

然而,即使在一些最强大的内存模型上,通常也需要内存屏障,因为,代码实际运行时的位置,很多时候是和直觉不一样的,处理器设计的趋势,鼓励了较弱的内存模型,因为,这种模型放宽了缓存一致性,增加了跨多个处理器,和大内存时的可扩展性。

编译器对代码的重新排序,加重了写入操作,对另一个线程可见性的问题。例如,编译器有时会认为,调整某些写操作的顺序,可能会更高效,只要程序的语义(预期逻辑)没发生变化,就可以自由地进行代码顺序的调整。

如果,编译器因调整代码顺序,导致某个操作推迟,则另一个线程在执行时,不会看到推迟后的操作。此外,也可能把程序对内存的写入,调整到更早,在这种情况下,其他线程可能会在程序“实际发生”之前看到写入。

所有这些灵活性都是经过设计的 -- 在内存模型允许的范围内,通过给编译器、运行时、硬件,提供最佳执行顺序的自由,以实现更高的性能。

一个简单的示例:

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。开发人员可能会认为,在x写入操作之后,读取x时看到的值必然为1。但是,写入操作可能已经被重新排序。如果发生这种情况,则可能出现对y的写入后,会读取两个变量,然后才会发生对x的写入。结果r1的值为2,但r2的值为0。

Java内存模型描述了多线程中,代码哪些行为是合法的,以及线程如何通过内存进行交互。它描述了在真实的计算机系统中,一个程序中的变量,与内存存取细节之间的关系。它的实现方式可以通过使用各种各样的硬件,和各种各样的编译器优化来正确实现。

Java包括一些语言结构:volatile、final和synchronized,它们旨在帮助开发人员向编译器描述程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是,它确保一个正确同步的Java程序,在所有处理器架构上都能正确运行。

其他语言,比如C ++,有内存模型吗?

大多数其他编程语言(如C和C ++)的设计,并未直接支持多线程。这些语言针对编译器和体系结构中,发生的各种重新排序所提供的保护,在很大程度上取决于所使用的线程库(例如pthread),编译器以及运行平台所提供的保证。

什么是JSR133?

自1997年以来,在Java语言规范中定义的Java内存模型,发现了几个严重的缺陷。这些缺陷允许混淆行为,破坏了编译器执行常见优化的能力。

Java内存模型是一项充满野心的工程,这是程序语言规范,首次尝试合并一个内存模型,该内存模型可以为各种体系结构的并发,提供一致的语义。不幸的是,定义一个一致且直观的内存模型,比预期困难得多。

JSR 133为Java语言定义了一个新的内存模型,并修复了早期内存模型中的缺陷。为了达到这一点,需要改变final和volatile的语义。

完整的语义可以在The Java Memory Model上找到,但正式的语义却令人望而生畏,令人惊讶和发人深省的是,像同步这样看似简单的概念,实际上是那么的复杂。

幸运的是,开发者不需要了解正式语义的细节——JSR 133的目标是通过一组语义,提供一种直观的框架,方便了解synchronized、volatile和final是如何进行工作的。

JSR 133的目标包括

在保持现有的安全基础上(如类型安全),在其他方面提供更强的安全保障。例如,变量值可能不会“凭空”创建:一个线程观察到的每个变量的值,必须是某个线程合理设置的值。

正确同步程序的语义应尽可能的简单直观。应限制不完全或不正确的同步语义,以便最大程度地减少潜在的安全隐患。开发者应该能够自信地说出,多线程程序如何与内存交互。可以在主流硬件架构中设计正确的、高性能的JVM实现。

提供初始化安全性的新保证,如果正确构造了一个对象(这表示对象的引用在构造期间不会被转义),那么,看到该对象引用的线程,也能看到构造方法对final属性设置的值,而不需要做任何同步,对现有代码的影响也是最小的。

什么是指令重排序

在许多情况下,访问程序变量(对象的属性,类静态字段和数组元素)的顺序,可能与程序指定的顺序不同,编译器能够以优化的名义自由地对指令进行排序。处理器在某些情况,会以不同于程序指定的顺序,在寄存器、处理器高速缓存和内存之间传递数据。

如果一个线程向属性a,和属性b进行写入操作,并且属性b的值不依赖于属性a的值,那么,编译器就可以重新安排这些操作,而缓存则可以在“a”之前,将“b”刷新到主内存。有许多重新排序的潜在源,例如:编译器、JIT和缓存。

编译器、运行时和硬件,应该协同创建“as-if-serial”语义的假象,这表示在单线程中,程序不应觉察到重新排序的影响。但是,重新排序可能会在错误同步的多线程程序中产生作用,其中一个线程能够察觉到其他线程的产生影响。大多数情况下,一个线程并不关心另一个线程在做什么,当发生指令重排序时,同步的意义就体现出来了。

同步有什么作用?

同步有几个方面,最常理解的是互斥 - 同一时刻只有一个线程,可以持有同一个监视器,因此,在监视器上进行同步,意味着一旦一个线程进入受监视器保护的同步块,其他任何线程,都无法进入受该监视器保护的块,直到第一个线程退出同步块。

但是,除了互斥之外,同步还可以做的更多,同步确保同步块之前,或同步期间的线程,对内存的写入,以可预测的方式进行,并对在同一监视器上同步的其他线程可见。

在一个线程退出synchronized块之后,会释放了监视器,这时,同步块的作用是将缓存刷新到主内存中,因此,该线程所做的写操作对其他线程是可见的。

在一个线程进入同步块之前,会获取监视器,同步块的作用:是使处理器缓存失效,以便从主内存重新加载变量,然后,新进入的线程将能够看到,上一个线程退出同步块前所有的写操作。

从高速缓存的角度看这个问题,听起来好像这些问题,只在多处理器上受影响。但是,在单个处理器上也能轻松看到重新排序的效果。例如,编译器无法把同步块中的代码,移动到获取同步之前或释放同步之后。当我们说获取和释放作用于缓存时,我们是把一些可能的效果进行了简化。

新的内存模型语义在内存操作(读取字段,写入字段,锁定,解锁),和其他线程操作(启动和加入)上是部分有序的,当一个动作发生在另一个动作之前时,第一个动作保证排在第二个动作之前,并且可见。排序规则如下:

线程中的每个操作,都发生在该线程中后续操作之前。

监视器上的解锁,发生在同一监视器上的后续锁定之前。

对易失性(volatile )属性的写入会发生在,后续每个对同一个属性读取之前。

线程上的start()调用发生在,基于该线程上的任何操作之前。

线程中的所有操作都发生在,其他线程从该线程的join()成功返回之前。

这表示在退出同步块之前,对线程可见的任何内存操作,对同一监视器上的后续线程都是可见的,因为,所有内存操作,都在释放之前发生,并且释放发生在获得之前。另一个含义是,一些用来强制进行内存屏障的模式是不起作用,如下所示:

synchronized (new Object()) {}

这实际上是一个无效操作,并且,编译器可以完全删除它,因为编译器知道,没有任何线程将在同一监视器上同步。必须为一个线程设置一个happens-before关系,以查看另一个线程的结果。重要说明:两个线程在同一监视器上同步,以便正确设置happens-before关系。

final字段如何在JMM下工作?

对象的final字段的值,会在构造方法中进行设值。假设对象的构造方法处理得当,一旦一个对象构造完成,在构造方法中对final字段的赋值,将对其他线程可见,而不用进行任何同步操作。

对象的正确构造意味着什么?

它意味着在构造过程中,不允许正在构造的对象的引用“逃逸”。换句话说,不要在另一个线程可能看到的地方,放置正在构造的对象的引用,不要将它分配给静态字段,不要将其注册为,其他对象的侦听器,依此类推。这些任务应在构造方法完成后进行操作,而不应在构造方法中完成。

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字段的示例,线程执行读取器保证看到fx的值为3 ,因为,它是被final修饰过的。不能保证看到y的值为4 ,因为,它不是final的,如果FinalFieldExample的构造方法如下所示:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // 有问题的构造过程 - 它允许对象(this)发生逃逸(escape)
  global.obj = this;
}

global.obj不保证看到x为3。

volatile有什么用

volatile字段是用于在线程之间,传递状态的特殊字段。每次读取volatile都会看到,线程对该volatile的最后一次写入。禁止编译器和运行时,将它们分配到寄存器中。还必须确保在写入之后,将它们从缓存中刷新到主内存,这样它们就可以立即被其他线程看到。

类似地,在读取volatile字段之前,必须使高速缓存失效,以读取主内存中的值(而不是处理器的高速缓存),重新排序对volatile变量的访问还有其他限制。

在旧的内存模型下,对volatile变量的访问不能相互重新排序,但可以让非volatile变量访问重新排序。这破坏了把volatile字段作为从一个线程,到另一个线程发送信号的手段。

在新的内存模型下,仍然可以确定volatile变量不能相互重新排序。不同之处在于,现在对它们周围的正常字段,进行重排序不再那么容易。volatile字段写入与监视器释放具有相同的记忆效应,从volatile字段读取具有与监视器获取相同的记忆效应。

实际上,因为新的内存模型,对其他字段访问(volatile或非volatile)volatile字段,重新排序施加了更严格的限制,所以当线程A对volatile字段f写入时,线程A可见的任何内容在线程B读取f时都可见。