美文网首页
JSR 133 (Java Memory Model) FAQ

JSR 133 (Java Memory Model) FAQ

作者: 风洛洛 | 来源:发表于2019-04-15 22:35 被阅读0次

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

    什么是内存模型?

    在多处理器系统中,处理器通常有一层或多层内存缓存,加快数据访问速度(因为数据离处理器更近了)和减少共享内存总线上的流量(因为许多内存操作在本地内存就已经可以满足)从而提升性能。

    内存缓存可以提高性能,但他们提出一系列新的挑战.比如,当发生两个处理器在相同时间检查相同的内存地址时?在什么条件下我们会看到相同的值?

    在处理器级别,内存模型定义了必要和充分的条件,以便知道当前处理器可以看到其他处理器对内存的写操作,其他处理器也可以看到当前处理器的写操作。一些处理器显示了一个强大的内存模型,其中所有处理器在任何给定的内存位置上都可以看到完全相同的值。其他处理器表现出较弱的内存模型,其中需要称为内存屏障的特殊指令来刷新或使本地处理器缓存失效,以便查看其他处理器的写操作,或使该处理器的写操作对其他人可见。这些内存屏障通常在执行锁定和解锁操作时执行;在高级语言中,它们对程序员是不可见的。

    有时为强内存模型编写程序更容易,因为对内存屏障的需求减少了。然而,即使是在一些最强的记忆模型中,内存屏障也是必要的;它们的位置常常违反直觉。处理器设计的最新趋势鼓励了较弱的内存模型,因为它们对缓存一致性的放宽允许跨多个处理器和更大内存的更大可伸缩性。

    当一个写对另一个线程可见时,编译器重新排序代码的问题就更加复杂了。例如,编译器可能认为在程序的后面移动写操作更有效;只要这个代码动作不改变程序的语义,它就可以这样做。如果编译器延迟一个操作,另一个线程将不会看到它,直到它被执行;这反映了缓存的效果。

    此外,对内存的写入可以在程序中更早地移动;在这种情况下,其他线程可能会在程序中实际“发生”之前看到写操作。所有这些灵活性都是通过设计实现的——通过给编译器、运行时或硬件以最佳顺序执行操作的灵活性,在内存模型的范围内,我们可以实现更高的性能。

    一个简单的例子可以在下面的代码中看到:

    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++,在设计时并没有直接提供多线程。这些语言对编译器和体系结构中发生的各种重新排序提供的保护很大程度上依赖于所使用的线程库(如pthreads)、所使用的编译器和代码运行的平台所提供的保证。

    什么是JSR133?

    自1997年以来,在Java语言规范第17章中定义的Java内存模型中发现了几个严重的缺陷。这些缺陷允许混淆行为(比如观察final字段以更改它们的值),并破坏编译器执行常见优化的能力。

    Java内存模型是一项雄心勃勃的工作;这是编程语言规范第一次尝试合并内存模型,该模型可以为各种体系结构的并发性提供一致的语义。不幸的是,定义一个既一致又直观的内存模型比预期的要困难得多。JSR 133为Java语言定义了一个新的内存模型,它修复了早期内存模型的缺陷。为此,需要更改final和volatile的语义。

    完整的语义可以在http://www.cs.umd.edu/users/pugh/java/memoryModel中找到,但是正式的语义并不适合胆小的人。发现同步等看似简单的概念实际上有多么复杂是令人惊讶和清醒的。幸运的是,您不需要了解正式语义的细节——JSR 133的目标是创建一组正式语义,它为如何进行易失性、同步和最终工作提供了直观的框架。

    JSR 133 的目标包括:

    • 保留现有的安全保障,如类型安全,并加强其他保障。例如,变量值可能不是凭空创建的:某个线程观察到的变量的每个值都必须是某个线程能够合理放置的值。
    • 正确同步程序的语义应该尽可能简单和直观。
    • 应该明确不完全或不正确同步程序的语义,以便将潜在的安全风险降到最低。
    • 程序员应该能够自信地推断多线程程序如何与内存交互。
    • 应该可以在各种流行的硬件架构上设计正确的高性能JVM实现。
    • 应该提供初始化安全的新保证。如果一个对象被正确构造(这意味着对它的引用在构造期间不会逃逸),那么所有看到对该对象的引用的线程也将看到在构造函数中设置的其final字段的值,而不需要同步。
    • 对现有代码的影响应该是最小的。

    什么是重排序?

    在许多情况下,访问程序变量(对象实例字段、类静态字段和数组元素)的顺序可能与程序指定的顺序不同。编译器可以自由地以优化的名义对指令进行排序。在某些情况下,处理器可能会执行错误的指令。数据可以在寄存器、处理器缓存和主内存之间按程序指定的不同顺序移动。

    举个例子,如果一个线程写入字段a然后写入字段b,并且b的值不依赖的a的值,然而编译器是能够自由的重新排序这些操作,和在a之前随意的将缓存刷新到主内存。有很多的潜在来源重排序,如编译器、JIT和缓存。

    编译器、运行时和硬件应该共同创建as-if-serial语义的假象,这意味着在单线程程序中,程序不应该能够观察到重新排序的效果。然而,重新排序可能在同步不正确的多线程程序中发挥作用,其中一个线程能够观察到其他线程的影响,并且可能能够检测到变量访问对其他线程可见,其顺序与程序中执行的或指定的顺序不同。

    大多数时候,一个线程并不关心另一个线程在做什么。但是当它发生时,这就是同步的作用。

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

    在旧的内存模型中有几个严重的问题。他很难被理解,因此经常被广泛的违法使用。举个例子,旧的内存模型在许多情况下,不允许发生重排序在每个JVM中。这种对旧模型含义的混淆迫使JSR-133的形成。

    例如,人们普遍认为,如果使用final字段,那么线程之间的同步是不必要的,以确保另一个线程将看到字段的值。虽然这是一个合理的假设和明智的行为,以及我们希望事物如何工作,但在旧的记忆模型下,这根本不是真的。在旧的内存模型中,final字段与其他字段的处理没有任何不同---这意味着同步是确保所有线程都能看到构造函数编写的final字段值的唯一方法。因此,线程可以看到字段的默认值,然后在稍后的某个时候看到其构造的值。例如,这意味着像String这样的不可变对象可能会改变它们的值——这确实是一个令人不安的前景。

    旧的内存模型允许用非volatile读和写对volatile进行重新排序,这与大多数开发人员对易失性的直觉不一致,因此造成了混淆。

    最后,正如我们将看到的,程序员对于程序同步不正确时会发生什么的直觉常常是错误的。JSR-133的目标之一是对这一事实引起注意。

    你怎么理解不正确的同步?

    不正确同步代码对于不同的人意味着不同的含义。当我们讨论不正确同步代码的时候在Java内存模型上下文下,我们意味着代码在:

    1. 一个线程写一个变量
    2. 在另一个线程中同时在读相同的变量
    3. 写和读不是按同步顺序排列的

    当违反这些规则时,我们说我们对该变量进行了数据竞争。具有数据竞争的程序是同步不正确的程序。

    同步都做了什么?

    同步有几个方面。最广为人知的是互斥。一次只有一个线程可以持有一个监视器,因此在监视器上同步意味着一旦一个线程进入一个由监视器保护的同步块,其他线程就不能进入该监视器保护的块,直到第一个线程退出同步块。

    但是同步不仅仅是互斥,同步确保同步块之前或期间的线程的内存写入以可预测的方式对同步在同一监视器上的其他线程可见。在退出同步块之后,我们释放monitor,它的作用是将缓存刷新到主内存,这样其他线程就可以看到这个线程的写操作。在进入同步块之前,我们获取monitor,它的作用是使本地处理器缓存失效,以便从主内存重新加载变量。然后,我们将能够看到前一个版本中所有可见的写操作。

    从缓存的角度讨论这个问题,听起来好像这些问题只影响多处理器机器。然而,在单个处理器上很容易看到重新排序的效果。例如,编译器不可能在获取代码之前或发布之后移动代码。当我们说获取和释放作用于缓存时,我们是在使用一些可能的效果的简写。

    新的内存模型语义对内存操作(读字段、写字段、Lock、Unlock)和其他线程操作(start和join)创建了部分排序,其中一些操作据说发生在其他操作之前。当一个动作在另一个动作之前发生时,第一个动作一定是在第二个动作之前排序的,并且对第二个动作可见。排序的规则如下:

    • 每个操作在线程中happens before 在这个线程中程序顺序之后的每个操作
    • 一个Unlock对于monitor happens before 同一个monitor的随后的Lock
    • 对于volatile的写操作happens before 同一个volatile的随后的读操作
    • 对于Thread调用start() happens before 开始线程中的任何操作
    • 在一个线程中的所有操作 happens before 任何对于这个线程join()正确返回后的任何其他线程

    这意味着在线程退出同步块之前对线程可见的任何内存操作,在它进入由同一监视器保护的同步块之后对任何线程都是可见的,因为所有内存操作都发生在释放之前,而释放发生在获取之前。

    另一个暗示是,下面的模式,一些人用来强制内存屏障,不起作用:

    synchronized (new Object()) {}
    

    这实际上是一个no-op,编译器可以完全删除它,因为编译器知道没有其他线程会在同一监视器上同步。您必须为一个线程设置happens-before关系,以查看另一个线程的结果。

    重要提示:请注意,为了正确设置happens-before关系,两个线程在同一个监视器上同步是很重要的。并不是线程A在对象X上同步时可见的所有内容在对象Y上同步后对线程B也可见。发布和获取必须“匹配”(即作用在同一个monitor)以获得正确的语义。否则,代码就会有数据竞争。

    final字段如何改变它们的值?

    final字段的值如何变化的最好例子之一涉及String类的一个特定实现。

    一个String是通过三个字段来实现的---一个character数组,一个偏移量offset对于数字和一个length长度。通过这种方式实现String的基本原理是,它允许多个String和StringBuffer对象共享同一个字符数组,并避免额外的对象分配和复制,而不只是字符数组。因此,例如,方法string .substring()可以通过创建一个新字符串来实现,该字符串与原始字符串共享相同的字符数组,只是长度和偏移量字段不同。对于String,这些字段都是final字段。

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

    String s2 的offset是4,length是4.但是,在老的模型下,另一个线程可以看到偏移量的默认值为0,之后再看到正确的值为4,就会出现字符串“/usr”更改为“/tmp”的情况。

    原始Java内存模型允许这种行为;一些jvm已经显示了这种行为。新的Java内存模型使其不合法。

    final字段再新的JMM内存模型中是如何工作的?

    对象的final字段的值在其构造函数中设置。假设对象构造“正确”,一旦构造了对象,赋给构造函数中最后字段的值将对所有其他线程可见,而不需要同步。此外,这些final字段引用的任何其他对象或数组的可见值至少与final字段一样是最新的。

    一个对象被正确构造意味着什么?它只是意味着在构造过程中不允许“逃逸”对正在构造的对象的引用。(See Safe Construction Techniques for examples.)换句话说,不要把对正在构造的对象的引用放在其他线程可能看到它的地方;不要将它分配给静态字段,不要将它注册为任何其他对象的侦听器,等等。这些任务应该在构造函数完成后完成,而不是在构造函数中。

    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将保证能看到值3对于f.x,因为他是final。但是不保证能看到值4对于f.y,因为他不是final。如果FinalFieldExample的构造方法像如下这样:

    public FinalFieldExample() { // bad!
      x = 3;
      y = 4;
      // bad construction - allowing this to escape
      global.obj = this;
    }
    

    然后线程的read的引用从this改为global.obj将不保证看到3对于x

    能够看到正确构造的字段值很好,但是如果字段本身是引用,那么您还希望代码能够看到它指向的对象(或数组)的最新值。如果您的字段是final字段,这也是可以保证的。因此,那你可以有一个final的数组,而不必担心其他线程看到数组引用的正确,但是数组内容的值不正确的情况。同样,这里的“正确”指的是“对象构造函数结束时的最新数据”,而不是“可用的最新值”。

    现在,说了所有这些之后,如果一个线程构造了一个不可变的对象(也就是说,一个只包含final字段的对象),您想要确保它被所有其他线程正确地看到,您通常仍然需要使用同步。例如,没有其他方法可以确保对不可变对象的引用被第二个线程看到。程序从最终字段获得的保证应该经过仔细的调整,并对代码中如何管理并发性有深入和仔细的理解。

    如果希望使用JNI更改最终字段,则没有定义行为。

    volatile做了什么?

    Volatile字段是用于在线程之间通信状态的特殊字段。每次读取volatile都将看到任何线程对该volatile的最后一次写入;实际上,程序员将它们指定为字段,对于这些字段,由于缓存或重新排序而导致的“陈旧”值是不可接受的。编译器和运行时被禁止在寄存器中分配它们。它们还必须确保在写入之后,将它们从缓存中清除到主内存中,这样其他线程就可以立即看到它们。类似地,在读取volatile字段之前,必须使缓存失效,这样才能看到主内存中的值,而不是本地处理器缓存中的值。对volatile变量的重新排序也有额外的限制。

    在旧的内存模型下,对volatile变量的访问不能彼此重新排序,但是可以对unvolatile变量访进行重新排序。这削弱了volatile字段作为从一个线程向另一个线程发送条件信号的一种方法的有效性。

    在新的内存模型下,volatile变量之间不能重新排序仍然是正确的。不同之处在于,现在对它们周围的普通字段访问重新排序不再那么容易了。写入volatile字段具有与释放监视器相同的内存效果,从volatile字段读取具有与获取监视器相同的内存效果。实际上,由于新的内存模型对volatile字段访问与其他字段访问的重新排序施加了更严格的限制,无论是否volatile,线程A写入volatile字段f时可见的任何内容在线程B读取f时都变为可见的。

    下面是一个如何使用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中的write to v将write to x释放到内存中,而v的read将从内存中获得该值。因此,如果读者看到v的值为真,那么也可以保证看到之前发生的对42的写操作。在旧的内存模型下,这是不正确的。如果v不是volatile的,那么编译器可以在writer中重新排序写操作,而reader对x的读操作可能会看到0。

    实际上,volatile的语义得到了很大的增强,几乎达到了同步的水平。出于可见性的目的,volatile字段的每一次读或写都相当于同步的“一半”。

    重要提示:
    请注意,为了正确设置happens-before关系,两个线程访问同一个volatile变量是很重要的。并不是说线程A写入volatile字段f时可见的所有内容在线程B读取volatile字段g后都变为可见的(需要保证对于相同的volatile字段)以获得正确的语义。

    新的内存模型修复了双重校验锁的问题吗?

    (臭名昭著的)双重检查锁定习语(也称为多线程单例模式)是一种技巧,旨在支持延迟初始化,同时避免同步的开销。在非常早期的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内存模型来修复它。More in-depth information can be found at Double-checked locking: Clever, but broken and The "Double Checked Locking is broken” declaration

    许多人认为使用volatile关键字可以消除在尝试使用双重检查锁定模式时出现的问题。在1.5之前的jvm中,volatile不能确保它能工作(您的里程可能有所不同)。在新的内存模型下,使实例字段volatile将“修复”双重检查锁定的问题,因为在构造线程初始化某个东西和读取它的线程返回它的值之间存在happens-before关系。

    相反,使用随需应变的初始化Holder习惯用法,这是线程安全的,更容易理解:

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

    由于静态字段的初始化保证,此代码被保证是正确的;如果在静态初始化器中设置了字段,则保证它对访问该类的任何线程都是可见的。

    相关文章

      网友评论

          本文标题:JSR 133 (Java Memory Model) FAQ

          本文链接:https://www.haomeiwen.com/subject/ffrmwqtx.html