Skip to content

内存溢出 vs 内存泄漏(分析/案例)

内存溢出 vs 内存泄漏:两个不同的概念

很多人把 OOM 和内存泄漏混为一谈,但它们有本质区别:

维度内存溢出(OutOfMemoryError)内存泄漏(Memory Leak)
本质对象无法分配到足够内存对象不再使用却无法回收
原因内存耗尽持有不必要的对象引用
症状立即抛出 OOM逐渐耗尽内存
GC 影响GC 后仍无法分配GC 回收不了
堆表现堆接近最大值,GC 后仍满堆持续增长,GC 无法回收

内存溢出(OOM)

堆溢出

java
public class HeapOOM {
    public static void main(String[] args) {
        // 不断分配对象,直到堆耗尽
        List<byte[]> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                list.add(new byte[1024 * 1024]);  // 每次分配 1MB
                i++;
            }
        } catch (OutOfMemoryError e) {
            System.out.println("分配了 " + i + " MB 后 OOM");
            throw e;
        }
    }
}
java.lang.OutOfMemoryError: Java heap space
    at HeapOOM.main(HeapOOM.java:9)

元空间溢出

java
public class MetaspaceOOM {
    public static void main(String[] args) {
        // 不断生成新的类,填满 Metaspace
        int i = 0;
        try {
            while (true) {
                // 使用 CGLib 或反射动态生成类
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(MetaspaceOOM.class);
                enhancer.setCallback((MethodInterceptor) (o, m, p, a) -> m.invoke(o, a));
                enhancer.create();
                i++;
            }
        } catch (Error e) {
            System.out.println("生成了 " + i + " 个类后 OOM");
            throw e;
        }
    }
}
java.lang.OutOfMemoryError: Metaspace

直接内存溢出

java
public class DirectMemoryOOM {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                // 直接内存分配
                ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 256);  // 256MB
                buffers.add(buffer);
                i++;
            }
        } catch (OutOfMemoryError e) {
            System.out.println("分配了 " + i + " 块直接内存后 OOM");
            throw e;
        }
    }
}
java.lang.OutOfMemoryError: Direct buffer memory

OOM 的常见原因

原因说明解决方案
大量对象分配一次性分配太多对象减少对象数量、增加堆大小
内存泄漏对象无法回收排查泄漏根源
类加载过多反射/动态代理生成类太多增大 Metaspace
直接内存未释放NIO 使用过多增大直接内存上限
字符串常量池大量 intern()增大 StringTable

内存泄漏(Memory Leak)

内存泄漏的本质

内存泄漏是对象应该被回收,但由于被其他对象引用而无法回收。GC 的可达性分析无法发现这些对象,因为 GC Roots 仍然可以访问它们。

典型泄漏场景一:静态集合持有引用

java
public class StaticCollectionLeak {
    // 静态集合持有对象引用,永不释放
    private static final Map<String, Object> CACHE = new HashMap<>();

    public static void main(String[] args) {
        // 不断向静态集合添加数据
        // 即使集合中的数据不再需要,也永远不会被回收
        // 因为 CACHE 是 GC Root(静态变量)
        while (true) {
            CACHE.put(UUID.randomUUID().toString(), new byte[1024 * 1024]);
        }
    }
}

GC Roots:静态变量 CACHE

泄漏路径CACHE(GC Root) → HashMap → Entry → key/value → 对象

典型泄漏场景二:未关闭的资源

java
public class ResourceLeak {
    public static void main(String[] args) throws IOException {
        // 文件流、数据库连接、网络连接
        // 如果不显式关闭,这些对象不会被 GC
        // 即使程序不再使用
        FileInputStream fis = new FileInputStream("big.txt");
        // 没有 fis.close()!
        // FileInputStream 持有 native 资源,不关闭 = 内存泄漏
    }
}

正确做法

java
// 方式一:try-with-resources(JDK 7+)
try (FileInputStream fis = new FileInputStream("big.txt")) {
    // 使用 fis
}  // 自动关闭

// 方式二:finally
FileInputStream fis = null;
try {
    fis = new FileInputStream("big.txt");
} finally {
    if (fis != null) fis.close();
}

典型泄漏场景三:监听器未注销

java
public class ListenerLeak {
    private static final List<EventListener> LISTENERS = new ArrayList<>();

    public void addListener(EventListener listener) {
        LISTENERS.add(listener);  // 添加了监听器
        // 但没有 removeListener() 方法,或调用者忘记移除
    }

    public static void main(String[] args) {
        // 每次创建对象都添加监听器
        // 但从未移除
        while (true) {
            addListener(new EventListener() {
                @Override
                public void onEvent(Object e) {}
            });
        }
    }
}

典型泄漏场景四:ThreadLocal 未清理

java
public class ThreadLocalLeak {
    private static final ThreadLocal<byte[]> DATA = new ThreadLocal<>();

    public static void main(String[] args) {
        // 线程池中的线程会长期存活
        // 如果 ThreadLocal 没有清理
        // ThreadLocalMap 中的 Entry 就不会被回收
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < 10; i++) {
            pool.submit(() -> {
                DATA.set(new byte[1024 * 1024]);  // 设置了
                // 任务结束后,DATA 没有被 remove()
                // 线程复用,DATA 中的数据永远不会被清理
            });
        }
    }
}

正确做法

java
pool.submit(() -> {
    try {
        DATA.set(new byte[1024 * 1024]);
        // 使用 DATA
    } finally {
        DATA.remove();  // 任务结束后清理
    }
});

典型泄漏场景五:字符串驻留

java
public class StringInternLeak {
    public static void main(String[] args) {
        // 大量 intern() 调用,导致 StringTable 持续增长
        // JDK 6 中会撑满 PermGen
        while (true) {
            String s = generateRandomString();
            s.intern();  // 不要对随机字符串调用 intern()
        }
    }
}

排查方法

排查 OOM

bash
# 1. 生成堆转储
java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError MyApp

# 2. 生成 dump 后继续运行
java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/tmp/ \
     -XX:+ExitOnOutOfMemoryError \
     MyApp

排查内存泄漏

bash
# 1. 定期生成堆 dump
jmap -dump:format=b,file=/tmp/heap.hprof <pid>

# 2. 对比多次 dump 的对象数量变化
jmap -histo <pid> | grep <可疑类>

# 3. 使用 MAT 分析 dump
# MAT(Memory Analyzer Tool)可以:
# - 找出占用最大的对象
# - 追踪引用链路
# - 找出泄漏根源

jstat 监控内存趋势

bash
# 每秒输出一次 GC 统计
jstat -gcutil <pid> 1000

# 输出:
# S0   S1    E    O    M    CCS   YGC   YGCT  FGC  FGCT   GCT
# 0.00 0.00 65.21 78.33 92.00 88.00  123 2.34    5 12.34   0  14.68
# 如果 O(老年代)持续增长且 FGC(Full GC)后不降 → 内存泄漏

内存泄漏 vs 内存溢出 vs 内存不足

内存泄漏:
  对象 A 持有对象 B 的引用
  A 被 GC Root 引用(A 是有用的)
  B 被 A 引用但实际不再使用
  B 无法被回收
  → 持续占用内存

内存溢出:
  堆 / 元空间 / 直接内存 全部耗尽
  无法再分配新对象
  → 抛出 OOM

两者关系:
  内存泄漏 → 内存占用持续增长 → 最终内存不足 → OOM

本节小结

内存溢出与内存泄漏的对比:

维度内存溢出内存泄漏
定义无法分配到足够内存对象无法被 GC 回收
表现抛出 OutOfMemoryError内存持续增长,GC 无法回收
排查dump + MAT 分析jstat 监控 + 对比 dump
常见场景堆/元空间/直接内存耗尽静态集合、ThreadLocal、未关闭资源
解决方案增大内存、优化分配移除不必要的引用

理解内存泄漏和溢出的关系,有助于从根本上解决 JVM 内存问题。

到这里,「GC 基础理论」部分全部完成。接下来进入「垃圾回收器(实战)」部分,首先看 GC 性能指标/回收器发展/组合关系

基于 VitePress 构建