众所周知,final 关键字在 java 语言中可以用来修饰类、方法和成员变量:final 关键字修饰一个类,表示这个类不能被继承;final 关键字修饰的方法不能被重写;final 关键字修饰的成员变量必须要赋初始值,而且是只能初始化一次。不过本文的重点并不在这,而是讲解 final 在并发领域中的应用,总结起来就是两个关键词:重排序
和逸出
。
tips:Java 中有两个词叫做发布(publish)和逸出(Escape),发布是指发布一个对象,使对象能够在当前作用域之外的代码中使用。而逸出和发布是相对的,即当某个不应该发布的对象被发布时,这种情况就被称为逸出。(什么,没听懂,好吧,我再举个栗子,二师兄年少不经事,妈妈总说要做完作业才能出去玩,如果二师兄听妈妈的话,做完作业才出去玩,那就会得到妈妈爱的抱抱。然而二师兄天性顽皮,只要妈妈一不留神就溜出去玩了,后果就是遭受毒打,没做完作业就出去玩这里对应的就是对象没初始化完成就被引用了,遭受毒打对应的就是引用到了还没初始化完成的对象导致出问题了。final 关键词的作用就是说,先把门锁死,做完作业了才放出去玩。)
final 域的重排序规则
对于 final 域,编译器和处理器要遵守两个重排序规则:
1、在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2、初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
/**
* @author SpringRoot
* @create 2019-11-08 21:01
*/
public class FinalExample {
int i; //普通变量
final int j; //final变量
static FinalExample obj;
public FinalExample() { //构造函数开始(2)
i = 1; //写普通域i(3)
j = 2; //写final域y(4)
} //构造函数结束(5)
public static void writer() {
obj = new FinalExample(); //构造一个FinalExample类型 的对象(1);把构造对象的引用赋值给引用变量obj(6)
}
public static void reader() {
FinalExample object = obj; //读对象引用obj(7)
int a = object.i; //读对象的普通域i(8)
int b = object.j; //读对象的final域y(9)
}
}
代码1.1
1、代码 1.1 包含了写 final 域和读 final 域,接下来用这段代码分别分析写 final 域和读 final 域的重排序规则。
2、writer( )方法只包含一行代码: obj = new FinalExample(),这行代码包含两个步骤,如下:
1)构造一个 FinalExample 类型 的对象(1)。
2)把构造对象的引用赋值给引用变量 obj(6)。
3、reader()方法包含 3 个操作:
1)初次读引用变量 obj。(7)
2)初次读引用变量 obj 指向对象的普通域 j。(8)
3)初次读引用变量 obj 指向对象的 final 域 i。(9)
4、这里假设一个线程 A 执行 writer()方法,随后另一个线程 B 执行 reader()方法。
写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面两个方面:
- JMM
禁止
编译器把 final 域的写重排序到构造函数之外
。 - 编译器会在 final 域的写之后,构造函数
return之前
,插入一个StoreStore
屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外
。
上图中,线程 B 读对象引用与读对象的成员域之间没有重排序,写线程 A 发生了重排序。
- 绿色箭头表示 final 变量
正确
读取初始化之后
的值,因为写 final 域的操作被写 final 域的重排序规则限定
在了构造函数之内,线程 B 可以正确地读取。 - 红色箭头表示普通变量 i
错误
地读取了初始化之前
的值。这是因为写普通域的操作被编译器重排序到了构造函数之外,导致被线程 B 错误地读取了。
写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被
正确初始化
过了,而普通域不具有这个保障。以上图为例,在读线程 B看到
对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域的写操作被重排序到构造函数外,此时初始值 1 还没有写入普通域 i)。
读 final 域的重排序规则
读 final 域的重排序规则是:
- 在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作,编译器会在读 final 域操作的前面插入一个
LoadLoad
屏障。 - 初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在
间接依赖
关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器的。
上图中,写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,而线程 B 读对象引用与读对象的成员域之间发生了重排序。
- 绿色箭头表示 final 变量正确读取初始化之后的值,因为读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,线程 B 可以正确地读取。
- 红色箭头表示普通变量 i 错误地读取了初始化之前的值。这是因为读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,导致被线程 B 错误地读取了。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为
null
,那么引用对象的 final 域一定已经被 A 线程初始化过了。
为什么 final 引用不能从构造函数内“逸出”
前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了(构造函数完成,对象引用才会产生)。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中逸出
。
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample () { //构造函数开始(2)
i = 1; //写final域(3)
obj = this; //this引用在此“逸出”(4)
} //构造函数结束(5)
public static void writer() {
new FinalReferenceEscapeExample (); //构造一个FinalExample类型的对象(1)
}
public static void reader {
if (obj != null) { //读取不为null的对象引用(6)
int temp = obj.i; //读final域(7)
}
}
}
假设线程A
执行writer()
方法,线程B
执行reader()
方法。
- 红色箭头表示读
final域(7)
错误地读取了初始化之前的值。这是因为这里的操作(4)
使得对象还未完成构造
前就为线程B可见
。即使这里的操作(4)
是构造函数的最后一步
,且在程序中操作(4)
排在操作(3)
后面,执行read()
方法的线程仍然可能无法看到final域被初始化后
的值,因为这里的操作(3)
和操作(4)
之间可能被重排序,导致 final 域在被正确初始化之前
对象引用就暴露了,从而在线程B的 reader 中访问到未初始化
的 final 域。
final 语义在处理器中的实现
以 X86 处理器为例:由于 X86 处理器不会对写-写
操作做重排序,所以在 X86 处理器中,写 final 域需要的StoreStore
屏障会被省略掉。同样,由于 X86 处理器不会对存在间接依赖关系的操作做重排序,所以在 X86 处理器中,读 final 域需要的LoadLoad
屏障也会被省略掉。也就是说,在 X86 处理器中,final 域的读/写不会插入任何内存屏障.
JSR-133 为什么要增强 final 的语义
在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整型 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变。
为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则
,可以为 Java 程序员提供初始化安全保证
:只要对象是正确构造
的(被构造对象的引用在构造函数中没有逸出
),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。
总结
final关键词在并发中禁止重排序依赖的就是内存屏障
- 写final域:编译器会在 final 域的写之后,构造函数
return之前
,插入一个StoreStore
屏障。 - 读final域:在一个线程中,初次读对象引用与初次读该对象包含的 final 域,编译器会在读 final 域操作的前面插入一个
LoadLoad
屏障。