"亲爱的华生,内存去哪了?跟我来,我们一起破案!" —— 福尔摩斯·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️⃣ 修复验证:修改代码并验证 │
│ - 修改代码 │
│ - 压测验证 │
│ - 持续监控 │
└──────────────────────────────────────┘
记住这五个常见泄漏点:
- 静态集合 📦 —— 只添加不删除
- ThreadLocal 🧵 —— 用完不清理
- 监听器 📞 —— 注册不注销
- 资源未关闭 🚪 —— 连接、流、锁
- 内部类 🎭 —— 持有外部类引用
下次面试官问内存泄漏排查,你就说:
"内存泄漏就是对象不再使用但仍被引用,导致GC无法回收。排查流程是:先用jstat监控发现问题,然后用jmap生成堆转储,再用MAT分析找到泄漏对象,最后追踪GC Roots路径定位代码。常见的泄漏场景有静态集合、ThreadLocal、监听器、未关闭的资源和内部类持有外部引用。预防的关键是:资源用完及时释放,静态集合要限制大小,ThreadLocal用完必须remove!" 🕵️
🎉 掌握内存泄漏排查,做JVM的福尔摩斯! 🎉