volatile
的 System.out.println
问题
在学习volatile
可见性问题的过程中,发现一个很奇怪的现象。在没有添加volatile
关键字保证可见性的情况下,多个线程间可见性的问题,竟然神奇的被一行 System.out.println
给解决了。具体什么情况,我们通过下面的案例来看看/
public class Test {
//线程1
static boolean isFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(!isFlag){
doSomething();
}
},"A").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("sleep ending");
//main 等待线程A启动完成
isFlag = true;
System.out.println("main run ending");
}
public static void doSomething(){
System.out.println("1");
}
}
/** * 场景1:doSomething方法体内无代码,isFlag还未被volatile修饰 * public static void doSomething(){} * 结果: 由于 isFlag 变量的可见性,程序将会变为死循环 * * 场景2:doSomething方法体内无代码,isFlag还被volatile修饰 * public static void doSomething(){} * 结果: 添加了volatile 关键字,保证了isFlag字段的可见性,程序正常结束 * * 场景3:doSomething方法体内有代码,isFlag还被volatile修饰 * public static void doSomething(){System.out.println("1");} * 结果: 虽然没有 volatile 关键字,但是程序却没有死循环,while循环将在执行一段时间后停止。 * */
场景1
和场景2
都很好理解。为什么场景3
在没有volatile
关键的情况下,通过System.out.println
解决了可见的问题;这个还得从JIT
说起。例如场景1
你可以通过-Xint
禁用 JIT
,同样可以退出死循环,不信你试试?
1. JIT(Just-in-time)的优化
在 JIT编译器
编译字节码时,可不是仅仅是简单的直接将字节码翻译成机器码,它在编译的同时还会做很多优化,比如循环展开、方法内联等等…
JIT编译器的优化技术
:
- 表达式提升(
expression hoisting
) - 表达式下沉(
expression sinking
) - 循环展开(
Loop unwinding/loop unrolling
) - 内联优化(
Inling
)
1.1 表达式提升(expression hoisting
)
场景3
问题出现的原因是JIT编译器
的优化技术之一表达式提升(expression hoisting)
导致的;
先来看个例子,在这个 hoisting
方法中,for 循环里每次都会定义一个变量 y,然后通过将 x*y
的结果存储在一个 result
变量中,然后使用这个变量进行各种操作
public void hoisting(int x) {
for (int i = 0; i < 1000; i = i + 1) {
int y = 654;
int result = x * y;
}
}
但是在上面这个例子里,result
的结果是固定的,并不会跟着循环而更新。所以完全可以将 result
的计算提取到循环之外,这样就不用每次计算了。JIT
分析后会对这段代码进行优化,进行表达式提升的操作:
/**
* 优化后的代码
*/
public void hoisting(int x) {
int y = 654;
int result = x * y;
for (int i = 0; i < 1000; i = i + 1) {
}
}
这样一来,result
不用每次计算了,而且也完全不影响执行结果,大大提升了执行效率。
注意,编译器更喜欢局部变量,而不是静态变量或者成员变量;因为静态变量是 “逃逸在外的”,多个线程都可以访问到,而局部变量是线程私有的,不会被其他线程访问和修改。
编译器在处理静态变量 / 成员变量
时,会比较保守,不会轻易优化。
比如下面的这个例子(和上面的场景一相同
)中,stopRequested
就是个静态变量,编译器本不应该对其进行优化处理;
static boolean stopRequested = false;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
// leaf method(没有调用任何方法)
i++;
}
}) ;
backgroundThread.start();
TimeUnit.MICROSECONDS.sleep(10);
stopRequested = true ;
}
但由于你这个循环是个leaf method
,即没有调用任何方法,所以在循环之中不会有其他线程会观测到stopRequested
值的变化。那么编译器就冒进的进行了表达式提升的操作,将stopRequested
提升到表达式之外,作为循环不变量(loop invariant
)处理:
int i = 0;
boolean hoistedStopRequested = stopRequested;// 将stopRequested 提升为局部变量
while (!hoistedStopRequested) {
i++;
}
这样一来,最后将 stopRequested
赋值为 true
的操作,影响不了提升的hoistedStopRequested
的值,自然就无法影响循环的执行了,最终导致无法退出。
接着让我们回到场景3
,为什么添加了println()
之后,循环就可以正常退出了?
因为你这行 println
代码影响了编译器的优化。println
方法由于最终会调用 FileOutputStream.writeBytes 这个 native
方法,所以无法被内联优化(inling)
。而未被内敛的方法调用从编译器的角度看是一个 “full memory kill”
,也就是说 副作用不明 、必须对内存的读写操作做保守处理。
在场景3
里,下一轮循环的 isFlag
读取操作按顺序要发生在上一轮循环的 println 之后。这里 “保守处理” 为:就算上一轮我已经读取了 isFlag
的值,由于经过了一个副作用不明的地方,再到下一次访问就必须重新读取了。
所以在你增加了 prinltln
之后,JIT
由于要保守处理,重新读取,自然就不能做上面的表达式提升优化了。
以上对表达式提升的解释,总结摘抄自 R 大的知乎回答。R 大,行走的 JVM Wiki!
这都是 JIT
干的好事,你要是禁用 JIT
就没这问题了(-Xint
参数)
1.2 表达式下沉(expression sinking
)
和表达式提升
类似的,还有个表达式下沉
的优化,比如下面这段代码:
public void sinking(int i) {
int result = 543 * i;
if (i % 2 == 0) {
} else {
}
}
由于在 else
分支里,并没有使用 result 的值,可每次不管什么分支都会先计算 result
,这就没必要了。JIT
会把 result
的计算表达式移动到 if 分支里,这样就避免了每次对 result
的计算,这个操作就叫表达式下沉
public void sinking(int i) {
if (i % 2 == 0) {
int result = 543 * i;
} else {
}
}
1.3 循环展开(Loop unwinding/loop unrolling)
下面这个 for 循环,一共要循环 10w
次,每次都需要检查条件。
for (int i = 0; i < 100000; i++) {
delete(i);
}
在编译器的优化后,会删除一定的循环次数,从而降低索引递增和条件检查操作而引起的开销:
for (int i = 0; i < 20000; i+=5) {
delete(i);
delete(i + 1);
delete(i + 2);
delete(i + 3);
delete(i + 4);
}
除了循环展开,循环还有一些优化机制,比如循环剥离、循环交换、循环分裂、循环合并……
1.4 内联优化(Inling)
JVM
的方法调用是个栈的模型,每次方法调用都需要一个压栈(push)
和出栈(pop)
的操作,编译器也会对调用模型进行优化,将一些方法的调用进行内联。
内联就是抽取要调用的方法体代码,到当前方法中直接执行,这样就可以避免一次压栈出栈的操作,提升执行效率。比如下面这个方法:
public void inline(){
int a = 5;
int b = 10;
int c = calculate(a, b);
}
public int calculate(int a, int b){
return a + b;
}
在编译器内联优化后,会将 calculate
的方法体抽取到 inline
方法中,直接执行,而不用进行方法调用:
public void inline(){
int a = 5;
int b = 10;
int c = a + b;
}
不过这个内联优化是有一些限制的,比如 native
的方法就不能内联优化;
2. 如何避免因 JIT优化
导致的问题?
JIT
这么多优化机制,很容易出问题啊,我平时写代码要怎么避开这些呢,平时在编码的时候,不用刻意的去关心 JIT
的优化,就比如上面那个 println
问题,JMM
本来就不保证修改对其他线程可见,如果按照规范去加锁或者用 volatile
修饰,根本就不会有这种问题。
而那个提前置空导致的问题,出现的几率也很低,只要你规范写代码基本不会遇到的。在日常编码过程中,不用刻意的猜测 JIT
的优化机制,JVM
也不会完整的告诉你所有的优化。而且这种东西不同版本效果不一样,就算搞明白了一个机制,可能到下个版本就完全不一样了。
所以,如果不是搞编译器开发的话,JIT 相关的编译知识,作为一个知识储备就好。