除了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关键字将不能保障原有的可见性。