ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JIT编译后的代码存在哪:从CodeCache到可执行内存的完整解析

JIT编译后的代码存在哪:从CodeCache到可执行内存的完整解析 最近排查一个 Java 服务线上问题GC 正常、CPU 均值不高但接口 RT 翻倍压测一看发现是 Code Cache 满了之后 JIT 编译器“罢工”热点方法全部退回解释执行。同事问了句特别本质的话“JIT编译后的代码不就在内存里吗怎么还能满” 这句话把我问住了因为我平时关注 GC 区域多却很少把 JIT 代码当做一个有容量上限、有生命周期、需要专门管理的对象去对待。这篇文章就想把“JIT编译后的代码存在哪”这件事讲透包括它到底在内存哪个区域、怎么被生成和发布、怎么被调用和失效、满了会发生什么以及 V8、.NET 里有哪些不同的存法。适合对运行时机制好奇的人也适合做 JVM 性能调优、怀疑“解释执行拖慢系统”的同事参考。1. JIT代码的真身可执行内存里的“一次性产物”先说结论JIT 编译后的机器码不是文件不会像 GCC 那样生成.o或.exe落盘而是运行期间由 JIT 编译器线程写入进程虚拟地址空间里的一段可执行内存。在 HotSpot JVM 里这段内存有个专门名字叫 Code Cache在 V8 里是可执行内存池和代码空间在 .NET 里则是 ExecutionManager 管理的可执行堆。它们的共同特征是内存属性通常为“可读 可执行”RX写入时可能需要临时加上写权限写完再恢复为只读可执行尽量符合现代系统提倡的 W^X 安全模型。你可以在 Linux 上直接查看进程映射来确认这一点cat /proc/pid/maps | grep r-xpJava 进程的运行结果里除了 libjvm.so 这类原生库的映射段还能看到若干个匿名可执行段那基本就是 JIT 生成的机器码所在位置。它们和 Java 堆rw 属性是完全不同的区域所以 GC 不会直接扫描这些指令也是“CodeCache 满”这个现象和“Java 堆满”完全两回事的根本原因。1.1 程序运行中同时存在三种“代码形态”如果只说“JIT 代码在 CodeCache”还是不够精确。一个 JVM 进程里其实同时存在三种可执行代码形态最初的字节码加载 Class 文件后方法的字节码一直存放在方法区对应的Method对象中现代 JDK 里就是 Metaspace 管理的那块元数据。解释器执行时每一条字节码都从这里读取。解释器本身的机器码解释器是 libjvm.so 里的原生代码执行时在原生栈上跑它本身不是 JIT 编译出来的。JIT 生成的机器码热点方法被触发后C1/C2 编译器把字节码转成当前 CPU 架构的指令写到 Code Cache并登记成nmethod结构体。所以当你问“编译后的代码存在哪”完整回答是解释执行阶段代码活在字节码里JIT 之后代码活在可执行内存里而且两者会在运行中反复切换。这一点对排查问题很重要因为看到 JVM 进程内存涨的时候很多人只想到堆和 Metaspace忘了这块堆外可执行区域也有配额上限。1.2 为什么 JIT 代码必须“随用随编译”一个好问题是既然要机器码为什么不直接在启动时全部编译完存到磁盘因为 JIT 编译的核心优势就是利用运行时信息。C2 会根据实际收集到的类继承关系、方法调用频率、参数类型分布来做激进内联和分支预测优化。比如一个接口在运行期只有两个实现类编译器可以把虚调用改成类型判断 直接调用性能提升非常明显。这些信息只有跑到一半才完整所以必须“运行中生成、运行中使用”而且因为优化依赖的假设可能被打破代码还要支持“反悔”。这也是为什么 JIT 代码存放位置的设计不能简单粗暴——它不像静态编译产物一次性写好就不管了而要有一套发布、补丁、失效、回收机制。接下来逐个拆。2. 从热点检测到生成完毕JIT代码是怎么“搬”进内存的我以 HotSpot 为例讲完整链路因为最典型。V8 和 .NET 的差异后面单独对比。2.1 方法调用计数电梯调度式的热点判定JVM 不会一上来就编译所有方法。解释器每执行一次方法调用或者每次走过循环回边都会更新方法对应的计数器。方法调用计数器达到阈值后这个方法会被放入编译队列。经典参数-XX:CompileThreshold默认在服务端 VM 下是 10000 次调用左右开启分层编译后内部有一套更细的动态阈值一般不手工调。循环回边触发的编译更特殊叫 OSROn-Stack Replacement比如一个方法第一行就是个跑几小时的大循环正常情况下方法调用次数根本不够触发编译可循环体内每一轮都会产生回边计数。OSR 编译的目标是正在执行的栈帧把解释器帧直接切换成机器码帧做到循环体内热代码及时被优化。你可以在-XX:PrintCompilation日志里看到行尾带%的编译任务那就是 OSR 编译。2.2 C1 和 C2 的接力先快后慢现代 JVM 默认开启分层编译TieredCompilation。分层的意义在于C1 编译快、产出虽不是最优但能很快替换解释执行C2 编译慢、分析深逃逸分析、锁消除、循环展开通常在方法被调得很热之后才接手。C1 编译出来的带 profiling 的机器码会继续收集运行信息供后续 C2 参考。这就是为什么 Code Cache 里会有“profiled nmethod”和“non-profiled nmethod”两类段。你可以在 HSDB 或jcmd输出里看到分段统计C2 的产物通常集中在 non-profiled 段。2.3 nmethod 不只是“一堆指令”JIT 编译完成后HotSpot 会把机器码连同描述信息打包成一个nmethod对象分配在 CodeCache 里。它的内部结构至少包含指令区真正的机器码还包括异常处理桩、内联缓存等作用域描述scopes记录机器码地址对应哪条字节码这是栈回退和去优化恢复解释器帧的依据OopMap记录机器码栈帧哪些位置保存了 Java 对象引用供 GC 准确扫描编译元数据编译级别、编译 ID、依赖的类层次信息等。所以“代码存在哪”不仅是“指令存在哪”还包括这一大套管理元数据存在哪。这也是为什么 CodeCache 占用会比你想的更大——一个优化得很深的方法描述信息可能和指令本体差不多大。2.4 发布动作safepoint 下的“安装”机器码编译好之后不能立刻生效因为别的线程可能正在解释执行旧版本或者已经进入编译前的方法入口。HotSpot 的做法是JIT 编译器线程先完成所有写入然后等待一次全局 safepoint在这个安全点把方法入口更新为新 nmethod同时把旧版本标记为 not entrant。全局 safepoint 保证了没有线程正在执行旧代码也保证指令缓存一致性不会出问题。这解释了一个现象-XX:PrintCompilation里看到编译完成的时间和实际“方法开始使用新代码”的时间之间往往会有轻微延迟因为要等 safepoint。高并发、大堆场景下 safepoint 延迟本身也可能成为 JIT 生效慢的原因。3. 机器码住进去之后调用、热替换和“变卦”这一节讲“存在那之后发生了什么”很多人只知道代码在 CodeCache但不知道它还面临被 patch、被废弃、被拆除的命运。3.1 调用路径不是每次查哈希表方法被 JIT 编译后后续调用希望直接跳转到机器码而不是回解释器。HotSpot 会在方法入口、调用点做修正。对于静态方法或最终方法调用点可以直接生成绝对跳转对于虚方法编译器会借助内联缓存或 vtable/itable 做分发。C2 内联之后被内联的方法体已经不存在独立入口整个调用路径直接落到外层方法的大机器码块里。因此从“存在哪”的角度看编译后的方法入口是动态修补的如果后续发现目标方法被重新编译或者内联假设失效JVM 要找出所有指向旧代码的跳转点并打补丁。这些跳转点本身也可能被记录为 relocation 信息存在 nmethod 里。所以一个 nmethod 和它依赖的代码段之间是一张动态关联网。3.2 去优化JIT 代码也会“翻车”JIT 编译器靠运行时 profile 做冒险优化风险在于假设可能被打破。最经典的场景是C2 基于“某个抽象类只有 A、B 两个子类”做内联虚分发运行到中途classpath 里加载出新的 C 子类此前所有依赖这个类层次结构的 nmethod 都失效了。这时候 JIT 代码必须“撤回”。HotSpot 先把这个 nmethod 标记为not entrant不允许新调用进入然后等栈上正在执行它的线程走到安全点如果有人在执行中间会触发去优化陷阱deopt trap沿着 scopes 描述恢复出解释器帧回到字节码继续跑。之后这个 nmethod 再变为zombie等待 CodeCache 清理。这种“代码生成了还要准备拆掉”的机制正是 JIT 内存管理和 AOT 最大区别。3.3 修改正在执行的代码为什么很危险现代 CPU 有指令缓存I-Cache和分支预测。如果你直接改写另一线程正在执行的指令CPU 可能还在执行旧的预取指令结果不可预料。JVM 的 safepoint 机制、V8 对可执行页的写保护与刷新生效本质都是在规避这个问题。所以“代码在内存里”不等于“想改就能改”这背后是一套严谨的发布同步协议。你甚至可以这样理解JIT 代码像一座正在使用的桥每次重铺桥面都得先把桥上的人和车请出去safepoint铺完再放行。4. 仓库的布局和容量以HotSpot Code Cache为例既然代码在内存里那它占多大、满不满就是实战中非常核心的问题。4.1 CodeCache 不是一个大水池而是分区的仓库JDK 9 以后HotSpot 默认把 Code Cache 分成三块这是为了减少碎片并区分不同性质代码代码堆区域主要存放内容典型大小non-method解释器例程、函数入口桩、异常处理桩较小几 MBprofiled nmethodsC1 编译后的带 profiling 代码随业务波动non-profiled nmethodsC2 编译代码、未带 profiling 的代码通常最大三个堆区域各自独立分配、独立管理。-XX:ReservedCodeCacheSize控制的是总保留虚拟内存大小默认值在不同 JDK 版本间差异明显我习惯用下面命令确认实际值java -XX:PrintFlagsFinal -version | grep CodeCache生产环境常见的默认结果在 240MB 到 512MB 之间。容器里如果限制的是物理内存还要考虑这块堆外保留内存对整体内存画像的影响。4.2 CodeCache 满了会发生什么这是我最想提醒大家的事情。CodeCache 满不会直接抛 OOM但 JIT 编译器会被禁用日志会出现CodeCache is full. Compiler has been disabled.然后所有新热点都回解释执行原本压测能抗住的 QPS 一下子打回原形RT 翻几倍很正常。而且更隐蔽的是如果开启了UseCodeCacheFlushingJVM 会尝试清理 zombie/not entrant 方法来腾空间这可能导致“编译—清理—再编译”的抖动循环CPU 悄悄被拉高。我遇到过一类典型场景业务用 Groovy 动态生成大量类每次动态类调用都是新热点很快把 profiled/nm 段塞满服务表现为整体变慢但 GC 完全正常。这时候用jstack看线程栈基本都是解释执行的调用栈因为那些方法压根没有被 JIT。4.3 怎么判断当前 CodeCache 状态最直接的方式是jcmd pid Compiler.codecache输出会列出每个代码堆的大小、已用空间、空闲块、还剩余可分配空间等。也可以启动时加-XX:PrintCodeCacheJVM 退出时打印整体统计。压测时我通常这样盯jcmd pid Compiler.codecache | tail -20观察used占比是否持续增长。如果稳定在 80% 以内问题不大如果频繁接近上限就要考虑是代码生成量本身太大还是容量配置偏小。顺便说一句ReservedCodeCacheSize是保留虚拟内存不代表物理立即全占但只要 JIT 持续写代码物理占用会跟着涨。5. V8和.NET的“存法”有什么不一样JIT 不只在 JVM 里前端、服务端都有。不同运行时对“机器码放哪、谁来管理生命周期”的答案差别很大。5.1 V8代码也是 GC 管理的对象V8 里 JS 代码被 TurboFan 或 Maglev 编译后会生成 Code 对象指令流存放在可执行内存中。V8 在启动时通常会预留一段大的代码范围CodeRange把大部分可执行代码集中放在里面便于代码空间的统计、清理和指针压缩场景下的访问管理。和 HotSpot 最大的不同是V8 的 Code 对象是 GC 管理的对象。不再被任何 JS 函数引用的编译代码会被 V8 的垃圾回收器标记并回收指令内存也可以归还。而 HotSpot 的 nmethod 虽然也会被清理但机制上更像是一种代码缓存管理和堆对象回收不是一回事。如果你想亲眼看 V8 生成的机器码可以d8 --print-code test.js它会打印编译函数的代码地址、指令长度和反汇编片段。地址同样落在进程的可执行内存段里用 gdb 可以进一步查看真实指令。5.2 .NET可执行堆和 ReadyToRun 的边界.NET 运行时里JIT 编译生成的原生代码分配在可执行堆上由 ExecutionManager 统一管理同时也有分层编译Tier0 解释执行、Tier1 快速 JIT、Tier2 高质量重编译。这点和 JVM 的分层思想非常像。但 .NET 生态有一个特殊情况ReadyToRunR2R方式会在构建期生成预编译原生代码并直接打包进程序集文件里。运行时优先加载这部分代码不再走 JIT。严格来说 R2R 是 AOT 产物而不是“JIT 编译后的代码”但它也属于“编译后的代码存哪”的答案之一——存在文件系统里。所以如果你想更严谨地回答标题得加上一个边界条件JIT 默认不落盘但预编译的 AOT 代码可以落盘。5.3 嵌入式移动端为什么更爱 AOT理解了 JIT 代码的内存占用、生命周期和去优化复杂度就知道为什么 Android、iOS 以及大量嵌入式场景更倾向 AOT 编译。AOT 产物直接存在磁盘二进制里启动加载即可执行没有运行时编译器线程、没有 CodeCache 管理也不会有“编译器罢工”的问题。代价是失去了运行时 profile 优化空间且安装包更大。所以“编译后的代码存在哪”在不同运行时里有完全不同的答案关键要分清楚是 JIT 运行时生成的机器码还是 AOT 预先生成后落盘的机器码。6. 自己动手看到“它就在那”观测与排查实操理论部分结束给一套我常用的验证方法。你完全可以自己复现一遍看完就再也不觉得 JIT 代码是玄学。6.1 造一个必然被 JIT 的热点用一段简单 Java 代码public class JitDemo { static int hot(int n) { int s 0; for (int i 0; i n; i) { s i * i; } return s; } public static void main(String[] args) throws Exception { long total 0; for (int i 0; i 100_000; i) { total hot(10_000); } System.out.println(total); Thread.sleep(60_000); } }编译运行后用-XX:PrintCompilation可以看到大量编译任务用jcmd查看代码缓存统计javac JitDemo.java java -XX:PrintCompilation -XX:PrintCodeCache JitDemo jcmd pid Compiler.codecache能明显看到 profiled nmethods 和 non-profiled nmethods 出现占用。如果你装了 hsdis配合-XX:PrintAssembly还能看到具体生成的指令虽然反汇编日志量很恐怖但找几个关键方法的指令段看看就够。6.2 从 /proc/maps 里确认代码所在地址Linux 下再开一个终端找到进程 PIDgrep r-xp /proc/pid/maps你会看到若干匿名可执行段。把这些地址范围和PrintCompilation日志里打印的代码地址对应起来就能确认 JIT 代码确实落在这片区域。用 gdb 附加进程后也可以直接在地址处执行disassemble看到的就是 JIT 生成的机器码而不是 class 字节码。6.3 复现一次“CodeCache 满了”的实验严格复现需要把 ReservedCodeCacheSize 调得很小比如java -XX:ReservedCodeCacheSize16m -XX:PrintCodeCache JitDemo热点多跑一会日志里就会出现 “CodeCache is full. Compiler has been disabled.” 配合-XX:PrintCompilation你会看到编译任务先是大量出现然后戛然而止再往后性能曲线明显下滑。这个实验我建议每个人都做一次因为“解释执行兜底”听上去很安全实际性能体验却非常糟糕。6.4 排查 JIT 问题的三个实用顺序遇到“怀疑是 JIT 导致变慢”的情况我一般按这个顺序查看jcmd pid Compiler.codecache的使用率是否接近容量上限看-XX:PrintCompilation日志编译数量是否异常暴增或已经从活跃变成 zero看/proc/pid/maps里可执行匿名段大小结合压测确认是不是动态代码生成主导了内存增长。在没确认前别急着用-Xint或-XX:-UseCompiler关闭 JIT。那会让性能直接崩掉掩盖真正原因。更多时候问题出在代码生成量过大、CodeCache 容量配置不合理或者存在大量临时动态类。最后分享一个个人习惯压测前先记录一份 CodeCache 快照结束后再看一次。两次之间的 diff 比绝对数字更有价值。如果 diff 很大说明业务里有大量动态代码生成或热点路径反复替换那就值得深入看一下是哪些方法在编译、为什么会被反复编译。JIT 代码这回事搞懂“在哪、谁管、什么时候拆”比背一百条 JVM 参数都有用。
返回列表