代码跑着跑着就变快了这不是错觉做Java后端这几年我经常在压测或长跑任务里碰到一种诡异现象同一个接口刚启动时每秒只能处理几百个请求跑上几分钟后QPS自己就上去了翻了几倍甚至一个数量级。团队里的新同事常常一脸疑惑地问是不是服务器被别的进程干扰了还是GC突然变好了其实都不是。真正的原因藏在JVM内部一个叫即时编译器JIT Compiler的引擎里。它是Java性能最容易被忽视的幕后功臣也是面试八股里最常被背错的一块内容。这篇文章不打算给你复述教科书定义而是结合我实测过的现象、踩过的坑、以及排查性能问题时沉淀下来的经验把JIT的工作机制和调优边界讲透。无论你是刚入门想搞懂Java为什么越跑越快还是已经工作几年想弄明白JVM参数怎么调这篇文章都值得你读一遍。1. 先还原一下变快的真实场景1.1 压测数据里的爬坡曲线我最早对JIT产生强烈好奇是在一次线上接口压测里。我们有个订单查询服务刚启动时用wrk压请求延迟平均在80ms左右吞吐量大概3000 QPS。等压到10分钟后再看延迟掉到了25msQPS冲到了12000。中间没有任何代码变更也没有扩容纯粹是同一个进程在持续跑。后来读JVM日志才确认是C2编译器在后台把热点方法编译成了高度优化的机器码。这个越跑越快不是玄学它有一套完整的触发机制背后就是JIT——Just-In-Time Compiler即时编译器。很多人只记得Java是解释执行的却忘了现代JVM里解释器只是保底方案。一个方法被调用足够多次之后JVM会把它从逐行解释的字节码编译成本地机器码后续执行直接跑优化后的原生指令速度差距可以拉到几十倍。1.2 一个最简单的代码实验为了直观感受JIT的效果你自己就可以做一个实验。写一段空循环加简单运算的方法用两个线程跑一个线程预热一万次然后开始计时另一个线程从启动就开始计时。你会发现预热后的执行时间远小于未预热的时间。public class JitDemo { private static int sum(int n) { int acc 0; for (int i 0; i n; i) { acc i; } return acc; } public static void main(String[] args) throws InterruptedException { // 先跑1万次触发C1编译甚至C2编译 for (int i 0; i 10000; i) { sum(1000); } Thread.sleep(2000); // 开始计时 long start System.nanoTime(); int result 0; for (int i 0; i 100000; i) { result sum(1000); } long cost System.nanoTime() - start; System.out.println(预热后 10万次调用耗时: cost / 1000 us, result result); } }这段代码本身没有任何多线程复杂度纯粹靠JIT把sum方法编译成机器码后执行效率会明显提升。实测中冷启动的第一次调用可能耗时几百微秒而热调用稳定后可以做到几微秒甚至更低。这种差距在I/O密集的应用里不明显但在纯计算任务里非常刺眼。2. 解释器、AOT和JITJVM为什么要两头下注2.1 编译时静态编译和解释执行的矛盾Java最初打出的口号是一次编写到处运行所以它把源码编译成平台无关的字节码再由各个平台上的JVM来执行。这里有个天然矛盾如果JVM逐行解释字节码启动快但运行慢如果像C/C那样在程序运行前就编译成机器码运行快但失去跨平台性且启动慢。于是JIT走了一条中间路线程序刚开始运行时先用解释器顶住保证启动速度随着代码被反复执行JVM识别出热点方法再用编译器把它们动态编译成机器码。这样既保住了Java跨平台的底子又能在长时间运行后逼近甚至超过静态编译语言的性能。2.2 方法计数器与回边计数器那JVM怎么判断哪个方法是热点答案是一套基于计数器的探测机制。HotSpot虚拟机里有两类计数器方法调用计数器统计一个方法被调用的次数。回边计数器统计一个方法里循环体往回跳转的次数。当方法调用计数器超过CompileThreshold对于C1编译器默认值在客户端模式下是1500次服务端模式下是10000次JVM就会把这个方法提交给编译器。回边计数器则负责处理那些方法本身调用不多但内部有巨大循环的情况比如一个方法只被调用了一次内部却循环了上百万次——这种情况下方法调用次数不够但循环跳转次数早就爆表了同样会触发编译。这里需要注意计数器不是无脑累加的它会因为方法让步而衰减。在解释执行模式下当计数器超过阈值的一半时会被重置为阈值的一半防止某些方法在后台编译还没完成时计数器一直飙高。这套半衰期设计是为了避免激进编译导致资源浪费。2.3 C1和C2的分层配合HotSpot服务端虚拟机里至少有两套即时编译器C1编译器也叫Client Compiler和C2编译器也叫Server Compiler。C1的特点是编译速度快但优化程度相对保守。C2正好相反它启动慢、编译消耗大但能做的激进优化非常多方法内联、循环展开、逃逸分析、锁消除、自动向量化等。分层编译Tiered Compilation把这些组合了起来。JDK 8之后的默认策略基本是这样的第0层纯解释执行。第1层用C1编译但不做性能剖析profiling。第2层用C1编译并开启性能剖析。第3层用C1编译开启完整性能剖析。第4层用C2编译。大多数方法走到第3层后如果热度足够会被提升到C2编译。这意味着代码在运行早期就获得了一波提速然后在后台花更多时间做精细优化。代价是编译线程会吃掉部分CPU资源所以在压测刚开始的那几分钟你会看到JVM的CPU占用率比平时高一点那是编译线程在干活。3. JIT的看家本领内联、逃逸分析和锁消除3.1 方法内联把调用链摊平JIT最常做、收益最高的一类优化是方法内联。它的逻辑很简单如果一个方法a()调用了方法b()且b()已经被JIT判定为适合内联JIT会直接把b()的机器码展开放进a()里省去一次真实的调用。不要小看这一次省去。一次方法调用在机器层面要做的事情很多压栈、跳转、传参、返回值传递、恢复现场。在高频执行的热点路径里哪怕省掉几纳秒放大到每秒百万次调用收益都非常可观。JVM内联不是见一个就内联一个它会根据方法的大小、调用频率和调用点的虚分派情况做判断。默认情况下小于MaxInlineSize默认值约35字节的字节码的方法更容易被内联。这也是为什么很多性能优化的第一条建议是让方法短小精悍小方法才有资格进入JIT的内联候选名单。3.2 逃逸分析对象不一定要进堆逃逸分析Escape Analysis是C2编译器非常关键的一项优化。它的任务是判断一个对象只存在于当前方法或当前线程内部不会逃逸到方法外部、不会被其他线程访问。如果一个对象被证明没有逃逸C2就能做三件大事栈上分配对象不用在堆里申请内存直接在栈上分配方法退出时自动释放连GC都不用碰。标量替换如果对象足够简单JIT甚至可以完全不创建对象直接用几个局部变量标量替代对象字段再在读取的地方直接消解成对这个变量取值。同步消除如果这个对象没有被其他线程看到那么对象上的同步锁操作是多余的可以直接消除。我见过最典型的例子是很多代码里StringBuilder在方法内部拼接字符串每次循环都new一个。看起来很浪费但如果这个StringBuilder没有逃逸出循环体JIT会把它替换成几个局部变量操作循环内部根本不会有真实的堆分配。你写的时候毫不在意JIT其实已经帮你收拾干净了。3.3 锁消除的误解澄清很多人以为锁消除等于看到synchronized就自动优化这是错的。锁消除只发生在JIT通过逃逸分析确认对象不会逃逸出当前线程的前提下。如果你把一把锁对象传给了别的线程或者塞进了某个静态集合JIT就不会做消除。这也解释了为什么有些代码在加了synchronized之后在单线程压测里性能并没有明显下降——因为JIT认定那把锁没有被其他线程竞争和使用就把它优化掉了。但你千万不能把这种没降多少性能当成加锁没代价一旦对象逃逸真实锁竞争的开销会立刻暴露出来。4. 用JVM参数看见JIT在做什么4.1 观察参数和日志开关想确认一个方法到底有没有被编译、被内联最直接的方法是打开JVM的编译日志。常用参数组合如下-XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInliningPrintCompilation会打印每个方法的编译时间戳、编译层级、方法名。PrintInlining则会打印内联决策的明细包括哪些方法被内联了、哪些因为too big被拒绝了。在生产环境不建议长期开这些参数日志量很大但压测和排查问题时非常有用。我推荐一个更省事的做法先用-XX:PrintCompilation看方法编译的全景根据输出定位到可疑方法后再单独用PrintInlining验证内联情况。4.2 一段可复现的观察代码写一个可以被频繁调用的方法然后运行下面这段代码public class CompileLogDemo { private static int fib(int n) { if (n 2) { return n; } return fib(n - 1) fib(n - 2); } public static void main(String[] args) { long sum 0; for (int i 0; i 100000; i) { sum fib(20); } System.out.println(sum); } }运行时加上java -XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInlining CompileLogDemo你会在输出里看到一大片编译记录像java.lang.String::hashCode (55 bytes)这样的行代表一个方法被编译了。还能看到类似fib方法被标记为客户端编译或C2编译的日志。这个实验能直观感受到JIT确实是在程序运行过程中被触发的而不是启动时把全部代码编译完。4.3 解读日志时要注意的坑看PrintCompilation输出时有几点要提醒你行首的时间戳不是方法执行时间而是JVM启动后的毫秒数用于排查哪一刻发生了编译。%标记代表这个方法是被替换为OOPs版本通常和栈上替换OSR有关这属于正常现象。!标记代表该方法发生了异常处理变体编译不代表出错。日志里的made not entrant代表某个旧版本编译代码被废弃常见于多次反优化deoptimization之后。出现大量反优化有时是性能问题信号值得深入排查。5. 调优心得JIT的配置与踩坑记录5.1 千万别一上来就乱调编译参数我在不少项目里见过有人凭感觉塞-XX:CompileThreshold1000或者把-XX:-TieredCompilation关掉理由往往是网上说这样更快。实际上默认的JIT参数本身已经经过了大量调优绝大多数业务场景不需要碰它。如果你真的遇到启动后性能爬坡太慢希望提前达到最佳状态更合适的思路不是瞎改编译阈值而是做主动预热。启动时跑一遍核心路径的冒烟请求把热点方法先顶起来比调低编译阈值要安全得多。很多高性能服务框架比如某些RPC框架都有单独的warmup模块就是这个道理。5.2 大数据量计算下的JIT适配有一类场景特别容易踩坑你写了一个遍历大量数据的批处理任务数据量几千万行。程序刚开始跑发现速度很慢循环里各种新对象的分配导致GC频繁跑了一两分钟突然变快了GC也稳住了。这在批处理任务里很常见因为JIT的C2编译正好在那段时间完成。问题是如果你把批处理任务分片并行每个分片只跑几十秒就结束那JIT根本来不及发挥每个分片都在吃解释执行的亏。这种情况下分片任务越短JIT越帮不上忙你不能指望代码越跑越快来弥补架构选型上的问题应该考虑减少JVM启动次数、加大单分片处理时长或者提高预热权重。5.3 编译线程和CPU的争夺战JIT编译需要CPU这在多核机器上通常无所谓但如果在压测时机器只有2核编译线程会跟业务线程抢资源表现为QPS在启动初期有波动甚至出现短时间的整体性能下降。遇到这种情况可以调整编译线程数-XX:CICompilerCount2限定编译线程为2避免它吃掉过多CPU。但这属于术层面的经验前提是你要确认瓶颈真的在编译线程争抢而不是GC或者锁竞争。我见过有人一压测性能不稳就改编译线程数结果半天没效果最后发现是数据库连接池太小。编译线程的争抢也可以通过观察PrintCompilation的编译耗时判断出来。如果编译日志里大量方法花了超过几百毫秒才编译完成说明编译线程可能确实很紧张。6. 常见误解澄清八股文里的错误答案6.1 Java是解释执行的——这句话只对了一半准确的表述是Java字节码在最开始确实被解释执行但一旦进入热点JVM会用即时编译器把它编译成机器码执行。所以更严谨的说法是Java混合了解释执行和编译执行。如果你在面试里只答解释执行多半会被追问一句那JIT是干什么的答不上来就只能尴尬了。6.2 JIT越优化越快——不是所有场景都成立JIT的优化建立在代码路径稳定基础上。如果你的代码里充斥着反射、动态代理、字节码增强或者大量多态虚调用JIT很难稳定内联和优化甚至会反复反优化出现忽快忽慢的诡异现象。这也是为什么在高性能场景下很多人会刻意避免复杂的动态分发改用更直接的静态结构。6.3 面试官真正想听什么面到JIT时面试官通常不是在考参数背诵而是想确认你有没有理解这几个层次为什么需要JIT答跨平台需求与执行效率之间的平衡。热点怎么判断答计数器机制和分层编译。JIT做了什么优化答内联、逃逸分析、锁消除、标量替换。Java能保证越跑越快吗答不能保证依赖代码特征和运行场景。把这四点串起来比背一条参数命令要有说服力得多。7. 最后分享一个小技巧如果你已经看完了上面的内容其实已经能解决工作中90%的代码变快问题了。还有一个很少被提到的实用经验JIT对小而稳定的方法最友好对大而杂的方法几乎无能为力。你与其花大把时间调JVM参数不如把核心热点路径上的大方法拆成多个小方法让JIT有更多机会做内联。这一步对性能的收益往往比你去设置-XX:MaxInlineSize还要明显。我最近一次线上排查就是把一个几百行的巨无霸方法按业务阶段拆成五个小方法结果同样负载下CPU下降了近20%压测曲线也平稳了不少。这不是什么魔法纯粹是给JIT让出了发挥空间。下次你再看到代码跑着跑着就变快了别再归因于玄学——去翻一下编译日志你会看到那位幕后引擎一直在默默工作。