4、JVM层瓶颈诊断:GC日志分析
说到JVM性能调优,GC绝对是绕不开的坎儿。我见过太多系统,代码写得挺漂亮,一到高并发就卡死,最后查下来,全是GC惹的祸。说白了,GC就是Java的自动内存管理机制,但自动不代表完美。你想想看,交易系统每秒处理成千上万笔订单,如果GC频繁停顿,那延迟就上去了。
我个人习惯,遇到性能问题,第一件事就是打开GC日志。别急着调参数,先看看GC到底在干什么。
4.1 Young GC与Full GC:频率与耗时
Young GC和Full GC,这两个概念大家都不陌生。但真正能看懂日志的人,其实不多。
Young GC,也叫Minor GC,发生在新生代。新生代又分Eden区和两个Survivor区(From和To)。对象先分配在Eden,Eden满了就触发Young GC。存活的对象复制到Survivor,年龄够了就晋升到老年代。
我在项目中遇到过,有个交易网关,Young GC每秒触发好几次,每次耗时不到1ms。看起来挺正常对吧?但问题是,频率太高了,CPU都花在GC上了,业务线程反而抢不到时间片。
Full GC,那就严重多了。它回收整个堆,包括新生代、老年代,还有元空间(Metaspace)。Full GC通常伴随着Stop-The-World,所有业务线程都得停下来。一次Full GC如果超过1秒,交易系统基本就废了。
来看一段典型的GC日志:
2025-01-15T10:23:45.123+0800: [GC (Allocation Failure) [PSYoungGen: 2048K->512K(2560K)] 4096K->3072K(8192K), 0.0012345 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2025-01-15T10:25:01.456+0800: [Full GC (Metadata GC Threshold) [PSYoungGen: 1024K->0K(2560K)] [ParOldGen: 6144K->4096K(6144K)] 7168K->4096K(8704K), [Metaspace: 20480K->20480K(20480K)], 0.5678901 secs] [Times: user=0.50 sys=0.05, real=0.57 secs]
你看第一行,Young GC只花了1.2ms,非常快。第二行Full GC花了570ms,这就有点疼了。如果Full GC频繁出现,比如每分钟一次,那系统基本不可用。
关键指标:
- Young GC频率:每秒不超过1次,最好10秒1次
- Young GC耗时:单次不超过50ms,最好10ms以内
- Full GC频率:每小时不超过1次,最好0次
- Full GC耗时:单次不超过200ms,最好100ms以内
嗯,这里要注意,这些指标不是绝对的。交易系统分核心交易和非核心交易,核心链路要求更高。我见过一个极端的案例,某券商的核心交易系统,Full GC耗时控制在50ms以内,因为超过100ms就会导致交易超时。
4.2 JVM内存模型与堆外内存
JVM内存模型,说白了就是Java程序怎么用内存。堆内存是主角,但堆外内存也不能忽视。
堆内存分三块:
- 新生代:存放短命对象,GC最频繁的区域
- 老年代:存放长命对象,GC频率低但耗时长
- 元空间:存放类元数据,不再受永久代大小限制
我建议,交易系统的堆内存分配,新生代要够大,但别太大。新生代太小,对象频繁晋升到老年代,容易触发Full GC。新生代太大,Young GC耗时增加,影响延迟。一般新生代占堆的1/3到1/2比较合适。
堆外内存,这个很多人容易忽略。Direct Buffer、线程栈、JNI代码、甚至一些框架(比如Netty)都会使用堆外内存。堆外内存不受GC管理,但分配和释放成本高。如果堆外内存泄漏,系统会莫名其妙地挂掉,而且很难排查。
避坑指南:我曾经遇到过一个交易系统,运行几天后突然OOM,但堆内存分析下来很正常。最后发现是Netty的Direct Buffer没有正确释放,堆外内存被吃光了。从那以后,我每次上线前都会用-XX:MaxDirectMemorySize限制堆外内存大小,并监控堆外内存使用量。
堆外内存的监控,可以用jcmd或者Native Memory Tracking(NMT)。NMT开启后,能清晰看到每个区域的内存使用情况:
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
输出结果会显示Java Heap、Class、Thread、Code Cache、GC等各个区域的内存占用。如果发现某个区域异常增长,那就要警惕了。
4.3 JIT编译与CodeCache
JIT编译,全称Just-In-Time Compilation。Java代码先解释执行,热点代码会被JIT编译成机器码,直接运行。这样既保留了Java的跨平台特性,又获得了接近原生代码的性能。
但JIT编译不是免费的。编译过程本身消耗CPU,编译后的机器码存放在CodeCache中。CodeCache是一块堆外内存,专门存放JIT编译后的代码。
CodeCache如果满了,JIT编译器就会停止工作,热点代码退化为解释执行,性能急剧下降。我见过一个案例,系统运行一段时间后,TPS从5000掉到500,查了半天,发现是CodeCache满了。
个人经验:CodeCache的大小,默认是240MB(JDK 8),对于交易系统来说可能不够。我一般设置为256MB或512MB。用-XX:ReservedCodeCacheSize=256m来调整。同时开启-XX:+PrintCodeCache,监控CodeCache的使用情况。
JIT编译还有一个坑,就是编译线程占CPU。如果系统刚启动,大量热点代码需要编译,CPU会飙升。我建议用-XX:CICompilerCount控制编译线程数,一般设为CPU核心数的一半。
来看一个CodeCache的监控输出:
CodeCache: size=245760Kb used=180000Kb free=65760Kb max_used=200000Kb
如果used接近max_used,说明CodeCache快满了。这时候可以调大CodeCache,或者检查是否有大量内联代码导致CodeCache膨胀。
4.4 知识体系总览
为了让你更直观地理解JVM层性能诊断的全貌,我画了一张图:
这张图把JVM层性能诊断的三大模块串起来了。GC日志分析是入口,JVM内存模型是基础,JIT编译是进阶。三者缺一不可。
最后说一句,JVM调优没有银弹。每个系统的业务场景、并发量、硬件配置都不一样。我建议你先把GC日志打开,跑一段时间,看看数据。数据会告诉你问题在哪。
核心参数速查:
| 参数 | 说明 | 推荐值 |
|---|---|---|
| -Xms / -Xmx | 堆初始/最大大小 | 根据内存设置,建议相等 |
| -Xmn | 新生代大小 | 堆的1/3 ~ 1/2 |
| -XX:MetaspaceSize | 元空间初始大小 | 256M |
| -XX:MaxDirectMemorySize | 堆外内存上限 | 根据应用设置 |
| -XX:ReservedCodeCacheSize | CodeCache大小 | 256M ~ 512M |
| -XX:+PrintGCDetails | 打印GC详情 | 必须开启 |
| -XX:+PrintGCDateStamps | 打印GC时间戳 | 必须开启 |