volatile
的
System.out.println
问题
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(Just-in-time)的优化
在
JIT编译器
编译字节码时,可不是仅仅是简单的直接将字节码翻译成机器码,它在编译的同时还会做很多优化,比如循环展开、方法内联等等…
JIT编译器的优化技术
:
-
表达式提升(
expression hoisting
) -
表达式下沉(
expression sinking
) -
循环展开(
Loop unwinding/loop unrolling
) -
内联优化(
Inling
)
1.1 表达式提升(
expression hoisting
)
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
)
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
这么多优化机制,很容易出问题啊,我平时写代码要怎么避开这些呢,平时在编码的时候,不用刻意的去关心
JIT
的优化,就比如上面那个
println
问题,
JMM
本来就不保证修改对其他线程可见,如果按照规范去加锁或者用
volatile
修饰,根本就不会有这种问题。
而那个提前置空导致的问题,出现的几率也很低,只要你规范写代码基本不会遇到的。在日常编码过程中,不用刻意的猜测
JIT
的优化机制,
JVM
也不会完整的告诉你所有的优化。而且这种东西不同版本效果不一样,就算搞明白了一个机制,可能到下个版本就完全不一样了。
所以,如果不是搞编译器开发的话,JIT 相关的编译知识,作为一个知识储备就好。
引用