Java 运行期优化 —— 附示例代码、截图证明

目录

 

运行期优化

一、即时编译

1.1 逃逸分析

1.2 方法内联

1.3 字段优化

二、反射优化


运行期优化

Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称JIT编译器)

由于Java虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现(Implementation Specific)相关的内容,如无特殊说明,本文提及的编译器、即时编译器都是指HotSpot虚拟机内的即时编译器,虚拟机也是特指HotSpot虚拟机。

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

下面将通过代码示例,来阐述运行期优化。


一、即时编译

1.1 逃逸分析

先来看个例子

public class T01_RunTime_EscapeAnalysis {
    public static void main(String[] args) {
        for (int i = 0; i < 200; i++) {
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) {
                new Object(); // 循环创建对象
            }
            long end = System.nanoTime();
            System.out.printf("%d\t%d\n", i, (end - start));
        }
    }
}

执行时间片段如下:

0	86861
1	89557
2	68333
3	64021
4	67292
5	63446
-------------
67	22369
68	44501
69	30545
70	17963
71	21750
-------------
195	13104
196	14453
197	23107
198	13574
199	14051

测试结果:我们可以看到同样的代码,为什么后续执行的时间越来越短呢?原因是什么呢?这就需要先了解 JVM 即时编译知识。

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译(Tiered Compilation)的策略。

JVM 将执行状态分成了 5 个层次:

  • 0层,解释执行(Interpreter),将字节码解释为机器码
  • 1层,使用 C1 即时编译器编译执行(不带 profiling)
  • 2层,使用 C1 即时编译器编译执行(带基本的 profiling)
  • 3层,使用 C1 即时编译器编译执行(带完全的 profiling)
  • 4层,使用 C2 即时编译器编译执行

profiling 是指在运行过程中收集一些程序执行状态的数据,即信息统计工作,例如【方法的调用次数】,【循环的回边次数】等。

 即时编译器(JIT)与解释器的区别

  • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
  • JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,下次遇到相同的代码,直接执行,无需再编译
  • 解释器是将字节码解释为针对所有平台都通用的机器码
  • JIT 会根据平台类型,生成平台特定的机器码

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。执行效率上简单比较一下 :Interpreter < C1(可以提升5倍左右) < C2(可以提升10~100倍),总的目标是发现热点代码 (hotspot名称的由来)优化之。

刚才的一种优化手段称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用 -XX:-DoEscapeAnalysis 关闭,默认打开,再运行刚才的示例观察结果。会才发现后续的运行时间没有大缩短了。


1.2 方法内联

    private static int square(final int i) {
        return i * i;
    }

 方法内联:如果发现 square() 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置

        System.out.println(square(9));

还能够进行常量折叠(constant folding)的优化

        System.out.println(81);

内联测试代码如下:

// 运行期优化 —— 方法内联
public class T02_RunTime_Inlining {

    // -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining    // 查看 JVM 对代码的内联情况
    // -XX:CompileCommand=dontinline,*T02_RunTime_Inlining.square   // 禁用内联
    // -XX:+PrintCompilation

    public static void main(String[] args) {
        int x = 0;
        for (int i = 0; i < 500; i++) {
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) {
                x = square(9);
            }
            long end  = System.nanoTime();
            System.out.printf("%d\t%d\t%d\n", i, x, (end - start));
        }
    }

    private static int square(final int i) {
        return i * i;
    }
}

上述代码运行片段结果如下:

1	81	79805
2	81	35501
3	81	35220
4	81	31679
5	81	35823
--------------------------
91	81	7316
92	81	7322
93	81	7327
94	81	8118
95	81	7441
--------------------------
131	81	61
132	81	54
133	81	53
134	81	54
135	81	52

如果在上述代码加入 VM 参数(查看内联方法):-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining,再次运行

如果我们禁用内联-XX:CompileCommand=dontinline,*T02_RunTime_Inlining.square,再次测试,耗时片断如下:

1	81	49707
2	81	31806
3	81	22157
4	81	35130
5	81	35269
----------------------
91	81	8425
92	81	8722
93	81	13402
94	81	8650
95	81	8596
----------------------
131	81	5365
132	81	5444
133	81	5633
134	81	4731
135	81	5293

相比第一次测试,到了130多次后,耗时再没有下降,因为内联关闭了


1.3 字段优化

JMH 基准测试请参考: http://openjdk.java.net/projects/code-tools/jmh/

创建 maven 工程,添加依赖如下

        <dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-core</artifactId>
            <version>1.21</version>
        </dependency>
        <dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-generator-annprocess</artifactId>
            <version>1.21</version>
        </dependency>

字段优化代码示例如下:

// 运行期优化 —— 字段优化

// 热身,先热身再优化
@Warmup(iterations = 5, time = 1)
// 5轮测试
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
public class T03_RunTime_FieldOptimize {

    int[] elements = randomInts(1_000);

    private static int[] randomInts(int size) {
        ThreadLocalRandom random = ThreadLocalRandom.current();
        int[] values = new int[size];
        for (int i = 0; i < size; i++) {
            values[i] = random.nextInt();
        }
        return values;
    }

    @Benchmark
    public void test1() {
        for (int i = 0; i < elements.length; i++) {
            doSum(elements[i]);
        }
    }

    @Benchmark
    public void test2() {
        int[] local = this.elements;
        for (int i = 0; i < elements.length; i++) {
            doSum(elements[i]);
        }
    }

    @Benchmark
    public void test3() {
        for (int element : elements) {
            doSum(element);
        }
    }

    static int sum = 0;

    @CompilerControl(CompilerControl.Mode.INLINE)  // 控制调用方法时是不是要进行方法内联;允许内联
    static void doSum(int x) { sum += x;}

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(T03_RunTime_FieldOptimize.class.getSimpleName())
                .forks(1)
                .build();

        new Runner(opt).run();
    }
}

开启方法内联:CompilerControl.Mode.INLINE,每个方法2轮热身,5轮测试。结果如下(每秒吞吐量,分数越高的更好):

接下来禁用 doSum 方法内联CompilerControl.Mode.DONT_INLINE

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)  // 控制调用方法时是不是要进行方法内联;
    static void doSum(int x) { sum += x;}

关闭方法内联,每个方法2轮热身,5轮测试。如果如下:吞吐量都有一定程度的下降

分析:

在上述的示例中,doSum() 方法是否内联会影响 elements 成员变量读取的优化:

如果 doSum() 方法内联了,test1 方法会被优化成下面的样子(伪代码)

    @Benchmark
    public void test1() { 
        // elements.length 首次读取会缓存起来 -> int[] local
        for (int i = 0; i < elements.length; i++) { // 后续 999 次 求长度 <- local
            // doSum(elements[i]);
            doSum(elements[i]); // 1000 次取下标 i 的元素 <- local
        }
    }

可以节省 1999 次 Field 字段读取操作

但如果 doSum() 方法没有内联,则不会进行上面的优化

本地变量访问长度、数据时,不需要去 class 元数据那里找,在本地变量就可以找到了,相当于手动优化。但是方法内联是由虚拟机来优化的。所以,test3 方法与test2 方法是等价的,test1 方法是运行期间优化了,test2 方法是手动优化了, test3 方法的 foreach 是 编译期间优化了。


二、反射优化

通过“反射”我们可以动态的获取到对象的信息以及灵活的调用对象方法等,但是在使用的同时又伴随着另一种声音的出现,那就是“反射”很慢,要少用。那么 JVM 是怎样做了反射优化的呢?下面我们一起来分析一下,上示例代码:

// 运行期优化 —— 反射优化
public class T04_RunTime_Reflect {
    public static void foo() {
        System.out.println("foo...");
    }

    public static void main(String[] args) throws Exception {
        Method foo = T04_RunTime_Reflect.class.getMethod("foo");
        // 前16次调用效率较低,第17次调用效率较高
        for (int i = 0; i < 16; i++) {
            System.out.printf("%d\t", i);
            foo.invoke(null);
        }
        System.in.read();
    }
}

查看 MethodAccessor 的实现类,进一步查看反射优化,会发现有个15次的阈值

下面,我们从源码的角度来分析反射底层是如何实现优化的:

第一步:先下断点到 foo.invoke(null); 运行程序到此行

第二步:点击进去 invoke() 方法实现,找到 MethodAccessor 的实现类

第三步:继续打开NativeMethodAccessorImpl 实现类并定位到 invoke() 方法,打下断点。如下图所示:

 第四步:通过 Idea 查看 Evaluate,来查看反射多次调用后,由 JVM 动态生成的实现类名 - GeneratedMethodAccessor1,这个名字在后面需要用的。且此时 this.numInvocations 已经到了 16了。

 


注意:接下来,我们通过 阿里的 arthas 工具来进行调试。需要在 Run 模式下运行程序。 

第一步:以 Run 模式运行程序

接着需要下载好 arthas工具:官网链接 https://github.com/alibaba/arthas/blob/master/README_CN.md

下载方式:curl -O https://arthas.aliyun.com/arthas-boot.jar  

第二步:运行 arthas:java -jar arthas-boot.jar

第三步:查看帮助,找到进行反编译的指令 jad (Decompile class)

第四步:通过 jad sun.reflect.GeneratedMethodAccessor1 将 JVM 对反射优化后的类的字节码反编译出来

package sun.reflect;

import com.jvm.t11_runtime_optimize.T04_RunTime_Reflect;
import java.lang.reflect.InvocationTargetException;
import sun.reflect.MethodAccessorImpl;

public class GeneratedMethodAccessor1
extends MethodAccessorImpl {
    /*
     * Loose catch block
     * Enabled aggressive block sorting
     * Enabled unnecessary exception pruning
     * Enabled aggressive exception aggregation
     * Lifted jumps to return sites
     */
    public Object invoke(Object object, Object[] arrobject) throws InvocationTargetException {
        // 比较奇葩的做法,如果有参数,那么抛非法参数异常
        block4: {
            if (arrobject == null || arrobject.length == 0) break block4;
            throw new IllegalArgumentException();
        }
        try {
            // 可以看到,已经是直接调用了
            T04_RunTime_Reflect.foo();
            // 因为没有返回值;如果方法有返回值,它会拿到返回值然后返回
            return null;
        }
        catch (Throwable throwable) {
            throw new InvocationTargetException(throwable);
        }
        catch (ClassCastException | NullPointerException runtimeException) {
            throw new IllegalArgumentException(super.toString());
        }
    }
}

优化生成的 GeneratedMethodAccessor1 类也继承了 MethodAcessorImpl,它里面的 invoke() 方法是怎样写的呢?invoke() 方法本来我们理解应该是反射调用,但实际在它生成的 invoke() 里面变成了 T04_RunTime_Reflect.foo() ,因为在T04_RunTime_Reflect 类中的 foo() 方法是静态方法,所以使用:类名.静态方法名 来调用,这还是不是反射调用呢?已经不是反射调用了。所以第17次开始,JVM 虚拟机已经将我们的反射方法调用转换为 静态方法调用。

注意:

通过查看 ReflectionFactory 源码可知

  • sun.reflect.noInflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算)
  • sun.reflect.inflationThreshold 可以修改膨胀阈值

 


文章最后,给大家推荐一些受欢迎的技术博客链接

  1. Hadoop相关技术博客链接
  2. Spark 核心技术链接
  3. JAVA相关的深度技术博客链接
  4. 超全干货--Flink思维导图,花了3周左右编写、校对
  5. 深入JAVA 的JVM核心原理解决线上各种故障【附案例】
  6. 请谈谈你对volatile的理解?--最近小李子与面试官的一场“硬核较量”
  7. 聊聊RPC通信,经常被问到的一道面试题。源码+笔记,包懂
  8. 深入聊聊Java 垃圾回收机制【附原理图及调优方法】

 


欢迎扫描下方的二维码或 搜索 公众号“10点进修”,我们会有更多、且及时的资料推送给您,欢迎多多交流!

                                           

       

 

  • 7
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

不埋雷的探长

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

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

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

打赏作者

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

抵扣说明:

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

余额充值