你的 Java 程序为什么总是先流畅后卡成狗?——JVM 内存、垃圾回收与调优求生指南
凌晨两点,你看着监控大屏上一条锯齿状的曲线,血压也跟着过山车。
“堆内存使用率每 20 分钟冲到 98%,然后突然跌落 10%,如此往复……”
运营同事发来消息:“用户反馈点个查询要转圈 8 秒,你能不能优化一下?”
你信心满满地重启了服务,果然,前五分钟快如闪电,然后再次卡得妈妈都不认识。
为什么你的 Java 程序总是刚重启时像法拉利,跑一会儿就变成了拖拉机?
答案就藏在那个默默替你管理内存的“管家”——JVM(Java虚拟机)身上。
它不是没干活,反而干活太勤快,而且干活的方式,你完全可以“调教”。
今天这篇文章,我们就从一台服务器内存飙涨的血案出发,一次性搞定三件事:
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(全堆回收,通常连带老年代+元空间):堪称性能杀手,STW(Stop The World)时间可能长达数秒,就是它让你的系统突然转圈。
四、清洁工天团:经典垃圾收集器盘点
JVM 提供了多种收集器,就像不同风格的保洁团队:
Serial / Serial Old:单线程,暂停所有用户线程,适合桌面小应用。你自个儿扫地,让全家人都站着别动。
Parallel Scavenge / Parallel Old:多线程并行回收,吞吐量优先,JDK 8 默认。就像派好几个清洁工同时打扫,依然全家静止。
ParNew + CMS:ParNew 是 Serial 的多线程版,配合 CMS(并发标记清除)可在老年代回收时和用户线程并发执行,减少停顿。CMS 曾是低延迟宠儿,但会产生碎片,且“并发模式失败”会退化成 Serial Old。
G1(Garbage-First):JDK 9 起默认,把堆分成多个 Region,优先回收垃圾最多的区域,支持可预测的停顿时间。它像一位聪明管家,每次只收拾最脏的那个房间,而不是把整栋楼都锁起来。
ZGC / Shenandoah:低延迟终极武器,目标暂停时间低于 1ms,无论堆多大。ZGC 甚至用上了染色指针等黑科技。
你的“先快后卡”很可能就是 JDK 8 默认 Parallel 回收器在老年代撑爆时触发 Full GC,导致长时间 STW。
升级到 G1 或 ZGC,并合理调参,往往立竿见影。
五、调优现场:用一道实战参数拯救你的服务
假设你的 Spring Boot 应用,最大堆内存 4G,但 Old 区增长过快,频繁 Full GC,怎么破?
第一步:排出内存泄漏嫌疑
先 dump 出堆内存(jmap -dump:live,format=b,file=heap.hprof <pid>),用 MAT 或 JProfiler 分析,看看是不是某个HashMap不断增肥。
如果有泄漏,修代码;没有,才往下调参数。
第二步:挑选合适的收集器
bash
复制
下载
# JDK 11 及以上推荐 G1,目标是低延迟-XX:+UseG1GC# 设置期望的最大暂停时间,200ms 是常见目标-XX:MaxGCPauseMillis=200第三步:调整堆区和新生代比例
bash
复制
下载
# 设定堆的初始大小和最大大小,建议相等,减少动态扩充开销-Xms4096m-Xmx4096m# 设置年轻代大小,G1 下一般不手动设,让它自动调节# 但如果你用的是 Parallel,可指定 -Xmn第四步:关键阀值与并行线程数
bash
复制
下载
# 并行 GC 线程数,通常等于 CPU 核数-XX:ParallelGCThreads=8# 元空间上限,防止把系统内存吃光-XX:MaxMetaspaceSize=256m# 打印 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 停了几秒?欢迎在评论区留下数字,我们看看能不能评出一个“世界暂停纪录”。
