虚拟机(十三).编译器的编译过程(二)

实现从字节码变成机器代码的过程

1.解释器和编译器

        (1).当程序需要快速启动和执行的时候,解释器可以省去编译时间,立即执行

        (2).随着时间推移,编译器把越来越多的代码编译成本地代码,可以获得更高的执行效率

        (3).同时,解释器可以作为编译器的“逃生门”。当编译器进行一些激进优化不成功后,可以通过逆优化退回到解释状态继续执行

 

2.HotSpot虚拟机的解释器、编译器

        (1).HotSpot内置两个即时编译器,分别是Client Compiler(C1编译器)和Server Complier(C2编译器)

        (2).默认采用解释器+一个编译器直接配合的方式。编译器和解释器搭配使用叫做“混合模式”

        (3).由于编译器编译本地代码需要占用程序运行时间,为了又好又快编译,解释器需要提编译器收集性能监控信息。虚拟机还会才用分层编译的策略:

                1).第0层,程序解释执行,解释器不开启性能监控功能

                2).第1层,即C1编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要假如性能监控逻辑

                3).第2层及以上,即C2编译,也是将字节码编译成本地代码,会启动一些耗时较长的优化,还会进行一些不可靠的激进优化

 

3.什么对象会被编译?

        (1).被多次调用的方法

        (2).被多次执行的循环体

        注:以上两种对象,都是对整个方法进行编译。第二种因为编译发生在方法执行过程中,也叫作“栈上替换”

 

4.编译被触发的条件是什么?

        上面都提到多次,多少次算多次呢?

        热点探测

                1).基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,这个方法就是热点方法

                2).基于计数器的热点探测:为每个方法建立计数器,统计执行次数。若超过一定的阈值,则认为是热点方法(虚拟机采用的是该方法)。其中计数器分为方法调用计数器和回边计数器。

                ==>方法调用计数器:

                        *1.首先看方法是否被编译过,如果被编译,就用编译的本地代码执行

                        *2.若没有,方法调用计数器+1。再判断方法调用计数器+回边计数器的和是否超过方法调用计数器的阈值,如果超过则向编译器提交代码编译请求

                        *3.方法调用计数器不是方法被调用的绝对值,而是某一段时间被调用的次数。当超过一定时间,方法调用计数器的值减半,这叫做热度的衰减。这个时间就是半衰周期。热度衰减的动作实在虚拟机进行垃圾收集时进行的

                ==>回边计数器:统计一个方法中循环体代码执行的次数

                        *1.当解释器遇到一条回边指令,先检查是否有编译好的版本,若有,就执行编译好的版本

                        *2.若没有,回边计数器+1。再判断方法调用计数器+回边计数器的和是否超过阈值。如果超过,提交便已请求,并把回边计数器的值降低一点。

                        *3.回边计数器没有热度衰减的过程。当计数器溢出的时候,会把方法计数器的值也调整为溢出状态,这样下次一定就会进行编译

 

5.编译的过程?

(1).Client Complier

        1).将字节码构造成高级中间代码(HIR),HIR使用静态分配的形式来代表代码值。在此之前,编译器会进行一些基础优化,如方法内联、常量传播

        2).从HIR中产生低级中间代码(LIR),在此之前会进行另外一些优化,如空值检查消除、范围检查消除等

        3).线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,产生机器代码

(2).Server Complier

        1).执行所有的经典优化动作。

        2).它的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合

 

6.常用的优化技术

        (1).方法内联:把目标方法的代码“复制”到发起调用的方法中,避免发生真实的调用

                1).去除方法调用的成本

                2).为其他优化建立良好的基础

/**
* 内联前
*/
static class B{
    int value;
    final int get(){
        return value;
    }
}

public void foo(){
    y = b.get();
    //todo
    z = b.get();
    sum = y + z;

}


/**
* 内联后
*/
public void foo(){
    y = b.value;
    //todo
    z = b.value;
    sum = y + z;

}

        (2).进行冗余访问消除

/**
* 冗余访问消除后
*/
public void foo(){
    y = b.value;
    //todo
    z = y;
    sum = y + z;

}

        (3).进行复写传播

/**
* 复写传播后
*/
public void foo(){
    y = b.value;
    //todo
    y = y;
    sum = y + y;

}

        (4).无用代码消除

/**
* 无用代码消除后
*/
public void foo(){
    y = b.value;
    //todo
    sum = y + y;

}

        (5).公共子表达式消除

                1).含义:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生什么变化,那么E的这次出现就成为了公共子表达式。对于这种表达式就不需再计算,直接拿结果用。

                2).如果这种优化仅限于程序基本块内,称为局部公共子表达式消除;如果涵盖多个基本块,称为全局公共子表达式消除

        (6).数组边界检查消除

                1).对数组边界的检查是一定需要的,但在运行期间是否需要一次不漏的检查,则是可以商量的:如果编译器通过数据流分析可以判定循环变量的取值范围在某个区间内,那个整个循环就可以把数组的上下界检查消除

                2).隐式异常处理(空指针检查和算术运算中除数为空用的就是这个):

/**
*未优化的伪代码
*/
if(foo != null){
    return foo.value;
}else{
    throw new NullPointException();
}

/**
* 优化后的伪代码
*/
try{
    return foo.value;
}catch(segment_fault){
    uncommon_trap;
}

                虚拟机会注册一个异常处理器,当foo不为空的时候,不会额外消耗一次对foo判断空的开销。代价就是如果foo真的为空了,博旭转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判断空慢。所以当null很少的话,隐式异常优化是值得的,否则不值得

        (7).方法内联(续)

                1).方法内联就是不发生真正调用,把目标方法中代码“复制”到发起调用的发放中。但是,只有静态方法踩在编译期进行解析,很多方法因为在运行时会进行方法接受者的多态选择,所以就不发确定对象的实际类型是什么。如果再有继承关系的话,究竟是用父类的还是子类的也不好判断。那么方法内联怎么解决这些问题呢?

                ==>首先,引入“类型继承关系分析”(CHA)的技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。

                ==>编译器在进行内联时,如果是静态方法,可直接进行稳定内联。

                ==>如果是虚方法,则会向CHA查询此方法是否有多个目标版本可供选择,如果只有一个版本,则也可以内联,但是这是激进优化,需要有一个“逃生门”,称为“守护内联”。即当真的发生意外的话,可以通过守护内联抛弃已经编译的代码,退回到解释状态执行或者重新编译

                ==>如果CHA查询到有多个版本可供选择,编译器会进行最后一次努力:内联缓存。原理大致为:在未发生方法调用前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接受者的版本信息,并且每次进行方法调用都比较接受者版本。如果后来的接受者版本都一样,就可以一直用。否则,说明程序使用了多态的特性,仇晓内联,查找虚方法表进行方法分派

        (8).逃逸分析

                1).逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中定义后,它可能会被外部方法所引用,例如作为调用参数传递到其他方法中去,称为方法逃逸。甚至可能会被外部现成访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

                2).若能证明一个对象对象不会逃逸到方法或者线程之外,则可对这个对象进行优化:

                ==>栈上分配:java堆中对象对于各线程都是共享和可见的,这是常识。垃圾回收机制可以回收堆中不再使用的对象,但是无论是回收还是筛选都需要时间。所以如果确定一个对象不会逃逸,可以将这个对象在栈上分配内存,对象所占用的内存空间随着栈帧出栈而销毁

                ==>同步消除:线程同步是一个相对耗时的过程,如果一个对象却不会逃逸出线程,那么对这个变量实施的同步措施也可以消除掉

                ==>标量替换:标量是指一个数据已经无法再分解成更小的数据来表示了(int,long等都是变量)。相对的,若一个数据可以继续分解,那就是聚合量。对象就是典型的聚合量。如果确定对象不会逃逸,那么可以将对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来进行访问。这叫做标量替换。省去了创建对象,也为后续优化创建条件

 

7.java和c/c++编译器的比较

        java:即时编译器。c/c++:静态编译器

        java劣势:

        (1).即时编译器占用的是用户程序时间,时间压力大

        (2).java是动态的类型安全语言,意味着需要由虚拟机确保程序不会违反规定。虚拟机进行动态检查还是需要不少运行时间

        (3).java没有virtual关键字,但是虚方法的使用频率却远远高于C/C++,意味着运行时对方法接受者进行多态选择的频率要远远高于C/C++。也意味着进行一些优化的时候难度大于C/C++的静态优化编译器

        (4).java是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得全局的优化很困难

        (5).java对象分配在堆上,C/C++既可以在堆上也可以在栈上,内存回收压力java更大。而且C/C++主要用户程序回收内存,不存在无用对象的筛选过程,因此,运行效率高于java垃圾收集机制(排除开发效率)

        java优势:

        (1).java上述的劣势都是为了换取开发上的优势而付出的代价

        (2).java的别名分析就远远比C/C++简单

        (3).因为java编译器是动态的,因此比C/C++多了运行期性能监控为基础的优化措施

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

鹏哥哥啊Aaaa

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

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

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

打赏作者

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

抵扣说明:

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

余额充值