JVM(四):内存问题、调优与线上排障
JVM(四):内存问题、调优与线上排障
导语:最后一篇聚焦工程落地。从「哪块内存炸了、为什么炸」的 OOM 分类与内存泄漏排查,到常量池体系、调优参数与诊断工具,再到线上 CPU 飙高、频繁 Full GC 的完整排查链路,共 12 题。
一、内存问题与 OOM
1. 常见的 OOM 类型及成因?
答:
| 错误信息 | 原因 |
|---|---|
Java heap space | 堆中对象过多 / 内存泄漏(静态集合无限增长、缓存未淘汰、批量查询全量数据) |
Metaspace | 类加载过多(动态代理、CGLIB、Groovy 脚本、热部署反复加载类不卸载) |
Direct buffer memory | 堆外直接内存分配超限(NIO 直接缓冲区未释放,-XX:MaxDirectMemorySize 过小) |
unable to create new native thread | 线程数超系统/用户限制(线程池配置不当、无限创建线程) |
GC overhead limit exceeded | GC 占比超过 98% 却只回收不到 2% 内存,系统近乎停滞(JDK 8 起默认开启该检测,可用 -XX:-UseGCOverheadLimit 关闭,但掩盖问题而已) |
Requested array size exceeds VM limit | 申请的数组长度超过 VM 限制(接近 Integer.MAX_VALUE) |
Compressed class space | 元空间中的压缩类空间(-XX:CompressedClassSpaceSize,默认 1G)耗尽,本质仍是类加载过多 |
排查第一原则:先看错误信息里的具体区域关键词,再决定查堆、查元空间还是查线程/直接内存——不同区域的排查工具与思路完全不同(见第 11 题)。
2. StackOverflowError 与 OutOfMemoryError 在栈上的区别?
答: 同一区域(虚拟机栈/本地方法栈)的两种不同失败:
StackOverflowError:线程请求的栈深度超过 JVM 允许(如无限递归、栈帧过大、-Xss设得过小)——是「深度」问题。可通过增大-Xss或改写成迭代解决;OutOfMemoryError: unable to create new native thread:创建新线程时没有足够内存建立新的栈,或触发了操作系统的线程数限制(ulimit -u、/proc/sys/kernel/threads-max)——是「数量 / 本机内存」问题。缓解方式是减少线程数(用线程池限流)、减小-Xss、提高系统限制。
一句话区分:递归太深报 StackOverflowError,线程太多报 unable to create new native thread。
3. System.gc() 一定会立即 GC 吗?
答: 不一定。System.gc() 只是建议 JVM 执行 Full GC,是否执行、何时执行由 JVM 决定(HotSpot 通常还是会响应)。
-XX:+DisableExplicitGC:禁用显式 GC(默认是 false,即不禁用)。生产中常开启以防误调用造成长停顿,但若使用了堆外内存(DirectByteBuffer)要谨慎——它的回收依赖System.gc()触发,禁用后可能OOM: Direct buffer memory(此时改用-XX:MaxDirectMemorySize兜底,或手动Cleaner清理);-XX:+ExplicitGCInvokesConcurrent:让System.gc()触发 CMS/G1 的并发 GC,而不是完全 STW 的 Full GC,降低停顿;- 更规范的做法是在代码中禁止使用
System.gc()(可用静态分析/规范约束),而不是依赖参数兜底。
4. 内存泄漏(Memory Leak)与内存溢出(OOM)的关系?
答: 二者是「原因」与「结果」的关系,但并非一一对应:
- 内存泄漏:对象已不再使用,却因引用未释放而无法被 GC 回收,导致可用内存持续缩小;
- 内存溢出(OOM):内存不足、分配失败时抛出的错误。长期泄漏最终必然诱发 OOM,但 OOM 也可能是一次性分配过大(如一次性查询百万行数据、超大数组)而非泄漏所致。
定位泄漏的标准步骤:
- 复现或线上触发时用
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=xxx自动 dump,或jmap -dump:live,format=b,file=xxx <pid>主动导出; - 用 MAT(Memory Analyzer) / JProfiler 打开 dump,看 Dominator Tree(支配树) 找出占用最大的对象;
- 用 「GC Roots 到对象的引用路径(Path to GC Roots,勾选 exclude weak/soft references)」 找出谁在持有它——这是定位泄漏源头的关键;
- 结合代码定位「长生命周期容器(静态集合、缓存、监听器、ThreadLocal)」中的未清理引用。
5. 强引用导致的内存泄漏常见场景有哪些?
答:
- 静态集合(
static Map/List)长期持有对象引用且不清理; - 未关闭的资源(连接、流、Session)被集合缓存引用;
ThreadLocal在线程池中未remove()——线程被复用,value随线程长期存活,还会导致跨任务串味;- 监听器 / 回调注册后未注销(尤其被长生命周期对象持有的短生命周期对象);
- 缓存无淘汰策略(未使用
WeakHashMap/ 软引用 / LRU / Caffeine); equals()/hashCode()实现不当,对象存入HashMap后无法被正确查找移除;- 内部类隐式持有外部类引用(非静态内部类/匿名类),使外部大对象无法释放。
二、常量池体系
6. Java 的常量池分为哪几种?
答: 面试常把「常量池」当一回事,实际是三个层次:
- Class 文件常量池(Constant Pool):class 文件中的一张表,存放字面量(文本字符串、
final常量值)与符号引用(类/接口全限定名、字段名、方法名等)。它是静态的、编译期产物; - 运行时常量池(Runtime Constant Pool):类加载后,把 Class 文件常量池的内容搬入方法区(JDK 8 后随类元信息在元空间),并支持运行期动态添加(典型就是
String.intern())。是 Class 文件常量池的「运行时内存版本」; - 字符串常量池(StringTable):JVM 层面对字符串的驻留(intern)缓存,本质是一个哈希表,key 是字符串内容。
记忆:Class 文件常量池在磁盘,运行时常量池在内存,字符串常量池是其中专门管字符串的那张哈希表。
7. String 常量池(StringTable)在 JDK 中的位置变化?intern() 有什么用?
答:
- 位置变化:JDK 7 之前,StringTable 位于永久代;JDK 7 起移至堆中(注意是 JDK 7,不是 JDK 8),JDK 8 随永久代移除后仍在堆中。这意味着 intern 产生的字符串现在受堆 GC 管理,避免了永久代 OOM;
intern()的作用:返回字符串在 StringTable 中的驻留引用——- 若常量池中已有内容相同的字符串,直接返回池中引用;
- 若没有,JDK 7 起会把堆中该对象的引用(而非复制对象)登记进 StringTable 并返回;
- 调优参数:
-XX:StringTableSize调整桶数量(默认约 60013,JDK 8 起),大量intern()时可调大以减少哈希冲突; - 实践:
intern()适合「重复度高、量可控」的字符串去重(如状态、枚举名),但滥用会导致 StringTable 膨胀与 Full GC 停顿,不要用它处理海量动态字符串。
三、调优参数与诊断工具
8. 常用 JVM 内存与 GC 调优参数?
答: 高频参数清单:
堆与各代
-Xms/-Xmx:初始/最大堆。生产建议设相等,避免动态扩缩容带来的性能抖动(扩容需申请连续内存,可能触发 Full GC);-Xmn:新生代大小(等价于-XX:NewSize+-XX:MaxNewSize);-XX:NewRatio:老年代 : 新生代比例(默认 2);-XX:SurvivorRatio:Eden : 单个 Survivor 比例(默认 8);-XX:MaxTenuringThreshold:晋升老年代的年龄阈值(默认 15,Parallel 用 15,CMS 默认 6);-XX:PretenureSizeThreshold:大对象直接进老年代阈值(仅 Serial/ParNew 生效);-Xss:单个线程栈大小(默认约 512K~1M,Linux x64 常见 1M)。
元空间与直接内存
-XX:MetaspaceSize/-XX:MaxMetaspaceSize:元空间初始阈值 / 上限(MetaspaceSize是首次触发 GC 的水位,不是一个限制);-XX:MaxDirectMemorySize:直接内存上限(不设置时默认等于-Xmx);-XX:+UseCompressedOops/-XX:+UseCompressedClassPointers:指针压缩(默认开启)。
收集器与行为
-XX:+UseG1GC/-XX:+UseZGC/-XX:+UseParallelGC/-XX:+UseConcMarkSweepGC(历史);-XX:MaxGCPauseMillis:G1/ZGC 的目标停顿(注意是「目标」不是「保证」,设得过小反而会让 GC 更频繁);-XX:G1HeapRegionSize:G1 的 Region 大小(1~32MB,2 的幂);-XX:InitiatingHeapOccupancyPercent(G1 并发周期启动阈值,默认 45)/-XX:CMSInitiatingOccupancyFraction(CMS 老年代触发比);-XX:+DisableExplicitGC:禁用System.gc();-XX:+HeapDumpOnOutOfMemoryError+-XX:HeapDumpPath:OOM 时自动 dump;-XX:+AlwaysPreTouch:启动时预触碰所有堆内存页,牺牲启动时间换取运行期稳定(大堆服务常开);-XX:ParallelGCThreads/-XX:ConcGCThreads:并行/并发 GC 线程数(容器内常需手动下调,避免与业务抢 CPU)。
提醒:参数之间有约束(如
-Xmn与-XX:NewRatio互斥、-XX:MaxMetaspaceSize不设则可能吃满本机内存),且容器环境(Docker/K8s)务必设-XX:MaxRAMPercentage或显式-Xmx,否则 JVM 可能按宿主机内存计算堆大小而被 OOMKilled。
9. 常用 GC 日志与诊断参数?
答:
GC 日志
- JDK 8 及之前:
-XX:+PrintGCDetails、-XX:+PrintGCDateStamps、-XX:+PrintHeapAtGC、-Xloggc:gc.log; - JDK 9 起这些参数被移除,统一为
-Xlog体系:-Xlog:gc:GC 基本信息;-Xlog:gc*:全部 GC 相关日志(含各阶段耗时);-Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags:输出到文件并带时间戳;- 旧参数可临时用
-Xlog:gc:gc.log替代,不要在新 JDK 上继续用-XX:+PrintGC*。
命令行诊断工具(JDK 自带)
| 工具 | 用途 |
|---|---|
jps | 列出 Java 进程及主类 |
jstat -gcutil <pid> 1000 | 各区域使用率与 GC 次数/耗时,排查 GC 问题第一命令 |
jmap -histo:live <pid> | 对象直方图(哪个类实例最多) |
jmap -dump:live,format=b,file=a.hprof <pid> | 导出堆快照(配合 MAT) |
jstack <pid> | 线程栈,排查死锁/线程卡点 |
jinfo <pid> | 查看运行期 JVM 参数 |
jcmd <pid> <cmd> | 综合入口:GC.heap_dump、Thread.print、VM.flags、GC.class_histogram、JFR.start |
jhat | JDK 9 已移除,用 jcmd GC.heap_dump + MAT 替代 |
jstat 关键列速查(-gcutil 为百分比,-gc 为绝对值)
S0/S1、E、O、M:Survivor / Eden / 老年代 / 元空间的使用率;YGC/YGCT:Young GC 次数与总耗时;FGC/FGCT:Full GC 次数与总耗时;GCT:GC 总耗时;- 判读:
O持续接近 100% 且FGC快速增长 → 老年代泄漏或对象晋升过快。
可视化/增强工具
- MAT:分析堆 dump,找大对象与 GC Roots 引用链(标配);
- VisualVM / JConsole:本地/远程监控,实时看堆曲线与线程;
- Arthas:
dashboard、thread、heapdump、jvm、vmtool、trace,线上无侵入诊断首选; - JFR(JDK 11+ 开源):低开销飞行记录,
-XX:StartFlightRecording或jcmd JFR.start。
10. JVM 调优的一般思路是什么?
答:
- 明确目标:吞吐优先还是低延迟优先?确定可接受的 GC 频率与停顿指标(如 P99 停顿 < 50ms);
- 建立基线:先监控 GC 日志与
jstat指标,知道「正常」长什么样; - 定位瓶颈:Full GC 频繁 → 查老年代大小/泄漏;停顿长 → 换低延迟收集器或减小堆;YGC 过频 → 调大新生代;
- 只改一个变量:一次只调一个参数,在少量机器上先验证,避免多个参数同时变导致无法归因;
- 压测验证 + 持续跟踪:用真实流量模型压测,观察指标是否达标,上线后持续盯盘。
最重要的一条前提:架构与代码优化永远优先于 JVM 调优。内存泄漏、大对象、无界集合不解决,调参只能把问题往后拖;「先改代码、再调参数、最后换硬件」是正确顺序。
11. 如何排查线上 CPU 飙高 / 死循环 / 频繁 Full GC?
答: 两条独立的排查链路,先分清楚是「CPU 问题」还是「内存/GC 问题」。
链路一:CPU 飙高 / 死循环
top找到高 CPU 的进程 PID;top -Hp <pid>找到进程内高 CPU 的线程 TID;- 把 TID 转成十六进制(
printf "%x\n" <tid>); jstack <pid> | grep -A 30 <nid十六进制>找到该线程在执行的栈,定位到具体业务代码(死循环、正则回溯、频繁 GC 线程等);- 若怀疑是 GC 线程占用 CPU,改用
jstat -gcutil <pid> 1000看 GC 频率; - 线上可用 Arthas:
thread -n 3(CPU 最高的 3 个线程)、thread -b(找出阻塞其他线程的线程)、dashboard。
链路二:频繁 Full GC / 内存问题
- 先确认是否真的在频繁 Full GC:
jstat -gcutil <pid> 1000,观察FGC/FGCT增长速率与O(老年代)占用趋势; - 看 GC 日志中各阶段耗时,区分是「老年代空间不足」「并发模式失败」还是「元空间不足」;
jmap -histo:live <pid> | head -20看实例数最多的类,快速判断是否有异常对象堆积;- 导出 dump 用 MAT 分析保留集最大的对象与 GC Roots 引用链(见第 4 题);
- 常见根因:静态集合缓存未清理、一次性加载全量数据、
ThreadLocal未 remove、元空间动态生成类过多、缓存未设过期/上限、-Xmx过小或容器内存未对齐。
12. JVM、JDK、JRE 的区别与关系?
答:
- JVM(Java Virtual Machine):运行 Java 字节码的虚拟机,是「一次编译、到处运行」的基石。它是一套规范,各厂商可有不同实现(最常用的 HotSpot,另有 J9/OpenJ9、Zing、JRockit 等);
- JRE(Java Runtime Environment):Java 运行时环境,包含 JVM + 运行所需的核心类库(如模块化后的
java.base),面向运行 Java 程序的用户; - JDK(Java Development Kit):Java 开发工具包,是 JRE 的超集,额外提供编译器
javac、监控诊断工具(jps/jstat/jstack/jmap/jcmd)、打包工具jar、javadoc、jlink等,面向开发者。
关系:JDK ⊃ JRE ⊃ JVM,开发用 JDK,运行用 JRE。
重要纠正(JDK 版本):JDK 9 起,Oracle 不再随 JDK 提供独立的 JRE 目录——因为模块化(JPMS)后可以按需裁剪,需要最小运行时可使用
jlink定制镜像。因此「运行环境装 JRE 即可」的说法在 JDK 8 及之前成立,JDK 9+ 已不再准确(实际部署时通常直接装 JDK,或用 jlink 生成运行时镜像)。
系列完结:四篇覆盖了 JVM 的内存结构 → 对象布局 → GC 与收集器 → 类加载与 JMM → 调优与排障全链路。建议结合《Java并发》系列一起复习,两者在 volatile、内存屏障、锁膨胀等知识点上是交叉验证关系。
