Java内存模型JMM之五final内存语义

 

除了volatile和synchronized可实现可见性之外,final关键字也可以实现可见性(但final域所属的引用不能从构造方法中“逸出”)。synchronized同步块的可见性是由happens-before的锁定规则获得的。下面就来详细的研究一下final关键字的内存语义。

 

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

 

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

        该规则禁止把final域的写操作重排序到构造函数之外,因为执行构造函数进行实例化对象,底层可以分为3个操作: 分配内存,在内存上初始化成员变量,把对象实例引用指向内存。这3个操作可能重排序,即先把引用指向内存,再初始化成员变量。

        该规则还是通过内存屏障实现的:编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。从而达到禁止处理器把final域的写重排序到构造函数之外。

        该规则可以保证,在对象引用对任意线程可见之前(但引用不能从构造方法中“逸出”),对象的 final 变量已经正确初始化了,而普通变量则不具有这个保障。

  

读final域规则:初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

        该规则禁止把对final域的读操作重排序到读取这个final域所属的对象引用之前,而这是普通变量是无法保证的。

        该规则还是通过内存屏障实现的:编译器会在final域的读操作的前面插入一个LoadLoad屏障。从而达到要读取final域必须先读取final域所属的引用。

        该规则可以保证,在读一个对象的 final 变量之前,一定会先读这个对象的引用。如果读取到的引用不为空表示其对象引用已经对当前读线程可见,根据上面的写final域规则,说明对象的 final 变量一定以及初始化完毕,从而可以读到正确的变量值。

 

public class FinalExample {
    int i;                            //普通变量
    final int j;                      //final变量
    static FinalExample obj;

    public void FinalExample () {     //构造函数
        i = 1;                        //写普通域
        j = 2;                        //写final域
    }

    public static void writer () {    //写线程A执行
        obj = new FinalExample ();
    }

    public static void reader () {       //读线程B执行
        FinalExample object = obj;       //读对象引用
        int a = object.i;                //读普通域
        int b = object.j;                //读final域
    }
}

 

 针对上面的示例代码,这里假设一个线程A先执行writer()方法,模拟执行写操作,随后有线程B执行reader()方法。

  根据读final域规则:线程B对普通域的读操作完全有可能会被重排序到读取对象引用操作之前,从而形成一个错误的读取操作,而对final域的操作则由于读final域规则的保障,一定会先读包含这个final域的对象的引用,在该示例中,如果该引用不为空,那么其final域一定已经被A线程初始化完毕,所以变量b一定为2.

 

  根据写final域规则:线程A对普通域的写入操作完全有可能会被重排序到构造函数之外,但是对final域的写操作则不会。所以(假设线程B读取对象引用操作与读取对象的普通域没有发生重排序):线程B执行读取操作时,一定能够读取到final域的正确初始化后的值2,但是不一定能够读取到普通域初始化之后的值1,而是可能会读取到初始化之前的值0。

 

 引用类型的final域

 上面的示例都是基本类型的final域,如果是引用类型的final域,那么除了必须遵守以上的读写final域规则之外,写 final 域的重排序规则对编译器和处理器增加了如下约束:

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

          该规则保证了 对象的final 成员变量在对其他线程可见之前,能够正确的初始化完成。

 

public class FinalReferenceExample {
    final int[] intArray;
    static FinalReferenceExample obj;
 
    public FinalReferenceExample() {
        intArray = new int[1];// 1
        intArray[0] = 1;// 2
    }
 
    public static void writerOne() {// A线程执行
        obj = new FinalReferenceExample(); // 3
    }

    public static void writerTwo () {          //写线程B执行
        obj.intArray[0] = 2;                 //4
    }
 
    public static void reader() {// 读线程 C 执行
        if (obj != null) { // 5
            int temp1 = obj.intArray[0]; // 6
        }
    }
}
 

 

 针对上面的示例代码,这里假设一个线程A执行writerOne()方法,执行完后线程 B 执行writerTwo()方法,执行完后线程 C 执行reader ()方法,根据普通final域读写规则,操作1和操作3不能重排序,根据引用类型final域的写规则,操作2和操作3也不能重排序,JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。而写线程B对数组元素的写入,读线程C可能看的到,也可能看不到。JMM不能保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。

 

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

 

 避免对象引用在构造函数当中溢出

写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了即对其他线程可见。这就是在文章开头final关键字带来的可见性实现。

但是要得到这个效果,有一个前提条件:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。

public class FinalReferenceEscapeExample {
    final int i;
    int j;
    static FinalReferenceEscapeExample obj;
 
    public FinalReferenceEscapeExample() {
        i = 1;                    // 1
        j = 2;                    // 2
        obj = this;               // 3 避免怎么做!!!对象引用逸出。
    }
 
    public static void writer() {
        new FinalReferenceEscapeExample();
    }
 
    public static void reader() {
        if (obj != null) {        // 4
            int a = obj.i;     // 5
            int b = obj.j;     //6
        }
    }
}
 

针对上面的this引用逸出构造函数的示例代码,假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作3使得对象还未完成构造前引用就为线程B可见。即使这里的操作3是构造函数的最后一步,且即使在程序中操作3排在操作1和操作2后面,执行read()方法的线程B仍然可能无法看到final域以及普通域被初始化后的值,因为这里的操作1和操作2、操作3之间可能被重排序。

 

因此,在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没有被初始化,如果对象引用提前逸出,将破坏final关键字的语义,也就是说,final关键字将不能保障原有的可见性。

 

猜你喜欢

转载自pzh9527.iteye.com/blog/2400685