1. 别再把两件事混为一谈了:先分清“编译”的两个含义
只要写过Java的人,基本都干过这两件事:一是执行javac把.java文件编译成.class文件,二是在IDE里点一下运行,然后看着程序跑起来。表面上看,这两个动作都叫“编译”,实际上背后是两套完全不同的机制在干活。javac做的是传统意义上的静态编译,把源代码翻译成字节码;而JIT(Just-In-Time)实时编译,是发生在程序运行期间的动态编译,它把字节码再翻译成CPU能直接执行的机器码。
很多刚接触JVM调优或者面试被问到“Java是编译型还是解释型语言”的人,就是栽在这个概念混淆上。如果你也曾经被“Java既是编译型又是解释型”这种说法绕晕过,那这篇文章就是为你准备的。我会把javac编译和JIT编译各自的职责边界、底层原理、关键区别掰开揉碎讲清楚,还会附带一些我在实际排查性能问题时用到的观察手段和参数配置。看完之后,你至少能明白一件事:你在命令行敲的javac和JVM运行时悄悄干的“编译”,完全是两个维度的活。
适合谁来读呢?刚学完Java基础、开始接触JVM排查工具的同学;写过一段时间业务代码、但没深入看过字节码和运行时行为的开发;还有准备面试、需要把“编译”这个概念讲清楚的人。这篇文章不追求教科书式的全面,而是把你最可能混淆、最可能在实践中踩坑的点挑出来重点说。
2. javac的编译到底编了什么
2.1 从.java到.class:一份朴素的三步流程
javac干的活,本质上是一个“翻译”过程:把人写的Java源码翻译成JVM能认识的字节码指令。这个翻译过程通常被分为三步。
第一步是词法分析和语法分析。词法分析把源码字符串拆成一个个Token,也就是关键字、标识符、运算符、字面量这些最小单元;语法分析则把这些Token按照Java语言规范组装成一棵抽象语法树(AST)。这一步相当于把你写的中文句子拆成词,再按语法规则画出句子结构。
第二步是语义分析与注解处理。编译器会检查类型匹配、变量是否已定义、方法重载选择是否正确等。同时,如果你用到了注解处理器(比如Lombok、一些ORM框架的编译期处理),就是在这个阶段执行的。这里有个容易被忽略的点:注解处理器可以修改或生成新的源文件,而这些新生成的源文件会被再次编译进同一轮流程里。
第三步是字节码生成。编译器把校验过的语法树转换成符合JVM规范的Class文件,里面包含类元信息、字段表、方法表、异常表,以及最重要的Code属性——方法体对应的字节码指令序列。
日常写代码时你感觉不到这三个步骤,因为IDE一键编译就把所有细节吞掉了。但如果你用javap -c去反汇编一个简单的类,看到一堆aload_0、invokespecial、ireturn之类的指令,就会直观感受到:字节码确实是一种“中间语言”,它既不是给人看的源码,也不是能直接扔给CPU执行的机器码。
2.2 字节码为什么不能直接被CPU执行
CPU只认机器码,也就是由操作码和操作数构成的二进制指令。不同CPU架构(x86、ARM等)有自己的指令集,机器码格式天差地别。Java为了让一套代码能跑在多个平台上,就搞了字节码这种“抽象指令集”——它不针对任何特定CPU,而是针对“JVM这台虚拟的机器”定义的指令。
JVM把字节码当作自己的“汇编语言”,但真正要执行的时候,还是得把字节码翻译成宿主机器听得懂的话。谁来翻译?答案是JVM内部的解释器,或者JIT编译器。这就引出了第二个问题:既然解释器已经能把字节码翻译成机器码去执行了,为什么还要搞JIT?
2.3 javac做了优化吗:答案可能和你想的不一样
很多人以为javac会做类似编译型语言那种激进的代码优化,比如把常量表达式提前算好、把冗余变量消除掉之类的。真实情况是:javac只做非常少量、非常保守的优化,比如常量折叠(int a = 1 + 2会直接变成int a = 3),还有字符串常量的编译期拼接。大部分真正的优化,比如循环展开、方法内联、逃逸分析、锁消除,javac一概不管,全部留给JIT运行时去做。
这样设计是故意的。Java的设计哲学是“一次编写,到处运行”,如果javac针对某个CPU平台做了激进优化,那生成出来的.class文件可能就不具备跨平台性了。保持字节码的简单和稳定,让运行时根据当前的硬件环境、程序运行特征去做针对性优化,这比编译期优化更灵活。用大白话说:javac负责把话说对,JIT负责把话说得漂亮、说得快。
3. JIT实时编译的核心机制
3.1 从解释执行说起:为什么不能只靠JIT
JVM启动后,字节码先是交给解释器执行的。解释器逐条读取字节码指令,每读一条就翻译成对应的机器码操作并立即执行。这种方式启动快、不需要编译等待时间,但性能低。因为解释器每次执行同一条指令,都要重新做一遍“读字节码→翻译→执行”的流程,重复开销很大。
那么问题来了:能不能启动时就全部用JIT编译?理论上可以,但代价是你每次启动程序都要先把所有字节码编译成机器码,这个编译时间可能比你的业务启动时间还要长,而且编译出来的机器码占用的内存也大。对于很多“启动一次只跑几分钟的任务型程序”,这个代价完全不划算。所以JVM采取了一个务实的策略:默认先用解释器跑,运行过程中检测哪些方法是热点、被反复执行,再针对这些热点方法做JIT编译。一旦编译完成,后续执行这个方法的调用就直接用编译好的机器码,不再走解释器。
这就是“混合模式”的由来。Java被视为“半解释半编译”语言,原因就在这。
3.2 热点怎么判定:方法调用计数器和回边计数器
JIT不会神经质地随便挑个方法就编译,它需要有依据地判断哪些代码值得编译。HotSpot虚拟机的判断依据是计数器。
第一个是方法调用计数器。JVM为每个方法维护一个计数器,每次方法被调用就加一,达到一定阈值后,这个方法就会被认定为“热点方法”,触发JIT编译。第二个是回边计数器。光有方法入口计数还不够,因为循环体可能在一个方法内部被反复执行。回边计数器统计的是方法内循环的回跳次数,比如for循环每完成一次迭代往回跳的时候就会触发计数。这个计数器主要用来判定“循环体内的代码是不是热点”。
阈值是多少呢?在服务端模式下,方法调用计数器的默认阈值为10000次(不同版本和参数可能不同,也可能由虚拟机的自适应调优动态调整)。这意味着一个方法至少要被调用约一万次,才可能进入JIT的编译候选名单。注意是“可能”,因为JVM还会根据代码复杂度、编译队列的长度等做综合判断。
有个细节容易踩坑:方法调用计数器的热度是会衰减的。如果一段时间内这个方法没有被大量调用,计数器的值就会随“热度衰减”机制减半。也就是说,如果你的程序是那种高峰时期猛调一个方法、低峰期又完全空闲的图案类型,计数器可能一直攒不够阈值,导致热点方法迟迟不被编译。
3.3 分层编译:C1和C2是怎么配合的
HotSpot的JIT编译器主要有两款:C1(客户端编译器)和C2(服务端编译器)。
C1的特点是编译速度快,但生成的机器码优化程度一般。它适合对启动速度有要求的场景,编译耗时控制在很低的水平。C2则相反,它做非常激进、非常深度的优化,生成的机器码执行效率高,但编译本身耗时长、消耗内存大。如果一上来就用C2编译所有热点方法,程序很可能在运行早期被漫长的编译过程拖累,造成卡顿。
现代JVM默认开启分层编译(Tiered Compilation),目的是两者优势互补。当方法刚达到热点阈值,先用C1快速编译一版,让程序迅速享受到编译执行带来的性能提升;之后如果代码继续被频繁执行、热度进一步上升,再触发C2重新编译,替换掉C1生成的低质量机器码。整个过程中,C1作为过渡,C2作为终极形态。
分层编译的状态大体可以简化为:解释执行(第0层)→ C1编译(第1、2、3层,随着profiling信息逐步深入)→ C2编译(第4层)。每一层获取到的运行时profile信息都会传给下一层,让更高级的编译器有足够的数据做激进优化。极端情况下JVM也会做逆优化,即发现C2基于错误假设做了优化、需要回退时,重新回到解释执行或低层编译状态重新收集信息。
3.4 JIT到底优化了什么:几个看得见的例子
JIT的比较级优化手段里,最有代表性的是方法内联、逃逸分析、锁消除和死代码消除。
方法内联:一个方法如果体量很小但被频繁调用,频繁的调用开销(参数传递、栈帧创建销毁)占比会很高。JIT会把方法体直接“粘贴”到调用点,省去调用过程。典型例子是各种getter/setter方法,大量访问器方法的存在让方法调用频率极高,内联带来的收益非常可观。你可以在JIT日志里经常看到类似于“已经内联了几个字节码”的记录。
逃逸分析:分析对象的作用范围是否“逃逸”出方法或线程之外。如果一个对象只在方法内部创建、内部用,没有返回、没有存入全局变量,那JVM就有底做几件事。第一是栈上分配,把对象直接分配到栈帧上而不是堆上,方法结束随栈帧一起销毁,完全免掉垃圾回收的负担。第二是标量替换,当对象没有被外部引用时,JVM甚至可以不用创建整个对象,直接把对象的字段拆开当作局部变量处理。第三是锁消除,如果JIT判断对象引用不会逃逸到其他线程,那么对这个对象加的锁就是毫无意义的(因为只有一个线程碰得到它),JVM会把锁直接去掉,省掉加锁解锁的开销。
这些优化后端到执行期,针对的是“运行时的实际情况”,而不是源码层面的静态结构。这也是JIT和javac一个非常根本的差异:javac面对的是代码本身,JIT面对的是代码加运行时行为数据的综合样本。
4. 一次搞懂两者的不同:时间、对象、目的、结果
很多人觉得“编译”就是“把源码变成可执行文件”,但Java生态里可执行文件这个概念是分层的。要理解javac和JIT的区别,我习惯用一套画流程的比喻:如果把程序执行比作开餐厅,javac相当于根据菜谱写出一份精确的备菜单,备菜单是JVM这口锅能看懂的中介文本;而JIT则是热灶上的厨师,根据客人点菜的规律,把重复做的菜提前做熟放在保温柜里,点得越多做得越熟、越快。底下那张备菜单一直都在,保温柜只是加快了一样菜重复出品的速度。
对应到技术细节上,两者的区别可以从下面几个维度看:
| 对比维度 | javac(静态编译) | JIT(运行时编译) |
|---|---|---|
| 执行时机 | 程序运行前,开发期或构建期 | 程序运行期间,持续进行 |
| 输入内容 | Java源码(.java) | 字节码(.class)加上运行时profiling数据 |
| 输出内容 | 字节码文件(.class) | 当前CPU架构下的机器码 |
| 核心目的 | 语法检查、类型检查、生成跨平台中间表示 | 基于实际运行行为做性能优化,提升执行效率 |
| 是否依赖具体硬件 | 不依赖,面向JVM规范 | 依赖,需要针对宿主机CPU指令集生成代码 |
| 优化策略 | 基本不优化,只做常量折叠等零碎处理 | 方法内联、逃逸分析、锁消除、循环优化等全面优化 |
| 执行次数 | 一次编译,生成的.class长期复用 | 每次运行都可能重新编译、逆优化、再编译 |
这里需要特别强调一个常见的错误认知:有些人以为JIT是把.java文件直接编译成机器码。不对。JIT处理的对象是已经经过javac处理、被JVM类加载器加载进内存的字节码。如果你直接用一段手写字节码加载进JVM(比如用字节码操作工具生成的Class文件),JIT同样可以参与编译。JIT根本不关心你的字节码最初来自什么源码,它只认字节码本身。所以严格说,Java完整的构建链路是“源码 → 字节码 → 机器码”,JIT是后半程的推动者。
还有一个容易被忽略的层面:javac一次编译的产物可能被多个应用、多次启动复用,处理过程是线性的,编完就结束了。JIT则是一个持续的、动态演化的过程,一次运行里可能同一个方法被编译多次:先被C1编译,又被C2编译替换,接着因为缓存失败逆优化退回解释执行,再被重新编译。这种反复横跳的现象,在日常开发里是可以用日志观察到的。
5. 实际排查中如何观察和验证JIT的行为
5.1 白盒观察:用JVM参数打开编译日志
想要确认一段代码到底有没有被JIT编译、用了哪层编译器编译,最直接的办法是打开编译日志功能。测试环境启动时加上下面这几个参数,程序跑一段时间后会在日志文件里看到JIT的编译记录。
-Xint # 强制纯解释执行,禁用JIT(用来做对照实验) -Xcomp # 强制所有方法都走JIT编译,没有预热过程 -XX:+PrintCompilation # 打印JIT编译事件 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining # 打印内联细节 -Xlog:jit+compilation=debug # JDK 9+ 的新日志系统写法,视版本而定拿-XX:+PrintCompilation来说,开启后你会看到类似下面这样的日志,每行代表一次JIT编译事件:
345 1 % ( 3 ) java.lang.String::hashCode @ 3 (52 bytes) 412 2 4 com.example.biz.OrderService::calc (87 bytes) 780 3 4 java.util.HashMap::putVal (267 bytes)日志格式大致是:第一列为编译触发的时间戳,第二列是编译编号,后面会标注编译层级(1/2/3/4层),以及方法名、字节码大小。看到层级数字为4,说明是C2编译;看到带百分号%,说明是编译了方法内某个循环的“OSR版本”(On-Stack Replacement,栈上替换,用于循环热点)。
如果你写了段循环测试代码,发现循环跑完了也没出现编译日志,那你的方法可能是“小方法且不够热点”,或者你开的是短时间测试、热度衰减把它清零了。这种情况先加大循环次数,把调用量推到几万量级,一般就能看到动静。
5.2 黑盒感知:为什么程序越跑越快
JIT最直观的外在表现是:同一段代码,第一次跑很慢,多跑几轮之后性能突然上去。很多做过JMH(Java微基准测试套件)的人应该深有体会,JMH为什么要预热(warmup),本质就是因为JIT需要时间识别热点并完成编译。预热阶段是在给计数器攒热度,也是给JIT时间把热点方法编译成高频机器码。
我在实际工作中遇到过不少类似的“怪现象”:某次压测刚启动时QPS很低,跑了一两分钟后突然翻倍。如果对JIT不了解,很容易怀疑是不是程序里有缓存被触发加载了,或者数据库连接池终于预热好了。其实有很大一部分原因就是JIT编译完成了,热点方法从解释执行切换到了机器码执行。反过来,如果一次压测只跑30秒就结束,那大概率还没进入最佳性能状态。
如果你想验证一个方法在解释执行和JIT编译之间的性能差距,可以这样操作:写一个方法反复做短时间内能完成的循环计算,先用-Xint强制解释执行跑一遍,记录耗时;再用默认混合模式跑一遍,记录耗时。你会发现两者可能有数量级的差距。我第一次做这个实验的时候,差距大得让我对“解释执行慢”有了非常直观的认知。
5.3 常见难点:为什么有的方法就是不被编译
我在调试JIT相关问题时,比较常遇到的困惑是把方法写得太大,JIT反而不编译它。原因在于JIT会有一个“编译阈值”的判断:方法体过大(字节码尺寸超过一定阈值),C2可能因为编译成本太高而拒绝编译,或者只选择方法里的热点循环做部分编译(OSR编译)。这种情况典型出现在那种动辄几百行的方法里。
另外,JIT编译队列是异步的。一个方法达到热点阈值后,并不是立刻编译完成,而是要进入编译队列,由编译线程慢慢处理。如果你的程序在很短时间内就结束,队列里积压的编译任务根本来不及执行完。所以短时间运行的小工具类程序,JIT带来的收益很有限,这就是为什么很多CLI命令行工具和批处理程序倾向于直接用纯解释模式,省去编译线程的资源开销。
还有一个很多刚接触的人不知道的点:反射、动态代理生成的大量匿名类,同样可以作为字节码被JIT编译。但这类类往往由运行时代码生成,缺少静态分析信息,profiling数据的收集效率比较低,有时没有足够的时间充分优化。
5.4 性能调优场景:该不该手动干扰JIT
如果你遇到的是长命服务(24小时不重启的后端服务),我个人的经验是:绝大多数情况不要手动干预JIT决策,默认参数已经很成熟。但这并不代表没有可调的空间。生产环境常见的有两种调整方向。
第一,启动完成后需要立刻扛住大流量的场景,比如秒杀系统。这类服务不能接受“预热几分钟”的慢热过程。解决方案包括:启动后先打一段流量做预热,把热点方法触发到编译完成;或者适当调低编译阈值参数,让C2更早介入,例如:
-XX:CompileThreshold=5000注意这个参数在不同的分层编译模式下的实际含义可能不同,必须配合当前JVM版本和编译模式确认。我在生产上更建议的做法是做预热而非盲目改阈值,因为调低阈值可能导致编译线程过载、拖累整体吞吐。
第二,编译线程数量调整。如果JIT编译队列积压严重,可以增加编译线程数。默认编译线程数会随CPU核数调整,服务端场景一般够用。遇到编译延迟导致运行期卡顿问题时,考虑:
-XX:CICompilerCount=4这个参数要谨慎使用,加太多线程抢占CPU反而会让业务线程饿着。
6. 几个常见坑位和面试考点的裁判意见
6.1 关于“Java是编译型还是解释型语言”的经典争论
这个问题的标准理解是:Java里有编译型的部分,也有解释型的部分,取决于你观察的是哪一段流程。面向字节码的javac编译是编译型行为;早期JVM纯靠解释器运行字节码阶段是解释行为;JIT介入后又回到编译执行。所以严谨的说法是:Java是“先编译成字节码,再混合执行”的语言。你跟面试官答“Java是编译型语言”会显得认知不完整,答“Java是解释型语言”更是暴露概念缺失。把javac和JIT的职责分清楚,这个问题就能答得自然且全面。
6.2 关于“AOT会不会取代JIT”的讨论
提到JIT就免不了要提到AOT(Ahead-Of-Time,提前编译)技术。像GraalVM就提供Native Image方案,把Java应用直接编译成原生可执行文件,启动速度极快、内存占用低,这是AOT的亮点。但AOT落地有个天然短板:它没法拿到运行时profiling数据,也就做不了JIT那些“针对实际运行形态”的深度优化。动态分派、反射、多态调用在AOT里都是难点,常常需要额外配置或限制。现阶段更常见的定位是互补关系:追求极致启动和低内存的事件驱动型服务用AOT,追求长期稳定吞吐的应用继续用JIT。不存在谁完全替代谁的问题。
6.3 我自己踩过的坑:预热测试和压测脚本的误导
最后说一个我实际踩过的坑。有一次我写了一个压测脚本,想着验证一下某个接口的极限QPS,跑了一分钟看到结果挺满意。后来拉长到十分钟,发现QPS还能再涨三成。同事提醒我“你预热时间不够吧”,我才意识到:由于JIT的编译延迟和热度衰减机制,短时间压测的数据基本只能代表“解释执行状态”的性能,不代表线上真实水平。自此之后,凡是做性能对比,我都会把压测时间拉到足够长,或者干脆用JMH这类自带预热机制的工具,避免被初始慢速段误导。
还有一次,我在排查某个线上接口频繁卡顿,观察GC日志没看出问题,CPU也不高。后来想起开JIT编译日志,发现一个很大的方法在一段高流量窗口内反复被C2编译、然后逆优化、再编译,形成了循环消耗。定位到这个现象后,解决方案不是去改JIT参数,而是把那个几百行的大方法拆成若干小方法——让C2的编译决策更轻快,也顺势解决了它反复编译带来的抖动。这种“大方法导致的编译抖动”在真实服务里并不罕见,也让我越来越觉得,深入理解JIT不是纯理论猎奇,而是实打实影响性能和稳定性的基本功。