一次 Full GC 并不只意味着堆空间不足。本文从 JVM 内存区域、对象可达性、晋升失败、类加载器和 Java 内存模型出发,结合代码与命令,建立一套从现象到根因的性能排查路径。
很多 Java 应用出现延迟升高时,监控面板上会同时出现几个看似矛盾的现象:堆使用率没有达到上限,Young GC 很频繁,偶尔却发生 Full GC,甚至 Full GC 之后内存很快又涨回来。
这时如果只盯着“堆还剩多少空间”,往往会错过真正的问题。GC 判断的不是简单的空闲比例,而是对象能否被回收、对象能否在代际之间移动、类是否仍然可以卸载,以及当前分配请求是否能在合适的区域完成。
先明确:内存不是一块连续的池子
从 Java 程序员的角度看,最常接触的是堆,但 JVM 的运行时内存至少还包括以下几类:
- 堆:保存绝大多数对象实例,由垃圾收集器管理。
- Java 虚拟机栈:保存线程栈帧、局部变量和操作数栈。栈溢出与堆内存不足不是同一类问题。
- 元空间:保存类元数据,位于本地内存中。类加载器泄漏可能导致元空间持续增长。
- 代码缓存:保存 JIT 编译后的机器码。
- 直接内存:例如 NIO 的堆外缓冲区,不计入普通堆使用率。
因此,“堆使用率不高”并不能证明进程没有内存压力。直接内存耗尽、元空间无法扩展、晋升失败,或者大对象无法找到合适区域,都可能引起异常停顿。
对象能否回收,核心取决于可达性。GC Roots 通常包括活动线程栈中的引用、静态字段、JNI 引用以及同步相关对象等。只要一条从 GC Root 到对象的引用链仍然存在,即使业务上已经不再需要它,GC 也不能回收。
一个容易复现的“内存没满但回收无效”例子
下面的代码模拟了一个无上限缓存。它的问题不是创建对象本身,而是静态字段一直持有这些对象,使对象始终从 GC Root 可达:
import java.util.ArrayList;
import java.util.List;
public class StaticCacheLeak {
private static final List<byte[]> CACHE = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
while (true) {
CACHE.add(new byte[1024 * 1024]);
Thread.sleep(20);
}
}
}CACHE 是静态字段,生命周期接近于对应的类和类加载器。列表中的字节数组即使已经没有业务意义,仍然能通过 StaticCacheLeak.class -> CACHE -> byte[] 这条引用链被访问,所以 Full GC 也只能清理其他不可达对象。
实际项目中,类似问题更常见于无上限本地缓存、监听器列表、全局 Map、失败任务重试队列,以及把请求对象错误地放入静态集合。判断内存泄漏不能只看一次 GC 前后的曲线,而要看多次 Full GC 之后的存活对象是否持续增长。
为什么 Young GC 之后对象会进入老年代
对象通常先在年轻代分配。新生代回收时,仍然存活的对象会被复制到 Survivor 区,经过多次回收或满足收集器策略后晋升到老年代。短命对象多时,Young GC 可能很频繁,但每次回收都能释放大量空间,这通常不是问题。
真正危险的是存活对象增长太快。例如一次批量查询把数十万条记录全部转换成 DTO,并在请求结束前一直保留;或者消息消费速度跟不上生产速度,导致队列中的对象持续存活。此时 Survivor 区放不下存活对象,或者老年代没有足够空间接收晋升对象,就可能触发更重的回收。
对于 G1,还要考虑大对象和区域化管理。一个对象达到一定大小后可能占用 Humongous Region。大 byte 数组、超大的字符串拼接结果、过大的序列化缓冲区,都可能造成区域利用率下降和回收压力增加。具体阈值与堆区域大小有关,不应在排查时写死一个固定字节数。
Java 内存模型会怎样影响排查
Java 内存模型解决的是线程之间的可见性、有序性和原子性,不是 GC 参数调优,但它会影响我们对对象生命周期的判断。
例如,一个线程把对象放入共享容器,另一个线程没有通过锁、volatile、并发容器或其他建立 happens-before 关系的方式读取它,就不能仅凭代码顺序推断另一个线程一定能及时看到最新状态。另一方面,已经发布到共享容器中的对象,即使业务线程认为“马上不用了”,只要容器仍持有引用,GC 仍会把它视为存活对象。
下面这种写法既有并发安全风险,也容易让排查变得困难:
class RequestContext {
private static final java.util.HashMap<Long, byte[]> DATA = new java.util.HashMap<>();
static void put(long id, byte[] value) {
DATA.put(id, value);
}
static byte[] get(long id) {
return DATA.get(id);
}
}如果确实需要共享缓存,应明确它的并发语义和生命周期,例如使用有界缓存、过期策略和并发容器。不要把“加了 volatile”当成所有并发问题的解决方案。volatile 能保证特定变量的可见性,但不能让 HashMap 的复合操作自动变成线程安全,也不会自动释放仍被集合引用的对象。
一套可执行的排查顺序
第一步是确认现象,而不是马上修改参数。记录进程启动参数、JDK 版本、收集器、堆大小、元空间配置、直接内存配置以及 GC 日志。JDK 9 及之后可以使用统一日志格式:
java -Xms2g -Xmx2g \
-Xlog:gc*,safepoint:file=/var/log/app-gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
-jar app.jar-Xms 与 -Xmx 是否相同不是绝对要求,但固定堆大小有助于减少动态扩容带来的干扰。生产环境应结合实际内存配额预留元空间、线程栈、直接内存和代码缓存,不能把容器内存全部配置给堆。
第二步是区分 Young GC、Mixed GC、Full GC 和停顿来源。重点观察回收前后堆占用、老年代占用、暂停时间、晋升情况以及是否伴随分配失败。G1 下出现一次 Full GC,并不等于所有问题都来自老年代泄漏;可能是并发标记未及时完成、晋升失败或分配请求无法满足。
第三步是在问题发生时采集对象和线程信息。常用命令包括:
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
jcmd <pid> Thread.print
jcmd <pid> VM.native_memory summaryGC.class_histogram 可以帮助确认哪些类的实例数量和总大小异常。需要注意,直方图通常有额外开销,是否使用带完整 GC 的选项要根据线上延迟要求决定。VM.native_memory 需要在启动时配合 -XX:NativeMemoryTracking=summary,否则无法获得完整的本地内存分类信息。
第四步是分析堆转储中的引用链,而不是只看排名靠前的类。一个占用很大的 byte[] 可能只是结果,真正的原因可能是某个缓存、队列或 ThreadLocal。重点追踪它的 GC Root 路径,并比较不同时间点的转储,确认对象是暂时堆积还是持续存活。
类加载问题要单独看
类加载器泄漏与普通对象泄漏表现相似,都会让内存持续增长,但对象根因不同。应用容器、插件系统、动态代理、脚本引擎和热部署场景中,旧类加载器如果仍被线程、静态字段、ThreadLocal 或注册表引用,那么它加载的类元数据也不能卸载。
排查时可以观察已加载类数量和类加载器变化:
jcmd <pid> VM.classloader_stats
jcmd <pid> VM.classes不同 JDK 和发行版支持的诊断命令可能存在差异,执行前应以目标环境的 jcmd <pid> help 为准。类数量上升本身不一定是泄漏,动态代理本来就可能产生新类;关键是旧类加载器是否持续存活,以及卸载后数量是否回落。
实践中的几个边界
不要看到 Full GC 就直接增大 -Xmx。如果根因是无界缓存,扩大堆只会延后故障;如果根因是直接内存或元空间,增大堆甚至没有帮助。
不要只看平均停顿时间。接口超时通常更关心长尾,应该把 GC 暂停与请求延迟、线程池队列、CPU、分配速率放在同一时间线上观察。
不要在生产环境随意频繁执行堆转储和强制 GC。它们可能制造新的长停顿,应提前评估磁盘、网络和业务容量,并优先在相同版本和相近流量的环境复现。
不要把 System.gc() 当作修复手段。它只是一个可能被 JVM 忽略或延迟处理的请求,无法改变对象仍被引用的事实。
总结
“堆还没满却频繁 Full GC”通常不是一个单一参数问题,而是对象分配速率、存活对象规模、代际晋升、收集器状态、类加载器生命周期和本地内存共同作用的结果。
一条可靠的排查路径应该是:先确认收集器和日志,再判断回收类型;接着比较 GC 前后的存活量,定位异常对象;随后沿 GC Root 分析引用链,同时检查元空间、直接内存和线程状态。只有找到对象为什么还活着,或者类加载器为什么还没被释放,调大堆、调整新生代比例等参数才有明确意义。
性能排查最重要的产物不是某个参数值,而是一条能够解释现象的证据链:谁创建了对象,谁持有它,为什么它没有及时死亡,以及这条生命周期是否符合业务设计。