ARTICLE DETAIL

资讯详情

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

JVM JIT即时编译与逃逸分析实战 为什么你的Spring Boot代码忽快忽慢

JVM JIT即时编译与逃逸分析实战 为什么你的Spring Boot代码忽快忽慢 你有没有遇到过这种情况同一个方法第一次调用慢得离谱多调几次就飞快了或者线上一个接口明明逻辑很简单却偶尔卡一下这不是玄学这是 JVM 的 JIT 即时编译在热身。我曾在生产环境排查过一个诡异问题同一个订单查询接口每天早高峰前几次请求特别慢之后恢复正常查了半天数据库、Redis最后发现罪魁祸首是——JIT 还没把热点代码编译到最优状态。今天我把 JITJust-In-Time即时编译器和逃逸分析这两块最容易被忽视的 JVM 性能知识用 Spring Boot 实战讲透。一、这个问题到底是什么先说说我那次线上事故。有一个 Spring Boot 下单接口高峰期每秒要处理几百个请求。监控面板上这个接口的 P9999% 请求的耗时上限可以理解为最慢的那批请求的耗时平时是 50 毫秒左右但每天凌晨刚过零点、请求量刚上来的一两分钟里P99 会飙到 800 多毫秒。过几分钟又自己恢复正常。当时团队排查顺序是先查数据库慢查询——没有再查 Redis 连接——正常又怀疑是不是缓存冷启动——也不是。折腾了大半天最后是运维大哥提醒了一句“你们看看 JIT 编译日志吧是不是每天重启后都在重新热身”这句话点醒了我。我们的服务每天晚上 0 点会定时重启一批实例灰度发布重启后 JVM 是冷的——热点方法还没被 JIT 编译还在用解释器一条条地执行字节码速度自然慢。等跑几分钟C2 编译器JVM 里的高级优化编译器把这些热点方法编译成本地机器码速度就上来了。这背后的机制就是 JIT 即时编译。理解它你才能真正看懂代码为什么忽快忽慢也才能在关键时刻解决线上性能毛刺。下面我用大白话拆解。二、底层原理到底怎么回事2.1 Java 代码是怎么运行的先建立一个基础认知Java 代码不是直接变成机器码运行的。它要经过两步编译成字节码javac把.java源码编译成.class字节码文件。字节码是给 JVM 看的中间语言不依赖具体操作系统。JVM 解释执行运行时JVM 的解释器把字节码翻译成当前系统的机器码去执行。问题就在于第二步。如果 JVM 只用解释器执行每个方法每次调用都要翻译一遍速度很慢。那有没有办法把翻译好的结果存下来复用有这就是 JIT 编译器干的事。JITJust-In-Time即时编译JVM 会统计每个方法被调用的次数当某个方法达到阈值比如默认 10000 次就认为它是热点方法把它整个编译成本地机器码缓存起来以后直接执行机器码不再解释。就像你整理了一份常用电话号码的快速拨号表常用的直接按一个键不用每次翻通讯录。2.2 分层编译为什么会有忽快忽慢的错觉JVM 的 JIT 不是只有一种编译器它采用分层编译Tiered Compilation分 4 层第 0 层解释执行启动初期全部用解释器跑最慢但启动最快。第 1-3 层C1 编译器客户端编译器做基础优化编译快但优化少。第 4 层C2 编译器服务端编译器做激进优化编译慢但优化多性能最强。JVM 会根据方法的调用热度动态升级编译层级。这就是为什么你观察到一个方法越跑越快——它从解释执行升级到 C1再升级到 C2每升一级就快一截。但 C2 编译期间还有逆优化机制如果它假设的前提被打破比如某个对象的类型变了会退回低层级重新编译这也会造成偶发性能抖动。2.3 逃逸分析JIT 最实用的一招C2 编译器做的优化里和日常代码关系最大的就是逃逸分析Escape Analysis。它研究的是你 new 出来的对象会不会逃逸出当前方法不逃逸对象只在方法内部使用没有被返回、没有被存入全局、没有被别的线程拿到。逃逸对象被方法返回了或者被塞进了成员变量、静态变量或者其他线程能看到的地方。如果对象不逃逸C2 会做三件好事栈上分配Stack Allocation对象直接分配在栈上方法一结束就自动销毁不用等垃圾回收GC来清理。省了 GC 压力。标量替换Scalar Replacement把对象拆成它的各个字段标量直接用 CPU 寄存器或栈空间存储连对象头都省了访问更快。锁消除Lock Elimination如果 synchronized 锁的对象不逃逸说明没有其他线程能拿到这把锁锁没有意义直接去掉省掉加锁开销。一个关键事实这些优化能不能生效取决于 JIT 是否开启了逃逸分析。从 JDK 6u23 开始默认开启但你能通过-XX:DoEscapeAnalysis显式确认、用-XX:-DoEscapeAnalysis强制关闭。2.4 用大白话总结原理把 JIT 和逃逸分析比作外卖配送解释执行每单都现场现做现送慢但接单快。JIT 编译把销量最高的几道菜提前做好放保温柜点单直接出快。分层编译先备好普通食材C1再给爆款菜建立中央厨房流水线C2越做越熟手。逃逸分析判断这道菜是只给门口取餐不逃逸可提前做、不打包还是必须排队打包带走逃逸只能正常做。理解了这些你就明白线上 JVM 应用的性能是动态的不是一启动就满血。三、实战手把手写代码下面我用 Spring Boot 写一个完整的可运行项目演示两件事一是怎么主动触发并观察 JIT 编译优化二是逃逸分析失效会带来什么影响怎么排查。3.1 项目结构jit-demo/ ├── pom.xml └── src/main/java/com/example/jitdemo/ ├── JitDemoApplication.java // 启动类 └── JitDemoController.java // 接口模拟热点方法先看pom.xml。本文用的是 Spring Boot 4.1.0当前 Maven Central 最新 GA 稳定版Java 21。?xml version1.0 encodingUTF-8?projectxmlnshttp://maven.apache.org/POM/4.0.0xmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsdmodelVersion4.0.0/modelVersionparentgroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-parent/artifactIdversion4.1.0/versionrelativePath//parentgroupIdcom.example/groupIdartifactIdjit-demo/artifactIdversion1.0.0/versionnamejit-demo/namedescriptionJVM JIT 与逃逸分析实战演示/descriptionpropertiesjava.version21/java.version/propertiesdependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependency/dependenciesbuildpluginsplugingroupIdorg.springframework.boot/groupIdartifactIdspring-boot-maven-plugin/artifactId/plugin/plugins/build/project这段配置做了三件事继承 Spring Boot 4.1.0 的父 POM统一管理依赖版本不用自己写版本号指定 Java 版本为 21引入 web 起步依赖提供 Web 容器让我们能发起 HTTP 请求。3.2 启动类JitDemoApplication.java是标准的 Spring Boot 入口类。SpringBootApplication是组合注解开启自动配置、组件扫描和配置类支持。main方法启动内嵌的 Tomcat 服务器。packagecom.example.jitdemo;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;SpringBootApplicationpublicclassJitDemoApplication{publicstaticvoidmain(String[]args){SpringApplication.run(JitDemoApplication.class,args);}}3.3 接口一个模拟热点的计算接口JitDemoController.java提供一个计算接口。它内部有一个循环计算平方和的私有方法用来模拟一个会被频繁调用的热点方法。我们故意把它写成能被逃逸分析的写法对象不逃逸也提供一种逃逸的写法方便对比。packagecom.example.jitdemo;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;RestControllerRequestMapping(/api)publicclassJitDemoController{// order 字段故意写成实例变量看它会不会被不同请求共享privatelongorder0;// 列表条目包含一组字段的简单对象publicstaticclassCalcResult{longsum;longcount;}// 接口1热点计算用不逃逸的本地对象利于逃逸分析GetMapping(/hot/escape-off)publicCalcResulthotNoEscape(){returncomputeSquareSum(10_000);}// 接口2把结果组装进一个字段级逃逸的方式便于观察区别GetMapping(/hot/escape-on)publicCalcResulthotWithShared(){CalcResultrnewCalcResult();r.sumcomputeSquareSum(10_000);r.countorder;// 用共享实例字段模拟对象被外部看到returnr;}// 核心热点方法循环计算平方和privatelongcomputeSquareSum(intn){longsum0;for(inti0;in;i){sum(long)i*i;}returnsum;}}这段代码里hotNoEscape返回的CalcResult其实还是会逃逸方法要把它返回出去我这样命名是为了让你对照两个接口的差异。真正演示逃逸分析有效与否更干净的方法是打开或关闭 JIT 的逃逸分析开关然后看接口耗时的变化——这才是重点看下面。3.4 怎么实际看到 JIT 在工作光写代码看不出 JIT 效果你得让 JVM 把编译过程画出来。启动时加上这几个参数java -XX:PrintCompilation -XX:UnlockDiagnosticVMOptions \ -XX:PrintInlining -jar jit-demo.jar-XX:PrintCompilation打印每次方法被编译的记录你能看到方法是在第几层被编译的1%表示 C14表示 C2。-XX:PrintInlining打印内联信息C2 会把小方法内联进调用方省去调用开销这也是 JIT 常用优化。启动后你用压测工具比如 ApacheBench持续打/api/hot/escape-off接口几万次然后观察控制台最开始会不断出现解释执行的输出打够次数后会出现4 ... com.example.jitdemo.JitDemoController::computeSquareSum这样的 C2 编译记录——这就是热点方法被编译成本地机器码了。想直观看到性能变化可以在打热身后对比刚启动冷态直接压测第一秒的吞吐量。打热后热态同一接口继续压测最后几秒的吞吐量。实测中纯计算型热点方法冷热态吞吐量可以差 5~20 倍因为解释器每条字节码都要翻译而 C2 编译后是直接的机器指令还能做标量替换、循环展开这些重优化。3.5 逃逸分析到底省了什么为了让你直观感受逃逸分析的价值可以用-XX:-DoEscapeAnalysis关掉它给/api/hot/escape-on加 50 万次循环制造大量临时对象然后对比开关前后的GC 次数用jstat观察或用 GC 日志。先说结论关闭逃逸分析后由于临时对象全部堆分配、只能靠 GC 回收Young GC 次数会明显变多吞吐下降。开启时不逃逸的对象走栈上分配GC 压力小得多。完整的对比验证步骤读者可自行复现用默认参数开启逃逸分析跑服务GC 日志加-Xlog:gc观察 Young GC 次数。换个端口用-XX:-DoEscapeAnalysis关闭逃逸分析再跑同样压测。对比同样请求量下的 Young GC 次数和平均延迟你会发现关闭后 GC 更频繁、延迟毛刺更多。要写出利于逃逸分析的高性能代码记住两个习惯局部变量优于成员变量只在本方法内使用的临时对象别塞进成员变量。避免无谓的全局缓存不要把每个请求都 new 的对象存到静态集合里那必然逃逸。四、踩坑经验和最佳实践4.1 坑一误以为重启就能解决性能问题很多团队把重启治百病当习惯但每次重启都意味着 JVM 要重新热身热点方法要重新被 JIT 编译。如果你的服务每天定时重启就等于每天都有一段慢启动期。最佳实践是用优雅滚动发布分批次重启别同时重启所有实例上一批的热身期由其他批次扛住流量。对关键的启动后预热可以写个脚本在刚启动时主动打一遍核心接口比如调用主要查询接口几百次让 JIT 提前完成编译把这叫预热身。关闭明显的每天全量重启策略除非有充分理由。4.2 坑二用打印当前时间戳去证明JIT 优化有人想验证 JIT写了个循环里System.out.println的测试发现速度没差别就得出结论JIT 没用。这是错的。打印也是 I/O 操作一个println的耗时远超 JIT 带来的优化把优化效果完全掩盖了。正确验证方式是要么用 JMH 基准测试框架Java 官方性能测试标准要么看 GC 日志和-XX:PrintCompilation输出别在热循环里打印。4.3 坑三逃逸分析关闭了还抱怨 GC 频繁有些老项目的部署脚本里至今带着-XX:-DoEscapeAnalysis早期为了解决某个 BUG 或配合老 JDK 关的在新 JDK 上它会让所有不逃逸对象都走堆分配白白增加 GC 负担。排查检查生产环境的 JVM 启动参数如果发现这个关闭开关且没有明确原因去掉它再观察 GC。JDK 21 上默认开启除非你看到真实的问题否则别关。4.4 坑四拿首次请求的慢当数据库慢冷启动的 JIT 热身、类加载、Spring 容器初始化、连接池建立这些叠加会让首次请求特别慢。这不代表你的数据库或接口写的有问题。最佳实践是健康检查脚本里先打几轮预热请求让监控系统别把冷启动误判成故障告警。五、性能对比和技术选型5.1 JIT 相关调优参数怎么选场景参数说明确认逃逸分析开启-XX:DoEscapeAnalysisJDK 21 默认开启显式写出来便于排查关闭逃逸分析不推荐-XX:-DoEscapeAnalysis排查问题时临用正常别关诊断 JIT 编译-XX:PrintCompilation看方法在哪一层被编译诊断内联-XX:PrintInlining看方法是否被内联优化查看编译统计数据-XX:PrintC1Statistics/-XX:PrintC2Statistics分析编译开销用5.2 解释执行 vs C1 vs C2什么时候选谁默认分层编译推荐兼顾启动速度和峰值性能是绝大多数线上 Java 应用的正确选择。不要轻易动。只用 C1-XX:TieredStopAtLevel1只在 GUI 应用、需要极快启动的小型工具类场景用面向运行时间短、不在乎峰值性能的程序。只用 C2适合长时间运行、追求极致吞吐的服务端但启动慢、可能偶发性能抖动。选型建议Spring Boot 后端服务默认分层编译不动即可。你该花精力的是保证启动后有热身时间并把 GC 日志和编译日志配齐出问题能定位而不是瞎改编译层级。六、总结JIT 即时编译和逃逸分析是 JVM 性能里最看不见摸不着却又最实用的两块知识。核心要点就几条JVM 应用性能是动态的热点方法要经过解释→C1→C2 的编译升级被编译成机器码后才达到峰值性能所以有热身期。分层编译让 JVM 在启动速度和峰值性能之间自动平衡默认配置就是合理的别乱动。逃逸分析让不逃逸的对象走栈上分配、标量替换、锁消除减轻 GC 压力写代码时用局部变量、别制造无谓全局缓存就能让优化生效。线上排查性能毛刺除了看数据库、Redis别忘了看 JIT 编译日志和 GC 日志冷启动热身往往才是忽快忽慢的元凶。验证 JIT 效果要用 JMH 或看编译/GC 日志别在热循环里打印那会掩盖优化。以后你再遇到同一个接口忽快忽慢每天刚重启特别慢这类问题先别急着怀疑代码写的烂想想 JVM 是不是还在热身。把 JIT 和逃逸分析搞清楚你排查线上性能问题的工具箱里就多了一把趁手的家伙。
返回列表