早期(编译期)优化
编译器类型
前端编译器:把java文件变成class文件;比如我们的idea,javac等。(早期优化)
- 1.JVM把class文件变成字节码
JIT编译器(just in time):把字节码变为机器码的过程;比如hotspotVM的C1和C2编译器。属于虚拟机的后端运行期编译器
AOT编译器(ahead of Time compiler):直接把java文件变成本地机器代码。
Javac编译器
解析与填充符号表过程
插入式注解处理器的注解处理过程
分析与字节码生成过程
晚期(运行期)优化
HotSpotVm 在java程序最初是通过解释器进行解释执行,当某个方法或者代码块的运行特别频繁,就会把这些代码认定为热点代码进而把热点代码编译成本地平台相关的机器码,从而进行各种层次的优化。完成这种任务的就是JIT编译器。
hotSpot虚拟机内的即时编译器
JIT编译器包含Client compiler(C1)和Server Compiler(C2)
主流的虚拟机都是采用解释器和一个JIT来配合,具体采用C1还是C2我们可以根据参数指定(-client,-server),这种方式也是叫混合模式.
可以通过-Xint强制只使用解释模式(编译器不介入),反之可以使用-Xcomp(当然解释器还是要在编译器无法工作的时候执行工作)
通过-version可以看到使用的哪种模式。
JIT需要解释器收集信息,进而进行编译。采用的是分层编译。
第0层: 程序解释执行,解释器不开启性能监控,可触发第一层编译。
第1层:也成为C1编译器,将字节码编译为本地代码。主要是进行简单可靠的优化。如果有必要将加入性能监控的逻辑。
第2层(或2层以上):也称为C2编译,也是将字节码编译为本地代码。但是会启用一些编译耗时较长的优化,甚至是一些不可靠的激进优化。
启用分层编译c1和c2会同时工作
虚拟机为啥要使用解释器与编译器并存的架构
- 1.解释器可以在程序需要迅速启动和执行的时候,解释器可以立即执行(不需要编译)。
- 2.当程序运行后,随着事件推移,(即时)编译器把越来越多的代码编译成本地代码可获取更高的执行效率。
- 3.因为本地代码需要很多内存,而解释器可以节约内存。
- 4.作为JIT的激进优化的一个逃生门,当编译器根据概率选择一个大多数都能提升运行速度的优化手段,当激进优化的假设不成立了(比如依赖的类被卸载了),可以通过逆优化退回到解释状态执行。
为何虚拟机要实现两个不同的即时编译器
- 1.因为C1主要是进行简单的优化(编译速度更快)
- 2.C2主要是进行一些更为耗时且激进的优化。(获取更好的编译质量)
程序何时使用解释器执行?何时使用编译器执行?
- 1.初期启动的时候采用解释执行。
- 2.随着程序执行过程中开启分层编译,当然了只是对热点代码进行编译。
哪些程序会被编译成本地代码?如何编译本地代码?
- 1.热点代码:被多次调用的方法,被多次调用的循环体。
- 2.针对第一个类型热点代码,编译的对象就是整个方法,对于第二种虽然是因为循环体触发但是编译会以整个方法作为编译对象。
- 3.这种编译方式发生在方法执行过程中,因此被很形象地称为栈上替换。
探测热点代码方式
- 1.基于采样的热点探测:采样这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。采样的好处就是实现简单高效,而很容易获取方法调用关系(进入栈中即可看到)。缺点就是精度不一定对,比如某些线程阻塞了一直停留在栈顶。
- 2.基于计数器的热点探测:JVM会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果超过阈值就是热点。好处就是精确,但是需要为每个方法建立并维护计数器,而且不能获取到方法的调用关系。
- 3.阈值可以通过-XX:CompileThreshold来人工设定。
HotSpotVm采用第二种探测热点代码的方式。
- 1.准备了方法调用计数器和回边计数器
- 2.回边计数器:统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为回边。
C1--方法调用计数器的工作逻辑
- 1.如果一个方法被调用,会先检查该方法是否存在被JIT编译的版本,如果存在则使用编译后的本地代码来执行。
- 2.如果不存在则计数器增加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。
- 3.如果超过阈值则将会向JIT编译器提交该方法的代码编译请求。
- 4.默认情况下执行引擎并不会同步等待编译请求完成,而是继续按照解释器解释执行。
- 5.知道提交的请求被编译器完成,编译完成之后,这个方法的调用入口地址就会被系统自动改写完成新的地址。下一次调用该方法就会使用已编译的版本。
- 6.计数器存在半衰周期,即当计数器长期没有增加,则之前的计数值会减少,通过-XX:-UserCounterDecay来关闭衰减。还可以通过-XX:CounterHalfLifeTime参数设置半衰减的周期,默认单位是秒。
C1--回边调用计数器的工作逻辑
- 1.遇到回边指令,会先查找将要执行的代码片段是否有意见编译好的版本,有就执行。
- 2.否则增加回边计数器的值,然后判断方法调用计数器和回边计数器的值之和是否超过回边计数器的阈值(-XX:BackEdgeThreshold)。超过阈值则 提交一个OSR(ON-STACK ReplaceMent)编译,并把回边计数器的值降低一些(因为没有衰退周期),以便继续在计时器中执行循环。
C2模式的计数器工作逻辑会更为复杂。
后台编译的时候,C1和C2编译过程不一样
- 1.C1主要是三段式编译:第一段、前端生成一种高级中间代码(HIR,会在HIR中进行控制检查消除,范围检查消除等),第二段后端从HIR中产生低级中间代码(LIR),
第三段是后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。 -
2.具体如下图:
- 3.C2则是专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器。
- 4.C2优化主要包含:无用代码消除,循环展开,循环表达式外提,公共子表达式消除,常量传播,基本块重排序,范围检查消除,控制检查消除(也会在代码运行过程中进行优化),也会根据解释器或C1提供的性能监控信息进行一些不稳定的激进优化,如守护内联,分支频率预测
几种优化解释
方法内联(将多个方法合并)
- 1.减少栈帧,为其他优化建立基础(比如公共表达式消除)
- 2.因为虚方法只有在运行期才可以确定,JVM为了能对虚方法进行内联,引用了Class Hierarchy Analysis(CHA)
- 3.编译器进行内联如果是非虚方法那么直接进行内联就可以,如果是虚方法则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择。
- 4.如果只有一个版本就可以提供内联(但是属于激进优化,需要预留逃生门称为内敛守护)。
- 5.如果程序执行后期,JVM加载到了会令这个方法的接受者的继承关系发生变化的类,则内联优化就只能放弃,只能退回到解释执行。
- 6.如果CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器会尝试内联缓存来完成方法内联。
- 7.内联缓存是建立在目标方法正常入口之前,未发生方法调用之前,缓存为空。第一次调用后缓存记录下方法接受者的版本信息。如果后期接受者版本不变则使用内联优化,发送变化就取消内联只能去查虚方法表。
冗余方法消除(x=a.value,y=a.value 那么 可以替换为y=x)
公共子表达式消除
逃逸分析
- 1.如果一个对象无法被外部方法所引用,则该对象可以尝试以下几种优化的方式。
- 2.栈上分配
- 3.同步消除(因为不会被多线程访问,则同步消除)
- 4.标量替换(即如果我们只在一个方法中使用到了某个对象的某个属性,则直接使用该属性而不需要创建对象)
TLAB
- 1.由于堆可以多线程分配对象,所以堆上对象分配一般采用CAS
- 2.JVM开辟了一个区域-TLAB,TLAB本身占用eEden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。
- 3.LAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%
- 4.实际上虚拟机内部会维护一个叫作refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象.