Volatile可见性的System.print.out 问题

  • Post author:
  • Post category:其他





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编译器的优化技术

  1. 表达式提升(

    expression hoisting

    )
  2. 表达式下沉(

    expression sinking

    )
  3. 循环展开(

    Loop unwinding/loop unrolling

  4. 内联优化(

    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 相关的编译知识,作为一个知识储备就好。



引用


volatile内存语义、原理详解、内存屏障


一个 println 竟然比 volatile 还好使?



版权声明:本文为weixin_45683550原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。