Java编译器的后端编译步骤包括字节码生成和优化、类加载和字节码解释/即时编译(JIT)。
类加载
类加载阶段:
在运行时,类加载器(Class Loader)将.class文件加载到JVM中。分为一下几个阶段
- 加载(Loading):从文件系统或网络加载类的二进制数据。
- 链接(Linking):包括验证(确保字节码格式正确)、准备(为类的静态变量分配内存并初始化默认值)、解析(将符号引用转换为直接引用)。
- 初始化(Initialization):执行类的静态初始化块和静态变量的初始化。
字节码解释和即时编译:
-
- 字节码解释:JVM内置的解释器逐行解释执行字节码。这种方式简单直接,但执行速度较慢。
- 即时编译(JIT):为了提高执行效率,JVM内置的JIT编译器会将热点代码(频繁执行的代码)动态编译为本地机器代码。编译后的本地代码可以直接在CPU上运行,提高性能。
在 Java HotSpot 虚拟机中,有两种不同的即时编译器(Just-In-Time Compiler,JIT Compiler):客户端编译器(Client Compiler)C1 和服务器编译器(Server Compiler)C2。
1. 热点代码探测
热点代码探测是Java虚拟机(JVM)性能优化中的一个关键机制,它用于识别哪些代码段被频繁执行,从而决定是否将这些代码编译为高效的本地机器代码。
1.1. 热点代码探测过程
- 解释执行:Java程序启动时,JVM使用解释器逐行解释执行字节码,并通过计数器记录执行频率。
- 计数器增加:每次方法调用或循环执行时,JVM增加相应的计数器。
- 达到阈值:当计数器的值达到阈值时,JVM将代码段标记为热点代码,并触发JIT编译器对其进行编译和优化。
- 编译优化:JIT编译器对热点代码进行编译,应用各种优化策略,生成高效的本地机器代码。
- 代码缓存:编译生成的本地代码存储在代码缓存区,以便后续调用时直接使用,减少重复编译的开销。
1.2. 计数器机制
JVM通过计数器机制来探测热点代码,主要使用两种计数器:方法调用程序计数器,回边程序计数器。
1.2.1. 方法调用程序计数器:
-XX:CompileThreshold=1500 默认阈值1500,记录方法被调用次数。当某个方法被调用时,相应的方法调用计数器会增加计数。一般来说,HotSpot VM 会根据方法调用计数器的值来判断哪些方法是热点方法。在默认情况下,当某个方法的调用次数达到一定的阈值(通常是 1500 次),HotSpot VM 就会将这个方法标记为热点方法,并将其进行优化编译。
1.2.2. 回边程序计数器:
记录循环代码块执行的次数。当Java程序执行到一个循环结构时,每当循环执行到达循环体的入口处(即循环的回边),相应的回边计数器就会增加计数。与方法调用计数器不同,循环回边计数器并不是直接设置阈值来确定循环是否是热点。相反,HotSpot VM 会根据计数器的值和其他因素(如循环的大小、复杂度等)来动态地确定哪些循环是热点。通常来说,当某个循环的执行次数达到一定的阈值,并且被判断为热点时,HotSpot VM 就会将其进行优化编译。
1.3. 基于采样的热点代码检测机制:
JVM还可以通过基于采样的方式来检测热点代码。这种方法通过在程序运行过程中周期性地对代码执行情况进行采样,来判断哪些代码片段被频繁执行。一般来说,采样频率越高,检测到的热点代码越精确。
1.3.1. 原理概述
- 采样频率:JVM在运行时周期性地(比如每隔几毫秒)中断程序执行,检查当前所有线程的调用栈状态。这一过程被称为采样。
- 调用栈分析:在每次采样时,JVM会记录每个线程当前的调用栈顶部的信息,包括所在方法、行号等。这些信息反映了程序在采样时刻的执行状态。
- 热点识别:通过对多个采样点收集的数据进行统计分析,JVM可以识别出哪些方法或代码块经常出现在调用栈的顶部,即被频繁调用。这些频繁出现的方法被认为是热点代码。
- 计数与权重:为了更准确地识别热点,JVM可能还会为每个方法分配权重,根据其在采样中出现的频率和深度来调整。深度较深的方法(即调用栈中位置较高的方法)可能被赋予更高的权重,因为它们在程序的执行路径中更为关键。
1.3.2. 理论上的简化实现框架
理论上的简化实现框架
1. 设置采样间隔
首先,JVM会设置一个采样间隔时间,比如每10ms或根据CPU使用率动态调整。这个间隔决定了多久进行一次堆栈采样。
static final int INTERVAL = 100;
2. 定时采样线程
创建一个后台线程,该线程负责按照设定的间隔时间中断所有线程的执行,收集堆栈信息。
// 分析线程
Thread analyze = new Thread(() -> {
sample_analyze();
});
analyze.start();
// 模拟方法调用
Thread method = new Thread(() -> {
sample_method();
});
method.start();
3. 收集样本
sample_collect()函数模拟了采样过程,它遍历所有活动线程,获取它们的调用栈信息。在实际JVM中,这涉及到与操作系统层面的交互,使用如pthread_get_stackaddr_np(在Unix-like系统中)之类的API来获取线程的堆栈信息。
private static void sample_collect() {
while (true) {
// 获取所有线程的堆栈信息
Map<Thread, StackTraceElement[]> stackTraces = Thread.getAllStackTraces();
for (Thread thread : stackTraces.keySet()) {
StackTraceElement[] elements = stackTraces.get(thread);
if (thread.isAlive() && elements != null && elements.length > 0) {
// 获取当前方法调用栈的顶部元素
StackTraceElement topElement = elements[0];
String key = topElement.getFileName()+topElement.getClassName()+topElement.getMethodName();
METHOD_COUNTS.put(key,METHOD_COUNTS.getOrDefault(key,0L)+1);
}
}
// 休眠
try {
Thread.sleep(INTERVAL);
} catch (InterruptedException e) {
}
}
}
4. 分析与统计
收集到的堆栈信息被用来统计每个方法或代码块的出现频率。为了减少内存消耗和提升效率,可以使用哈希表或其他数据结构来存储方法标识符及其出现次数。
private static void sample_analyze(){
int i = 0;
while (true){
for (Map.Entry<String, Long> entry : METHOD_COUNTS.entrySet()) {
String key = entry.getKey();
Long val = entry.getValue();
if (val>HOT_THRESHOLD){
System.out.println("识别热点代码:"+key);
// 本地编译
System.out.println("本地编译:"+key);
// 本地编译后 重新计数探测,判断是否进行更高级本地编译
METHOD_COUNTS.remove(key);
}
}
// 休眠
try {
Thread.sleep(INTERVAL);
} catch (InterruptedException e) {
}
}
}
5. 热点识别与优化决策
基于收集到的统计数据,JVM可以识别出那些出现频率远高于其他方法的方法或代码块,即热点代码。一旦识别出热点,JVM就可以触发即时编译或其他优化措施。
1.3.3. 优势
- 低开销:相比于持续计数的方法调用或回边计数器,采样技术的系统开销较低,因为它不需要对每一次方法调用或循环迭代进行计数。
- 统计有效性:通过多次采样,可以得到一个相对准确的热点代码概览,尽管可能不如持续计数精确,但对于大多数优化目的已经足够。
- 灵活性:采样策略可以根据程序运行时的行为动态调整,例如在CPU利用率高时减少采样频率,以减少额外的性能影响。
1.3.4. 局限性
- 准确性:采样可能存在漏检问题,即某些实际上被频繁执行的代码可能因采样频率不够高而未被正确识别为热点。
- 延迟反应:相比直接计数,采样机制识别热点的响应时间可能更慢,因为它依赖于积累足够的采样数据来做出判断。
2. 即时编译器
1.1 C1:
特点:启动快,响应快,通过对热点代码初步优化,提高程序的执行效率。
1,设计目标:
- 快速编译:C1编译器优先快速生成本地代码,以减少启动时间和提高响应速度。
- 适用场景:主要用于客户端应用程序,侧重于短时间内启动和执行效率,而不是长时间运行的服务器应用程序。
2,C1工作流程:
C1工作流程:
- 解释执行开始:程序启动时,JVM先使用解释器逐行执行字节码。
- 热点检测:JVM通过计数器监控方法调用和循环执行频率,当达到一定阈值时,标记为热点代码。
- 编译请求:热点代码提交给C1编译器进行即时编译。
- 字节码转换:C1编译器将字节码转换为中间表示(IR)。
- 优化过程:进行一系列优化(方法内联、常量折叠、常量传播、死代码消除、冗余消除等)。
- 生成本地代码:将优化后的中间表示生成高效的本地机器代码。
- 代码缓存:编译生成的本地代码存储在代码缓存区,供后续调用直接使用。
3,主要优化:
C1主要优化
- 方法内联 :将频繁调用的小方法嵌入调用者方法中,减少调用开销。
- 常量折叠:编译时计算常量表达式,减少运行时计算量。
- 常量传播:传播常量值以优化代码。
- 死代码消除:移除不会执行的代码。
- 冗余消除:消除重复操作。
1.2 C2:
特点:编译速度慢于c1,运行性能比c1高30%。通过高级优化,C2编译器生成的机器代码具有更高的执行效率。
1. 设计目标
- 深度优化:C2编译器通过更复杂和耗时的优化过程生成高效的本地代码,适合长期运行的服务器应用程序。
- 适用场景:主要用于服务器端应用程序,侧重于高吞吐量和长期性能优化,而不是启动时间。
2. 工作流程
c2 工作流程
- 解释执行开始:程序启动时,JVM先使用解释器逐行执行字节码。
- 热点检测:JVM通过计数器监控方法调用和循环执行频率,当达到更高的阈值时,标记为热点代码。
- 编译请求:热点代码提交给C2编译器进行即时编译。
- 字节码转换:C2编译器将字节码转换为中间表示(IR)。
- 深度优化过程:进行高级优化(方法内联、常量折叠、循环优化、逃逸分析、垃圾收集优化等)。
- 生成本地代码:将优化后的中间表示生成高效的本地机器代码。
- 代码缓存:编译生成的本地代码存储在代码缓存区,供后续调用直接使用。
3,主要优化:
c2 优化内容
- 方法内联:将频繁调用的小方法嵌入调用者方法中,减少调用开销。
- 常量折叠:编译时计算常量表达式,减少运行时计算量。
- 常量传播:传播常量值以优化代码。
- 循环优化:包括循环展开、循环交换等,优化循环结构。
- 逃逸分析:确定对象是否可以在栈上分配内存而不是堆上,减少垃圾回收的压力。
- 垃圾收集优化:JIT 编译器会与 GC 紧密合作,优化内存分配和回收。
- 分支预测:优化条件分支,减少分支跳转的开销。
3. 分层编译
分层编译是JVM中的一项优化技术,它结合了解释器和多级即时编译器(JIT),以实现启动速度和运行时性能的最佳平衡。其中,C1编译可能包含了多个优化阶段,包括不同程度的profiling信息使用。
3.1. 分层编译的目标
- 启动快:通过解释器和低级别编译器(如C1编译器)快速启动程序。
- 运行时性能高:通过高级别编译器(如C2编译器)对热点代码进行深度优化,提高程序的长期运行性能。
3.2. 详细过程
- 第0层(解释执行):
-
- 解释器执行:当Java程序启动时,JVM使用解释器逐行解释执行字节码。这一阶段不涉及任何编译,启动速度最快,但运行速度最慢。
- 计数器机制:JVM为每个方法和循环体维护计数器(如方法调用计数器和回边计数器),用于记录执行次数。
- 默认开启性能监控(Profiling),收集运行时信息
- 启动热点检查:当某个方法或循环体的计数器达到预设的阈值时,JVM将其标记为热点代码。
-
-
- 计数器阈值:当某个方法或循环体的计数器达到预设的阈值时,JVM将其标记为热点代码。
- 编译请求:对于标记为热点的代码,JVM触发编译请求,准备将其编译为本地机器代码。
-
- 第1层(C1编译,无Profile或基本优化):
-
- C1编译器也称为Client Compiler,对热点代码进行初步的即时编译,生成轻量级的本地机器码。使用C1编译器进行简单的编译,应用基本的优化策略,如方法内联和常量折叠。此时生成的代码执行速度比解释执行快,但编译时间较短。
- 这一层的编译可能不启用Profiling或者仅进行基础级别的优化,以快速生成可执行的本地代码,提高首次执行速度。
- 这一层次启动速度较快,适合短期执行。
- 第2层(C12编译,有限Profile数据优化):
-
- 继续使用C1编译器,来指导优化。会根据收集到的部分Profile信息但此时(如方法调用次数、循环回边执行次数等)
- 编译器在这一层级可能会选择性地对热点代码进行更精细的优化,如方法内联和逃逸分析。
- 第3层(C1编译,全量Profile数据优化):
-
- 在这一层,C1编译器进一步利用全面的Profiling信息来进行更深入的优化,包括但不限于内联等技术。
- 第4层(C2编译,高级优化):
-
- C2编译器,又称Server Compiler,对热点代码进行深度优化编译,生成高度优化且执行效率更高的本地代码。
- C2编译器相比于C1编译器来说,它的优化更加复杂和耗时,但是产出的代码质量更高,适用于长时间运行且需要持续优化的应用场景。
通过这样的分层编译机制,JVM能在程序生命周期的不同阶段智能地选择最优的执行策略,确保既快速响应用户请求,又能随着时间推移提升整体运行性能。
3.3. 动态调整和回退机制
- 动态调整:JVM根据代码的运行表现和执行频率,动态调整编译层次。例如,若某段代码在Level 1编译后仍频繁执行,JVM可将其提升至Level 2或Level 3。
- 回退机制:如果在高级别编译中发现某些优化策略不合适或引发性能问题,JVM可回退到较低级别的编译或解释执行,确保程序稳定运行。
3.4. 代码缓存和再编译
- 代码缓存:编译生成的本地机器代码存储在代码缓存区,以便后续调用时直接使用,减少重复编译的开销。
- 再编译:JVM定期或根据特定条件重新评估热点代码,必要时进行再编译,以适应动态变化的执行环境和性能需求
4. 本地代码缓存。
在jvm中,热点代码被即时编译器编译成本地代码,编译后的本地代码存储和使用方式:
4.1. 代码缓存(Code Cache):
- 代码缓存Code Cache 是JVM内存结构中的一个独立区域,专门用于存储JIT编译生成的本地机器代码。
- 热点代码被即时编译器编译成本地代码后,热点代码片段会被存储在CodeCahce中。
- 代码缓存是独立于堆和方法区的一个内存区域,可以通过JVM参数进行配置和管理。
-
- -XX:ReservedCodeCacheSize:设置代码缓存的最大大小。
- -XX:InitialCodeCacheSize:设置代码缓存的初始大小。
4.2. 程序执行过程中如何使用本地代码
4.2.1. 动态替换和使用
- 代码补丁(Code Patching):
-
- JVM支持动态替换已经编译的方法。这意味着在运行时,如果某个方法被重新编译或优化,JVM可以更新代码缓存中的对应条目。
- JVM会确保正在执行的线程可以平滑地从旧代码切换到新代码,而不需要中断程序的执行。
- 堆栈补丁(On-Stack Replacement,OSR):
-
- 对于长时间运行的循环,JVM支持堆栈补丁技术,允许在循环执行过程中切换到优化后的本地代码。
- 当检测到一个循环成为热点,JIT编译器会生成该循环的本地代码,并在适当的时机(如回边时)切换到新编译的代码。
4.2.2. 具体流程
- 检测到热点代码:
-
- JVM通过方法调用计数器和回边计数器等机制检测到某个方法或循环是热点代码。
- 触发JIT编译:
-
- JIT编译器开始编译热点代码,将字节码转换为本地机器代码。
- 存储在代码缓存中:
-
- 编译后的本地机器代码被存储在代码缓存中。
- 更新调用点信息:
-
- JVM更新该方法的调用点信息,使后续调用可以直接跳转到本地代码。
- 执行本地代码:
-
- 在后续执行中,JVM直接从代码缓存中提取并执行本地机器代码,提高执行效率。
- 在后续执行中,JVM直接从代码缓存中提取并执行本地机器代码,提高执行效率。
5. Java后端编译优化
1,常量折叠:
遇到可预先结算结果的表达式时,会在编译期间就完成计算。例如
a=2+2 '
//优化后
a=4
2,常量传播:
遇到某个范围内值固定的变量,会在编译期间将这个变量替换为固定值。例如:
int a = 5;
int b = a * 2;
//优化后
int a = 5;
int b = 10;
3,死代码消除:
识别并移除不可能执行到的代码。
4,方法内联:
是指编译过程中,遇到繁调用的小型方法时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
例如:Java服务中存在大量getter/setter方法,如果没有方法内联,在调用getter/setter时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。内联了对 getter/setter的方法调用后,上述操作仅剩字段访问。从而减少指令跳转,提升CPU流水线效率。
在C2编译器 中,方法内联在解析字节码的过程中完成。当遇到方法调用字节码时,编译器将根据一些阈值参数决定是否需要内联当前方法的调用。
方法内联的条件:
编译器的大部分优化都是在方法内联的基础上。所以一般来说,内联的方法越多,生成代码的执行效率越高。但是对于即时编译器来说,内联的方法越多,编译时间也就越长,程序达到峰值性能的时刻也就比较晚。
5,循环优化:
- 循环不变量外提:将循环体内不会改变的计算移到循环外部。
- 循环展开(Loop Unrolling),减少迭代次数和条件判断开销。
6,数组边界检查消除:
当C2编译器进行即时编译时,会通过数据流分析和逃逸分析等手段来判断某个数组访问操作是否绝对安全,即确定索引值不会越界。
如果能证明数组访问一定不会越界,那么它会在生成机器码的过程中移除不必要的数组边界检查指令。这种优化可以提升代码执行效率,因为它减少了条件分支带来的开销。
7,逃逸分析:
确定对象是否有可能“逃逸”出当前作用域,从而可能进行栈上分配、标量替换等优化操作。
在Java编程语言中,无论对象是在方法内部创建还是作为类的成员变量初始化,只要它是通过new创建的,都会默认在堆上分配内存。尽管一般情况下对象都在堆中分配,但值得注意的是,现代JVM实现了一些优化技术,比如逃逸分析(Escape Analysis)。通过逃逸分析,编译器可以识别出某些对象生命周期仅限于某个方法或线程,并且没有“逃逸”出去,此时编译器可以选择不在堆上分配对象,而是可能在栈上直接分配空间或者进行标量替换等优化,以减少内存分配和垃圾回收的成本。即时编译器判断对象是否逃逸的依据有两种:
- 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
- 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
锁消除:
JIT编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只能够被一个线程访问,如果没有,jit在进行编译代码的时候,就会取消对这部分代码的同步操作,这样就能大大提高并发性和性能,这个过程就叫同步省略或是锁消除
栈上分配
当JVM通过逃逸分析发现一个新创建的对象在其生命周期内仅被当前方法或线程使用,并且不会“逃逸”到方法外部供其他线程或其他方法访问时,它可以采取栈上分配
例如,在以下Java代码中:
Java
1public void someMethod() {
2 MyObject localObject = new MyObject();
3 // 在这个方法内部使用localObject...
4}
如果通过逃逸分析确定localObject没有逃逸出somemethod()的作用范围,那么编译器或运行时即时编译器可以选择将MyObject实例直接在栈上分配,而不是在堆上分配。这样就可能避免了垃圾回收的开销,并提高了执行效率。
标量替换
标量:指一个无法再分解成更小的数据,java中的原始数据类型就是标量。
聚合量:可以分解的数据叫做聚合量。
JIT编译阶段,经过逃逸分析发现,一个java对象如果不会被外界访问,那么经过jit优化,就会把这个对象拆解成若干个成员变量来代替。这个过程就是标量替换。
public class demo(){
public static void main(String [] args){
Person p = new Person("L",21)
System.out.print("name : "+p.getName()+" age : "+p.getAge() );
}
}
@Data
@AllArgsConstructor
class Person{
private String name;
private int age;
}
标量替换后:
public class demo(){
public static void main(String [] args){
String name = "L";
int age=21;
System.out.print("name : "+name+" age : "+age);
}
}
通过标量替换,省略了对象 person创建过程,一旦不需要创建对象,那么就不需要进行堆内存分配。