Java 并发-7 final 及其内存语义

Posted by xflyme on September 13, 2015

前言

对于 final 关键字,想必都不陌生了,本文对其用法以及原理做一个总结。

用法

  1. 修饰类
    • 当用 final 去修饰一个类的时候,表示这个类不能被继承。

      注意:被 final 修饰的类,类中的成员变量可以根据需要设计成 final。final 类中的成员方法都被隐式的指定为成员方法。

  2. 修饰方法
    • 被 final 修饰的方法不能被重写

      注意: 一个 private 方法会被隐式的指定为 final 方法。如果父类中有 final 修饰的方法,那么子类不能去重写这个方法。

  3. 修饰本地变量
    • 被修饰的本地变量必须要在声明时赋值,而且只能被初始化一次。
  4. 修饰成员变量
    • 必须初始化值
    • 被 final 修饰的成员变量赋值有两种方式:1、直接赋值 2、全部在构造方法中赋值。
    • 如果被修饰的成员变量是基本类型,则表示这个变量的值不能被改变。
    • 如果被修饰的成员变量是引用类型,则是说这个引用地址的值不能被改变,但是这个引用所指向的对象里面的内容还是可以改变。

final 内存语义

final 域的重排序规则

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

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

示例:

public class FinalExample {
    int i;
    final int j;
    static FinalExample finalExample;
    
    public FinalExample() {
        i = 1;
        j = 2;
    }
    
    public static void writer() {
        finalExample = new FinalExample();
    }
    
    public static void reader() {
        FinalExample example = finalExample;
        int a = example.i;
        int b = example.j;
    }
}

这里假设线程 A 执行 writer() 方法,线程 B 执行 reader() 方法。

写 final 域的重排序规则

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

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

上面那段代码,下图是一种可能的执行时序:

图一

在这种时序中,写普通域的操作被编译器重排序到构造函数之外,读线程 B 错误的读取了普通变量初始化之前的值。

写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。

读 final 域的重排序规则

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

初次读取对象引用与初次读取该对象包含的final域,这两个操作间存在依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,不会重排序这两个操作。但少数处理器允许存在间接依赖关系的操作做重排序。这个规则就是专门用来针对这种处理器的。

图二

上图是一种可能发生的时序,读对象的普通域操作被重排序到读对象引用之前,读普通域时,该域还没有被线程 A 写入,这是一个错误的读取操作,而读 final 域的重排序规则会把读对象的 final 域限定在读对象引用之后,此时该 final 域已经被 线程 A 初始化过了,这是一个正确的读取操作。

final 域为引用类型

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

为什么 final 引用不能从构造函数逸出

写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实要见到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是说对象引用不能在构造函数中逸出。

例:

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample finalReferenceExample;
    
    public FinalReferenceEscapeExample() {
        i = 1;
        finalReferenceExample = this;
    }
    
    public static void writter() {
        new FinalReferenceEscapeExample();
    }
    
    public static void reader() {
        if (finalReferenceExample != null) {
            int temp = finalReferenceExample.i;
        }
    }
    
    
}

图三

上面的时序图已经很清楚了,不在一一解释。

在旧的 java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。为了修补这个漏洞,JSR-133 专家组增强了 final 的内存语义。通过为 final 域增加读和写重排序规则,可以为程序员提供初始化安全保证,只要对象是正确构造的(被构造对象的引用没有在构造方法中逸出),那么不需要使用同步就可以保证任意线程看到这个 final 域在狗砸函数中被初始化的值。