深入理解JVM-类执行机制

本文深入解析Java类执行机制,涵盖字节码分析、指令解释执行的不同分派方式、栈顶缓存及部分栈帧共享优化手段等内容。同时,详细介绍了JIT编译器的工作原理及其优化手段,包括方法内联、去虚拟化、冗余剔除等。
摘要由CSDN通过智能技术生成

Java类执行是在完成将class文件信息加载到jvm中并且产生Class对象之后。字节码只是一种中间代码形式,在实际运行时要由jvm解释执行。

字节码分析

因为Java采取的是中间码的形式,应用程序可以在不同的操作系统上由JVM解释执行,因此具有良好的扩展性。那么JVM肯定会有一套自己的执行方法的指令。在JVM中:
invokestatic调用static方法
invokevirtual调用对象实例方法
invokeinterface调用接口方法
invokespecial调用private方法和init方法

每个方法每次调用都会产生栈帧,栈帧包含局部变量区和操作数栈。局部变量区存放方法的局部变量和参数、操作数栈存放中间结果。下面是一段代码以及对应的字节码信息

代码

    public static int foo() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 5;
        return c;
    }

字节码

public static void noFoo();
    descriptor: ()V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: iconst_1 // 将类型为int,值为1的常量放到操作数栈
         1: istore_0 // 将操作数栈顶的值弹出放到局部变量区的位置1
         2: iconst_2 // 将类型为int,值为2的常量放到操作数栈
         3: istore_1 // 将操作数栈顶的值弹出放到局部变量区的位置2
         4: iload_0  // 装载局部变量区位置1的变量到操作数栈
         5: iload_1  // 装载局部变量区位置2的变量到操作数栈
         6: iadd     // 执行int类型的add操作,并将计算结果放到操作数栈
         7: iconst_5 // 将类型为int,值为5的常量放到操作数栈
         8: imul     // 执行int类型的mul操作,并将计算结果放到操作数栈
         9: istore_2 // 将操作数栈顶的值弹出放到局部变量区的位置3
        10: return   // 返回
      LineNumberTable:
        line 29: 0
        line 30: 2
        line 31: 4
        line 32: 10
解释执行

指令解释执行有switch-threading、token-threading、direct-threading、subroutine-threading、inline-threading等分派方式

Sun JDK主要在解释器上主要采用token-threading方式,另外还有栈顶缓存以及部分栈帧共享等优化手段。
栈顶缓存:即将本来位于操作数栈顶的值直接缓存在寄存器上。
部分栈帧共享:后一方法可以将前一方法的操作数栈作为当前方法的局部变量。

编译执行

很有名的JIT编译器,主要为了提高解释执行的效率。编译上分为Client和Server两种,即C1和C2,在启动jvm时可以通过参数-client 或者-server启动。
编译时优化手段有方法内联、去虚拟化、冗余剔除。

C1:
方法内联:多个方法调用时,通常要参数传递,返回值传递以及跳转等。所以可以直接将被调用方法的代码放在调用代码块中。 注:35字节 & -XX:PrintInlining参数

去虚拟化:装载class文件后,进行类层次分析,如果发现类中的方法只有一个实现类,那么调用此方法的地方也进行方法内联。

冗余剔除:个人认为有点像语法糖那种方式的优化。如果方法中的代码块肯定不会执行,那么这段代码在编译时就相当于没有。

boolean debug = false;
if (debug) {
    // debug log
}

C2:
比较重要的是逃逸分析。所谓逃逸分析就是比如说方法中的一个变量不会被外部所读取,那么这个变量就不是逃逸的。编译时的优化手段有标量替换、栈上分配和同步削除。
标量替换:用标量替换聚合量,比如说仅仅为了打印一个对象中的成员变量值,而new了一个对象,那么就用一个局部的标量来替换这个对象。好处就是如果创建的对象并没有用到其中的全部变量,则节省一定的内存。对于代码块而言不会去寻找对象引用,效率更高。

栈上分配:如果变量没有逃逸,那么直接在栈帧上分配,而不是Java堆。更加快速,回收更方便。

同步削除:如果同步的对象不是逃逸的,那么会自动去掉同步,毕竟同步会造成开销。

注:逆优化:运行后C1和C2编译出来的机器码不再符合优化条件,则会进行逆优化,也就是回到解释执行的方式。

除了C1、C2还有OSR编译方法,比较特殊。

OSR:
OSR编译方式只替换循环代码体的入口,而C1、C2替换的是方法调用的入口。也就是说方法的整段代码都被编译,但是直有循环部分才执行编译 的机器码,其他部分还是解释执行方式。

程序在未编译期间解释执行会比较慢,Sun JDK主要依据方法上的两个计数器是否超过阀值,来决定是否编译。一个计数器为调用计数器,方法被调用一次,会+1,另一个计数器为回边计数器,即方法中循环执行部分代码的执行次数。

CompileThreshold:调用计数器阀值,调用计数器超过此阀值会编译为机器码
通过-XX:CompileThreshold=10000设置。

OnStackReplacePercentage:用于计算出发OSR编译的阀值

OSR编译阀值计算方法:
client模式下规则为:CompileThreshold * (OnStackReplacePercentage / 10)

server模式下规则为:(CompileThreshold*(OnStackReplacePercentage-IntepreterProfilePercentage)) / 100

当方法上回边计数器达到这些规则计算出来的阀值时,会OSR编译成机器码。并且将调用计数器设置为CompileThreshold的值,回边计数器的值设置为CompileThreshold/2,这样的目的是为了OSR被频繁触发,同时方法被再次调用时可以触发正常编译(C1、C2编译),当累计的回边计数器的值再次达到阀值时,如果OSR编译完成,那么执行编译的代码,如果OSR编译未完成,那么就会将回边计数器的值继续减掉一些。

下面是模拟触发OSR编译的代码:

OSRTest.java

package excution;

import java.lang.reflect.Method;

/**
 * 测试结果如下:
 * ①java -server -Xms128M -Xmx128M OSRTest
 * 直接调用消耗的时间为:5毫秒
 * 不缓存Method,反射调用消耗的时间为:5毫秒
 * 缓存Method,反射调用消耗的时间为:4毫秒
 *
 * ②java -server -Xms128M -Xmx128M -Xint OSRTest
 * 直接调用消耗的时间为:246毫秒
 * 不缓存Method,反射调用消耗的时间为:416毫秒
 * 缓存Method,反射调用消耗的时间为:277毫秒
 */

public class OSRTest {

    private static final int WARMUP_COUNT = 10700;

    private ForReflection testClass = new ForReflection();

    private static Method method = null;

    public static void main(String[] args) throws Exception {
        method = ForReflection.class.getMethod("execute", new Class<?>[]{String.class});
        OSRTest osrTest = new OSRTest();

        for (int i = 0;i < 20;i++) {
            osrTest.testDirectCall();
            osrTest.testCacheMethodCall();
            osrTest.testNoCacheMethodCall();
        }
        long beginTime = System.currentTimeMillis();
        osrTest.testDirectCall();
        long endTime = System.currentTimeMillis();
        System.out.println("直接调用消耗的时间为:" + (endTime-beginTime) + "毫秒");

        beginTime = System.currentTimeMillis();
        osrTest.testNoCacheMethodCall();
        endTime = System.currentTimeMillis();
        System.out.println("不缓存Method,反射调用消耗的时间为:" + (endTime-beginTime) + "毫秒");

        beginTime = System.currentTimeMillis();
        osrTest.testCacheMethodCall();
        endTime = System.currentTimeMillis();
        System.out.println("缓存Method,反射调用消耗的时间为:" + (endTime-beginTime) + "毫秒");
    }

    public void testDirectCall() {
        for (int i = 0;i < WARMUP_COUNT;i++) {
            testClass.execute("hello");
        }
    }

    public void testCacheMethodCall() throws Exception {
        for (int i = 0;i < WARMUP_COUNT;i++) {
            method.invoke(testClass, new Object[]{"hello"});
        }
    }

    public void testNoCacheMethodCall() throws Exception {
        for (int i = 0;i < WARMUP_COUNT;i++) {
            Method testMethod = ForReflection.class.getMethod("execute", new Class<?>[]{String.class});
            testMethod.invoke(testClass, new Object[]{"hello"});
        }
    }
}

ForReflection.java

package excution;

import java.util.HashMap;
import java.util.Map;

public class ForReflection {
    private Map<String, String> caches = new HashMap<>();

    public void execute(String message) {
        String b = this.toString() + message;
        caches.put(b, message);
    }
}

通过代码执行结果可以看出反射执行虽然很动态灵活,但是会影响效率。所以对于同一个方法需要很多此的反射执行,可以将该Method缓存起来。这样会提高效率。

此博客为读书笔记

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值