关于java:一个-println-竟然比-volatile-还好使

8次阅读

共计 5161 个字符,预计需要花费 13 分钟才能阅读完成。

先点赞再看,养成好习惯

前两天一个小伙伴忽然找我求助,说筹备换个坑,最近在零碎学习多线程常识,但遇到了一个刷新认知的问题……

小伙伴:Effective JAVA 里的并发章节里,有一段对于可见性的形容。上面这段代码会呈现死循环,这个我能了解,JMM 内存模型嘛,JMM 不保障 stopRequested 的批改能被及时的观测到。

static boolean stopRequested = false;

public static void main(String[] args) throws InterruptedException {Thread backgroundThread = new Thread(() -> {
        int i = 0;
        while (!stopRequested) {i++;}
    }) ;
    backgroundThread.start();
    TimeUnit.MICROSECONDS.sleep(10);
    stopRequested = true ;
}

但奇怪的是在我加了一行打印之后,就不会呈现死循环了!难道我一行 println 能比 volatile 还好使啊?这俩也没关系啊

static boolean stopRequested = false;

public static void main(String[] args) throws InterruptedException {Thread backgroundThread = new Thread(() -> {
        int i = 0;
        while (!stopRequested) {

            // 加上一行打印,循环就能退出了!System.out.println(i++);
        }
    }) ;
    backgroundThread.start();
    TimeUnit.MICROSECONDS.sleep(10);
    stopRequested = true ;
}

我:小伙子八股文背的挺熟啊,JMM 张口就来。​

我:这个……其实是 JIT 干的坏事,导致你的循环无奈退出。JMM 只是一个逻辑上的内存模型,外部有些机制是和 JIT 无关的

比方你第一个例子里,你用 -Xint 禁用 JIT,就能够退出死循环了,不信你试试?

小伙伴:卧槽,真的能够,加上 -Xint 循环就退出了,好神奇!JIT 是个啥啊?还能有这种效用?

JIT(Just-in-Time)的优化

家喻户晓,JAVA 为了实现跨平台,减少了一层 JVM,不同平台的 JVM 负责解释执行字节码文件。尽管有一层解释会影响效率,但益处是跨平台,字节码文件是平台无关的。

在 JAVA 1.2 之后,减少了 即时编译(Just-in-Time Compilation,简称 JIT) 的机制,在运行时能够将执行次数较多的热点代码编译为机器码,这样就不须要 JVM 再解释一遍了,能够间接执行,减少运行效率。

但 JIT 编译器在编译字节码时,可不仅仅是简略的间接将字节码翻译成机器码,它在编译的同时还会做很多优化,比方循环展开、办法内联等等…… ​

这个问题呈现的起因,就是因为 JIT 编译器的优化技术之一 – 表达式晋升(expression hoisting) 导致的。

表达式晋升(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 的后果是固定的,并不会跟着循环而更新。所以齐全能够将 result 的计算提取到循环之外,这样就不必每次计算了。JIT 剖析后会对这段代码进行优化,进行表达式晋升的操作:

public void hoisting(int x) {
    int y = 654;
    int result = x * y;

    for (int i = 0; i < 1000; i = i + 1) {// ...... 基于这个 result 变量的各种操作}
}

这样一来,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 的值,天然就无奈影响循环的执行了,最终导致无奈退出。​

至于你减少了 println 之后,循环就能够退出的问题。是因为你这行 println 代码影响了编译器的优化。println 办法因为最终会调用 FileOutputStream.writeBytes 这个 native 办法,所以无奈被内联优化(inling)。而未被内敛的办法调用从编译器的角度看是一个“full memory kill”,也就是说 副作用不明 、必须对内存的读写操作做 激进解决。​

在这个例子里,下一轮循环的 stopRequested 读取操作按程序要产生在上一轮循环的 println 之后。这里“激进解决”为:就算上一轮我曾经读取了 stopRequested 的值,因为通过了一个 副作用不明 的中央,再到下一次拜访就必须从新读取了。​

所以在你减少了 prinltln 之后,JIT 因为要激进解决,从新读取,天然就不能做下面的 表达式晋升 优化了。​

以上对 表达式晋升 的解释,总结摘抄自 R 大 的知乎答复。​

我:“这下明确了吧,这都是 JIT 干的坏事,你要是禁用 JIT 就没这问题了”

小伙伴:“卧槽🐂🍺,一个简略的 for 循环也太多机制了,没想到 JIT 这么智能,也没想到 R 大这么🐂🍺”

小伙伴:“那 JIT 肯定很多优化机制吧,除了这个表达式晋升还有啥?”

我:我也不是搞编译器的……哪理解这么多,就晓得一些罕用的,简略给你说说吧

表达式下沉(expression sinking)

和表达式晋升相似的,还有个表达式下沉的优化,比方上面这段代码:

public void sinking(int i) {
    int result = 543 * i;

    if (i % 2 == 0) {// 应用 result 值的一些逻辑代码} else {// 一些不应用 result 的值的逻辑代码}
}

因为在 else 分支里,并没有应用 result 的值,可每次不论什么分支都会先计算 result,这就没必要了。JIT 会把 result 的计算表达式挪动到 if 分支里,这样就防止了每次对 result 的计算,这个操作就叫表达式下沉:

public void sinking(int i) {if (i % 2 == 0) {
        int result = 543 * i;
        // 应用 result 值的一些逻辑代码
    } else {// 一些不应用 result 的值的逻辑代码}
}

JIT 还有那些常见优化?

除了下面介绍的表达式晋升 / 表达式下沉以外,还有一些常见的编译器优化机制。

循环展开(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);
}

除了循环展开,循环还有一些优化机制,比方循环剥离、循环替换、循环决裂、循环合并……

内联优化(Inling)

JVM 的办法调用是个栈的模型,每次办法调用都须要一个压栈(push)和出栈(pop)的操作,编译器也会对调用模型进行优化,将一些办法的调用进行内联。​

内联就是抽取要调用的办法体代码,到以后办法中间接执行,这样就能够防止一次压栈出栈的操作,晋升执行效率。比方上面这个办法:

public  void inline(){
    int a = 5;
    int b = 10;
    int c = calculate(a, b);

    // 应用 c 解决……
}

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;

    // 应用 c 解决……
}

不过这个内联优化是有一些限度的,比方 native 的办法就不能内联优化

提前置空

来先看一个例子,在这个例子中 was finalized! 会在 done.之前输入,这个也是因为 JIT 的优化导致的。

class A {
    // 对象被回收前,会触发 finalize
    @Override protected void finalize() {System.out.println(this + "was finalized!");
    }

    public static void main(String[] args) throws InterruptedException {A a = new A();
        System.out.println("Created" + a);
        for (int i = 0; i < 1_000_000_000; i++) {if (i % 1_000_00 == 0)
                System.gc();}
        System.out.println("done.");
    }
}

// 打印后果
Created A@1be6f5c3
A@1be6f5c3 was finalized!//finalize 办法输入
done.

从例子中能够看到,如果 a 在循环实现后曾经不再应用了,则会呈现先执行 finalize 的状况;尽管从对象作用域来说,办法没有执行完,栈帧并没有出栈,然而还是会被提前执行。​

这就是因为 JIT 认为 a 对象在循环内和循环后都不会在应用,所以提前给它置空了,帮忙 GC 回收;如果禁用 JIT,那就不会呈现这个问题…… ​

这个提前回收的机制,还是有点危险的,在某些场景下会引起 BUG

HotSpot VM JIT 的各种优化项

下面只是介绍了几个简略罕用的编译优化机制,JVM JIT 更多的优化机制能够参考上面这个图。这是 OpenJDK 文档中提供的一个 pdf 资料,外面列出了 HotSpot JVM 的各种优化机制,相当多……

如何防止因 JIT 导致的问题?

小伙伴:“JIT 这么多优化机制,很容易出问题啊,我平时写代码要怎么避开这些呢”

平时在编码的时候,不必刻意的去关怀 JIT 的优化,就比方下面那个 println 问题,JMM 原本就不保障批改对其余线程可见,如果依照标准去加锁或者用 volatile 润饰,基本就不会有这种问题。​

而那个提前置空导致的问题,呈现的几率也很低,只有你标准写代码根本不会遇到的。

我:所以,这不是 JIT 的锅,是你的……

小伙伴:“懂了,你这是说我菜,说我代码写的屎啊……”

总结

在日常编码过程中,不必刻意的猜想 JIT 的优化机制,JVM 也不会残缺的通知你所有的优化。而且这种货色不同版本成果不一样,就算搞明确了一个机制,可能到下个版本就齐全不一样了。​

所以,如果不是搞编译器开发的话,JIT 相干的编译常识,作为一个常识储备就好。

也不必去猜想 JIT 到底会怎么优化你的代码,你(可能)猜不准……

正文完
 0