有 GC,为什么还会 OOM?这么问好像略显白痴一些
一句话答案
GC 只能回收没人用的对象。
如果对象一直有人拿着引用不放,GC 永远不敢动它,内存就会撑爆。
二、用生活场景理解
把 JVM 堆内存想象成一个停车场,GC 是停车场管理员。
停车场(Heap 堆内存) ┌─────────────────────────────────────┐ │ 🚗 车A(有人在用) │ │ 🚗 车B(有人在用) │ │ 🚗 车C(没人用,但钥匙还插着) │ ← GC 不敢拖走! │ 🚗 车D(有人在用) │ │ 🚗 车E(有人在用) │ │ 🚗 车F(有人在用) │ │ 🚗 车G(有人在用) │ │ ....(停满了) │ └─────────────────────────────────────┘GC 的工作原则:只要有一把"钥匙"(引用)指向这辆车,我就不能拖走它。
OOM 发生的原因:停车场停满了,新车进不来,但所有车都"有钥匙",管理员一辆都不能拖走。
停车场满了,新车来了 →java.lang.OutOfMemoryError: Java heap space
三、那 GC 到底在干什么?
GC 会定期扫描,把真正没人用的对象清掉:
GC 扫描: main() ─────▶ List list ─────▶ [obj1, obj2, obj3] │ ← 可以从 main 追踪到,还活着,不回收 ──┘ ╔══════════════════════════════╗ ║ 找不到任何路径能追踪到的对象 ║ ← GC 回收这些 ╚══════════════════════════════╝关键词:“可达性”。只要从程序入口能追踪到这个对象,GC 就认为它"还活着",绝对不回收。
四、常见的 OOM 真实原因
原因 1:一次加载数据太多(最常见)
// 我们项目的 OOM 就是这个!List<Map<String,Object>>saleList=salesDataGateway.batchSelectMap(query);// pageSize = 10000,每条 SalesData 有几十个字段// 10000 条 × 5KB/条 = 50MB 在堆里// 然后 bulk() 把它序列化成 byte[],又占 50MB// 峰值内存 = 50MB × 3(对象 + 序列化 + 网络缓冲)= 150MB// 这批数据还没处理完,下一批又进来了// 内存越堆越多 → OOM类比:你让搬运工一次搬 10000 箱货,他抱不动,直接跪倒。
原因 2:List / Map 无限增长(内存泄漏)
// 全局静态的 Map,往里加东西,从不清理staticMap<String,Object>cache=newHashMap<>();voidprocess(Requestreq){cache.put(req.getId(),req.getData());// 一直加// 永远没有 remove}// GC 看到 cache 还活着(静态变量) → 永远不回收// cache 里的东西越来越多 → OOM类比:仓库(HashMap)一直进货,从不出货,终于放不下了。
原因 3:循环里不断创建大对象
for(inti=0;i<1000000;i++){byte[]data=newbyte[1024*1024];// 每次创建 1MB 的数组process(data);// 以为 data 用完就没了// 但 GC 来不及回收!// 循环太快,内存创建速度 >> GC 回收速度 → OOM}类比:工厂每秒生产 1000 个箱子,但清理工每秒只能处理 100 个,堆积越来越多,仓库炸了。
原因 4:字符串拼接(大报文场景)
Stringresult="";for(Stringline:millionLines){result=result+line;// ❌ 每次都创建新字符串对象!}// 前一个 result 虽然没人用了,但 GC 还没来得及回收// 新的已经创建出来,内存翻倍 → OOM正确做法:用StringBuilder,原地拼接,不产生中间对象。
原因 5:ByteArrayOutputStream 无限扩容
// 我们 ES 写入 OOM 的直接原因// RestHighLevelClient.bulk() 内部:ByteArrayOutputStreamout=newByteArrayOutputStream();for(IndexRequestreq:requests){byte[]json=serialize(req);// 把每个文档序列化out.write(json);// 往 ByteArrayOutputStream 里写}// ByteArrayOutputStream 内部是 byte[]// 写满了就 Arrays.copyOf 扩容(扩成原来的 2 倍!)// 10000 条数据:50MB → 扩容 → 100MB → 扩容 → 200MB → OOM类比:快递公司把所有快递都装进一个袋子再发出去,袋子越撑越大,最后裂开了。
五、GC 为什么来不及救场?
GC 不是随时都在工作的,它有触发条件:
内存分配时序: 程序申请内存 │ ▼ Eden 区(年轻代)满了? │ ▼ 是 触发 Minor GC(清理年轻代) │ 还不够?Old Gen(老年代)满了? │ ▼ 是 触发 Full GC(清理全部) │ Full GC 后还不够? │ ▼ 是 OOM !!!关键矛盾:
- 程序申请内存速度:很快(循环 + 批量操作)
- GC 回收速度:相对慢(需要 STW 停顿,有开销)
当申请速度 >> 回收速度,就算 GC 拼命跑,也追不上。
六、为什么 GC 不回收"正在用的"对象?
这是 GC 的安全保证:
假设 GC 强行回收正在用的对象: Thread A: list.get(0).getName() ↑ GC 突然把这个对象回收了 ↑ Thread A: NullPointerException 崩溃! 所以 GC 宁可 OOM,也不会回收有引用指向的对象。 这是 Java 内存安全的基础。七、OOM 的本质总结
OOM 本质 = 内存需求 > 可用内存 两种情况: 情况 1:真的用了太多内存(一次批量太大) 解决:减少批量大小、流式处理、分批写入 情况 2:内存泄漏(该释放的没释放) 解决:检查静态集合、检查缓存是否有上限、 用 WeakReference、及时 close 资源 GC 能做的: ✅ 自动回收"没有引用"的对象 ❌ 不能回收"有引用但逻辑上不用了"的对象 ❌ 不能阻止程序一次申请过多内存八、我们项目 OOM 的具体原因和修法
原因链: batchSelectMap 查 10000 条 │ 50MB List<Map> ▼ bulk() 序列化所有数据到 ByteArrayOutputStream │ ByteArrayOutputStream 扩容 → 50MB → 100MB → 200MB ▼ HTTP 发送(还要序列化一遍) │ 再占一份内存 ▼ OOM !!! GC 想回收,但上面每一步的对象都"有人拿着",没法回收。 等到 bulk() 执行完,GC 才能回收,但那时已经 OOM 了。 修法 1:减小 pageSize(治标) 10000 → 2000,峰值内存直接降 5 倍 修法 2:BulkProcessor(治本) 每 500 条 / 每 2MB 自动 flush 一次 flush 完这批,对象释放,GC 及时回收 下一批再来时内存已经空出来了 峰值内存始终控制在 2MB 级别九、让我们记住这一句话
GC 是清道夫,但它只清"没人要的垃圾"。
如果你的代码一直"抱着"数据不放,GC 就算再努力也救不了你。
真正的解决之道:不要一次抱太多。