一、为什么垃圾回收会频繁发生

咱们先来聊聊为什么JVM会频繁进行垃圾回收。想象一下你家的垃圾桶,如果垃圾产生速度太快,清洁工就得频繁来收垃圾。JVM里的垃圾回收也是类似的道理。

常见的原因主要有这几个:

  1. 对象创建太快,就像疯狂网购产生大量包装盒
  2. 内存设置太小,相当于给了个迷你垃圾桶
  3. 对象存活时间太短,就像只用一次的餐具
  4. 存在内存泄漏,相当于垃圾没被正确识别

来看个Java示例,展示典型的快速创建对象场景:

public class FrequentGCExample {
    public static void main(String[] args) {
        // 无限循环快速创建临时对象
        while (true) {
            // 每次循环都创建新对象,这些对象很快变成垃圾
            String temp = new String("This is a temporary object");
            System.out.println(temp);
            
            try {
                // 稍微延迟一下,方便观察
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

二、如何发现垃圾回收频繁的问题

发现问题是解决问题的第一步。就像医生看病要先做检查一样,我们也有几件法宝:

  1. JDK自带的工具:jstat、jvisualvm
  2. GC日志分析
  3. 专业APM工具

重点说说怎么看GC日志。启动JVM时加上这些参数:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

然后我们就能看到类似这样的日志:

2023-07-20T14:23:45.123+0800: [GC (Allocation Failure) [PSYoungGen: 524800K->38210K(611840K)] 524800K->38210K(2010112K), 0.0234567 secs] [Times: user=0.05 sys=0.01, real=0.02 secs]

关键信息解读:

  • Allocation Failure:分配失败触发了GC
  • PSYoungGen:年轻代回收
  • 524800K->38210K:回收前后大小
  • 0.0234567 secs:耗时

如果这种日志出现太频繁,比如几秒一次,那就是有问题了。

三、常见问题场景与解决方案

1. 年轻代设置过小

这种情况就像给幼儿园小朋友准备的游乐场太小,孩子们挤来挤去,老师不得不频繁维持秩序。

解决方案示例(Java启动参数):

// 设置年轻代初始和最大大小
-XX:NewSize=512m -XX:MaxNewSize=512m
// 设置老年代大小
-Xms2g -Xmx2g
// 设置年轻代中Eden和Survivor区的比例
-XX:SurvivorRatio=8

2. 短命对象过多

这种情况就像快餐店的一次性餐具,用一次就扔,垃圾产生很快。

优化代码示例:

public class ObjectPoolDemo {
    // 使用对象池重用对象
    private static final ObjectPool<StringBuilder> pool = 
        new GenericObjectPool<>(new BasePooledObjectFactory<StringBuilder>() {
            @Override
            public StringBuilder create() {
                return new StringBuilder();
            }
            
            @Override
            public PooledObject<StringBuilder> wrap(StringBuilder obj) {
                return new DefaultPooledObject<>(obj);
            }
        });
    
    public static void main(String[] args) {
        // 使用对象池代替直接创建
        StringBuilder sb = pool.borrowObject();
        try {
            sb.append("Reusing objects ");
            sb.append("reduces GC pressure");
            System.out.println(sb.toString());
        } finally {
            // 使用完归还对象池
            pool.returnObject(sb);
        }
    }
}

3. 大对象直接进入老年代

大对象就像家具,应该直接放到仓库(老年代),如果硬要塞进小房间(年轻代),就会导致频繁整理。

解决方案参数:

// 设置大对象阈值,超过的直接进入老年代
-XX:PretenureSizeThreshold=1m
// 使用G1收集器自动处理大对象
-XX:+UseG1GC

四、高级调优技巧

1. 选择合适的GC算法

不同的垃圾回收器就像不同的清洁工:

  • Serial GC:单人清洁工,适合小房间
  • Parallel GC:团队清洁工,注重吞吐量
  • CMS:快速清洁工,减少停顿
  • G1:智能清洁工,分区域打扫

G1配置示例:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45

2. 优化对象分配

对象分配也有讲究,就像超市货架摆放:

  • 栈上分配:小物件直接放柜台
  • TLAB:给每个收银员单独的区域

相关参数:

-XX:+DoEscapeAnalysis  // 启用逃逸分析
-XX:+UseTLAB          // 使用线程本地分配缓冲区
-XX:TLABSize=512k     // 设置TLAB大小

3. 内存泄漏排查

有时候不是GC太频繁,而是有内存泄漏,就像垃圾没被正确扔掉。

排查工具示例:

public class MemoryLeakDemo {
    static List<byte[]> leakList = new ArrayList<>();
    
    public static void main(String[] args) {
        // 模拟内存泄漏,不断往列表添加数据却不清理
        while (true) {
            byte[] data = new byte[1024 * 1024]; // 每次分配1MB
            leakList.add(data);
            
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            // 用jmap可以观察到heap不断增长
            if (leakList.size() % 10 == 0) {
                System.out.println("已分配 " + leakList.size() + "MB");
            }
        }
    }
}

五、实战案例分析

来看一个电商系统的真实案例。系统在促销时出现频繁Full GC,页面响应变慢。

问题现象

  • 每分钟3-4次Full GC
  • 每次GC停顿1-2秒
  • 年轻代回收每秒10+次

排查过程

  1. 检查JVM参数:发现-Xmx设置只有2G
  2. 分析GC日志:发现老年代98%时才触发GC
  3. 检查代码:发现缓存使用不当

解决方案

  1. 调整JVM参数:
-Xms4g -Xmx4g
-XX:NewRatio=2
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
  1. 代码优化:
// 原代码:使用HashMap做缓存,无限增长
private static Map<String, Product> cache = new HashMap<>();

// 优化后:使用Guava Cache,带过期时间和大小限制
private static LoadingCache<String, Product> cache = CacheBuilder.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(new CacheLoader<String, Product>() {
        @Override
        public Product load(String key) {
            return loadProductFromDB(key);
        }
    });
  1. 结果:
  • Full GC降为每天几次
  • 年轻代GC每分钟几次
  • 系统响应时间降低80%

六、注意事项与最佳实践

在调优过程中,有几点需要特别注意:

  1. 不要过度调优:GC就像呼吸,完全停止会死,但刻意控制呼吸反而难受
  2. 循序渐进:一次只改一个参数,观察效果
  3. 关注业务指标:最终目标是业务流畅,不是GC次数最少
  4. 生产环境谨慎:先在测试环境验证
  5. 记录变更:详细记录每次调整和效果

推荐的最佳实践组合:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xms4g -Xmx4g
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m
-XX:+AlwaysPreTouch
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log

七、总结

JVM垃圾回收调优就像城市垃圾管理,需要综合考虑产生速度、处理能力和环境影响。关键点在于:

  1. 先诊断再治疗,用数据说话
  2. 合理设置内存大小,给GC足够空间
  3. 选择合适的收集器,匹配业务特点
  4. 优化代码,减少不必要的对象
  5. 监控持续进行,调优不是一劳永逸

记住,没有最好的配置,只有最适合的配置。根据你的业务特点,找到那个平衡点,让GC既不太频繁,又不至于停顿太久,系统就能顺畅运行。