天天看点

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

作者:编程菌zfn

经常听到 Java 性能不如 C/C++ 的言论,也经常听说 Java 程序需要预热,那么其中主要原因是啥呢?

面试的时候谈到 JVM,也有很多面试官喜欢问,为啥 Java 程序越执行越快呢?

一般人都能回答上来,类加载,缓存预热等等,但是深入下去,最重要的一点却没有答上来,今天本系列文章就来帮助大家理解这个问题的关键。

一、首先是JIT 优化

我们从一个简单的例子看起,来感受下程序是否越来越快:

package com.test;

import java.util.concurrent.TimeUnit;

public class CompileTest {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            test1();
            TimeUnit.SECONDS.sleep(1);
        }
    }

    public static void test1() {
        long time1 = System.nanoTime();
        long count1 = 0;
        for (int i = 0; i < 10000; i++) {
            count1++;
        }
        //为了和编译日志区分,这里输出到error输出
        System.err.println(System.nanoTime() - time1 + "-----" + count1);
    }
}
           

运行时,加上参数-XX:+PrintCompilation,打印一下编译日志(其实这个参数以后也许就过期了,建议使用 JVM 标准日志参数:-Xlog:jit+compilation=info),可以看到:

432900-----10000
250800-----10000
194600-----10000
197200-----10000
131600-----10000
184000-----10000
   6369  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)
162300-----10000
   7369  375       3       com.test.CompileTest::test1 (61 bytes)
68300-----10000
60300-----10000
47200-----10000
48100-----10000
  11371  378 %     4       com.test.CompileTest::test1 @ 9 (61 bytes)
55600-----10000
  11388  374 %     3       com.test.CompileTest::test1 @ 9 (61 bytes)   made not entrant
  12372  379       4       com.test.CompileTest::test1 (61 bytes)
157600-----10000
  12389  375       3       com.test.CompileTest::test1 (61 bytes)   made not entrant
600-----10000
700-----10000
600-----10000
1200-----10000
900-----10000
900-----10000
           

从输出中可以看出,貌似JVM对test1这段代码做了一些事情,使方法运行越来越快了。这就是JIT做的优化,随着代码的执行,热点代码会被优化,让执行更加迅速。这也是为什么,通过一般方法(javac命令)编译出来java class文件在执行的时候,要预热之后,才能发挥最大性能。接下来,我们来详细介绍下JIT。

OpenJDK Hotspot JVM,是最广泛运用的Java JVM。主要包含两部分,执行引擎(execution engine)和运行时(runtime)。执行引擎包括两部分,一个是垃圾收集器,另一个就是我们今天的主题, JIT(just-in-time)编译器。

什么是JIT

JVM是Java一次编译,跨平台执行的基础。当java被编译为字节码形式的class文件之后,他可以在任意的JVM运行。这里说的编译,主要是指前端编译器。

Java中主要有两种编译器:

  1. 前端编译器,将.java文件编译为JVM可执行的.class字节码文件,即javac,主要职责包括:词法、语法分析,填充符号表,语义分析,字节码生成。输出为字节码文件,也可以理解为是中间表达形式(称为IR:Intermediate Representation)。对应上面的例子就是将CompileTest.java编译成符合Java规范的字节码文件CompileTest.class
  2. 后端编译器,在程序运行期间将字节码转变成机器码,通过解释器和运行时编译器混合模式(现在的 Java 程序在运行时基本都是解释执行加编译执行),如 HotSpot 虚拟机自带的解释器还有 JIT(Just In Time Compiler)编译器(分 Client 端和 Server 端),其中JIT还会将中间表达形式进行一些优化。对应上面的例子就是test1方法执行越来越快。

Java 9中还引入了实验编译器AOT(Ahead-Of-Time)编译器,直接生成机器码。主要用于减少JAVA启动预热时间,比较适用于单次执行时间有限需要高效执行的程序,或者是小集成芯片环境,对效率要求比较高。AOT与Graal我们会在系列的最后着重介绍。对应上面的例子就是,test1方法不用预热就会执行的和上面最会一样那么快。但是相应的,机器码占用的大小比字节码大的多得多,而且不能跨平台。

为什么要这么区分呢?首先,不同机器的机器码是不一样的,编译生成统一的字节码保证了跨平台应用的可能性。然后,将字节码优化(中间表达形式优化)放到运行时优化,这样低版本的java编译出来的字节码,在高版本的JVM运行,仍能享受高版本的JVM新的优化机制带来的性能提升,这是一种很好的向后兼容机制。所以有的时候,我们可以先把JVM升级到新版本来享受更高效的优化算法。

刚刚提到了JVM使用混合模式来从字节码转换成机器可以运行的机器码,混合模式包括解释器和JIT:

解释器工作机制:

在编译时,主要是将java源代码文件编译为java统一的字节码,但是编译成的字节码并不能直接运行,而是通过JVM读取运行。JVM中的解释器就是将.class文件一行一行翻译之后再运行,翻译就是转换成当前机器可以运行的机器码,它不会一次性把整个文件都翻译过来,而是翻译一句,执行一句,再翻译,再执行,所以解释器的程序运行起来会比较慢,每次都要解释之后再执行。所以,有些时候,我们想是否可以把解释之后的内容缓存起来,这样不就可以直接运行了?但是,如果每段代码都要缓存起来,例如仅仅执行一次的代码也缓存起来,这样太浪费内存了。所以,引入一个新的运行时编译器,JIT来解决这些问题,加速热点代码的执行。

JIT运行时编译器工作机制:

JIT针对热点代码,进行编译与深度优化,优化后的机器码会被缓存起来,存入CodeCache中。对于非热点代码,例如只运行一次的代码(类构造器等等),直接解释执行,更加快速。JIT不仅花更多时间去编译优化,而且还多耗费了很多内存,并且 CodeCache 发生变化会发生部分或者所有线程进入 Safepoint 导致 Stop the world。字节码转换为可执行的机器码,大小会大很多很多倍。这也是为啥,解释器每次都要翻译并且执行,JIT只针对热点代码进行编译优化的原因。JIT编译器执行的一些常见优化操作包括数据分析,从堆栈操作到寄存器操作的转换,通过寄存器分配减少内存访问,消除常见子表达式等。JIT编译器进行的优化程度越高,在执行阶段花费的时间越多。因此,JIT编译器无法承担所有静态编译器所做的优化,这不仅是因为增加了执行时间的开销,而且还因为它只对程序进行了限制。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

对于上面的例子,刚开始的时候,test1方法是解释器执行的,由于多了一步转换,所以比较慢。后面随着代码的运行和JIT优化,test1方法的机器码被优化并且存入代码缓存,下次执行直接从代码缓存读取执行。

JIT的基本工作原理

首先,需要判断一个方法是否是热点方法:在HotSpot虚拟机中使用的基于计数器的热点探测方法,他为每个方法都准备了两个计数器:方法调用计数器和loop-back-edge计数器。

  • 方法调用计数器:顾名思义,这个计数器用于统计方法被调用的次数。在一个方法被调用时,根据前面所述,会先看看是否存在于codecache中,也就是jit编译的版本,如果不存在,则将计数加一,判断是否大于阈值,如果大于,则那么将会向即时编译器提交一个该方法的代码编译请求。如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进行解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
  • loop-back-edge计数器:专用来统计loop次数的,就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为loop-back-edge。这个计数器机制与上面的方法调用计数器一致。

有了这些计数器,JIT可以根据这些计数器里面的统计信息,进行优化。当然,不止有这些计数器,还有一些其他更复杂的采集点。JIT编译器在JDK 8之前,例如JDK 7是区分client模式(C1编译器)还是server模式(C2编译器)的,从JDK 8开始,不做这个区分了,都是C1+C2编译器合作,分层优化。C1是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。C2则是专门面向服务器端的,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器。从Java 8开始,JIT编译优化是分层优化,分为5层,每层都会有C1或者C2参与。

  • 第0层(Tier-0),只有解释器参与,解释执行
  • 第1层(Tier-1),执行不带任何采集的的C1优化代码
  • 第2层(Tier-2),执行仅带方法调用计数器和loop-back-edge计数器profiling的C1优化代码
  • 第3层(Tier-3),执行带所有采集的的C1优化代码
  • 第4层(Tier-4),执行C2优化代码

二、其次是TLAB 预热。

TLAB(Thread Local Allocation Buffer)线程本地分配缓存区,这是一个线程专用的内存分配区域。

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

既然是一个内存分配区域,我们就先要搞清楚 Java 内存大概是如何分配的。

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

我们这里不考虑栈上分配,这些会在 JIT 的章节详细分析,我们这里考虑的是无法栈上分配需要共享的对象。

对于 HotSpot JVM 实现,所有的 GC 算法的实现都是一种对于堆内存的管理,也就是都实现了一种堆的抽象,它们都实现了接口 CollectedHeap。当分配一个对象堆内存空间时,在 CollectedHeap 上首先都会检查是否启用了 TLAB,如果启用了,则会尝试 TLAB 分配;如果当前线程的 TLAB 大小足够,那么从线程当前的 TLAB 中分配;如果不够,但是当前 TLAB 剩余空间小于最大浪费空间限制(这是一个动态的值,我们后面会详细分析),则从堆上(一般是 Eden 区) 重新申请一个新的 TLAB 进行分配。否则,直接在 TLAB 外进行分配。TLAB 外的分配策略,不同的 GC 算法不同。例如G1:

  • 如果是 Humongous 对象(对象在超过 Region 一半大小的时候),直接在 Humongous 区域分配(老年代的连续区域)。
  • 根据 Mutator 状况在当前分配下标的 Region 内分配

这里,我们先只关心 TLAB 分配。 对于单线程应用,每次分配内存,会记录上次分配对象内存地址末尾的指针,之后分配对象会从这个指针开始检索分配。这个机制叫做 bump-the-pointer (撞针)。 对于多线程应用来说,内存分配需要考虑线程安全。最直接的想法就是通过全局锁,但是这个性能会很差。为了优化这个性能,我们考虑可以每个线程分配一个线程本地私有的内存池,然后采用 bump-the-pointer 机制进行内存分配。这个线程本地私有的内存池,就是 TLAB。只有 TLAB 满了,再去申请内存的时候,需要扩充 TLAB 或者使用新的 TLAB,这时候才需要锁。这样大大减少了锁使用。

TLAB 初始化

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

TLAB 分配

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

GC 时 TLAB 回收与重计算期望大小

90% 的 Java 程序员都说不上来:为何 Java 代码越执行越快?

为何 Java 代码越执行越快 - TLAB预热

根据之前的分析,每个线程的 TLAB 的大小,会根据线程分配的特性,不断变化并趋于稳定,大小主要是由分配比例 EMA 决定,但是这个采集是需要一定运行次数的。并且 EMA 的前 100 次采集默认是不够稳定的,所以 TLAB 大小也在程序一开始的时候变化频繁。当程序线程趋于稳定,运行一段时间后, 每个线程 TLAB 大小也会趋于稳定并且调整到最适合这个线程对象分配特性的大小。这样,就更接近最理想的只有 Eden 区满了才会 GC,所有 Eden 区的对象都是通过 TLAB 分配的高效分配情况。这就是 Java 代码越执行越快在 TLAB 方面的原因。

作者:干货满满张哈希

链接:https://juejin.cn/post/6925560351836602375

继续阅读