【Java 内存模型】— final 域的内存语义

final 域的重排序规则

对于 final 域,编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个final 域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

写 final 域的重排序规则

写 final 域的重排序规则禁止把 final 域重排序到构造函数之外。这个规则的实现包含下面2个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障会禁止处理器把 final 域重排序到构造函数之外。

public class FinalDemo {
    int i;          // 普通变量
    final int j;    // final 变量
    
    static FinalDemo obj;
    
    public FinalDemo {  // 构造函数
        i = 1;          // 写普通域
        j = 2;          // 写 final 域
    }
    
    public static void write() {    // 写线程 A 执行
        obj = new FinalDemo();
    }
    
    public static void read() { // 读线程 B 执行
        FinalDemo object = obj; // 读对象引用
        int a = object.i;       // 读普通域
        int b = object.j;       // 读 final 域
    }
}

下图中是一种上述代码的执行时序:

线程执行时序图

在图中,写普通域的操作被编译器重排序到构造函数之外,线程B读到了 i 未初始化的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,线程 B 能正确读到 final 变量初始后的值。

写 final 域的重排序规则可以确保:

在对象引用被任意线程可见之前,对象的 final 域已被正确的初始化,而普通变量不会有这个保障。

读 final 域的重排序规则

读 final 域的重排序的规则是:

在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

初次读对象的引用与初次读对象的 final 域,这两个操作之间存在简介依赖关系。编译器遵守这种关系,所以编译器不会重排序这两个操作。大部分处理器也遵守,但是依然有少数的处理器不遵守从而会导致这两种操作的重排序,这个规则就是专门用来针对这种处理的。

假设线程 A 没有重排序,同时处理器不遵守这种间接依赖关系,那么上述代码的一种可能执行时序如下图:

image

在图中,读对象的普通域被重排序到读对象引用之前。读普通域时,该域还没被写线程 A 写入,这是一个错误的读取操作。而 final 域的读操作会被“限定”在对象引用之后,此时 final 域已经被正确初始化。

读 final 域的重排序规则可以确保:

在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用

final 域为引用类型

对于引用类型,写 final 域的重排序对编译器和处理器增加了如下约束:

在构造函数内对一个 final 引用对象的成员域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

看下面代码


public class FinalReferenceDemo {
    final int[] intArray;           // final 是引用类型
    static FinalReferenceDemo obj;
    
    public FinalReferenceDemo() {
        intArray = new int[1];
        intArray[0] = 1;
    }
    
    public static void writeOne() {         // 线程 A
        obj = new FinalReferenceDemo();
    }
    
    public static void writeTwo() {         // 线程 B
        obj.intArray[0] = 2;
    }
    
    public static void read() {             // 线程 C
        if (obj != null) {
            int temp = obj.intArray[0];
        }
    }
}

假设线程 C 在线程 B 之前执行,JMM 可以确保线程 C 可以看到线程 A 在构造函数中对 final 域的写入,即 C 能看到数组下标 0 的值为 1。但是 C 对 B 的写入,不一定看得到,因为 B 和 C 存在数据竞争。

final 引用逃逸

上面提到过,写 final 重排序规则可以确保:

在引用变量为任意线程可见之前,该引用指向的对象中的 final 域在构造函数已经被正确的初始化了。

要达到上面效果,必须有一个保证,那就是:在构造函数内部,不能让这个被构造对象的引用被其他线程所见,也就是该对象引用不能再构造函数中“逃逸”

看下面代码


public class FinalReferenceEscapeDemo {
    final int i;
    static FinalReferenceEscapeDemo obj;
    
    public FinalReferenceEscapeDemo() {
        i = 1;                      // 1 写 final 域
        obj = this;                 // 2 this 在此”逃逸“
    }
    
    public static void write() {
        new FinalReferenceEscapeDemo();
    }
    
    public static void read() {
        if (obj != null) {          // 3
            int temp = obj.i;       // 4
        }
    }
    
}

上面代码中 1 和 2 会存在重排序,所以线程执行到 3 时,obj 可能不为 null,但是其实这时候 i 还没有初始化,所以会读到错误的值。

final 语义在处理器中的实现

final 语义在处理器中的具体实现如下:

  • 写 final 域的重排序规则会要求在 final 域的写之后,构造函数 return 之前插入一个 StoreStore 屏障。
  • 读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。

总结

JMM 为 Java 程序员提供初始化安全保证:

只要对象是正确构造的(没有 this “逃逸”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化后的值。

posted @ 2022-06-08 18:15  Tailife  阅读(87)  评论(0编辑  收藏  举报