你的 Java 程序为什么总是先流畅后卡成狗?——JVM 内存、垃圾回收与调优求生指南
凌晨两点你看着监控大屏上一条锯齿状的曲线血压也跟着过山车。“堆内存使用率每 20 分钟冲到 98%然后突然跌落 10%如此往复……”运营同事发来消息“用户反馈点个查询要转圈 8 秒你能不能优化一下”你信心满满地重启了服务果然前五分钟快如闪电然后再次卡得妈妈都不认识。为什么你的 Java 程序总是刚重启时像法拉利跑一会儿就变成了拖拉机答案就藏在那个默默替你管理内存的“管家”——JVMJava虚拟机身上。它不是没干活反而干活太勤快而且干活的方式你完全可以“调教”。今天这篇文章我们就从一台服务器内存飙涨的血案出发一次性搞定三件事JVM 究竟把内存划分成了哪些区域垃圾回收到底在扫什么、怎么扫你又该如何给它戴上嘴套让它别总在你最需要性能的时候跳出来大扫除一、JVM 内存地图你家管家把钱都藏在了几个口袋里你启动了 Spring Boot 应用JVM 进程会向操作系统申请一大块内存然后在里面自己当家做主。这块内存被切成了几个功能迥异的区域就像一个合租公寓1. 堆Heap—— 公共客厅几乎所有对象都诞生于此new User()出来的对象首先扔进堆里。堆是所有线程共享的。JVM 又把堆分成两大区年轻代Young Generation刚出生的对象绝大部分活不过几毫秒。这里又分 Eden 区和两个 Survivor 区S0、S1。老年代Old Generation活得够久的对象会被“熬”进老年代待遇类似转正员工。2. 方法区Method Area/ 元空间Metaspace—— 类信息、常量、静态变量的档案室在 JDK 8 之前叫永久代PermGen后来搬到本地内存里叫元空间有效避免了java.lang.OutOfMemoryError: PermGen space的噩梦。类的元数据、方法字节码、运行时常量池都在这里。3. 虚拟机栈JVM Stack—— 每个线程的私人账本方法调用的时候局部变量表、操作数栈、返回地址等都会压入栈帧。方法结束栈帧弹出局部变量消失。4. 程序计数器PC Register—— 线程的进度条指向当前线程正在执行的字节码指令地址极小极小一块不会溢出。5. 本地方法栈Native Method Stack和虚拟机栈差不多只不过伺候的是用 C 写的 native 方法。堆是你调优的主战场这里每天上演着对象从生到死的全生命周期。二、垃圾回收GC你家管家其实是个有强迫症的清洁工Java 程序员爽在哪里不用free对象不用管内存释放JVM 自动帮你扫垃圾。那么 JVM 怎么判定一个对象是“垃圾”引用计数法给对象加个计数器引用一次 1失效 -1到 0 就回收。可惜 Java 没选它因为它解不了循环引用。可达性分析JVM 的实际选择。从一组叫“GC Roots”的根对象出发沿着引用链一路往下找。找得到的活着找不到的判死刑。GC Roots 包括虚拟机栈里引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用的对象等。判了死刑以后垃圾回收器就要动手了。三、扫地功法垃圾回收算法的四大流派1. 标记-清除Mark-Sweep先标记所有存活对象然后统一把没标记的都干掉。缺点很明显干完活后内存碎片化严重找块连续空间越来越难。2. 标记-整理Mark-Compact标记存活对象然后让它们都往前挪挤掉碎片腾出一整块空闲区。优点没碎片。缺点挪动对象耗时会暂停应用。3. 复制算法Copying把内存分成两半每次只用一半。回收时把这一半里的存活对象抄到另一半整整齐齐码好然后原空间全清空。优点极快、无碎片。缺点内存利用率只有 50%。JVM 把它改良用在年轻代Eden 一个 Survivor 当活动区另一块 Survivor 当备份区存活率低时效率极高。4. 分代收集Generational Collection这是 JVM 的融合大招。年轻代对象死得快用复制算法。老年代对象活得久用标记-清除或标记-整理。一次 Minor GC年轻代回收Eden 满了触发把 Eden 和 From Survivor 的存活对象复制到 To Survivor活过多次的对象提升到老年代。速度快但仍有短暂停顿。一次 Major/Full GC全堆回收通常连带老年代元空间堪称性能杀手STWStop The World时间可能长达数秒就是它让你的系统突然转圈。四、清洁工天团经典垃圾收集器盘点JVM 提供了多种收集器就像不同风格的保洁团队Serial / Serial Old单线程暂停所有用户线程适合桌面小应用。你自个儿扫地让全家人都站着别动。Parallel Scavenge / Parallel Old多线程并行回收吞吐量优先JDK 8 默认。就像派好几个清洁工同时打扫依然全家静止。ParNew CMSParNew 是 Serial 的多线程版配合 CMS并发标记清除可在老年代回收时和用户线程并发执行减少停顿。CMS 曾是低延迟宠儿但会产生碎片且“并发模式失败”会退化成 Serial Old。G1Garbage-FirstJDK 9 起默认把堆分成多个 Region优先回收垃圾最多的区域支持可预测的停顿时间。它像一位聪明管家每次只收拾最脏的那个房间而不是把整栋楼都锁起来。ZGC / Shenandoah低延迟终极武器目标暂停时间低于 1ms无论堆多大。ZGC 甚至用上了染色指针等黑科技。你的“先快后卡”很可能就是 JDK 8 默认 Parallel 回收器在老年代撑爆时触发 Full GC导致长时间 STW。升级到 G1 或 ZGC并合理调参往往立竿见影。五、调优现场用一道实战参数拯救你的服务假设你的 Spring Boot 应用最大堆内存 4G但 Old 区增长过快频繁 Full GC怎么破第一步排出内存泄漏嫌疑先 dump 出堆内存jmap -dump:live,formatb,fileheap.hprof pid用 MAT 或 JProfiler 分析看看是不是某个HashMap不断增肥。如果有泄漏修代码没有才往下调参数。第二步挑选合适的收集器bash复制下载# JDK 11 及以上推荐 G1目标是低延迟-XX:UseG1GC# 设置期望的最大暂停时间200ms 是常见目标-XX:MaxGCPauseMillis200第三步调整堆区和新生代比例bash复制下载# 设定堆的初始大小和最大大小建议相等减少动态扩充开销-Xms4096m-Xmx4096m# 设置年轻代大小G1 下一般不手动设让它自动调节# 但如果你用的是 Parallel可指定 -Xmn第四步关键阀值与并行线程数bash复制下载# 并行 GC 线程数通常等于 CPU 核数-XX:ParallelGCThreads8# 元空间上限防止把系统内存吃光-XX:MaxMetaspaceSize256m# 打印 GC 详情上线分析-XX:PrintGCDetails-XX:PrintGCDateStamps-Xloggc:gc.log第五步极端情况加保险bash复制下载# 发生 OOM 时自动 dump 堆方便事后再查-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/logs/调完上线后那个锯齿曲线变得平缓Full GC 从每分钟一次变成半小时一次8 秒卡顿变为 200 毫秒。运营同事发来表情包“用户说丝滑给你加鸡腿。”六、调优口诀与思维清单任何 JVM 调优都不要凭感觉要跟着数据走监控先行用jstat -gc pid 1000看实时 GC 频率与时长用 Prometheus Grafana 建立可视化。确定目标你的应用是要高吞吐批处理还是低延迟Web 服务前者要减少总 GC 耗时比例后者要控制每次 STW 时间。选器配参吞吐优先选 Parallel响应优先选 G1 / ZGC。控制对象生命周期尽可能在方法内部创建对象避免无谓的老年代引用缓存注意过期策略。迭代验证每次只改一个参数压测对比观察 GC 日志。七、最后三句话JVM 内存模型是一份藏宝图知道每个区域存什么溢出往哪看。垃圾回收不是性能杀手不合理的回收策略才是。它像心跳平稳跳动能泵血紊乱就休克。调优不是玄学是带着 GC 日志和内存 dump 与 JVM 讨价还价的艺术。下次半夜看板上的内存曲线再也不慌了你会像一个经验丰富的驯兽师拍拍 JVM 的脑袋“小伙扫慢点别把客人吓跑了。”你的应用最惨烈的一次 Full GC 停了几秒欢迎在评论区留下数字我们看看能不能评出一个“世界暂停纪录”。