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

volatileSystem.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 还好使?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

王叮咚

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值