执行引擎、JIT、逃逸分析

Java是半编译半解释型语言

  • 编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。
  • 解释型语言:程序不需要编译,程序在运行时才翻译成机器语言,每执 行一次都要翻译一次。
    在这里插入图片描述

两种解释器的底层实现

字节码解释器

做的事情是:java字节码->c++代码->硬编码
原理:

  • 通过while(true) 或者 for 将读取的字节码进行一个个编译

源码:

CASE(_new): {
        u2 index = Bytes::get_Java_u2(pc+1);
        ConstantPool* constants = istate->method()->constants();
        if (!constants->tag_at(index).is_unresolved_klass()) {
          // Make sure klass is initialized and doesn't have a finalizer
          Klass* entry = constants->slot_at(index).get_klass();
          assert(entry->is_klass(), "Should be resolved klass");
          Klass* k_entry = (Klass*) entry;
          assert(k_entry->oop_is_instance(), "Should be InstanceKlass");
          InstanceKlass* ik = (InstanceKlass*) k_entry;
          if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
 ……

 
 
模板解释器

做的事情:java字节码->硬编码
原理:

  • 申请一块内存:可读可写可执行
  • 将处理new字节码的硬编码拿过来(硬编码怎么拿到?) lldb 解析可执行文件
  • 将处理new字节码的硬编码写入申请的内存
  • 申请一个函数指针,用这个函数指针执行这块内存
  • 调用的时候,直接通过这个函数指针调用就可以了

源码:

void TemplateTable::_new() {
  transition(vtos, atos);

  Label slow_case;
  Label done;
  Label initialize_header;
  Label initialize_object;  // including clearing the fields

  Register RallocatedObject = Otos_i;
  Register RinstanceKlass = O1;
  Register Roffset = O3;
  Register Rscratch = O4;

  __ get_2_byte_integer_at_bcp(1, Rscratch, Roffset, InterpreterMacroAssembler::Unsigned);
  __ get_cpool_and_tags(Rscratch, G3_scratch);
  // make sure the class we're about to instantiate has been resolved
  // This is done before loading InstanceKlass to be consistent with the order
  // how Constant Pool is updated (see ConstantPool::klass_at_put)
  __ add(G3_scratch, Array<u1>::base_offset_in_bytes(), G3_scratch);
  __ ldub(G3_scratch, Roffset, G3_scratch);
  __ cmp(G3_scratch, JVM_CONSTANT_Class);
  __ br(Assembler::notEqual, false, Assembler::pn, slow_case);
……    

在这里插入图片描述

三种运行模式

-Xint:纯字节码解释器模式
-Xcomp:纯模板解释器模式
-Xmixed:字节码解释器+模板解释器模式(默认)
验证效率:

public class IntCompMixed {

    public static void main(String[] args) {
        long star = System.currentTimeMillis();

        test1(100000);

        long end = System.currentTimeMillis();

        System.out.println(end - star + " ms");

//        while (true);
    }

    public static void test1(int n){
        int num=0;
        boolean sign;
        for(int i=2;i<n;i++){
            if(i % 2 == 0 && i != 2  )  continue; //偶数和1排除
            sign=true;
            for (int j=2;j<i;j++){
                if(i%j==0){
                    sign=false;
                    break;
                }
            }
            if (sign){
                num++;
                /*         System.out.println(""+i);*/
            }
        }
    }
}

分别设置三种模式
在这里插入图片描述

   -Xint       5247 ms
   -Xcomp      2564 ms
   -Xmixed     1981 ms

 
 

即时编译器(编译完模板解释器执行硬编码)

  • jdk6以前是没有混合编译的,后来根据两种编译器的使用场景组合起来使用进一步提升性能
  • 在部分的商用虚拟机中(Sun HotSpot、IBM J9)中,Java
    程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot SpotCode)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。

C1编译器
-client模式启动,默认启动的是C1编译器。有哪些特点呢?
1、需要收集的数据较少,即达到触发即时编译的条件较宽松
2、自带的编译优化的点较少
3、编译时较C2,没那么耗CPU,带来的结果是编译后生成的代码执行效率较C2低

C2编译器
-server模式启动。有哪些特点呢?
1、需要收集的数据较多
2、编译时很耗CPU
3、编译优化的点较多
4、编译生成的代码执行效率高

混合编译
目前的-server模式启动,已经不是纯粹只使用C2。程序运行初期因为产生的数据较少,这时候执行C1编译,程序执行一段时间后,收集到足够的数据,执行C2编译器

即时编译触发条件

判断一段代码是否是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种,分别如下:

  • 基于采样的热点探测(Sample Based Hot Spot Detection):
    虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的
    reduce,容易受到线程阻塞或其他外因扰乱。
  • 基于计数器的热点探测(Counter Based Hot Spot Detection):
    为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。

HotSpot 使用的是第二种——基于计数器的热点探测,并且有两类计数器:方法调用计数器(Invocation Counter )和回边计数器(Back Edge Counter )。
这两个计数器都有一个确定的阈值,超过后便会触发 JIT 编译。

  • 首先是方法调用计数器。Client 模式下默认阈值是 1500 次,在 Server 模式下是 10000次,这个阈值可以通过 -XX:CompileThreadhold 来人为设定。如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内的方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就成为此方法的统计的半衰周期( Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。整个 JIT 编译的交互过程如下图
    在这里插入图片描述
  • 第二个回边计数器,作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”( Back Edge )。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。关于这个计数器的阈值, HotSpot 提供了
    -XX:BackEdgeThreshold 供用户设置,但是当前的虚拟机实际上使用了 -XX:OnStackReplacePercentage 来简介调整阈值,计算公式如下:
  • 在 Client 模式下, 公式为 方法调用计数器阈值(CompileThreshold)X OSR
    比率(OnStackReplacePercentage)/ 100 。其中 OSR 比率默认为 933,那么,回边计数器的阈值为
    13995。
  • 在 Server 模式下,公式为 方法调用计数器阈值(Compile Threashold)X (OSR
    比率(OnStackReplacePercentage) -
    解释器监控比率(InterpreterProfilePercent))/100。其中 onStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那么 Server 模式虚拟机回边计数器阈值为 10700 。
    整个执行过程,如下图:
    在这里插入图片描述

解释执行和 C1 代码中增加循环回边计数器的位置并不相同,但这并不会对程序造成影响。

实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。

具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。

当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。

所谓的动态调整其实并不复杂:在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
其中X是执行层次,可取3或者4;
queue_size_X是执行层次为X的待编译方法的数目;
TierXLoadFeedback是预设好的参数,其中Tier3LoadFeedback为5,Tier4LoadFeedback为3;
compiler_count_X是层次X的编译线程数目。

 
 

在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的(对应参数 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount=N 强制设定总编译线程数目时,CICompilerCountPerCPU 将被设置为 false)。

Java 虚拟机会将这些编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。举个例子,对于一个四核机器来说,总的编译线程数目为 3,其中包含一个 C1 编译线程和两个 C2 编译线程。

对于四核及以上的机器,总的编译线程的数目为:n = log2(N) * log2(log2(N)) * 3 / 2
其中N为CPU核心数目。

 
 

当启用分层编译时,即时编译具体的触发条件如下。

当方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数,或者当方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时,便会触发X层即时编译。
触发条件为:i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)

 
 

其中 i 为调用次数,b 为循环回边次数。

热点代码缓存区

  • 热点代码缓存是保存在方法区的,这块也是调优需要调的地方
  • server 编译器模式下代码缓存大小则起始于 2496KB
  • client 编译器模式下代码缓存大小起始于 160KB
  • java -XX:+PrintFlagsFinal -version | grep InitialCodeCacheSize

逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,它并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析获取对象的引用,分析对象动态作用域,分析其会不会逃逸到方法或者线程之外。

  • 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。
  • 若是被外部线程所访问,譬如赋值给类变量或可以在其他线程中访问实例变量,称为线程逃逸。
栈上分配

栈上分配。对象在栈上分配内存,对象占用空间随着栈帧出栈而销毁,降低了逃逸的可能性,就能节省GC过程中分析对象的消耗
验证:

  • 关闭gc
  • 查看对象个数
public class StackAlloc {

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 1000000; i++) {
            alloc();
        }

        long end = System.currentTimeMillis();

        System.out.println((end - start) + " ms");

        while (true);
    }

    public static void alloc() {
        StackAlloc obj = new StackAlloc();
    }
}

锁消除

同步消除。线程同步本身是一个相对耗时的过程,如果逃逸分析确认对象不会逃逸出线程,不会被其他线程访问,则不会有竞争,那么可以考虑去掉同步措施提高效率。

标量替换

标量替换。标量是指一个数据已经不能再分解成更小的数据来表示了,原始数据类型和reference引用类型都是。而对象则是聚合量,它可以继续分解。如果逃逸分析证明该对象不会逃逸到对象之外,那么它可以分解为方法、数据类型等标量,之后需要使用时可直接用标量替代,而不去创建整个对象,以节省资源。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值