内存溢出概述:你写代码时最怕看到的几个字
说实话,每个程序员早晚都会撞上内存溢出。我刚开始工作的第二年,线上一个Java服务半夜挂了,日志里就印着java.lang.OutOfMemoryError。那次我们排查了两天,最后发现是实习生写的缓存没做淘汰策略。嗯,从那以后我再也不敢轻视内存问题了。
今天咱们先把「内存溢出」这个老朋友从头到尾认识一遍。说白了,内存溢出就是程序向系统申请内存时,系统给不了这么多——或者给了,但程序把它的生命周期管坏了。你别小看这四个字,它背后藏着好几种截然不同的死法。
什么是内存溢出
官方定义:当程序需要分配内存,但可用内存不足以满足请求时,就会抛出内存溢出。但实际碰到的场景比这复杂得多。我总结下来,主要原因就三类:
- 申请过多 —— 比如一次性加载几千万条数据到集合里
- 内存泄露 —— 对象用了不还,GC回收不掉,慢慢把堆撑爆
- 配置不足 —— 虚拟机启动参数给的内存本身就不够你用
你想想看,同一个报错,根源完全不一样,排查手段也天差地别。所以第一件事情就是学会「看位置」——溢出发生在哪个内存区域?
四大溢出类型
1. 堆溢出(Heap Overflow)
这是最常见的一种,占了线上内存溢出故障的80%以上。堆上存的是对象实例和数组。当堆无法再创建新对象时,就会抛出 java.lang.OutOfMemoryError: Java heap space(Java)或者 Python 的 MemoryError。
我在项目中遇到过最典型的场景:一个批量导入接口,每处理一行都往ArrayList里塞对象,但忘了清空。跑了几万条后内存直接爆炸。看一下触发示例:
// Java 堆溢出示例 - 不断创建对象,不释放
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次申请1MB
}
# Python 堆溢出示例 - 无限往列表追加
data = []
while True:
data.append(' ' * 10**6) # 每次追加1MB字符串
2. 栈溢出(Stack Overflow)
栈存的是局部变量、方法调用帧。每次方法调用都会压栈,调用层次太深就会溢出。Java抛 java.lang.StackOverflowError,Python抛 RecursionError。
我记得有一次同事写了个递归,以为加了个终止条件就没事。结果那条终止条件永远进不去——因为参数传错了。栈帧一层层叠上去,几百层后直接挂掉。
// Java 栈溢出 - 无限递归
public void stackLeak() {
stackLeak();
}
# Python 栈溢出 - 递归深度超限
def recurse():
recurse()
recurse()
-Xss 调整,但增大栈空间并不能解决递归逻辑错误,只能延缓崩溃。
3. 元空间溢出(Metaspace Overflow / 方法区溢出)
Java 8 之后,永久代(PermGen)被元空间(Metaspace)取代。元空间存的是类的元数据、方法字节码、常量池等。如果生成类太多或者运行时频繁做CGlib/动态代理,就可能撑爆它。报错信息是 java.lang.OutOfMemoryError: Metaspace。
我曾经排查过一个框架,每次请求都用ASM生成一个新类,结果几千个请求后类加载器就废了。你想想看,元空间默认只受本地内存限制,但也不是无限的。
// 元空间溢出触发示例 - 使用CGLib动态生成大量类
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) ->
proxy.invokeSuper(obj, args));
enhancer.create();
}
4. 直接内存溢出(Direct Memory Overflow)
直接内存不是JVM堆的一部分,而是通过 java.nio.DirectByteBuffer 分配的堆外内存。Netty、Kafka等框架用得比较多。溢出时可能报 OutOfMemoryError: Direct buffer memory,也可能只是 IOException: Operation not permitted。
这个是四个中最隐蔽的。我见过一个案例:服务用了Netty做RPC,但ByteBuf用完没release。堆内存看起来好好的,但直接内存悄悄涨到操作系统上限。你查GC日志看不出问题,得用 jcmd 或者 pmap 才能定位。
// 直接内存溢出示例 - 分配大量DirectBuffer
ByteBuffer.allocateDirect(1024 * 1024); // 一次分配1MB
// 连续分配不释放,最终撑爆
Java vs Python:内存模型差异
为什么单独拿出来说?因为我发现很多Python转Java的同事,或者Java转Python的同事,都会在内存管理上翻车。其实两套语言的内存模型差异很大:
| 对比维度 | Java | Python |
|---|---|---|
| 内存管理方式 | 自动GC(分代收集) | 引用计数 + 分代GC |
| 堆溢出表现 | OutOfMemoryError: Java heap space |
MemoryError 或进程被OOM Killer杀掉 |
| 栈溢出表现 | StackOverflowError |
RecursionError: maximum recursion depth exceeded |
| 元空间溢出 | OutOfMemoryError: Metaspace |
无对应概念(Python类也是对象,存在堆里) |
| 直接内存溢出 | OutOfMemoryError: Direct buffer memory |
通过ctypes或numpy可能触发,但较少见 |
| 默认堆大小 | 物理内存的1/4(<=1GB) | 无单独配置,依赖系统 |
你看,Python没有元空间这个隔离区域,所有类定义也是堆里的对象。所以Python的「元空间溢出」实际上表现为堆溢出。理解了这个差异,你在排查跨语言系统时就不容易跑偏了。
本章知识体系
下面这张图把内存溢出的类型和关联关系串起来了,你可以当个速查地图用。
一个真实案例串起来看看
我最近帮一个团队排查过线上问题。他们用Python写了个数据处理服务,跑了一周后突然报 MemoryError,然后进程被OOM Killer杀掉。乍一看是堆溢出,但检查了代码——没有无限追加列表。后来我让他们加了两行日志,打印出 sys.getsizeof 的每个大对象,发现是某个numpy数组反复拼接导致。
你说这是堆溢出?对。但真正的病根是numpy在拼接时创建了大量的中间数组,旧数组没被及时回收。所以你看,内存溢出排查不能只看表面错误,得深入到数据结构的生命周期里。
这一章先铺垫到这里。后面的章节我会带你一步步学会:怎么捕获溢出信号、怎么用工具定位泄露点、怎么优雅地恢复Pipeline。咱们先从「认识敌人」开始,后面就是「精准打击」了。