JVM Full GC 优化指南
频繁的 Full GC 会引发长时间的应用程序停顿(Stop-The-World),严重影响服务的响应速度和稳定性。为了帮助你系统地解决这个问题,我梳理了一个优化流程,并汇总了常见的 Full GC 触发原因及应对策略。
本文将注解介绍发生Full GC的常见的原因,并给出具体的排查方向,主要可以从代码层面上的优化以及参数上的调优。
具体定位问题的工具上怎么使用。
一 、概述
下面的流程图展示了核心的排查与优化步骤,你可以根据它来定位和解决问题:
二、排查内存泄漏
当发现 Full GC 在固定时间间隔频繁发生,并且老年代内存在每次 GC 后也未见明显回落,这通常预示着存在内存泄漏。你可以按照以下步骤排查:
2.1 生成堆转储文件:在发生 Full GC 后,使用 jmap -dump:format=b,file=heap.bin <pid> 命令导出堆内存快照。
2.2 分析堆转储文件:
使用 Eclipse Memory Analyzer (MAT) 等工具分析导出的堆转储文件。重点关注:
1.重复创建的大对象
例如巨大的数组或集合。大对象因为年轻代空间较小而无法安置,JVM会把大对象直接放入到老年代,因此为了能够安置大对象,会更加频繁的触发Full GC压缩老年代的内存空间。这就对象的到来的危害,在不同的GC中会有不同的处理方式。但是本质上都是快速回收加上压缩空间,或者是在代码设计上避免大对象。
2.GC Roots 引用路径
查找哪些对象不该被引用却无法被回收,主要原因主要有以下几种:
1) 未正确释放的 ThreadLocal 变量。
原因分析:ThreadLocal 内部使用 ThreadLocalMap 存储数据,其 Key 是 ThreadLocal 对象的弱引用,Value 是实际存储的值(强引用)。
泄漏路径:
- ThreadLocal 对象被回收 → Key 变为 null
- 但 Value 仍然被 Entry 强引用
- 如果线程长时间运行(如线程池线程),Value 永远无法被回收
代码示例:
public class ThreadLocalMemoryLeak {// 线程池,线程会复用private static final ExecutorService executor = Executors.newFixedThreadPool(1);public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 100; i++) {executor.execute(() -> {// 每次任务都创建新的ThreadLocal,但没有清理ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();threadLocal.set(new byte[1024 * 1024]); // 1MB数据// 模拟业务逻辑System.out.println("Task executed in thread: " + Thread.currentThread().getName());// 关键问题:没有调用 threadLocal.remove()// threadLocal 对象会被回收(弱引用),但value(1MB数据)会一直存在});Thread.sleep(100);}// 查看内存情况System.out.println("查看内存使用...");executor.shutdown();}
}
解决方案:
public class ThreadLocalFixed {private static final ExecutorService executor = Executors.newFixedThreadPool(1);public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 100; i++) {executor.execute(() -> {ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();try {threadLocal.set(new byte[1024 * 1024]);System.out.println("Task executed");// 业务逻辑...} finally {// 必须在使用完后清理threadLocal.remove();}});Thread.sleep(100);}executor.shutdown();}
}
2)生命周期过长的缓存(如 ConcurrentHashMap)。
原因分析:使用 ConcurrentHashMap 等作为缓存时,如果没有合理的淘汰策略,对象会一直被引用,即使业务上已经不再需要。
代码案例:
public class CacheMemoryLeak {// 全局缓存,生命周期与应用相同private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();private static final List<String> USED_KEYS = new ArrayList<>();public static void main(String[] args) throws InterruptedException {// 模拟不断向缓存添加数据for (int i = 0; i < 10000; i++) {String key = "data-" + i;byte[] data = new byte[1024 * 1024]; // 1MB数据CACHE.put(key, data);USED_KEYS.add(key);// 模拟业务逻辑:只使用前100个keyif (i >= 100) {// 但忘记从缓存中移除不再使用的数据// 导致后面的9900个1MB对象永远无法回收}if (i % 1000 == 0) {System.out.println("已添加 " + i + " 个对象到缓存");printMemory();}Thread.sleep(10);}}static void printMemory() {Runtime runtime = Runtime.getRuntime();long usedMemory = runtime.totalMemory() - runtime.freeMemory();System.out.printf("已使用内存: %.2f MB%n", usedMemory / (1024.0 * 1024.0));}
}
解决方案:
public class CacheMemoryFixed {// 使用弱引用或软引用缓存private static final Map<String, SoftReference<byte[]>> SOFT_CACHE = new ConcurrentHashMap<>();// 或者使用有大小限制的缓存(推荐)private static final Map<String, byte[]> LIMITED_CACHE = new LinkedHashMap<String, byte[]>(100, 0.75f, true) {@Overrideprotected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {return size() > 100; // 限制缓存大小为100个元素}};// 最佳方案:使用专业的缓存框架private static final Cache<String, Object> GUAVA_CACHE = CacheBuilder.newBuilder().maximumSize(1000) // 最大容量.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后10分钟过期.expireAfterWrite(1, TimeUnit.HOURS) // 写入后1小时过期.build();public static void addToCache(String key, byte[] data) {// 方案1:软引用缓存(内存不足时自动回收)SOFT_CACHE.put(key, new SoftReference<>(data));// 方案2:固定大小缓存synchronized (LIMITED_CACHE) {LIMITED_CACHE.put(key, data);}// 方案3:Guava缓存(推荐)GUAVA_CACHE.put(key, data);}
}
3)匿名内部类持有外部类引用导致无法回收。
原因分析:匿名内部类隐式持有外部类的引用,如果这个内部类被长生命周期对象引用,会导致外部类也无法被回收。
代码案例:
public class AnonymousClassMemoryLeak {private String largeData = new String(new byte[1024 * 1024]); // 1MB数据public interface EventListener {void onEvent(String event);}private EventListener listener;public void registerListener() {// 匿名内部类隐式持有外部类 AnonymousClassMemoryLeak.this 的引用listener = new EventListener() {@Override` public void onEvent(String event) {// 这里可以访问外部类的largeData字段System.out.println("Event: " + event + ", data: " + largeData);}};// 假设listener被全局事件管理器持有GlobalEventManager.register(listener);}public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 100; i++) {AnonymousClassMemoryLeak leak = new AnonymousClassMemoryLeak();leak.registerListener();// 虽然leak对象超出作用域,但由于listener被全局持有// 导致leak对象(包含1MB数据)无法被GC回收if (i % 10 == 0) {System.gc();Thread.sleep(100);printMemory();}}}static void printMemory() {Runtime runtime = Runtime.getRuntime();long usedMemory = runtime.totalMemory() - runtime.freeMemory();System.out.printf("已使用内存: %.2f MB%n", usedMemory / (1024.0 * 1024.0));}
}// 模拟全局事件管理器
class GlobalEventManager {private static final List<EventListener> LISTENERS = new ArrayList<>();public static void register(EventListener listener) {LISTENERS.add(listener);}
}
解决方案:
public class AnonymousClassFixed {private String largeData = new String(new byte[1024 * 1024]);public interface EventListener {void onEvent(String event);}private EventListener listener;public void registerListener() {// 方案1:使用静态内部类,不持有外部类引用listener = new StaticEventListener(largeData);// 方案2:使用弱引用持有外部类WeakReference<AnonymousClassFixed> weakThis = new WeakReference<>(this);listener = new EventListener() {@Overridepublic void onEvent(String event) {AnonymousClassFixed outer = weakThis.get();if (outer != null) {System.out.println("Event: " + event + ", data: " + outer.largeData);}}};GlobalEventManager.register(listener);}// 静态内部类,不持有外部类引用private static class StaticEventListener implements EventListener {private final String data;StaticEventListener(String data) {this.data = data; // 只持有需要的数据,而不是整个外部类}@Overridepublic void onEvent(String event) {System.out.println("Event: " + event + ", data: " + data);}}// 提供清理方法public void unregisterListener() {if (listener != null) {GlobalEventManager.unregister(listener);listener = null;}}
}
2.3 代码修复
根据分析结果,修复代码中的问题,例如及时清理无用的对象引用、确保资源被正确关闭、修复可能导致循环引用的逻辑等。
注意:在内存泄漏问题没有解决之前,盲目调整JVM参数通常效果不佳。切记在工作过程中主要以预防为主、治理为辅。
三、 调整 JVM 参数
如果排除了明显的内存泄漏,或者 Full GC 的发生伴随着 对象晋升失败(promotion failed)、**并发模式失败(concurrent mode failure)**或 元空间(Metaspace)不足等现象,那么 JVM 参数可能需要进行调整。
-
合理分配堆与各代大小
- 初始堆与最大堆:建议将
-Xms和-Xmx设置为相同值,避免堆内存动态调整带来的性能损耗。 - 新生代大小:使用
-Xmn参数显式设置新生代大小。过小的新生代会导致大量对象提前进入老年代,容易引发 Full GC。通常可以尝试设置为整个堆大小的 1/3 到 1/2。 - Survivor 区比例:通过
-XX:SurvivorRatio调整 Eden 区与 Survivor 区的比例。过小的 Survivor 区 可能容纳不下 Minor GC 后的存活对象,导致它们直接进入老年代。
- 初始堆与最大堆:建议将
-
选择合适的垃圾收集器
根据你的应用需求和硬件配置,选择合适的垃圾收集器,并调整相应参数,可以显著减少GC停顿。以下是一些主流的选择:收集器 适用场景与参数建议 G1 适用于大内存、追求低停顿的应用。可设置 -XX:MaxGCPauseMillis=200(最大停顿时间目标)和-XX:InitiatingHeapOccupancyPercent=45(触发并发周期的堆占用率)。CMS JDK 9 之前常用,需警惕晋升失败和并发模式失败。可尝试调低 -XX:CMSInitiatingOccupancyFraction(如从70调至75),并搭配-XX:+UseCMSCompactAtFullCollection(Full GC时开启碎片整理)。ZGC JDK 11+ 引入,适用于超大堆和超低延迟(暂停时间<10ms)场景。 -
留意其他参数
- 元空间(Metaspace):JDK 8 之后,元空间溢出也会触发 Full GC。可以通过
-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置合适的大小,例如-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。 - 大对象阈值:通过
-XX:PretenureSizeThreshold控制直接进入老年代的对象大小,避免短命大对象侵占老年代。 - 禁用显式GC:使用
-XX:+DisableExplicitGC禁止在代码中调用System.gc(),因为这会建议JVM进行Full GC。
- 元空间(Metaspace):JDK 8 之后,元空间溢出也会触发 Full GC。可以通过
4 大对象的处理
核心的配置思路是:让大对象直接进入老年代,并优化老年代的垃圾回收,以避免因大对象在新生代来回拷贝造成的性能损耗,或过早触发Full GC。
下表汇总了关键的JVM参数:
| 参数名 | 作用与说明 | 推荐场景与值 |
|---|---|---|
-XX:PretenureSizeThreshold | 对象超过此值直接进入老年代。避免在Eden和Survivor区间大量内存复制。 | 例如 -XX:PretenureSizeThreshold=1M。注意:仅对Serial和ParNew新生代收集器有效。 |
-XX:MaxTenuringThreshold | 设置对象晋升老年代的年龄阈值。降低此值可让存活一定次数的对象提前进入老年代。 | 默认15。可适当调低,如 -XX:MaxTenuringThreshold=8。 |
-Xmn | 显式设置新生代大小。增大新生代可降低Minor GC频率,给对象更多在新生代回收的机会。 | 通常为堆大小的1/3到1/2。G1收集器不要设置此参数。 |
-XX:SurvivorRatio | 调整Eden区与Survivor区的比例。增大Eden可延缓Minor GC。 | 例如 -XX:SurvivorRatio=8 (Eden:Survivor=8:1)。 |
-XX:+UseG1GC | 启用G1垃圾收集器。G1设有Humongous区专门处理大对象(通常超过Region大小的50%)。 | 堆内存较大(如8G以上)或关注延迟稳定的应用。 |
-XX:G1HeapRegionSize | 在G1中,此参数决定Region大小,也间接决定何种对象被视为大对象(Humongous对象)。 | 手动指定,如 -XX:G1HeapRegionSize=8M。 |
5 不同收集器的配置策略
-
ParNew + CMS 组合:
如果你的应用使用此组合,可以这样配置:-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:PretenureSizeThreshold=1M # 让1MB以上对象直接进老年代 -Xmn2g # 设置新生代大小,例如堆大小为6G时 -XX:SurvivorRatio=8 -XX:CMSInitiatingOccupancyFraction=70 # 老年代使用70%时触发CMS要点是使用
PretenureSizeThreshold避开新生代,并通过-Xmn设定合适的新生代。 -
G1 收集器:
对于G1,你通常不需要直接配置大对象阈值,G1会自动在Humongous区分配大对象。-XX:+UseG1GC -XX:G1HeapRegionSize=8m # 若堆内存8G以内且有较多大对象推荐设置此值 -XX:MaxGCPauseMillis=200 # 设定目标暂停时间G1的调优重点在
G1HeapRegionSize(影响大对象判定)和暂停时间目标。
四、 优化应用代码
良好的编码习惯是从根源上减少 Full GC 的关键。
- 避免创建过大的对象:如超大的数组或集合。尽量分块处理数据。
- 谨慎使用大对象:如果无法避免创建大对象,应确保这些对象是长时间使用的,以避免它们迅速占满老年代空间。
- 及时释放资源:对于如文件句柄、数据库连接等资源,确保使用后及时关闭。
- 优化常用类:例如,避免在循环或频繁调用的方法中创建
SimpleDateFormat等重量级对象,可以考虑使用ThreadLocal进行缓存。
五、 常用监控与诊断工具
- 命令行工具:
jps:查看 Java 进程 ID。jstat -gcutil <pid> <interval>:实时监控 GC 状态,包括各代使用率和 GC 次数/时间。jstack <pid>:查看线程堆栈,可用于分析死锁、线程阻塞等问题。
- 图形化工具:
- JVisualVM:JDK 自带,可监控内存、线程、CPU,并支持堆转储和分析。
- Eclipse MAT:强大的堆转储分析工具,擅长定位内存泄漏。
- Arthas:阿里开源的线上诊断神器,功能强大。
希望这份指南能帮助你定位并解决 Full GC 的烦恼。如果你的应用部署在特定的环境(例如 Docker 容器),或者有特殊的性能指标(如要求亚秒级延迟),可以提供更多信息,我们可以进一步探讨更具体的优化方案。
