Java/JVM · #performance#jvm#hotspot#gc

JVM 内部机制:我在排查线上问题时真正用到的那些

2019.12.17 Java 4 min 1.5k
// 目录 · contents

从 .NET 转到 Java 之后,JVM 是我花时间最多的一块。两者的 CLR 和 JVM 在大方向上相似——都是托管运行时、有 GC——但实现细节差很多,很多在 C# 里不需要操心的问题在 Java 里要手动处理。

这篇文章不是 JVM 的全面梳理,而是从实际排查线上问题的经历出发,写下那些真正用到的知识点。

类加载:什么时候会触发?

刚开始写 Java 时踩过一个坑:一个类明明存在,但在某些条件下就是加载不到,报 ClassNotFoundException。后来才搞清楚类加载的双亲委派模型。

双亲委派的意思是:任何一个类加载请求,先委托给父加载器,父加载器找不到才自己来。Bootstrap → Extension → Application ClassLoader,这个顺序保证了 java.lang.String 这样的核心类不会被用户代码替换。

这个机制在多框架共存的应用里会出问题。比如 Tomcat 里多个 Web 应用各自有自己的类加载器,保证 App A 和 App B 用的同名类互相隔离。但有时候你希望某个类用自己的版本而不是父加载器里的,这时候就得打破双亲委派(OSGi、SPI 机制都是这么干的)。

类的初始化(执行 <clinit> 静态初始化块)有明确的触发条件:new 对象、访问静态变量/方法、反射调用、初始化子类时父类未初始化。只有这几种情况,纯粹的”类引用”不会触发。

堆的分代结构

1
2
3
4
5
Heap
├── Young Generation(新生代)
│ ├── Eden(大部分对象在这里分配)
│ └── Survivor S0 / S1(每次 Minor GC 存活的对象在两块间复制)
└── Old Generation(老年代,长期存活的对象晋升到这里)

Young 区占堆的 1/3,Old 区占 2/3(默认值)。Young 区的 GC 叫 Minor GC,通常很快(几毫秒到几十毫秒)。Old 区触发的 GC(Major GC / Full GC)代价大得多,这也是 JVM 调优重点关注的地方。

对象从 Eden 分配,经过 Minor GC 存活后进入 Survivor,经过 15 次(默认)还在,就晋升到 Old Generation。如果 Survivor 放不下或者大对象超过阈值,也会直接进 Old。

GC 日志是最有效的排查工具

出现 GC 相关问题(停顿、OOM、内存泄漏),第一件事是看 GC 日志。

1
2
3
4
5
# JDK 9+ 的 GC 日志参数
-Xlog:gc*:file=/var/log/app-gc.log:time,level:filecount=5,filesize=20m

# JDK 8 的写法
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app-gc.log

有了 GC 日志之后,用 jstat 实时看:

1
2
3
4
5
6
# 每 1 秒打印一次 GC 统计
jstat -gcutil <pid> 1000

# 输出类似:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 18.32 72.41 85.30 95.81 93.42 892 6.82 3 0.52 7.34

O(Old 使用率)持续上涨是内存泄漏的信号。FGC(Full GC 次数)频繁意味着 Old 区压力大。这两个数字一般就能判断问题的大方向。

GC 日志上传到 GCEasy 可以可视化分析,比肉眼读日志快很多。

GC 收集器怎么选

Java 8 默认是 Parallel GC(吞吐量优先),但停顿时间不可控,生产环境推荐切到 G1:

1
2
3
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间,G1 会尽量满足
-Xms4g -Xmx4g # 堆大小建议最小=最大,避免动态扩容带来的停顿

JDK 11+ 可以用 ZGC,停顿时间可以压到个位数毫秒,适合延迟敏感的服务。

我现在的选择标准很简单:JDK 8 就上 G1,JDK 11/17 接口延迟要求高就试 ZGC,不复杂。

堆内存 dump 分析 OOM

遇到 OutOfMemoryError,配置在 OOM 时自动导出堆快照:

1
2
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dump/heap.hprof

dump 文件用 Eclipse MAT 或者 JVisualVM 打开,看”Leak Suspects Report”基本能直接告诉你哪个对象占了最多内存、被谁引用导致无法回收。

踩坑记录

坑 1:新服务上线后 Full GC 频繁,每次停顿 4-5 秒

jstat 看 Old 区使用率缓慢但持续上涨,最终在 90% 触发 Full GC。用 jmap -dump 导出堆快照,MAT 分析发现一个静态 HashMap 存了大量对象,而且没有清理机制,所有进过这个服务的请求上下文对象都在里面。

根本原因是早期的一个”请求日志”功能把对象放进了这个 Map,写的时候没想到生命周期问题。改用 Caffeine 加了过期时间,问题解决。

坑 2:容器里 JVM 把宿主机内存当自己的

这个坑坑了不少人。JDK 8u191 之前,JVM 不识别 cgroup 内存限制,会读取宿主机的物理内存大小(比如 64GB),然后按 1/4 设置默认堆(16GB),远超容器限制(比如 2GB),直接被 OOM Killer 干掉。

进容器一看日志,发现 JVM 正常启动,但隔一会儿进程就消失了,没有任何 Java 异常。在宿主机上 dmesg | grep -i kill 才看到 OOM Killed。

修复方法:JDK 8u191+ 加 -XX:+UseContainerSupport(JDK 10+ 默认开启),或者直接显式指定 -Xmx1500m(容器 2GB 内存,留 500MB 给 JVM 非堆用)。

坑 3:Metaspace 无限增长

服务跑一段时间报 OutOfMemoryError: Metaspace。用 -XX:+TraceClassLoading 看日志,发现类加载数量持续增长,最终确认是某个框架功能在运行时动态生成代理类,每次请求都创建新的 ClassLoader,旧的因为被某个地方持有引用而无法被 GC 掉。

临时加 -XX:MaxMetaspaceSize=512m 让 OOM 更快暴露而不是无限等待,然后修复了代理对象的生命周期管理。


JVM 调优没有固定公式,但有一个基本原则:先观察再调整,没有监控数据就动参数,改了也不知道为什么有效。GC 日志 + jstat + 堆快照,这三样工具覆盖了 90% 的 JVM 问题。

作者 · authorzt
发布 · date2019-12-17
篇幅 · length1.5k 字 · 4 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论