一、为什么GC停顿是个头疼的问题

想象一下你正在玩一款在线游戏,突然画面卡住几秒钟,等恢复时发现自己已经被击败了。这种"卡顿"在JVM世界里就是GC停顿。当垃圾收集器工作时,它会暂停所有应用线程,专心致志地清理内存垃圾。停顿时间越长,对用户体验影响越大。

我们来看个典型场景。假设有个电商平台,大促时每秒要处理上万订单:

// 技术栈:Java 11 + G1 GC
public class OrderProcessor {
    // 订单处理队列
    private Queue<Order> orderQueue = new ConcurrentLinkedQueue<>();
    
    public void processOrders() {
        while (true) {
            Order order = orderQueue.poll();
            if (order != null) {
                // 处理订单会产生临时对象
                processSingleOrder(order); 
            }
        }
    }
    
    private void processSingleOrder(Order order) {
        // 创建大量临时对象
        OrderDTO dto = convertToDTO(order);  // ① 转换对象
        validate(dto);                       // ② 校验数据
        saveToDatabase(dto);                 // ③ 持久化
        sendNotification(dto);               // ④ 发送通知
    }
}

这段代码在高峰期会出现什么问题?processSingleOrder方法每次调用都会创建多个临时对象,当订单量激增时,内存迅速填满,导致频繁GC。G1收集器虽然号称低延迟,但默认配置下仍可能出现50ms以上的停顿。

二、G1收集器的调优实战

G1(Garbage-First)是目前最主流的并行收集器,它的设计目标就是在有限时间内高效回收垃圾。我们通过几个关键参数来优化:

// JVM启动参数示例
-XX:+UseG1GC                           // 启用G1收集器
-XX:MaxGCPauseMillis=200               // 目标停顿时间
-XX:G1NewSizePercent=30                // 年轻代最小占比
-XX:G1MaxNewSizePercent=60             // 年轻代最大占比
-XX:ConcGCThreads=4                    // 并发GC线程数

让我们分解这些参数:

  1. MaxGCPauseMillis是调优的核心,但要注意这是个"目标值"而非保证值。设置过小会导致GC更频繁,反而降低吞吐量。

  2. 新生代比例需要根据对象生命周期调整。短期对象多的应用应该增大年轻代,长期存活对象多的则相反。

看个内存分配优化的例子:

public class CacheManager {
    // 不好的实现:频繁扩容的HashMap
    private Map<String, Product> productCache = new HashMap<>();
    
    // 优化后:预设容量避免扩容
    private Map<String, Product> optimizedCache = new HashMap<>(1024);
    
    public void loadProducts(List<Product> products) {
        // 旧方式:每次put都可能触发扩容
        for (Product p : products) {
            productCache.put(p.getId(), p);
        }
        
        // 新方式:一次性确保容量
        optimizedCache.putAll(
            products.stream()
                   .collect(Collectors.toMap(Product::getId, p -> p))
        );
    }
}

这个优化减少了临时对象的产生,从而降低GC压力。注释说明:

  • HashMap扩容会创建新数组并复制元素,产生垃圾
  • 预设大小可以避免多次扩容
  • 使用putAll比循环put更高效

三、对象分配的黄金法则

减少GC停顿的核心是减少垃圾产生。以下是几个实用技巧:

  1. 对象复用:对于频繁创建的临时对象,考虑对象池
  2. 大对象分离:超过Region大小一半的对象会被放入Humongous区,其回收成本高
  3. 避免过早晋升:长期存活对象应尽快移入老年代

示例:优化JSON解析场景

public class JsonParser {
    // 反例:每次解析都创建新ObjectMapper
    public Product parseProduct(String json) throws Exception {
        ObjectMapper mapper = new ObjectMapper();  // ① 创建开销大
        return mapper.readValue(json, Product.class);
    }
    
    // 正例:重用ObjectMapper
    private static final ObjectMapper sharedMapper = new ObjectMapper();
    
    public Product optimizedParse(String json) throws Exception {
        return sharedMapper.readValue(json, Product.class);
    }
    
    // 更优方案:使用线程局部变量
    private static final ThreadLocal<ObjectMapper> threadLocalMapper = 
        ThreadLocal.withInitial(ObjectMapper::new);
    
    public Product threadSafeParse(String json) throws Exception {
        return threadLocalMapper.get().readValue(json, Product.class);
    }
}

注释解析:

  • ObjectMapper创建成本高,应该重用
  • 静态变量在多线程环境下有安全问题
  • ThreadLocal为每个线程维护独立实例,兼顾性能与安全

四、监控与诊断工具链

优化离不开数据支撑,JDK自带的工具就很有用:

  1. jstat -gcutil:实时查看GC统计
  2. jmap -histo:分析堆内存分布
  3. GC日志分析:-Xlog:gc*参数启用详细日志

示例分析GC日志:

[1.234s][info][gc] GC(42) Pause Young (Normal) 512M->368M(1024M) 45.678ms
[2.345s][info][gc] GC(43) Pause Young (Normal) 689M->512M(1024M) 78.901ms

从这段日志可以看出:

  • 年轻代回收频率约1秒1次
  • 每次回收停顿时间在45-78ms之间
  • 内存使用率在35%-50%波动

建议添加-XX:+PrintAdaptiveSizePolicy参数,查看G1的自动调整决策。

五、特殊场景处理技巧

  1. 大内存机器:考虑增加-XX:G1HeapRegionSize(默认根据堆大小自动计算)
  2. 高并发系统:调整-XX:ConcGCThreads与-XX:ParallelGCThreads的比例
  3. 定时任务:在业务低峰期手动触发System.gc()

示例:处理海量数据时优化内存使用

public class BigDataProcessor {
    public void processLargeFile(String filePath) {
        // 旧方式:全量加载到内存
        List<Record> records = loadAllRecords(filePath); // ① 内存爆炸
        
        // 新方式:流式处理
        try (Stream<Record> stream = streamRecords(filePath)) {
            stream.forEach(this::processRecord);
        }
    }
    
    private Stream<Record> streamRecords(String path) {
        // 使用流API逐步处理
        return Files.lines(Paths.get(path))
                   .map(this::parseLine);
    }
}

注释说明:

  • 全量加载会导致单次GC需要处理大量对象
  • 流式处理保持内存占用稳定
  • 使用try-with-resources确保资源释放

六、总结与最佳实践

应用场景:

  • 对延迟敏感的系统(如交易系统、游戏服务器)
  • 内存使用波动大的应用
  • 需要长时间运行的守护进程

技术优点:

  • G1的停顿时间可预测
  • 并行回收效率高
  • 自动适应各种内存使用模式

注意事项:

  • 调优前必须先收集基准数据
  • 不同JDK版本的默认行为可能不同
  • MaxGCPauseMillis不是越小越好

最终建议的调优流程:

  1. 添加GC日志参数
  2. 运行典型负载24小时
  3. 分析日志确定瓶颈
  4. 针对性调整2-3个关键参数
  5. 重复验证直到达标

记住,没有放之四海而皆准的配置。比如同样是电商系统,商品详情页和订单系统的内存访问模式就完全不同。关键是要理解自己应用的特性,有的放矢地进行优化。