🕵️ 内存泄漏排查:做Java应用的福尔摩斯!

130 阅读9分钟

"亲爱的华生,内存去哪了?跟我来,我们一起破案!" —— 福尔摩斯·Java版


🚨 什么是内存泄漏?

生活中的例子 🏠

想象你的房间:

正常情况 ✅:

买东西 → 用完 → 扔掉/回收 → 房间干净
       ↑__________________|
          良性循环

内存泄漏 ❌:

买东西 → 用完 → 忘记扔 → 堆在角落 → 越堆越多...
  ↓
最后:房间塞满,没地方住了!💥

Java中的内存泄漏

// 内存泄漏示例
public class MemoryLeak {
    private static List<byte[]> list = new ArrayList<>();
    
    public void leak() {
        // 不断添加对象
        list.add(new byte[1024 * 1024]);  // 每次1MB
        // 问题:list是static的,永远不会被回收!
        // GC心想:这个list还在被引用,不能回收它的内容
    }
}

定义

  • 对象不再使用了(逻辑上已死)
  • 但仍然被引用着(GC认为还活着)
  • 导致GC无法回收,内存一直增长
GC的困惑:
👻 对象:我已经没用了...
🤖 GC:但你还被引用着,我不敢回收你!
📈 内存:越来越满...
💥 最终:OutOfMemoryError!

🔍 内存泄漏的常见场景

场景1:静态集合类 📦

// ❌ 典型的内存泄漏
public class CacheManager {
    private static Map<String, Object> cache = new HashMap<>();
    
    public void addCache(String key, Object value) {
        cache.put(key, value);  
        // 问题:只添加,不删除!
        // cache是static的,永远不会被GC
    }
}

// ✅ 正确做法1:使用WeakHashMap
public class CacheManager {
    private static Map<String, Object> cache = new WeakHashMap<>();
    // 当key没有被其他地方引用时,会自动被GC
}

// ✅ 正确做法2:设置过期时间
public class CacheManager {
    private static Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
    
    public void addCache(String key, Object value, long ttl) {
        cache.put(key, new CacheEntry(value, System.currentTimeMillis() + ttl));
        cleanExpired();  // 定期清理过期数据
    }
}

场景2:未关闭的资源 🚪

// ❌ 忘记关闭资源
public void readFile(String path) {
    FileInputStream fis = new FileInputStream(path);
    // ... 读取文件
    // 问题:忘记关闭fis!
    // 导致:文件句柄泄漏、native内存泄漏
}

// ✅ 使用try-with-resources
public void readFile(String path) {
    try (FileInputStream fis = new FileInputStream(path)) {
        // ... 读取文件
    }  // 自动关闭!
}

场景3:ThreadLocal不清理 🧵

// ❌ ThreadLocal泄漏
public class UserContext {
    private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    
    public void setUser(User user) {
        userThreadLocal.set(user);
        // 问题:在线程池中,线程会复用
        // 如果不清理,User对象一直被持有!
    }
}

// ✅ 正确使用ThreadLocal
public class UserContext {
    private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    
    public void setUser(User user) {
        userThreadLocal.set(user);
    }
    
    public void clear() {
        userThreadLocal.remove();  // 用完必须清理!
    }
}

// 在业务代码中:
try {
    userContext.setUser(user);
    // ... 业务逻辑
} finally {
    userContext.clear();  // 确保清理
}

场景4:监听器和回调 📞

// ❌ 监听器泄漏
public class EventBus {
    private List<EventListener> listeners = new ArrayList<>();
    
    public void register(EventListener listener) {
        listeners.add(listener);
        // 问题:只添加,不移除!
    }
}

// ✅ 提供注销机制
public class EventBus {
    private List<EventListener> listeners = new CopyOnWriteArrayList<>();
    
    public void register(EventListener listener) {
        listeners.add(listener);
    }
    
    public void unregister(EventListener listener) {
        listeners.remove(listener);  // 记得注销!
    }
}

场景5:内部类持有外部类引用 🎭

// ❌ 内部类泄漏
public class Outer {
    private byte[] data = new byte[1024 * 1024];  // 1MB
    
    public Inner createInner() {
        return new Inner();
    }
    
    // 非静态内部类,隐式持有Outer的引用
    class Inner {
        public void doSomething() {
            // ...
        }
    }
}

// 问题场景:
Outer outer = new Outer();
Inner inner = outer.createInner();
outer = null;  // 以为可以回收outer了
// 但是:inner还持有outer的引用,outer无法被回收!

// ✅ 使用静态内部类
public class Outer {
    private byte[] data = new byte[1024 * 1024];
    
    // 静态内部类,不持有外部类引用
    static class Inner {
        public void doSomething() {
            // ...
        }
    }
}

🛠️ 内存泄漏排查工具箱

工具1:JVM自带工具 🔧

# 1. jps - 查看Java进程
jps -lv

# 2. jstat - 监控内存使用
jstat -gcutil <pid> 1000 10
# 每1秒打印一次,共10次
# 关注:Old区的使用率是否持续增长

# 3. jmap - 生成堆转储文件
jmap -dump:live,format=b,file=heap.hprof <pid>

# 4. jstack - 查看线程堆栈
jstack <pid> > thread.txt

工具2:MAT(Memory Analyzer Tool)🔬

最强大的内存分析工具!

MAT的主要功能:
┌────────────────────────────────────┐
│ 1. Histogram(直方图)              │
│    - 查看每个类的实例数量和占用内存 │
│                                    │
│ 2. Dominator Tree(支配树)        │
│    - 查看谁占用了最多内存           │
│                                    │
│ 3. GC Roots(GC根对象)             │
│    - 追踪对象为什么不能被回收       │
│                                    │
│ 4. OQL(对象查询语言)              │
│    -SQL一样查询对象              │
│                                    │
│ 5. Leak Suspects(泄漏嫌疑)       │
│    - 自动分析可能的内存泄漏         │
└────────────────────────────────────┘

🎯 完整的内存泄漏排查流程

步骤1:发现问题 🚨

症状:

监控指标异常:
┌──────────────────────┐
│ 1. 内存使用持续增长   │
│    ████████████      │  ← 不断上升
│                      │
│ 2. Full GC频率增加   │
│    每10分钟一次 →     │
│    每1分钟一次!      │
│                      │
│ 3. GC耗时变长        │
│    100ms → 5秒!     │
│                      │
│ 4. 最终:OOM崩溃!    │
│    💥💥💥           │
└──────────────────────┘

监控命令:

# 持续监控GC情况
jstat -gcutil <pid> 1000

# 输出示例:
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  99.99  50.00  89.50  95.50  90.00   1000   10.500   50    25.000   35.500

# 关注点:
# - O(Old区):如果持续增长,可能有内存泄漏
# - FGC(Full GC次数):如果频繁增加,有问题
# - FGCT(Full GC总耗时):如果很长,影响性能

步骤2:生成堆转储文件 📸

# 方式1:手动生成
jmap -dump:live,format=b,file=heap.hprof <pid>

# 方式2:OOM时自动生成(推荐)
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/path/to/dumps \
     YourApp

# 方式3:通过JMX远程生成
# 使用JConsole或VisualVM连接后,点击"堆Dump"

步骤3:使用MAT分析 🔬

3.1 打开堆转储文件

1. 启动MAT
2. File → Open Heap Dump → 选择 heap.hprof
3. 等待分析(可能需要几分钟)

3.2 查看Leak Suspects报告

MAT自动分析后会显示:
┌────────────────────────────────────────┐
│ Problem Suspect 1                      │
├────────────────────────────────────────┤
│ One instance of "java.util.ArrayList"  │
│ loaded by "<system class loader>"      │
│ occupies 512.5 MB (85.2%) of memory    │
│                                        │
│ 可疑!一个ArrayList占用了85%的内存!    │
└────────────────────────────────────────┘

3.3 查看Histogram(直方图)

点击 Histogram 按钮:

Class Name                      | Objects | Shallow Heap | Retained Heap
-------------------------------|---------|--------------|---------------
byte[]                         | 10,000  | 500 MB       | 500 MB
com.example.User               | 100,000 | 10 MB        | 510 MB
java.util.HashMap$Node         | 50,000  | 5 MB         | 5 MB

分析:
- byte[] 占用了500MB,可疑!
- User对象有10万个,可能是缓存泄漏

3.4 查看Dominator Tree(支配树)

支配树显示:谁真正"控制"了内存

例如:
com.example.CacheManager @ 0x12345678  | 500 MB
  └─ java.util.HashMap @ 0x23456789    | 500 MB
      └─ java.util.HashMap$Node[]      | 490 MB
          └─ com.example.User[]        | 480 MB
              └─ byte[][]              | 470 MB

结论:
- CacheManager是罪魁祸首!
- 它通过HashMap持有了500MB的内存

3.5 追踪GC Roots路径

右键对象 → Path to GC Roots → exclude weak/soft references

显示:
GC Root: Thread <main>
  ↓
com.example.CacheManager (static field)
  ↓
java.util.HashMap
  ↓
User对象

结论:
- User对象被static的CacheManager持有
- 所以无法被GC回收!

步骤4:使用OQL查询 🔎

-- 查找所有User对象
SELECT * FROM com.example.User

-- 查找大于1MB的对象
SELECT * FROM java.lang.Object WHERE @retainedHeapSize > 1048576

-- 查找HashMap中元素超过1000的
SELECT * FROM java.util.HashMap WHERE size() > 1000

-- 查找所有ThreadLocal
SELECT * FROM java.lang.ThreadLocal

-- 查找所有未关闭的Stream
SELECT * FROM java.util.stream.* WHERE @GCRoot = true

步骤5:定位代码位置 📍

在MAT中:
1. 找到泄漏对象
2. 右键 → Show in View → Thread Overview
3. 查看对象是在哪个线程创建的
4. 查看线程的堆栈信息

示例堆栈:
Thread Name: http-nio-8080-exec-10
  at com.example.CacheManager.addCache(CacheManager.java:25)
  at com.example.UserController.getUser(UserController.java:50)
  ↑ 找到了!在这里添加到缓存但没清理

🎪 实战案例

案例1:ThreadLocal泄漏 🔥

问题现象:

线上服务:
- 内存每天增长200MB
- 1周后OOM重启
- 发生在使用了线程池的服务

排查过程:

# 1. 生成堆转储
jmap -dump:live,format=b,file=leak.hprof 12345

# 2. MAT分析发现
ThreadLocal$ThreadLocalMap占用了2GB内存!

MAT分析结果:

Dominator Tree:
java.lang.Thread @ 0x123456  | 2 GB
  └─ ThreadLocal$ThreadLocalMap | 2 GB
      └─ Entry[]                | 2 GB
          └─ com.example.User[] | 1.8 GB

GC Roots路径:
Thread Pool Thread → ThreadLocalMap → User对象

根因分析:

// 问题代码
public class UserContext {
    private static ThreadLocal<User> context = new ThreadLocal<>();
    
    public void setCurrentUser(User user) {
        context.set(user);
        // 问题:用完没调用 remove()!
    }
}

// 线程池中:
Thread-1: 处理请求A → setCurrentUser(userA) → 请求结束 
                                           → userA还在ThreadLocal中!
Thread-1: 处理请求B → setCurrentUser(userB) → 请求结束
                                           → userB也在ThreadLocal中!
...
Thread-1被复用100次 → ThreadLocal里堆了100个User对象!

解决方案:

// ✅ 修复后的代码
public class UserContext {
    private static ThreadLocal<User> context = new ThreadLocal<>();
    
    public void setCurrentUser(User user) {
        context.set(user);
    }
    
    public void clear() {
        context.remove();  // 关键!
    }
}

// 在拦截器或Filter中确保清理
public class UserContextInterceptor implements HandlerInterceptor {
    @Override
    public void afterCompletion(HttpServletRequest request, 
                                HttpServletResponse response, 
                                Object handler, Exception ex) {
        UserContext.clear();  // 请求结束必须清理!
    }
}

案例2:静态集合缓存泄漏 📦

问题代码:

// ❌ 有问题的缓存
@Service
public class DataCache {
    // static的HashMap,永远不会被GC!
    private static Map<String, List<Data>> cache = new HashMap<>();
    
    @PostConstruct
    public void init() {
        // 启动时加载数据
        loadAllData();
    }
    
    private void loadAllData() {
        // 从数据库加载1000万条数据
        List<Data> allData = dataMapper.selectAll();  // 10GB数据!
        
        // 按类型分组
        for (Data data : allData) {
            cache.computeIfAbsent(data.getType(), k -> new ArrayList<>())
                 .add(data);
        }
        // 问题:数据加载后就再也不会被释放了!
    }
}

修复方案:

// ✅ 方案1:使用本地缓存库(推荐)
@Service
public class DataCache {
    private final LoadingCache<String, List<Data>> cache = 
        CacheBuilder.newBuilder()
            .maximumSize(10000)  // 限制大小
            .expireAfterWrite(10, TimeUnit.MINUTES)  // 10分钟过期
            .build(new CacheLoader<String, List<Data>>() {
                @Override
                public List<Data> load(String type) {
                    return dataMapper.selectByType(type);
                }
            });
}

// ✅ 方案2:使用WeakHashMap
private static Map<String, List<Data>> cache = new WeakHashMap<>();

// ✅ 方案3:使用Redis做分布式缓存
@Service
public class DataCache {
    @Autowired
    private RedisTemplate<String, List<Data>> redisTemplate;
    
    public List<Data> getData(String type) {
        // 从Redis读取,不占用JVM堆内存
        return redisTemplate.opsForValue().get("data:" + type);
    }
}

🎯 预防内存泄漏的最佳实践

✅ DO(应该这样做)

// 1. 使用try-with-resources
try (Connection conn = getConnection()) {
    // 自动关闭
}

// 2. ThreadLocal用完必须清理
try {
    threadLocal.set(value);
    // 业务逻辑
} finally {
    threadLocal.remove();  // 必须!
}

// 3. 使用弱引用缓存
Map<String, Object> cache = new WeakHashMap<>();

// 4. 设置缓存上限
Cache<String, Object> cache = CacheBuilder.newBuilder()
    .maximumSize(1000)
    .build();

// 5. 监听器记得注销
try {
    eventBus.register(listener);
    // 业务逻辑
} finally {
    eventBus.unregister(listener);
}

// 6. 静态集合要定期清理
if (cache.size() > MAX_SIZE) {
    cache.clear();
}

❌ DON'T(不要这样做)

// 1. ❌ 不要滥用static
private static List<Object> list = new ArrayList<>();  // 危险!

// 2. ❌ 不要在循环中创建大对象
while (true) {
    byte[] big = new byte[1024 * 1024];  // 每次1MB
    list.add(big);  // 不释放
}

// 3. ❌ 不要忘记关闭资源
FileInputStream fis = new FileInputStream(file);
// ... 使用
// 忘记关闭!

// 4. ❌ 不要在长生命周期对象中引用短生命周期对象
public class LongLived {
    private List<ShortLived> list = new ArrayList<>();
    // ShortLived本该很快被回收,但被LongLived持有了
}

🎓 总结:内存泄漏排查秘籍

┌──────────────────────────────────────┐
│      内存泄漏排查四步法               │
├──────────────────────────────────────┤
│ 1️⃣ 发现问题:监控内存和GC指标        │
│    - jstat监控                       │
│    - 生产环境配置内存告警             │
│                                      │
│ 2️⃣ 获取证据:生成堆转储              │
│    - jmap手动生成                    │
│    - OOM时自动生成                   │
│                                      │
│ 3️⃣ 分析定位:使用MAT分析             │
│    - Leak Suspects                   │
│    - Histogram                       │
│    - Dominator Tree                  │
│    - GC Roots路径                    │
│                                      │
│ 4️⃣ 修复验证:修改代码并验证          │
│    - 修改代码                        │
│    - 压测验证                        │
│    - 持续监控                        │
└──────────────────────────────────────┘

记住这五个常见泄漏点:

  1. 静态集合 📦 —— 只添加不删除
  2. ThreadLocal 🧵 —— 用完不清理
  3. 监听器 📞 —— 注册不注销
  4. 资源未关闭 🚪 —— 连接、流、锁
  5. 内部类 🎭 —— 持有外部类引用

下次面试官问内存泄漏排查,你就说

"内存泄漏就是对象不再使用但仍被引用,导致GC无法回收。排查流程是:先用jstat监控发现问题,然后用jmap生成堆转储,再用MAT分析找到泄漏对象,最后追踪GC Roots路径定位代码。常见的泄漏场景有静态集合、ThreadLocal、监听器、未关闭的资源和内部类持有外部引用。预防的关键是:资源用完及时释放,静态集合要限制大小,ThreadLocal用完必须remove!" 🕵️

🎉 掌握内存泄漏排查,做JVM的福尔摩斯! 🎉