我当年第一次看JIT相关的内容,是准备Java面试的时候。当时网上查到的资料要么深奥到让人劝退,要么浅显到只告诉你“JIT会把热点代码编译成机器码,所以Java跑得快”——可面试官追问一句“那热点代码是怎么判定的”,很多人就卡住了。
后来自己动手调过几次JVM,把Java服务压测到报警,再去翻JIT编译日志、调编译阈值、处理过CodeCache被撑爆的问题,才慢慢把这块内容吃透。
这篇文章我会把JIT相关的内容,从原理到实战、从参数到排查方法,按照面试准备和实际调优两条线完整梳理一遍。不管你是准备面试,还是想把手上的服务再压榨出一点性能,这应该都是能直接用上的干货。
1. 先搞清楚:JIT到底解决什么问题
1.1 为什么Java不能像C++那样直接编译成机器码
要理解JIT,首先要理解Java程序的执行方式。C/C++程序在发布前会通过编译器(如GCC、MSVC)直接把源码编译成目标平台的机器码。这个过程叫静态编译(AOT,Ahead-Of-Time)。静态编译的好处是运行时没有额外的编译开销,坏处是编译出的二进制文件绑定特定平台——在Windows上编译的程序没法直接在Linux上跑,在x86上编译的程序在ARM上也不行。
Java的设计目标是“一次编写,到处运行”。如果也做静态编译,就得为每个平台准备一份编译产物,这跟平台无关性直接冲突。所以Java选择了一条不同的路线:源码先编译成字节码(.class文件),然后由JVM在运行时解释执行字节码。
解释执行就好比一个翻译员,逐字逐句地给你翻译一本外文书。好处是拿到什么文本都能马上读,坏处是慢——每次都要翻译一遍。同一个方法被调用一万次,翻译员就要翻译一万次。
1.2 解释执行与编译执行的天平两端
这里有个经典矛盾:如果Java只能用解释执行,那性能永远追不上C++;但如果Java只做AOT编译,平台无关性就没了。
JIT(Just-In-Time,即时编译)就是在这个矛盾中诞生的解决方案:程序启动时先用解释器快速跑起来,运行过程中不断收集“哪些代码是热点”,然后把热点代码在运行时编译成本地机器码。下次再跑到这段代码,直接执行机器码,不再解释。
你可以理解成:翻译员翻了几天外文书之后,发现“订单已确认”这句话反复出现,于是干脆把这句话背下来,后面遇到就直接说,不需要再查词典。
JIT的核心理念是“让20%的热点代码承担80%的耗时”,只编译真正有价值的代码,避免把时间浪费在极少执行的冷代码上。这个取舍特别重要,也是很多面试题的根子。
1.3 顺便说清JRE、JDK和执行引擎的关系
在热词里看到有人搜“jre和jvm之间的关系”,顺带提一句:JRE是Java运行环境,包含JVM和Java核心类库;JDK是Java开发工具包,包含JRE、编译器(javac)、调试器等开发工具。JVM是JRE的一部分,JIT编译器和解释器都在JVM内部,属于执行引擎(Execution Engine)的核心组件。
执行引擎的工作流程大致是:字节码装载进内存后,由解释器逐条解释执行,同时JIT编译器在后台采样分析,识别热点代码并编译成机器码。两者是协作关系,不是互斥关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 热点代码是怎么判定的——计数器与分层编译
2.1 方法调用计数器与回边计数器
JIT编译器的触发机制依赖两类计数器:
- 方法调用计数器(Method Invocation Counter):统计一个方法被调用的次数。
- 回边计数器(Back Edge Counter):统计一个方法体内循环体的执行次数。所谓“回边”指的是字节码中循环末尾跳回循环开头的那个分支。
两个计数器的阈值分别对应两个JVM参数:
-XX:CompileThreshold:方法调用计数器的编译阈值。在纯解释模式下(关闭分层编译),客户端模式下默认值是1500,服务端模式下默认值是10000。-XX:OnStackReplacePercentage:结合CompileThreshold计算回边编译阈值,计算公式为CompileThreshold * (OnStackReplacePercentage / 100)。比如服务端模式下回边阈值就是10000 * (140 / 100) = 14000。
这里要特别提醒一个坑:在JDK 8及以后版本,默认开启了分层编译(Tiered Compilation),此时-XX:CompileThreshold的默认值会被忽略,改为使用自适应逻辑。后面单独讲分层编译时再细说。
2.2 计数器衰减机制:半个生命周期
光知道计数器还不够,JVM还有一个容易被忽略的细节:方法调用计数器不是只增不减的统计值,它有一个“半衰周期”。
JVM每过一段固定时间(默认是-XX:CounterHalfLifeTime参数控制,单位为秒),就会把方法的调用次数减半。也就是说,如果一个方法在启动时被频繁调用,但后来就再没执行过,它的计数值会衰减,可能永远达不到编译阈值。这个设计很聪明:防止JIT把时间浪费在“只有启动阶段才热点”的代码上。
有时候你的应用启动时有一个初始化方法执行了上万次,看起来妥妥是热点,但因为这个方法后续不再被调用,计数值衰减到0,JIT就不会编译它。所以“被调用的总次数”和“达到编译阈值”之间不是简单的正比关系,时间窗口同样重要。
2.3 分层编译:C1与C2的配合
HotSpot虚拟机里有两种JIT编译器:
- C1编译器(Client Compiler):编译速度快,生成的代码优化程度相对低,适合对启动速度敏感的客户端程序。C1会做轻度优化,比如方法内联、去虚拟化等。
- C2编译器(Server Compiler):编译速度慢,但优化极其激进,能生成非常高效的机器码,适合长时间运行的服务端程序。
JDK 6之后引入了分层编译,把执行状态分为五个层级:
| 层级 | 执行状态 | 说明 |
|---|---|---|
| 0 | 解释执行 | 程序刚启动时的状态 |
| 1 | C1编译(无Profiling) | 不收集统计数据,纯优化 |
| 2 | C1编译(部分Profiling) | 收集部分运行时数据 |
| 3 | C1编译(完整Profiling) | 收集完整的运行时数据,供C2决策 |
| 4 | C2编译 | 最高优化级别,追求极致性能 |
分层编译的思路是:先用C1快速编译热门代码,获得一部分性能提升;同时C1在编译过程中收集运行时数据,送给C2做更深入的优化分析;等C2编译完成后,用C2编译的版本替换C1版本。
这就像组团队干活:先让一个经验一般的工程师快速上手干出成果(C1),他在干活过程中记录下各种问题细节(Profiling数据),然后请资深架构师基于这些记录重新设计一套更高效的方案(C2),最后整体替换。
分层编译在JDK 8中默认开启(-XX:+TieredCompilation),这也就意味着传统的-XX:CompileThreshold参数在默认配置下不会生效,而是由JVM动态调整。只有在显式关闭分层编译(-XX:-TieredCompilation)的情况下,CompileThreshold才恢复作用。
2.4 OSR:栈上替换,别等循环结束才编译
还有一个概念叫OSR(On-Stack Replacement,栈上替换)。它解决的是“热点循环”的编译问题。
假设一个方法里有一个迭代一万次的循环,单次循环体执行很快,方法本身只被调用了一次。按照方法调用计数器的逻辑,这个方法不会被标记为热点。但循环体执行了一万次,这明显是瓶颈所在。
回边计数器就是为了捕捉这种情况。当回边计数达到阈值,JIT会对循环体执行OSR编译——在循环仍然运行的时候,把正在解释执行的栈帧替换为编译后的机器码版本。
OSR是面试里容易丢分的点,很多人知道有计数器,但不知道还有个OSR机制来处理循环场景。提到OSR时如果再能补充“OSR编译的代码通常是C2层级的,编译期间解释器继续跑,所以OSR不会阻塞循环执行”,面试官会觉得你确实理解了这个过程。
3. 那些面试官爱问的JIT优化手段
3.1 逃逸分析:影响深远的“地基型”优化
逃逸分析(Escape Analysis)是JIT最基础的优化分析手段之一,它判断一个对象是否“逃逸”出方法作用域。三种逃逸状态:
- NoEscape(不逃逸):对象只在方法内部使用,没有被外部引用。
- ArgEscape(参数逃逸):对象作为参数传给其他方法,但调用方无法访问到该对象。
- GlobalEscape(全局逃逸):对象可以被外部线程或全局变量访问。
逃逸分析本身不产生代码优化,它是为下面三个优化“铺路”的:
栈上分配(Stack Allocation):如果对象不逃逸,说明这个对象只在当前方法栈帧内有效,JVM可以把它分配到栈上而不是堆上。栈上分配的对象随方法调用结束自动销毁,完全不需要GC参与。
但这里有个细节要说清楚:HotSpot目前的实现并没有真正做栈上分配。是的,你没看错。大家口口相传的“栈上分配”,在HotSpot中的实际实现是“标量替换”(Scalar Replacement)——把对象拆散成多个独立的标量字段,直接分配到寄存器或栈上。只有Zing JVM等非HotSpot实现才真正做栈上分配。面试被问到这一点时能指出来,会显得水平不一样。
标量替换(Scalar Replacement):如果判断对象不逃逸,JIT会把对象的字段拆开当成独立变量处理。比如有一个Point对象包含x和y两个int字段,创建了Point p = new Point(1, 2),标量替换后变成int x = 1; int y = 2;,对象本身就不存在了,也就不会在堆上分配内存。
锁消除(Lock Elimination):如果对象不发生逃逸,那么JVM知道不会有其他线程看到这个对象,再对这个对象做同步加锁就没有意义,可以把synchronized直接去掉。典型的场景是StringBuffer(内部方法带synchronized)在方法内部作为局部变量使用,逃逸分析会判定它不逃逸,锁消除会去掉同步操作。
值得注意的是,逃逸分析是JDK 1.6之后默认开启的,对应参数是-XX:+EliminateAllocations和-XX:+EliminateLocks。如果在压测中通过JFR或GC日志发现大量短生命周期对象被分配,但又没看到明显的GC压力,那很可能就是逃逸分析在起作用。
3.2 方法内联:收益最直接的优化手段
方法内联(Method Inlining)是所有JIT优化里最直观、收益最稳定的一项。原理很简单:把被调用方法的代码直接嵌入到调用方方法中,消除方法调用本身的开销。
方法调用的开销包括栈帧创建、参数传递、跳转和返回操作等,虽然单次开销很小,但架不住高频调用。我之前压测过一段代码,在热点方法上做了内联优化后,整体吞吐提升了约8%——这还是业务代码本身复杂度不高的情况下。
内联的触发条件有几个:
- 方法体小于
-XX:MaxInlineSize(默认35字节)才会被内联。 - 如果方法体大于35字节,但方法被频繁调用(属于热点),可以通过
-XX:FreqInlineSize(默认325字节)来放宽限制。 - 方法体大小只是决定是否“可内联”,最终是否内联还取决于调用频率估算的收益。
方法内联跟运行时多态有冲突:如果调用的是接口方法,JVM在编译时根本不确定实际调用的是哪个实现类,怎么内联?
HotSpot采用的方法是**“去优化+类型 profiling”**。C1在解释执行阶段收集调用点实际发生的类型信息,统计出“大部分情况下走的都是这个实现类”,于是C2编译时把接口调用优化成“先检查类型是否匹配,匹配则直接调用已知实现,不匹配再走慢速路径”。这种针对单态/多态调用点的优化叫“去虚拟化”(Devirtualization)或“类型预测”。
面试中如果把方法内联和逃逸分析、类型预测串起来讲,展示出优化手段之间是联动的而不是孤立的,通常能让面试官眼前一亮。
3.3 循环优化与分支预测
JIT对循环的优化种类不少,包括:
- 循环展开(Loop Unrolling):把循环体复制多份,减少循环控制指令(计数器增减、条件判断)的执行比例。比如循环体只有一个简单的加法,展开后一次迭代做4次加法,循环控制指令开销被摊薄。
- 循环剥离(Loop Peeling):把循环第一次迭代单独拿出来执行,因为第一轮可能要做边界检查,剥离后主循环可以走最快速的路径。
- 循环不变代码外提(Loop Invariant Code Motion):把循环体内不随迭代变化的计算提到循环外。比如循环里每次都执行
Math.abs(x),且x的值在循环过程中不变,编译器会把这段计算移到循环外面执行一次。
分支预测方面,JIT编译器会依据Profiling数据调整判断逻辑,把高频分支放在更靠前的位置,提高指令局部性。这些细节面试官未必会每个都深挖,但在深入讨论JIT优化手段时能提到,能体现出你对“编译优化”的整体认知。
3.4 现场实操:用一个小实验看看JIT到底做了啥
纸上谈兵终觉浅,写一段代码来实测一下。最简单的实验方式是:写一个空方法循环调用,然后观察JIT是否对它做了编译。
假设有这样一个方法:
java复制public class JitDemo {
private static int add(int a, int b) {
return a + b;
}
public static void main(String[] args) throws Exception {
long start = System.nanoTime();
int sum = 0;
for (int i = 0; i < 200_000; i++) {
sum += add(i, 1);
}
long cost = (System.nanoTime() - start) / 1_000_000;
System.out.println("sum=" + sum + ", cost=" + cost + "ms");
// 防止JIT提前退出编译,保持应用活跃一段时间
Thread.sleep(5_000);
}
}
运行前加参数:-XX:+PrintCompilation,能输出JIT编译日志。你会看到类似这样的输出:
text复制 145 35 % 3 JitDemo::main @ 13 (63 bytes) made not entrant
146 36 3 JitDemo::add (7 bytes)
日志各列含义:第一列是编译序号,第二列是方法被编译时的时间戳(ms),%表示OSR编译,!表示发生了异常处理,数字3或4表示编译层级(C1的完整Profiling是3,C2是4),后面是类名和方法名。
made not entrant表示这个方法之前被编译过,但那个编译版本被废弃,通常是因为Profiling数据更新导致C2重新编译了更优版本。
我实操时最深的体会是:大部分方法不会被JIT编译,运行一次小程序就能看到日志里只有极少几条记录。 所以千万别说“JIT把Java代码全编译了”——真正的场景是Only the hottest code gets compiled。
4. JIT实战:参数调优与故障排查
4.1 CompileThreshold、CodeCache与编译线程怎么配
很多人在JVM调优时会遇到和JIT相关的三个高频参数:
-XX:CompileThreshold:关闭分层编译时,设置方法调用触发的编译阈值。默认服务端模式是10000。调低这个值可以让热点方法更早被编译,提高启动后的性能,代价是编译更频繁,消耗更多CPU。对于长生命周期服务,一般不建议盲目调低,让默认的GC和系统自己决定更稳。
-XX:ReservedCodeCacheSize:JIT编译生成的机器码存放区域,称为CodeCache。默认值在JDK 8中是240MB(服务端模式)。CodeCache不参与堆内存管理,是独立的内存区域。当CodeCache满了,JIT会停止编译,已经编译的方法不会被执行,程序退回解释执行,性能会断崖式下跌。这是线上事故的高发区,比堆内存溢出的危害还要隐蔽——服务不挂,只是慢到离谱。
排查CodeCache是否满了,可以用-XX:+PrintCodeCache参数启动,看编译后CodeCache的占用率。或者用jcmd工具:
bash复制jcmd <pid> Compiler.codecache
输出会显示CodeHeap各区的使用情况。如果发现非方法区CodeHeap使用率接近100%,就需要调大-XX:ReservedCodeCacheSize,或者用-XX:+UseCodeCacheFlushing(JDK 8中默认开启)让JVM在CodeCache满时清理不常用的编译代码。
-XX:CICompilerCount:并行编译线程数。默认值是根据CPU核数自动计算的,公式大致是 (CPU核数 > 2) ? (CPU核数 / 2) : 1。C2编译非常吃CPU,如果机器同时承担很多业务请求,编译线程太多会抢占业务线程的CPU。但是调太低会导致热点代码编译不及时,拖慢性能。线上建议根据压测结果调整,不要拍脑袋。
4.2 反射、动态代理和JIT的相爱相杀
这是JIT实战中很容易踩的一个大坑:反射调用的性能问题,很大程度是因为JIT难以优化。
常规的Java方法调用,字节码里有明确的invokevirtual/invokeinterface指令指向目标方法,编译器可以定位并做内联。但反射调用Method.invoke()最终会走native方法进行方法分发,这个流程对JIT来说是个“黑盒”,很难被优化。即使被调用方法本身很小很频繁,也无法被内联。Method.invoke()每调用一次都要做大量的权限检查和参数封装。
这就解释了为什么反射调用比直接调用慢一个数量级——不是反射本身做了什么重活,而是JIT的优化在反射面前基本上失效了。
解决思路:
- 能不用反射就不用,用接口隔离来替代。
- 用
MethodHandle(Java 7引入)结合LambdaMetafactory生成函数式接口实例,性能接近直接调用,因为LambdaMetafactory生成的目标方法仍然保留直接调用语义,JIT可以内联。 - 如果一定要用反射,尽量把反射调用缓存到静态字段上,避免每次创建新的Method对象。
4.3 从JIT角度诊断“性能突然掉了”
一个很有代表性的故障场景:服务跑得好好的,突然某一天接口响应时间整体上涨,CPU使用率却没明显变化,堆内存和GC也都正常。这时候优先怀疑JIT可能出了问题。
排查路径一般是:
- 用
jstat -compiler <pid>查看编译统计信息。 - 用
jcmd <pid> Compiler.codecache查看CodeCache使用率。 - 看日志里有没有
CodeCache is full. Compiler has been disabled之类的提示。 - 检查是否在最近改动中把
-XX:ReservedCodeCacheSize设置得过小,或者无意中设置了-XX:-UseCodeCacheFlushing关闭了CodeCache清理。
这类问题如果线上不好抓,建议在下一次发布前的压测环境里故意把CodeCache调小(比如64MB),观察一下性能曲线,就能很容易复现出“CodeCache满了之后性能骤降”的全过程。
4.4 那些看起来没用但影响JIT的JVM参数
有些参数跟JIT没有直接关系,但会间接影响编译行为:
-XX:+AggressiveOpts:被称为“激进优化”开关,在JDK 8中它会把一些实验性的优化开关打开,比如-XX:+EliminateAutoBox。不过在JDK 8后面几个版本中,这个参数已经基本被废弃,推荐做法是单独开启你需要的优化项,不要无脑加AggressiveOpts。-XX:-TieredCompilation:关闭分层编译后,启动会变慢,因为少了C1阶段的快速提升,所有热点代码都得等到超过CompileThreshold后由C2编译。优点是编译过程稳定、代码质量高,适合对延迟非常敏感的长时间运行型应用。-XX:+AlwaysCompileLoopMethods:让JVM把所有循环方法都强制编译,忽略热点探测。这个参数线上慎用,因为可能把大量冷方法也编译了,浪费编译资源还占CodeCache。
5. 面试答题思路:把JIT讲成你真正懂的东西
5.1 典型问题怎么回答
整理几个高频面试题,结合我自己的答题经验,给你参考:
问题1:解释一下JVM的JIT编译和AOT编译的区别?
回答思路:先讲清楚解释执行与静态编译的取舍,引出JIT的热点检测逻辑。AOT编译(JDK 9的jaotc)是提前把字节码编译成机器码,优点是启动快、编译期CPU消耗可控,缺点是编译产物绑定平台,且无法基于运行时情况做动态优化(比如依据Profiling数据的后续深度优化)。JIT的优势在于能根据实际运行情况自适应优化,劣势是启动时段的解释执行可能导致初期性能不佳,且编译过程本身消耗CPU。
问题2:JIT是怎么识别热点代码的?
回答思路:讲清楚方法调用计数器、回边计数器、计数器半衰周期、阈值参数和OSR。如果能补充“分层编译之下CompileThreshold默认值失效”这个细节,会显得你真的研究得深。
问题3:什么是分层编译?C1和C2有什么区别?
回答思路:按0-4层的逻辑去讲,说明层级的递进关系,C1快速编译+收集数据,C2深度优化+性能极致。重点说“为什么需要分层”——单靠C2启动太慢,单靠C1最终代码质量不够。
问题4:JIT有哪些优化手段?
回答思路:逃逸分析(讲到标量替换和锁消除)、方法内联、循环优化、去虚拟化。最好穿插实际案例:比如压测中看到对象分配量大减,推测是标量替换生效。
问题5:JIT会导致OOM吗?
回答思路:JIT本身不管理堆内存,但生成本地代码占用的是CodeCache。CodeCache如果满了,JIT停止编译,性能下跌,甚至可能出现异常——在JDK 8u272之前,CodeCache满可能导致OutOfMemoryError: Compressed class space或直接崩溃。所以JIT间接能引发内存相关故障,但不是传统意义的堆OOM。
5.2 常见理解误区速查表
把这些年看到过的理解误区整理成表:
| 误区 | 实际情况 |
|---|---|
| JIT会编译所有代码 | 只编译阈值以上的热点代码,冷代码一直走解释执行 |
| 分层编译下CompileThreshold=10000 | 分层编译开启后该参数默认失效,阈值自适应 |
| 逃逸分析就是把对象分配到栈上 | HotSpot实际实现是标量替换,拆对象为字段 |
| 反射慢是因为动态分派开销大 | 根因是JIT无法内联优化反射调用链 |
| OSR是针对方法的优化 | OSR针对循环体,在环执行中替换栈帧 |
| CodeCache可以无限增大 | CodeCache有上限,且老年代GC等也会受到影响 |
5.3 一个小细节:用后台线程观察JIT日志
分享一个调试经验:线上环境不方便改启动参数时,可以利用Thread.setDefaultUncaughtExceptionHandler或者JFR(Java Flight Recorder)的jdk.JitCompilerStatistics事件来观察JIT编译情况。JFR在JDK 11之后默认可用,事件采集对性能影响很小,是我排查JIT相关问题时的首选工具。
bash复制jcmd <pid> JFR.start name=jtrace settings=profile
# 等待一段时间
jcmd <pid> JFR.dump name=jtrace filename=jtrace.jfr
打开jfr文件后,在Event Browser里搜索Compilation相关事件,能看到编译类名、编译耗时、编译层级、CodeCache变化趋势等,比翻PrintCompilation日志直观得多。
6. 一次真实压测中的JIT调优实验记录
6.1 实验环境与初始表现
去年我在一个内部中间件项目上做性能压测,目标是单机吞吐从2万QPS提高到3万。环境是8核16G的容器,JDK版本是AdoptOpenJDK 8u282,默认飞行参数。先用wrk压了30分钟,发现吞吐在2.1万左右徘徊,GC日志显示ParNew频繁但每次都在20ms以内,堆内存在2GB左右波动,看起来不是GC的锅。
从JFR快照里看到CodeCache使用率只有30%,编译线程利用率也不高。但CPU profiling显示,业务线程有大概12%的时间耗在Method.invoke()上——代码里有个老的动态代理调用链,每次请求要反射调3次方法。
6.2 针对性调整
第一步,先把反射链替换成预处理生成的方法调用。用的方案是javax.tools.JavaCompiler动态编译一个适配类,避免运行期反射。改动后压测,吞吐到了2.5万,Method.invoke相关的CPU占用降到了接近0。
第二步,把-XX:CompileThreshold从默认改为5000(关掉了分层编译)。为什么关分层编译?因为压测场景是纯CPU密集,启动阶段早就过去了,要C1的快速缓存没意义,反而占CodeCache。关闭分层编译后,热点方法都被C2编译,响应时间的P99从85ms降到了52ms,吞吐到了2.8万。
第三步,用-XX:CICompilerCount=4把编译线程从默认的4降为4(8核默认算出来就是4),发现编译线程CPU占用正常,没有再调。仔细看JFR发现C2编译期间有几次“编译变成了MakeNotEntrant”的日志,说明Profiling数据有波动,导致C2反复重新编译。解决办法是把-XX:MaxInlineSize从35提到80,让更多小方法直接内联,减少重新编译的触发频率。这一步把P99又降到了45ms。
最终压测结果:吞吐稳定在3.1万QPS,GC次数减少约15%(因为标量替换和逃逸分析让对象分配变少了)。
6.3 这个实验说明了什么
这套调整的核心逻辑并不是“照抄参数”,而是按“消除JIT盲区→调整编译策略→优化代码布局”的思路来推进。反射是JIT盲区,先解决;然后让JIT更早更积极地编译热点——这通过关分层+调低阈值达成;最后优化方法内联强度。
如果你的服务压测结果不理想,先别急着调堆内存,建议先看JFR里的编译事件和CPU火焰图,判断瓶颈是否在JIT优化不到位的代码路径上。
我在实际项目中反复踩过的坑,最值得提醒的几条:
- 不要在生产环境直接改CompileThreshold,一定要先压测验证。调低阈值虽然让热点代码更早被编译,但冷方法可能也会被误判为热点,白白消耗编译CPU。
- CodeCache不是越大越好,设置过大意味着老年代 heap 之外的“保留”内存太多,容器内存紧张时反而触发OOM。按经验8G堆配128MB-256MB CodeCache足够。
- JIT优化的效果在不同JDK版本之间差异很大,JDK 8和JDK 11、17的JIT实现都有变化,比如JDK 9之后默认使用G1,对象分配路径改了,JIT的逃逸分析效果也会不同。升级JDK版本时一定要重新压测,别假设旧参数在新版本上依然成立。
7. 最后再说一点个人体会
JIT这个东西,本质上回答了一个问题:Java如何在不牺牲平台无关性的前提下逼近C++的性能。它不像GC那样容易背调优参数,也不像线程池那样有直观的配置项,但它决定了你写出的每一行代码最终在CPU上到底怎么跑。
面试时被问JIT,不要只背概念。能说出“解释器负责快速启动,C1负责构建热点画像,C2负责极限优化,OSR负责处理循环热点”,再搭配一个实际的压测调优案例,要比把八股文背得一字不差有用得多。准备面试也好,做线上调优也好,建议自己动手跑一次PrintCompilation日志,观察你代码里的热点方法是怎么被编译、怎么被内联的,这个体验比看十篇文章都深刻。
