尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Java中javac和JIT编译到底有何区别?一文讲透两阶段编译机制
1. 别再把两件事混为一谈了先分清“编译”的两个含义只要写过Java的人基本都干过这两件事一是执行javac把.java文件编译成.class文件二是在IDE里点一下运行然后看着程序跑起来。表面上看这两个动作都叫“编译”实际上背后是两套完全不同的机制在干活。javac做的是传统意义上的静态编译把源代码翻译成字节码而JITJust-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编译器。这就引出了第二个问题既然解释器已经能把字节码翻译成机器码去执行了为什么还要搞JIT2.3 javac做了优化吗答案可能和你想的不一样很多人以为javac会做类似编译型语言那种激进的代码优化比如把常量表达式提前算好、把冗余变量消除掉之类的。真实情况是javac只做非常少量、非常保守的优化比如常量折叠int a 1 2会直接变成int a 3还有字符串常量的编译期拼接。大部分真正的优化比如循环展开、方法内联、逃逸分析、锁消除javac一概不管全部留给JIT运行时去做。这样设计是故意的。Java的设计哲学是“一次编写到处运行”如果javac针对某个CPU平台做了激进优化那生成出来的.class文件可能就不具备跨平台性了。保持字节码的简单和稳定让运行时根据当前的硬件环境、程序运行特征去做针对性优化这比编译期优化更灵活。用大白话说javac负责把话说对JIT负责把话说得漂亮、说得快。3. JIT实时编译的核心机制3.1 从解释执行说起为什么不能只靠JITJVM启动后字节码先是交给解释器执行的。解释器逐条读取字节码指令每读一条就翻译成对应的机器码操作并立即执行。这种方式启动快、不需要编译等待时间但性能低。因为解释器每次执行同一条指令都要重新做一遍“读字节码→翻译→执行”的流程重复开销很大。那么问题来了能不能启动时就全部用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:jitcompilationdebug # 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最直观的外在表现是同一段代码第一次跑很慢多跑几轮之后性能突然上去。很多做过JMHJava微基准测试套件的人应该深有体会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:CompileThreshold5000注意这个参数在不同的分层编译模式下的实际含义可能不同必须配合当前JVM版本和编译模式确认。我在生产上更建议的做法是做预热而非盲目改阈值因为调低阈值可能导致编译线程过载、拖累整体吞吐。第二编译线程数量调整。如果JIT编译队列积压严重可以增加编译线程数。默认编译线程数会随CPU核数调整服务端场景一般够用。遇到编译延迟导致运行期卡顿问题时考虑-XX:CICompilerCount4这个参数要谨慎使用加太多线程抢占CPU反而会让业务线程饿着。6. 几个常见坑位和面试考点的裁判意见6.1 关于“Java是编译型还是解释型语言”的经典争论这个问题的标准理解是Java里有编译型的部分也有解释型的部分取决于你观察的是哪一段流程。面向字节码的javac编译是编译型行为早期JVM纯靠解释器运行字节码阶段是解释行为JIT介入后又回到编译执行。所以严谨的说法是Java是“先编译成字节码再混合执行”的语言。你跟面试官答“Java是编译型语言”会显得认知不完整答“Java是解释型语言”更是暴露概念缺失。把javac和JIT的职责分清楚这个问题就能答得自然且全面。6.2 关于“AOT会不会取代JIT”的讨论提到JIT就免不了要提到AOTAhead-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不是纯理论猎奇而是实打实影响性能和稳定性的基本功。
RELATED

相关推荐

在 Python 编程中,字符串是最基础也最常用的数据类型之一

在 Python 编程中,字符串是最基础也最常用的数据类型之一

在 Python 编程中,字符串是最基础也最常用的数据类型之一。无论是数据处理、日志记录、用户交互还是报表生成,都离不开对字符串的格式化输出和清洗操作。str.format() 方法提供了强大的格式化能力,可以控制字段宽度、对齐方式、小数精度等&am…

📅 2026/10/18 14:53:41
AI工具装了没人用?从接入成本到习惯养成的落地指南

AI工具装了没人用?从接入成本到习惯养成的落地指南

“AI工具装好了,但团队没人用”——这句话我这两年听过太多次。几乎每个想推AI落地的开发团队,都会撞上同一个尴尬:工具选型报告写得漂漂亮亮,部署文档比需求文档还细,结果上线两周后后台一看,日活不到两位…

📅 2026/10/18 14:53:41
归并排序递归与非递归实现:从分治原理到工程化优化

归并排序递归与非递归实现:从分治原理到工程化优化

搞过一次大数据量排序之后,我才真正意识到递归版归并排序在教学上和工程上完全是两码事。理论课上讲归并排序,教科书永远是递归写法,简洁漂亮;但真到了处理大批量数据的时候,频繁的临时数组分配和函数调用开销会让人想…

📅 2026/10/18 14:53:41
MORE NEWS

更多资讯

📰

手把手开发AI双语翻译浏览器插件:实时对照阅读外文网页

几年前我开始做跨语言内容阅读的时候,每天最头疼的就是打开外文技术文档,一边读一边切翻译工具,来回复制粘贴,几篇文章翻下来头昏脑涨。后来我动了念想:能不能做一个浏览器插件,让双语对照直接呈现在网页正…

📰

campus-imaotai 完整实践指南:Docker 一键部署 i茅台每日自动预约

campus-imaotai 完整实践指南:Docker 一键部署 i茅台每日自动预约 【免费下载链接】campus-imaotai i茅台app自动预约,每日自动预约,支持docker一键部署(本项目不提供成品,使用的是已淘汰的算法) 项目地址…

📰

SeleniumBase实践:从需求拆解到稳定UI自动化用例设计

做UI自动化这些年,我最大的体会是:真正让项目推进不下去的,往往不是不会写脚本,而是不会设计用例。很多人原生Selenium玩得挺溜,定位元素一套一套的,结果用例跑两周就崩——要么前端改版全盘失效&#xff0…

📰

Alluxio缓存加速HDFS:突破磁盘IO瓶颈的实战指南

1. HDFS的统治时代与隐藏的性能天花板1.1 HDFS为什么能统治大数据存储这么多年HDFS之所以能成为大数据生态无可争议的底座,核心在于它把"用廉价机器存海量数据"这个难题解决了。它的设计思路非常直白:一个集群里有一台NameNode专门管元数据&am…

📰

Eclipse中创建Maven工程:从零搭建到依赖管理实战

1. 为什么在IDE里建Maven工程这件事值得单独拿出来讲很多人第一次接触Java项目构建,都是从"新建一个工程"这个动作开始的。看起来只是点几下鼠标的事,但真正动手之后你会发现,同样叫"创建Maven工程",在不同工…

📰

类模板偏特化完全指南:从语法到实战掌握编译期分派

模板学到这里&#xff0c;很多同学对template<typename T>这种常规声明已经能写得很顺&#xff0c;但一遇到template<typename T> struct Foo<T*>这种尖括号里带“模式”的写法就开始紧张&#xff1a;它到底是声明还是定义&#xff1f;匹配优先级怎么算&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬