我们的订单服务是一套很普通的老架构,Spring Boot 打包成 jar,扔在一台 4 核 8G 的云服务器上跑 java -jar。开发环境和测试环境一切正常,同样的代码,同样的数据量。问题是上线第七天夜里,值班手机突然响了接口大面积超时,监控图上 P99 从 80 毫秒直接飙到 12 秒。
重启了一下进程,流量立刻恢复,监控曲线也平了。我当时判断是偶发的 GC 抖动,还跟同事说"这种问题重载一下就好了"。结果第二天同样的时间,同样的告警又来了一次。第三天、第四天,重启的间隔从两天缩到一天、缩到半天。这事不能再拖了。
如果你也在排查 Java 服务跑久了就变慢、内存曲线只涨不跌的问题,或者正准备给 JVM 调大堆内存硬扛,先看看这几家的机器规格和技术说明再动手堆内存不够确实是根因之一,但更多时候真正的病灶在代码里,换机器只是把问题往后推了推:tnahosting(全球云服务器,KVM 与独立服务器)跑 Java 服务前先确认机器的物理内存和可分配内存,别让宿主机超售把你的堆上限压死。

第一步:别猜,先看堆内存到底涨没涨
很多人一遇到"服务变慢"就上手加内存、加线程、加机器。但内存不够和内存泄漏在监控上看起来完全不一样:内存不够是 QPS 一涨就涨、回落就降,呈锯齿状;内存泄漏是老年代占用只增不减,重启前一刻还在高位。
我用的第一个工具是 jstat,自带的,不用装任何东西:
jstat -gcutil <pid> 1000
输出里最关键的是这几列:O 是老年代占用率,YGC 是 Young GC 次数,FGC 是 Full GC 次数,YGCT 是 Young GC 总耗时,FGCT 是 Full GC 总耗时。我第一次跑出来是这样的:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 87.32 42.10 61.55 88.71 92.34 14 0.284 187 32.510 32.794
关键在 O 这一列61.55,而且一路在涨。我接着每隔五分钟采一次,连续采了两个小时,O 从 61 一路爬到 78,期间 FGC 从 187 涨到 213。这基本可以确认:有对象进了老年代,然后再也不出来。
这里有个坑要提醒。jstat 的 GC 次数是累计值,不重启就一直涨,很多人看了一眼 FGC=187 就慌了,其实这台机器已经跑了三个月。可行的做法是取差值:记下当前 FGC 和 FGCT,等 10 分钟后再采一次,用增量除以时间,得到的是"每分钟触发几次 Full GC、每次停多久"。我那台机器当时是每 10 分钟 13 次 Full GC、每次平均 150 毫秒,累计停顿一秒多这才是把 P99 拖到 12 秒的元凶。

第二步:分清是"堆太小"还是"对象逃不出去"
确认了老年代只涨不跌之后,下一步是判断原因。这有两条完全不同的路:
一条是堆确实不够。表现出来的是 FGC 触发后老年代占用会明显回落GC 把没活下来的对象清掉了,回落之后又慢慢涨上去,呈锯齿。解法是调大 -Xmx,或者换内存更大的机器。
另一条是对象该死却没死,老年代放完 Full GC 之后依然不降。这才是内存泄漏。我的机器属于后者:FGC 之后 O 列从 78 只掉到 71,剩下的部分是怎么扫都扫不掉。
怎么确认?最直接的办法是导堆快照。用 jmap:
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
注意 live 这个参数,不加的话堆里全是垃圾对象,快照能有几个 G,基本没法分析。加了 live 会先触发一次 Full GC,只保留存活对象,我那份快照只有 1.8G。dump 的一瞬间进程会卡住几十秒,生产环境上一定先摘流量,或者在备机上做。
有了 hprof,剩下的就是分析。用 Eclipse MAT 打开,点 Leak Suspects 报告,一分钟就能看到排行。不用 MAT 的话,也可以用 jhat 或者更轻的 jmap -histo:live <pid> 先看个大概,按对象大小排序列出前二十行。我们那台机器,histo 出来的第一行直接把我看愣了:
14:23:07 jmap -histo:live 18247 | head -5
num #instances #bytes class name
1: 184213 736852000 [B
2: 3120 88562400 java.util.HashMap$Node[]
1.8G 的老年代里,byte 数组占了 700 多兆,后面跟着一大坨 HashMap。这两行放在一起,基本就是答案了。

第三个原因:static 集合把对象钉死在老年代里
回头查代码,问题出在一个我完全没想到的地方。
我们的对账模块里有一个静态的 Map,用来缓存渠道方的结算数据,大概长这样:
private static final Map<String, byte[]> SETTLE_CACHE = new ConcurrentHashMap<>();
每天凌晨跑对账任务,把当天的结算报文读成 byte[] 塞进去,key 是日期。设计的时候想着"最多存几天的,不会太多"。结果呢?这一行是全代码里唯一一处问题,但它一个人就贡献了 700 兆常驻内存。
原因很直白:static 字段属于 Metaspace 里的类元数据,它的生命周期跟类一样长,跟应用的运行周期一样长。只要那个 key 不被 remove 掉,那个 byte[] 就永远可达。YGC 的时候它是存活对象,老年代做 Full GC 它还是存活对象垃圾回收器根本不认为它是垃圾,它是被 Map 实实在在引用着的。
Map 里的 key 是日期字符串,理论上一天一个 key,一年才 365 个 key,怎么也不会撑爆内存。真正的问题在于:key 是日期没错,但 value 是 byte[],而每个 byte[] 装的是当天的完整结算报文。我们这个量级,一天的报文能有 2 到 5 兆,加上有些渠道给的是明细文件,单个能到几十兆。几个 key 一叠加,700 兆就出来了。
改法很直接,两个字:remove。跑完对账就删掉当天的 key,或者干脆换成带过期时间的缓存(带 TTL 的 Caffeine 就够)。改完之后再跑一周,老年代占用稳定在 30% 上下,Full GC 从每十分钟 13 次降到每周两三次,而且每次只有十几毫秒因为真的没什么可回收的了,GC 也就没必要慢。
第二个原因是缓存没有上限。用的 Caffeine 倒是配了 maximumSize,但另一个自己写的 LinkedHashMap 缓存忘了加,结果它按业务方的一个错误参数把几十万条记录全留在内存里。这个和第三个原因本质上是同一类问题:引用没断。
第三个原因最隐蔽,是本地缓存和堆内存混在一起看不见。代码里有一处用 ThreadLocal 缓存大对象,ThreadLocal 的 key 是弱引用没错,但 value 是强引用,线程池里的线程一直活着,value 就一直不释放。这种问题在 MAT 里表现为 Dominator Tree 上一片孤零零的 byte[],但代码里几乎搜不到对应的引用,得顺着 ThreadLocal 一路往回找才能发现。

给 Java 服务定几条能落地的规矩
这次排查之后,我们在组里定了几条规矩,半年下来没再因为内存出过一次事故:
第一,监控里必须有老年代占用率和 Full GC 频率这两条曲线,别等告警了才去看。我们用的是 JVM 监控工具直接采 jstat 的原始指标,Prometheus 拉取后画图,阈值是老年代连续 15 分钟超过 60% 就报警,Full GC 每分钟超过 2 次也报。
第二,任何 static 集合必须有明确的清理逻辑,写完代码自己 grep 一遍 static Map、static List、static Set,问自己一句:这个集合里的东西,谁来负责删。
第三,ThreadLocal 用完必须 remove,尤其是在线程池里。这条后来又帮我们挡下了一个把内存吃满的 bug。
第四,别用「加内存」解决「内存泄漏」。我们那台机器最后升到了 8G 还是不行,因为泄漏速度跟堆大小是线性的,堆翻倍,Full GC 频率也跟着翻倍,只是把崩溃时间往后推了几天而已。真要加内存,前提是先用 jmap 加 MAT 确认没有泄漏。
第五,单机的 -Xmx 不要超过物理内存的 75%,剩下的留给 Metaspace、线程栈、直接内存(Netty 这类会走堆外)以及页缓存。Netty 直接内存的泄漏尤其隐蔽,堆内一切正常,进程 RSS 却一路涨,遇到这种情况要看 NMT 的 -XX:NativeMemoryTracking 汇总。
第六,如果确认代码已经改干净、剩下的量就是业务本身要吃掉的内存,那才轮到升配。这时候可以直接对比裸金属和弹性云服务器两条路:裸金属给的是独享的大内存条子,适合常驻服务;弹性云可以按峰值临时拉高再缩回去,适合流量有明显波动的业务。华瑞云(服务器与主机,海外云与裸金属方案)这类同时提供弹性云和裸金属的商家,适合在扩容前把两种规格的内存单价对比着看。
最后补一句关于 Full GC 本身的认知:很多人一听 Full GC 就想去调参数,换 G1、换 ZGC、改 MaxGCPauseMillis。但如果对象本来就该死,参数调得再漂亮也只是把停顿藏起来。我的经验是,先用 jstat 确认方向(老年代涨不涨),再用 jmap 加 MAT 定位到对象和引用链,最后才谈调参。反过来做,基本是白忙。
七天这个数字挺有意思。它不是随机的,取决于缓存里攒够多少数据、缓存淘汰策略多宽松。这次的教训是:上线后第一周一定要盯着内存曲线看,别觉得「上线三天没事就稳了」。


喜欢
顶
难过
囧
围观
无聊





