作为一个经常跟编译器和运行时打交道的开发者我经常被问到类似“JIT编译后的代码存在哪”这样的问题。这个问题的答案表面看很简单——存在内存里但真要展开牵扯到进程地址空间、代码缓存管理、CPU缓存一致性、热点检测机制等一系列底层问题。今天我就把自己多年排查这些问题积累的经验和踩过的坑整理出来从原理到实操一条条讲透。先说一个直接结论JITJust-In-Time编译生成的机器码会存放在进程的可执行内存区域中。注意这不是某个文件不是磁盘上的缓存目录而是操作系统给进程分配的、带有“可读可执行”权限的内存页面。如果你用ps或top看着内存占用这部分只是进程常驻内存的一部分。但为什么大家都说“代码缓存在堆外”“代码段”到底指哪里接下来我分几个层面拆解。1. JIT编译的本质从“解释执行”到“现场生成机器码”要理解代码存放在哪得先明白JIT是怎么工作的。JIT不是特定某一种技术它是一类在运行时Runtime动态生成并执行机器码的机制的统称。无论是JVM的HotSpot、V8引擎、LuaJIT还是PyPy本质思路都是程序跑起来之后运行时并不急着把所有代码都编译成机器码而是边跑边解释或用基础编译同时统计哪些代码是热点一旦判定某个方法或循环是热点就直接把它编译成当前硬件平台真正的机器码然后让CPU执行这份机器码。1.1 字节码/中间表示到机器码的转化以JVM为例Java源码会先编译成字节码bytecode存在.class文件里。程序启动后JVM默认用解释器逐条执行字节码。字节码不是机器码它是一条条抽象指令比如iload、iadd、invokevirtual。解释执行的好处是启动快坏处是慢——每条指令都要经过解释器分派。JIT编译器会在运行时把字节码或更进一步的中间表示转换成当前CPU架构比如x86-64、AArch64的汇编指令再编码成二进制机器码。这个过程不是“文本翻译”那么简单。现代JIT如C2、Graal、V8的TurboFan会做大量优化常量折叠、逃逸分析、循环展开、向量化、方法内联……优化完的中间表示最终变成机器指令再经过寄存器分配、指令调度等环节才生成可执行的机器码。这批机器码就是JIT编译后的“代码”它被写入内存并最终交给CPU执行。1.2 JIT编译的触发条件热点检测与编译层级并不是所有代码都会被JIT编译。JIT的“即时”体现为按需编译。哪些方法会进入编译队列主要看“热点”程度调用次数方法被频繁调用默认超过一万次可调整循环回边次数方法内部有高热度循环调用者热度如果一个高频方法内联了其他方法被内联的代码也算热点。JVM还分了C1客户端编译器和C2服务端编译器两个层级对应不同程度的优化。C1编译快优化少C2编译慢优化猛。HotSpot里还有“分层编译”先解释执行再用C1快速编译最后如果代码确实热再用C2深度优化。编译升级意味着同一段字节码会在运行期被编译成不同的机器码版本旧版本要么被替换要么被丢弃。这些版本全部存在内存的代码缓存里。2. 编译后的代码到底“存在哪”内存布局与代码缓存现在进入核心问题。JIT生成的机器码是二进制数据它必须满足两个基本条件才能被CPU执行一是必须存在于能被CPU取指的内存地址二是该内存页面必须有可执行权限。现代操作系统普遍采用NXNo Execute或W^X策略也就是说内存页要么可写要么可执行通常不允许同时可写且可执行。可写且不可执行比如普通堆数据、可读可执行比如共享库、可写可读普通数据都很常见但如果某块内存页同时可写又可执行系统要么拒绝要么会触发安全警告。JIT编译器怎么解决这个矛盾常见做法是先申请一块可写不可执行的内存把机器码写进去然后通过系统调用如mprotect把这块内存改成可读可执行。这样JIT代码就在一个“数据区”被写出来最终被“封存”为代码区。2.1 进程地址空间中的“代码段”在哪里一个常规的C/C程序它的代码段.text是编译链接时确定的存在ELF或PE文件里加载时映射到进程空间通常是一个固定的低地址区域。但JIT编译的代码不同它的代码段并不是一开始就有的而是在运行过程中动态出现的。操作系统并不区分这块内存是“JIT代码”还是“普通数据”——它只认权限映射。你可以查看一个正在运行的Java进程的内存映射。在Linux下用cat /proc/pid/maps你会看到类似这样的片段7f2c3c000000-7f2c3c022000 r--p 00000000 00:00 0 7f2c3c022000-7f2c3c3c2000 r-xp 00000000 00:00 0 7f2c3c3c2000-7f2c3c7c2000 ---p 00000000 00:00 0 7f2c3c7c2000-7f2c3c7c3000 rw-p 00000000 00:00 0其中r-xp那一段就是JIT代码缓存。r表示可读x表示可执行p表示私有映射。这段地址往往落在堆和栈之间的虚拟地址空间中由运行时通过类似mmap的方式分配。如果是C JIT库如LLVM ORC、AsmJit也会按照同样的原则在运行时创建可执行内存块。这里有一个常见的误解以为JIT代码会存在Java堆Heap里。其实不会。JVM堆是rw权限的内存还有一部分r堆内存执行的是对象管理策略而JIT编译代码在堆外是运行时自己管理的一块原生内存区域。JVM里专门有个结构叫“代码缓存CodeCache”它不在堆中而是JVM原生内存的一部分。2.2 以JVM为例CodeCache的结构与配置HotSpot JVM的CodeCache是理解这个问题的最佳案例。它是一个专门存放编译后机器码的内存池。默认情况下CodeCache会被分成几个区域区域用途说明非方法区Non-methodJVM内部生成的代码如解释器、模板启动时就分配方法区Profiled/Non-profiledC1或C2编译出的Java方法机器码按编译层级分开本地代码区NativeJIT生成的适配器、stub等用于主方法和不兼容类型调用时跳转JVM把CodeCache按编译层级隔离优化程度不同的代码互不干扰。你通过jstat -codecache pid或者jcmd pid Compiler.codecache可以看到CodeCache的使用情况。如果CodeCache满JVM会输出类似“CodeCache is full”或者CompilerThread0失败的警告然后退回解释执行——性能立刻大幅下滑。这也是为什么很多长时间运行的Java服务会碰到“突然变慢”的问题查一下CodeCache使用率常能发现问题。CodeCache的大小可以通过启动参数控制-XX:ReservedCodeCacheSize设置预留大小默认在较高版本是240MB按环境不同。-XX:InitialCodeCacheSize初始大小。-XX:CodeCacheMinimumFreeSpace最小剩余空间低于这个值JVM会停止编译。如果你跑的是大量使用JIT的动态语言比如Groovy、Scala、Kotlin的应用CodeCache耗尽的风险会明显增加。我自己就碰到过生产环境Groovy脚本频繁编译导致CodeCache爆掉最后通过增大ReservedCodeCacheSize并关闭不必要的编译层级解决的案例。2.3 代码缓存的生命周期编译、优化、淘汰JIT代码不是“写进内存就永久保留”的。它有完整的生命周期编译期JIT编译线程CompilerThread从热点方法列表抓取任务生成机器码通过mprotect把对应的内存页从rw改为r-x然后插入代码缓存。此时CPU指令缓存i-cache是空的第一次执行该代码时需要从主存加载到指令缓存。执行期CPU从可执行页面取指执行机器码。编译器还可能继续优化该代码生成新版本旧版本如果不再被引用就处于“失效”状态。回收期JVM的CodeCache有GC吗它不归堆GC管而是由内部机制“sweeper”线程扫描清除老化、无用、被替换的代码。当代码缓存中的“可回收块”积累到一定比例sweeper会释放这些内存。不过JVM的代码缓存淘汰策略相对保守一般不会频繁回收以免重新编译成本太高。如果代码缓存满了sweeper又来不及回收JVM可能直接退出正常编译模式只保留紧急编译如果在启动时开启-XX:UseCodeCacheFlushing则可能刷新部分代码。这种现象在线时长很长的服务中时有发生。所以说JIT代码“存在哪”不只是内存位置问题更是“存在多久”的管理问题。3. 实际探查与调试亲手找到JIT编译后的代码理论讲再多不如上手看。下面分享几个我常用的方法可以让你亲眼看到JIT编译后的代码“安家”的地方。3.1 通过/proc查看内存映射如果你有一个正在运行的Java进程先拿到PID。Linux下就这么看pid$(pgrep -f java.*YourAppName) cat /proc/$pid/maps | grep r-xp | head -n 20你会看到所有可执行映射其中[heap]之外没有文件路径的那种就是JIT代码区。为了更准确你还可以过滤JVM预留的CodeCache地址。先用jcmd $pid Compiler.codecache拿到CodeCache的边界地址再在maps里对号入座。不过这一步在HotSpot版本间命令名略有差异有些版本用jcmd $pid Compiler.codecache有些用jstat -codecache。实际工作里我更习惯直接用jstat快速看使用量jstat -codecache pid它输出的used一列就代表当前JIT代码占用的字节数。如果想看更精细的虚拟内存分布可以用pmappmap -X pid输出里的perms列同时带r-x的行通常就是JIT代码段。注意区分有些JIT引擎会把多个方法放在同一个大块区域内所以你会看到一个大大的可执行映射汇报的地址范围动辄几十MB甚至几百MB。3.2 通过PrintCompilation观察JIT编译事件HotSpot JVM提供了一个非常有用的参数-XX:PrintCompilation。启动Java程序时加上它控制台会不断输出类似下面的日志74 1 3 java.lang.String::hashCode (55 bytes) 75 2 3 java.lang.String::equals (81 bytes) 76 3 4 java.util.HashMap::put (111 bytes)每一行代表一次JIT编译事件左边是编译序号后面是编译层级3表示C14表示C2再后面是被编译的方法名和字节码大小。这个方法能让你直观看到JIT是在什么时间点触发、哪些方法进了解释器并被替换成机器码。这个参数还可以配合-XX:PrintCodeCache来输出CodeCache的大小变化或者配合-XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInlining查看内联细节。注意在JDK 9之后编译日志默认不输出到stdout可能需要加上-Xlog:compilationinfo类似的统一日志格式参数。不过对于老项目-XX:PrintCompilation依然有效。它让你意识到JIT编译并不是“开始就全编译”而是随着代码热度不断“即时”新增描述。3.3 在GDB中直接查看生成的机器码如果你想知道JIT代码的具体机器指令长什么样可以用调试器挂载到正在跑的Java进程上注意权限一般用jattach或gdb -p。首先用jcmd $pid Compiler.codecache找到CodeCache地址或者在Java代码里用sun.misc.Unsafe的getAddress不一定适用更直接的是使用HSDIS插件把JIT生成的机器码反汇编出来。配上-XX:UnlockDiagnosticVMOptions -XX:PrintAssemblyJVM会在每次编译后输出汇编指令。当然这需要下载hsdis-amd64.so放到JRE的lib路径下。如果只是想大概看一眼我常用的投机取巧法在/proc/$pid/maps里找到JIT代码段的起始地址然后用gdb把那一块内存指向的指令dump出来gdb -p $pid dump binary memory /tmp/jit_code.bin 0x7f2c3c022000 0x7f2c3c022f00然后离线用objdump -D -b binary -m i386:x86-64 /tmp/jit_code.bin。你会看到熟悉的汇编片段虽然中间会夹着大量内联缓存和跳转桩但至少能验证一件事JIT代码确实以真实机器指令的形式存在于内存中。同样的思路也适用于V8Node.js和LuaJIT。V8的W^X状态在某些配置下是r-x和rw交替的页面你可以用process.memoryUsage()或者cat /proc观察。LuaJIT则可以用jit.dump模块输出汇编前端信息。多语言对比下来“存在可执行内存里”是铁律。4. 常见问题与实战排查这一节我整理了经常被问到的问题和实际踩过的坑。这里的“常见”不是网上攒的那种烂大街QA而是我多年调试跑Java/JIT引擎时真实遇到过的。4.1 JIT代码会被写进磁盘上的某个文件吗不会。JIT代码只存在于内存中除非你主动dump比如用-XX:DumpPerfMap生成perf map文件但那不是代码本体而是一个符号地址映射或者用hsperfdata查看性能数据。很多人以为存在“某个缓存文件”甚至有人想通过找缓存文件来提取JIT代码——这是不可能的。运行时没有理由把机器码持久化因为每次启动的JIT策略、可执行内存基址、甚至CPU特性检测结果都可能不同。这也意味着JIT代码是易失的进程一退出代码就没了。这就是为什么JIT编译消耗的CPU时间会在每次启动时重新付出。但这里有一个例外AOTAhead-Of-Time编译技术比如GraalVM的Native Image、Java AOT或者OpenJDK的提前编译库。AOT代码在编译期生成并打包成可执行文件或共享库运行时不需再现场编译。它的存在形式就是文件和普通编译代码一样。AOT和JIT常常同台出现AOT结果作为启动时就能用的静态代码JIT结果继续动态优化。所以回答“JIT编译后的代码存在哪”时如果要严谨就区分“动态生成、存储于内存”的JIT代码和“预先生成、存储于文件”的AOT代码。4.2 CodeCache满了怎么办这个坑极其经典。场景通常是Java服务上线后逐渐变慢CPU占用不高但请求延迟变得极不稳定日志里偶尔出现很多解释执行跟踪或者类似警告VM warning: CodeCache is full. Compiler has been disabled.出现这种警告意味着新的方法无法被编译了大量热点方法只能用解释器跑性能瞬间崩塌。这种情况在以下情形常见应用内频繁调用Groovy、Scala、Kotlin的eval或动态代理生成用了很多动态代理库CGLIB、ASM、ByteBuddy每次启动生成大量新类某些框架在运行时不断生成新业务类导致代码缓存不断增长。我的处理顺序是这样的用jstat -codecache pid看used和max。如果used快接近max确认CodeCache满。看看是否有代码刷新开关-XX:UseCodeCacheFlushing老版本JDK8开启后会在CodeCache满时主动清理一部分非热点代码缓解问题。调大ReservedCodeCacheSize比如从默认的240MB调成512MB甚至1GB。检查应用是否真的需要那么多动态生成的代码。如果是公式、规则引擎导致的可以尝试预编译或缓存动态代码减少运行时编译次数。最后一步如果已经不可收拾重启进程并重新调整参数。这里要特别注意有些监控工具显示的CodeCache很大但实际是“保留”的不一定全部占用。JVM里CodeCache的reserved和committed有区别你可以用jcmd pid compiler.codecache输出感知。4.3 其他经验与避坑建议第一不要随意加-XX:ReservedCodeCacheSize巨量值。有人觉得CodeCache越大越不担心直接调成几GB。但CodeCache是内存资源过大的CodeCache会影响CPU指令缓存命中率因为可执行内存范围过大可能导致代码分散i-cache局部性变差性能反而下降。适合业务的才是好的。第二JIT编译线程不能轻易关闭。我见过有人为了“避免编译开销”用-Xint关掉JIT只走解释执行。那性能惨不忍睹纯属自残。除非是在启动阶段临时排查解释执行问题否则不要用。第三注意操作系统层面的过细权限限制。如果你在不同Linux安全模块如SELinux或seccomp下跑JIT进程系统可能禁止mprotect把数据页改为可执行页。我在容器环境里就碰到过自定义seccomp profile阻止JIT引擎设置PROT_EXEC导致Node.js或Java启动报错或直接崩溃。排查时如果遇到莫名其妙的SIGSEGV或安全策略拒绝先往这个方向想一想。第四其他JIT引擎也遵循同样的模式。V8将生成的机器码存在rwx较旧版本或rx新版启用W^X内存页中LuaJIT用mmap分配一块可执行内存来放汇编代码PyPy用gc之外的原生内存区作为代码缓冲。所以学透一种实现迁移到其他运行时能很快上手。提示如果你需要在不退出进程的情况下查看JIT代码区域的内存变化建议优先使用jcmd或jstat不要用gdb反复挂载进程因为gdb -p会短暂暂停进程生产环境可能触发告警。最后我再分享一个实用的小技巧当怀疑某些性能问题跟JIT代码被替换、回退有关时可以给应用添加启动参数-XX:PrintCodeCache -XX:PrintCompilation把日志输出到文件跑压测或线上观察一段时间。你会看到代码缓存从初值逐步增长也会看到某个时间点开始出现编译中断或者方法回退的解释行。这套组合拳能帮你精准定位到底是JIT编译压力大还是代码复用率太高导致代码缓存必然膨胀从而做针对性的架构调整。我自己第一次通过pmap看到JIT代码段时那种“原来编译后的代码就在这里”的直观感非常强。后来给团队做代码评估遇到类似“动态代码算不算内存泄露”的讨论我也会先拿出maps或jstat把问题拆成“代码缓存是否被合理管理”而不是空谈概念。搞懂了JIT代码的存在位置与生命周期后续再学GC、内存模型、性能调优都会顺手很多。