JSR133提案-修复Java内存模型

1. 什么是内存模型?

在多处理器系统中,为了提高访问数据的速度,通常会增加一层或多层高速缓存(越靠近处理器的缓存速度越快)。
但是缓存同时也带来了许多新的挑战。比如,当两个处理器同时读取同一个内存位置时,看到的结果可能会不一样?
在处理器维度上,内存模型定义了一些规则来保证当前处理器可以立即看到其他处理器的写入,以及当前处理器的写入对其他处理器立即可见。这些规则被称为缓存一致性协议
有些多处理器架构实现了强一致性,所有的处理器在同一时刻看到的同一内存位置的值是一样的。而其他处理器实现的则是较弱的一致性,需要使用被称为内存屏障的特殊机器指令使来实现最终一致性(通过刷新缓存或使缓存失效)。这些内存屏障通常在释放锁和获取锁时被执行;对于高级语言(如Java)的程序员来说,它们是不可见的。

在强一致性的处理器上,由于减少了对内存屏障的依赖,编写并发程序会更容易一些。但是,相反的,近年来处理器设计的趋势是使用较弱的内存模型,因为放宽对缓存一致性的要求可以使得多处理器系统有更好的伸缩性和更大的内存。

此外,编译器、缓存或运行时还被允许通过指令重排序改变内存的操作顺序(相对于程序所表现的顺序)。例如,编译器可能会往后移动一个写入操作,只要移动操作不改变程序的原本语义(as-if-serial语义),就可以自由进行更改。再比如,缓存可能会推迟把数据刷回到主内存中,直到它认为时机合适了。
这种灵活的设计,目的都是为了获得得最佳的性能,但是在多线程环境下,指令重排会使得跨线程可见性的问题变的更复杂。

为了方便理解,我们来看个代码示例:

Class Reordering {
  int x = 0, y = 0;
  //thread A
   public void writer() {
        x = 1;
        y = 2;
    }

    //thread B
    public void reader() {
        int r1 = y;
        int r2 = x;
     }
}

假设这段代码被两个线程并发执行,线程A执行writer(),线程B执行reader()。
如果线程B在reader()中看到了y=2,那么直觉上我们会认为它看到的x肯定是1,因为在writer()中x=1y=2之前 。
然而,发生重排序时y=2会早于x=1执行,此时,实际的执行顺序会是这样的:

y=2;
int r1=y;
int r2=x;
x=1;

结果就是,r1的值是2,r2的值是0。
从线程A的角度看,x=1与y=2哪个先执行结果是一样的(或者说没有违反as-if-serial语义),但是在多线程环境下,这种重排序会产生混乱的结果。

我们可以看到,高速缓存指令重排序提高了效率的同时也引出了新的问题,这显然使得编写并发程序变得更加困难。
Java内存模型就是为了解决这类问题,它对多线程之间如何通过内存进行交互做了明确的说明。
更具体点,Java内存模型描述了程序中的变量与实际计算机的存储设备(包括内存、缓存、寄存器)之间交互的底层细节。
例如,Java提供了volatile、final和 synchronized等工具,用于帮助程序员向编译器表明对并发程序的要求。
更重要的是,Java内存模型保证这些同步工具可以正确的运行在任何处理器架构上,使Java并发应用做到“Write Once, Run Anywhere”。

相比之下,大多数其他语言(例如C/C++)都没有提供显示的内存模型。
C程序继承了处理器的内存模型,这意味着,C语言的并发程序在一个处理器架构中可以正确运行,在另外一个架构中则不一定。

2. JSR 133是关于什么的?

Java提供的跨平台内存模型是一个雄心勃勃的计划,在当时是具有开创性的。但不幸的是,定义一个即直观又一致的内存模型比预期的要困难得多。
自1997年以来,在《Java语言规范》的第17章关于Java内存模型的定义中发现了一些严重的缺陷。这些缺陷使一些同步工具产生混乱的结果,例如final字段可以被更改。
JSR 133为Java语言定义了一个新的内存模型,修复了旧版内存模型的缺陷(修改了final和volatile的语义)
JSR的主要目标包括不限于这些:

  1. 正确同步的语义应该更直观更简单。
  2. 应该定义不完整或不正确同步的语义,以最小化潜在的安全隐患
  3. 程序员应该有足够的自信推断出多线程程序如何与内存交互的。
  4. 提供一个新的初始化安全性保证(initialization safety)。
    如果一个对象被正确初始化了(初始化期间,对象的引用没有逃逸,比如构造函数里把this赋值给变量),那么所有可以看到该对象引用的线程,都可以看到在构造函数中被赋值的final变量。这不需要使用synchronized或volatile。

3. 再谈指令重排序

在许多情况下,出于优化执行效率的目的,数据(实例变量、静态字段、数组元素等)可以在寄存器、缓存和内存之间以不同于程序中声明的顺序被移动。
例如,线程先写入字段a,再写入字段b,并且b的值不依赖a,那么编译器就可以自由的对这些操作重新排序,在写入a之前把b的写入刷回到内存。
除了编译器,重排序还可能发生在JIT、缓存、处理器上。无论发生在哪里,重排序都必须遵循as-if-serial语义,这意味着在单线程程序中,程序不会觉察到重排序的存在,或者说给单线程程序一种没有发生过重排序的错觉。
但是,重排序在没有同步的多线程程序中会产生影响。在这种程序中,一个线程能够观察到其他线程的运行情况,并且可能检测到变量访问顺序与代码中指定的顺序不一致。
大多数情况下,一个线程不会在乎另一个线程在做什么,但是,如果有,就是同步的用武之地。

4.同步都做了什么?

同步有很多面,最为程序员熟知的是它的互斥性,同一时刻只能有一个线程持有monitor。但是,同步不仅仅是互斥性。同步还能保证一个线程在同步块中的写内存操作对其他持有相同monitor的线程立即可见。
当线程退出同步块时(释放monitor),会把缓存中的数据刷回到主内存,使主内存中保持最新的数据。
当线程进入同步块时(获取monitor),会使本地处理器缓存失效,使得变量必须从主内存中重新加载。
我们可以看到,之前的所有写操作对后来的线程都是可见的。

5. final字段在旧的内存模型中为什么可以改变?

证明final字段可以改变的最佳示例是String类的实现(JDK 1.4版本)。
String对象包含三个字段:

  • 一个字符串数组的引用value
  • 一个记录数组中开始位置的offset
  • 一个记录字符串长度length
    通过这种方式,可以实现多个String/StringBuffer对象共享一个相同的字符串数组,从而避免为每个对象分配额外的空间。例如,String.substring()通过与原String对象共享一个数组来产生一个新的对象,唯一的不同是length和offset字段。
String s1 = "/usr/tmp";
String s2 = s1.substring(4); 

s2和s1共享一个字符串数组"/usr/tmp",不同的是s2的offset=4,length=4,s1的offset=0,length=8
在String的构造函数运行之前,Object的构造函数会先初始化所有字段为默认值,包括final的length和offset字段。当String的构造函数运行时,再把length和offset赋值为期望的值。
但是这一过程,在旧的内存模型中,如果没有使用同步,另一个线程可能会看到offset的默认值0,然后在看到正确的值4。结果导致一个迷幻的现象,开始看到字符串s2的内容是'/usr',然后再看到'/tmp'。
这不符合我们对final语义的认识,但是在旧内存模型中确实存在这样的问题。
(JDK7开始,改变了substring的实现方式,每次都会创建一个新的对象)

6.“初始化安全”与final字段?

新的内存模型提供一个新初始化安全`( initialization safety)保障
意味着,只要一个对象被正确的构造,那么所有的线程都会看到这些在构造函数中被赋值的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;
    }
  }
}

示例中,初始化安全保证执行reader()方法的线程看到的f.x=3,因为它是final字段,但是不保证能看到y=4,因为它不是final的。但是如果构造函数像这样:

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

初始化安全不能保证读取global.obj的线程看到的x的值是3,因为对象引用this发生了逃逸。不仅如此,任何通过final字段(构造函数中被赋值的)可以触达的变量都可以保证对其他线程可见。
这意味着如果一个final字段包含一个引用,例如ArrayList,除了该字段的引用对其他线程可见,ArrayList中的元素对其他线程也是可见的。
初始化安全增强了final的语义,使其更符合我们对final的直观感受,任何情况下都不会改变。

7. 增强volatile语义

volatile变量是用于线程之间传递状态的特殊变量,这要求任何线程看到的都是volatile变量的最新值。
为实现可见性,禁止在寄存器中分配它们,还必须确保修改volatile后,要把最新值从缓存刷到内存中。
类似的,在读取volatile变量之前,必须使高速缓存失效,这样其他线程会直接读取主内存中的数据。
在旧的内存模型中,多个volatile变量之间不能互相重排序,但是它们被允许可以与非volatile变量一起重排序,这消弱了volatile作为线程间交流信号的作用。我们来看个示例:

Map configs;
volatile boolean initialized = false;
. . .
 
// In thread A
configs  =  readConfigFile(fileName);
processConfigOptions( configs);
initialized = true;
. . .
 
// In thread B
while (initialized) {
    // use configs
}

示例中,线程A负责配置数据初始化工作,初始化完成后线程B开始执行。
实际上,volatile变量initialized扮演者守卫者的角色,它表示前置工作已经完成,依赖这些数据的其他线程可以执行了。但是,当volatile变量与非volatile变量被编译器放到一起重新排序时,“守卫者”就形同虚设了。重排序发生时,可能会使readConfigFile()中某个动作在initialized = true之后执行,那么,线程B在看到initialized的值为true后,在使用configs对象时,会读取到没有被正确初始化的数据。
这是volatile很典型的应用场景,但是在旧的内存模型中却不能正确的工作。

JSR 133专家组决定在新的内存模型中,不再允许volatile变量与其他任务内存操作一起重排序。这意味着,volatile变量之前的内存操作不会在其后执行,volatile变量之后的内存操作不会在其前执行。
volatile变量相当于一个屏障,重排序不能越过对volatile的内存操作。(实际上,jvm确实使用了内存屏障指令)
增强volatile语义的副作用也很明显,禁止重排序会有一定的性能损失。

8. 修复“double-checked locking”的问题

double-checked locking是单例模式的其中一种实现,它支持懒加载且是线程安全的。大概长这个样子:

private static Something instance = null;

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

它通过两次检查巧妙的避开了在公共代码路径上使用同步,从而避免了同步所带来的性能开销。
它唯一的问题就是——不起作用。为什么呢?
instance的赋值操作会与SomeThing()构造函数中的变量初始化一起被编译器或缓存重排序,这可能会导致把未完全初始化的对象引用赋值给instance。现在很多人知道把instance声明为volatile可以修复这个问题,但是在旧的内存模型(JDK 1.5之前)中并不可行,原因前面有提到,volatile可以与非volatile字段一起重排序。

尽管,新的内存模型修复了double-checked locking的问题,但仍不鼓励这种实现方式,因为volatile并不是免费的。
相比之下,Initialization On Demand Holder Class更值得被推荐,它不仅实现了懒加载和线程安全,还提供了更好的性能和更清晰的代码逻辑。大概长这个样子:

public class Something {
    private Something() {}
    //static innner class
    private static class LazyHolder {
        static final Something INSTANCE = new Something(); //static  field
    }

    public static Something getInstance() {
        return LazyHolder.INSTANCE;
    }
}

这种实现完全没有使用同步工具,而是利用了Java语言规范的两个基本原则,
其一,JVM保证静态变量的初始化对所有使用该类的线程立即可见;
其二,内部类首次被使用时才会触发类的初始化,这实现了懒加载。

9. 为什么要关心这些问题?

并发问题一般不会在测试环境出现,生成环境的并发问题又不容易复现,这两个特点使得并发问题通常比较棘手。
所以你最好提前花点时间学习并发知识,以确保写出正确的并发程序。我知道这很困难,但是应该比排查生产环境的并发问题容易的多。

延伸阅读

1.JSR 133 (Java Memory Model) FAQ,2004
2.volatile关键字
3.Double-checked问题
4.内存屏障和volatile语义
5.修复Java内存模型
6.String substring 在jdk7中会创建新的数组
7.Memory Ordering
8.有MESI协议为什么还需要volatile?
9.Initialization On Demand Holder Class
10.The JSR-133 Cookbook for Compiler Writers

posted @ 2020-06-17 13:41  元思  阅读(556)  评论(0编辑  收藏  举报