一、什么是Java内存泄漏

内存泄漏就像你家水龙头没关紧,水一直滴答漏着,虽然每次漏的不多,但时间长了水池就满了。在Java里,对象本该被回收却因为某些引用没释放,导致堆内存逐渐被占满,最终引发OOM(OutOfMemoryError)。

典型场景:

  1. 静态集合类长期持有对象引用
  2. 未关闭的数据库连接/文件流
  3. 监听器未注销
  4. 线程池未正确shutdown
// 技术栈:Java 8  
public class StaticLeak {
    static List<byte[]> cache = new ArrayList<>();  // 静态集合生命周期与类相同
    
    void addData() {
        while(true) {
            cache.add(new byte[1024 * 1024]);  // 每次添加1MB数据
            // 问题:cache会无限增长,直到OOM
        }
    }
}

二、如何定位内存泄漏

2.1 工具选择三件套

  • jmap:生成堆转储文件
    jmap -dump:format=b,file=heap.hprof <pid>
    
  • VisualVM:实时监控堆内存曲线
  • Eclipse Memory Analyzer (MAT):分析dump文件

2.2 实战分析示例

// 技术栈:Spring Boot 2.7  
@RestController
public class LeakController {
    private Map<String, Object> sessionMap = new ConcurrentHashMap<>();
    
    @GetMapping("/save")
    public String saveSession(@RequestParam String key) {
        sessionMap.put(key, new byte[10 * 1024]); // 存入10KB数据
        return "success";
    }
    // 问题:没有删除机制,map会无限膨胀
}

通过MAT分析步骤:

  1. 查找Retained Size最大的对象
  2. 查看GC Roots引用链
  3. 定位到sessionMap的强引用

三、六种常见泄漏场景修复

3.1 集合类泄漏修复

// 修复方案:使用WeakHashMap  
private Map<String, SoftReference<byte[]>> cache = new WeakHashMap<>();

void addData(String key, byte[] data) {
    cache.put(key, new SoftReference<>(data)); 
    // 当内存不足时,GC会自动回收
}

3.2 线程池泄漏

错误示范:

ExecutorService pool = Executors.newFixedThreadPool(5);
pool.submit(() -> { /* 长时间任务 */ });
// 忘记调用 pool.shutdown()

正确做法:

try {
    pool.shutdown();
    if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
        pool.shutdownNow();
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

四、进阶防护策略

4.1 内存限制与监控

在JVM参数中添加:

-XX:+HeapDumpOnOutOfMemoryError  
-XX:HeapDumpPath=/tmp/oom_dump.hprof

4.2 使用LeakCanary(Android特供)

dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9'
}

4.3 代码规范检查

通过SonarQube规则:

  • S2696:检查未关闭的资源
  • S1215:禁止在finally块中调用return

五、避坑指南

  1. 缓存选择:优先用Guava Cache/Caffeine,它们自带LRU淘汰策略

    LoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .build(key -> loadDataFromDB(key));
    
  2. 监听器陷阱

    // 注册监听器时
    eventBus.register(this);
    
    // 必须配套注销
    @Override
    protected void finalize() {
        eventBus.unregister(this);
    }
    

六、总结与最佳实践

  • 预防重于治疗:代码审查时重点检查集合操作、资源关闭
  • 监控常态化:生产环境配置Prometheus+Granfana监控堆内存
  • 工具链固化:将MAT分析流程写入团队Wiki

当遇到OOM时,记住三板斧:

  1. jstat -gcutil看GC情况
  2. jmap抓取dump
  3. 用MAT按"Retained Heap"排序分析