内存溢出与内存泄露:从原理到排查的实战指南

admin 2026-11-08 17:33:52 毒圈预测

1. 从“内存”说起:先弄明白内存是怎么被管理的

很多朋友一遇到“内存溢出”或“内存泄露”就头皮发麻,尤其当线上服务突然宕机,日志里甩出一行java.lang.OutOfMemoryError: Java heap space的时候,整个人都是懵的。我最早接触这两个概念是在一家外包公司做报表导出功能,当时每个月都要挂一次服务,后来运维甩给我一句“你代码有内存泄露”,可我当时连“泄露”和“溢出”都分不清,更别提定位了。

先说我的结论:内存溢出是“装不下了”,内存泄露是“占着茅坑不拉屎”。这两个词经常被混着说,但它们本质上一个是“结果”,一个是“原因”。想要彻底搞懂它们,得先知道内存到底是怎么被分配和释放的。

1.1 JVM内存区域划分

以Java虚拟机的内存模型为例(其他语言类似,但Java最典型),内存主要分为几块:

堆(Heap):对象实例、数组都在这分配,是内存管理的“主战场”。

方法区/元空间(Metaspace):存放类元数据、常量、静态变量等。

虚拟机栈(VM Stack):每个线程一个栈,存放局部变量、操作数栈、方法调用帧。

本地方法栈(Native Method Stack):供native方法使用。

程序计数器:记录当前线程执行的字节码行号,很小。

堆是动态分配内存的核心区域,我们讨论内存溢出和内存泄漏时,绝大多数情况都发生在堆上。你把堆想象成一个超市的仓库,所有商品(对象)都要放进去,仓库货架满了还想放新商品,那就会“溢出”;而有些商品被放在了角落里但一直没人买也没人清理,仓库实际可用空间在慢慢变小,这就是“泄露”。

1.2 内存分配与回收机制

现代JVM使用**垃圾回收(GC)**自动管理内存。GC会定期扫描堆里的对象,判断哪些对象“不可达”(即没有引用指向它),然后回收它们的空间。这就像保洁阿姨定时来检查仓库,把没贴标签的、没人要的箱子清走。

GC之所以能判断对象是否存活,最常用的是“可达性分析”,也就是从GC Roots(比如静态变量、线程栈里的局部变量、JNI引用等)出发,沿着引用链往下找,能被找到的对象就是活的,找不到就是可回收的。

但问题来了:GC只回收“不可达”的对象,如果某个对象你不再用了,却依然有引用指向它,那它在GC眼里就是“活”的,永远不会被回收。这才是内存泄露的核心:你忘了把指向无用对象的引用断开,导致GC无法回收它。就像你把几箱过季衣服放在仓库角落,但没有贴上“废弃”标签,保洁阿姨以为还是你的库存,永远不动它们。

所以,内存泄露和垃圾回收机制密不可分。更准确地说:内存泄露是因为“可达但无用”的对象太多,导致堆中可用空间被挤占;内存溢出是因为堆空间被占满(要么真的太大,要么被泄露对象蚕食),新对象再也没地方放了。前者是内存管理策略错误,后者是分配失败的结果。

2. 内存溢出(OutOfMemoryError)到底是什么

内存溢出在Java里对应OutOfMemoryError,它是Error而不是Exception,说明这是JVM级别的问题,一般应用代码catch不住(catch了也没用,因为抛出后线程可能已经挂了)。不同区域的溢出有不同的报错信息,常见的有:

java.lang.OutOfMemoryError: Java heap space:堆空间不足。

java.lang.OutOfMemoryError: Metaspace:元空间不足,类的元数据太多。

java.lang.OutOfMemoryError: unable to create new native thread:创建新线程失败(本地内存不足)。

2.1 内存溢出的典型场景与代码示例

最简单的堆溢出,直接死循环创建对象并强引用:

JAVA

复制

1

public class OomDemo {

2

public static void main(String[] args) {

3

List list = new ArrayList<>();

4

while (true) {

5

list.add(new byte[1024 * 1024]); // 每次分配1MB

6

}

7

}

8

}

运行时候加上-Xmx64m(堆最大64MB),没过几秒就报Java heap space。这种代码一看就知道是故意的,真实场景里往往更隐蔽,比如:

批量读取超大文件到内存:

JAVA

复制

1

byte[] data = Files.readAllBytes(Paths.get("huge_file.zip")); // 文件几个G,直接撑爆

递归调用没有退出条件,导致栈溢出(StackOverflowError),这也是溢出的一种,只不过发生在虚拟机栈。

一次性从数据库查询全部数据再处理:

JAVA

复制

1

List allUsers = userMapper.selectAll(); // 表中几千万行

这三类都是贪心地把过多数据塞进内存,属于“内存真不够了”的溢出。

2.2 XSSFWorkbook内存溢出:一个真实高频案例

很多人在做Excel导出时遇到过XSSFWorkbook内存溢出,这个坑我踩得太深了。Apache POI的XSSFWorkbook是操作.xlsx文件的API,它会用DOM解析方式把整个工作簿的所有单元格、样式、公式全部加载到内存。如果你的数据量有几万行、几十列,每个单元格都对应一个内存对象,很容易就撑爆几百MB的堆。

我记得有次做运营导出一张50万行、30列的导出任务,直接-Xmx2g都扛不住,控制台肉眼可见地卡死,然后报Java heap space。后来换成了SXSSFWorkbook(Streaming Usermodel API),它属于流式处理,会在内存中保留最新一批行(可以通过rowAccessWindowSize控制),旧的行会写到临时磁盘文件,从而大大降低内存占用。

简单对比一下:

方式

内存占用

适用场景

XSSFWorkbook

高,整个工作簿驻留内存

小数据量,需要读取或修改已有模板和样式

SXSSFWorkbook

低,只保留窗口内的行

大数据量生成,但无法读取现有模板(只支持写)

排查这类问题,第一步先看是不是选错了API。如果已经用了SXSSFWorkbook还是溢出,那就得检查代码里有没有把SXSSFWorkbook的临时文件对象错误地存进List里,因为有的时候你为了后面对数据进行二次处理,会顺手把所有行都add到一个ArrayList里,那跟全量加载有什么区别?

除了POI,像JSON.toJSONString(巨量对象)、Excel的EasyExcel虽然做了流式处理,但如果你自己把大数据量List堆积在内存中一样会溢出。

2.3 溢出和内存截取的区别

标题里的热搜词中有“溢出和内存截取的区别”,我猜测提问者可能是学编程时遇到了“溢出”和“截取”这两个概念。其实“内存截取”并不是一个标准术语,通常指的是对字符串或字节数组按指定偏移量截取一部分,比如Java的String.subString()、Arrays.copyOfRange()。它和溢出是两个维度的问题:截取是“有意识地只拿一部分数据”,溢出则是“数据多到内存装不下导致异常”。

举个例子,你在C语言里定义了一个固定长度的数组char buf[10];,然后用strcpy(buf, "hello world")把11个字符拷进去,这时候第11个字符越界写入,这叫缓冲区溢出。而内存溢出的概念更宽泛,不仅仅指数组越界,而是整个内存区域的分配失败。Java在数组越界时会抛ArrayIndexOutOfBoundsException,而不是内存溢出,所以这两者千万别混。

3. 内存泄露(Memory Leak)——真正的幕后黑手

比起显眼的内存溢出,内存泄露更像是慢性病,平时看不出来,甚至服务能跑几个月,但堆内存的占用率像温水煮青蛙一样缓缓上升,直到哪天突然触发Full GC,你发现老年代空间怎么都回收不完,然后服务就挂了。内存泄露最恶心的地方在于,它不会立刻报错,而是等时间慢慢积累出问题。

3.1 内存泄露与内存溢出的关系

一句话:内存泄露长期不解决,最终必然导致内存溢出。因为被泄露的对象占用了堆空间,随着时间推移,可用空间越来越少,新对象无处分配,GC线程疯狂运行但还是收不出足够空间,最终OutOfMemoryError登场。

但反过来,内存溢出不一定都是由内存泄露导致的,比如一次性加载了过大的数据,这算“瞬间冲击”,用完就释放了,不会持续占用。排查时需要区分清楚:到底是“临时性超大对象”还是“长期累积的泄漏”。

判断方法很简单:用jstat -gc 1000连续观察老年代(Old区)的占用率。如果老年代不断增长,而且连续多次GC后依然没有下降趋势,基本可以判定有泄露;如果老年代是锯齿状,涨上去又降下来,那就是正常波动,溢出只是因为某次波动尖峰太高。

3.2 常见内存泄漏场景(集合静态引用、连接未关闭、ThreadLocal等)

我总结了几类最常见的代码级内存泄露,都是实战里摸爬滚打遇到的:

第一类:静态集合持有没用完的对象

JAVA

复制

1

public class CacheHelper {

2

public static List orders = new ArrayList<>();

3

4

public static void addOrder(Order order) {

5

orders.add(order);

6

}

7

}

静态集合的生命周期和JVM一致,如果只往里塞、不从里面删(哪怕删除逻辑没写对),所有塞进去的对象就永世不得超生。很多缓存模块就这么干,越攒越多,最终老年代爆掉。

第二类:连接/流未关闭

数据库连接、IO流、Socket、HTTP客户端等,用完必须关闭。Java 7之前的代码忘了close(),连接对象和其关联的内部缓冲区就会驻留内存。虽然现在的连接池会回收物理连接,但底层的Reader、Stream如果没关,仍然会引发泄露。我见过有人用InputStream读文件后忘了close,文件不大,但程序重复操作几千次,内存就浮上来了。

第三类:ThreadLocal使用不当

ThreadLocal常用来保存线程上下文,但每次用完必须调用remove()清理。尤其是使用线程池(如Tomcat的工作线程)时,线程是复用不销毁的,如果线程池里的线程绑定的ThreadLocal值一直不释放,那么这个值会一直伴随着线程存活。由于ThreadLocal的Entry是以线程为key的,当线程被池化后,key永远不会回收,value就成泄露了。这就是为什么网上各种面试题都喜欢问“ThreadLocal会造成内存泄露吗”。

第四类:监听器/回调没有反注册

你给某个事件源注册了一个监听器或回调,但不再需要它时没注销,事件源会一直持有监听器的引用。如果是观察者模式,观察者被通知完就完事了,但内部列表还是挂在被观察者身上,导致无法GC。

第五类:非静态内部类持有外部引用

在Java中,非静态内部类隐式持有外部类的引用。如果你把一个非静态内部类实例放到静态集合里,那外部类也不会被回收。比如:

JAVA

复制

1

class Outer {

2

class Inner {

3

}

4

static List list = new ArrayList<>();

5

void add() {

6

list.add(new Inner());

7

}

8

}

每个Inner实例都含有一个指向外层Outer实例的引用,即使外层已经没用,也会因为内部类还活着而被连带保留。

3.2 如何定位到具体泄露点

定位内存泄露,常规思路是先拿到堆转储(heap dump),再分析对象引用链。我用过两种方式:

方式一:jmap生成堆dump

BASH

复制

1

jmap -dump:live,format=b,file=heap.hprof

生成后可以用Eclipse MAT或VisualVM打开,找到“Leak Suspects”报告。MAT会直接告诉我哪些对象占用了大堆空间,以及它们的GC Roots引用链。

方式二:连续抽样对比

用jmap -histo:live 打印存活对象实例数和内存大小,隔一段时间再打一次,对比哪些对象实例数只增不减。如果发现增加的对象是你的业务实体,那基本就定位到了。

我举个实际例子:一个定时任务每天生成几个ScheduledExecutorService线程池,但任务结束后没调用shutdown(),导致线程池里的线程一直活着(线程对象会挂在一个非守护线程组下)。通过jstack查看线程状态,能发现大量WAITING的线程,然后顺藤摸瓜找到创建线程池的代码。这类问题用VisualVM直接看线程数和堆内存曲线就能注意到。

4. 如何定位与排查内存问题(工具与方法)

工欲善其事,必先利其器。我把自己用过的几个实用工具和排查套路整理一下,按场景分类,从命令行的老牌工具到Windows图形化工具都有。

4.1 jstat、jmap、jvisualvm:JVM内置三件套

jstat:监控JVM垃圾回收和类加载情况。常用命令:

BASH

复制

1

jstat -gcutil 1000 10

输出结果里FGC(Full GC次数)和FGCT(Full GC耗时)如果持续增长,同时O(老年代使用率)长期在90%以上,说明内存有问题。我有次排查线上服务,通过jstat发现FGC一分钟内有8次,FGCT累计2.3秒,几乎线程都卡在GC上,这种就是典型的堆内存吃紧。

jmap:导出堆内存快照、查看堆信息。常用:

BASH

复制

1

jmap -heap # 查看堆配置和使用情况

2

jmap -histo:live # 打印类实例统计,按内存从大到小

3

jmap -dump:format=b,file=/tmp/dump.hprof # 导出dump文件

注意-dump:live会先触发一次Full GC,适合确认哪些对象是存活对象,如果这时候dump出来的还是有大量业务对象,说明它们在GC后依然存活,基本就是泄露对象了。

jvisualvm:图形化界面,可以远程连接或者看本地进程。我比较喜欢它的“Heap Dump”功能和“GC”按钮,能手动触发GC然后观察堆内存是否下降。有时候内存泄露的明显特征就是点击“执行GC”之后,堆使用率毫无反应,还是居高不下,那肯定有东西牢牢抓住了所有对象。

4.2 关于“gflags调试内存泄露”是怎么回事

热词里提到“glags 调试内存泄露”,我猜大概率是拼写错误,正确写法应该是GFlags,它是Windows Sysinternals套件里的一个调试工具,全称是Global Flags。

GFlags主要用于启用/配置系统的底层调试功能,和Java堆内存泄露排查不太一样,更多是用在C/C++开发中。它能设置进程的堆标志、调试器设置等,比如开启用户模式栈追踪来辅助定位堆内存泄露。

具体到Windows上的内存泄露调试,常用的是Application Verifier(也来自Sysinternals),它可以监控应用程序对堆的使用,检测到内存泄露时会把调用栈记录下来。GFlags配合Application Verifier,可以在每次内存分配时保存调用栈,之后通过WinDbg的!heap -l命令来查看未释放的堆块。这套流程对C++程序员很重要,但对普通Java业务开发来说,使用频率不算高。

我在早期用C++写过Windows服务,遇到过内存持续增长,就是用GFlags开启堆栈追踪,再通过WinDbg分析!heap -l输出定位到了某个库函数没有释放内存。如果你调试的是Windows桌面程序或原生服务,可以试试这个组合,但如果你是做Java后端的,建议优先用JVM自带的工具,不要一上来就研究GFlags,容易跑偏。

4.3 Win内存泄露怎么解决:任务管理器、资源监视器、PoolMon

热词里还有“win内存泄露怎么解决”,这也是个很常见的问题,而且网上回答大多模棱两可。我把它分两种情况:一种是系统层面的内存被吃光(不是针对某个Java应用),另一种是某个Windows进程内存持续走高。

第一步:用任务管理器快速定位

打开任务管理器,切到“进程”标签,按“内存”列排序,找到内存占用持续上升的进程。如果某个进程的“内存(活动的专用工作集)”数值几秒翻一倍,那基本是这个进程在泄露。这时候你可以右键点击进程,选择“结束任务”先恢复系统,但治标不治本。

第二步:用资源监视器看句柄和内存

资源监视器(Win+R输入resmon)里有个“内存”面板,按“硬错误/秒”排序,如果某个进程持续触发大量硬错误(硬错误意味着要从磁盘换页),说明它内存压力极大。另外,“句柄”选项卡可以看进程打开了多少句柄,如果句柄数无限增长,那必然存在句柄或内存泄露。

第三步:用PoolMon监控内核池

很多系统层面的内存泄露(比如驱动导致)在任务管理器里看的是“非页面缓冲的池内存”不断上涨。PoolMon是Windows驱动开发者常用的工具,能显示按标记分类的池分配信息。使用命令行:

BASH

复制

1

poolmon /p 30

它会把各种Pool标签对应的分配量实时显示出来,找出不断增大的标签,再去上游查是哪个驱动用了这个标签。

但普通用户其实更常见的还是浏览器、游戏或某些国产软件的内存泄漏。根治方法无非几招:升级软件版本、关闭不必要的后台驻留程序、清理无用的启动项、更新显卡驱动。如果是一个自研程序泄露,就要用上面说的Debug工具配合源代码分析了。

5. 预防内存泄漏与溢出的最佳实践

聊完了排查,更重要的是怎么从源头避免。我干了十几年开发,见过太多内存问题都是写代码时不注意细节埋下的隐患。这里分享一些实打实的规范和建议。

5.1 代码层面的防守习惯

第一,集合、Map、缓存类一定要设置边界。 凡是可能会无限增长的数据结构,都要规定好上限,或者至少设置淘汰策略。用ConcurrentHashMap做缓存时,可以配合CacheBuilder或Caffeine这些有自动过期的工具,而不是自己无脑往Map里塞。

第二,任何资源获取都采用try-with-resources(Java)或with(Python)。 同样一段代码:

JAVA

复制

1

try (InputStream in = new FileInputStream("test.txt");

2

OutputStream out = new FileOutputStream("out.txt")) {

3

// 处理

4

}

Java里实现了AutoCloseable的类都能这样用,确保文件流、网络连接在结束时一定关闭。同理,JDBC连接、Statement、ResultSet等也要遵循,或者使用连接池保证连接复用和回收。

第三,ThreadLocal必须使用后清理。 像这样:

JAVA

复制

1

private static final ThreadLocal df = ThreadLocal.withInitial(SimpleDateFormat::new);

2

3

public void doSomething() {

4

try {

5

df.get().format(new Date());

6

} finally {

7

df.remove();

8

}

9

}

在finally里remove是最保险的。如果在Web请求过滤器里使用了ThreadLocal,可以在afterCompletion或者过滤器的finally里清理。

第四,数组、列表按需扩容,别一次性开辟海量空间。 如果不知道数据量,就采用分批处理(分页查询、批量写入)。多大批量才安全?经验值:单批数据量控制在堆内存的1%以内,比如堆是2G,那单次加载的数据不要超过20MB。

第五,警惕全局静态变量被无意间写入大对象。 类的静态属性会一直留在老年代,即使你把属性置为null,如果没有其他对象再持有它,GC才能回收。但很多人压根没置null的习惯。我不建议使用全局静态集合来存业务数据,除非你明确做过容量规划。

5.2 配置层面的调优与监控预警

单靠代码堵漏还是有风险,毕竟线上环境不可控,我们还需要“最后一道保险”。

设置JVM参数要合理。 很多人一遇到内存溢出就无脑加-Xmx,其实这是错的。堆设得过大,会延长Full GC时间,反而增加卡顿。更好的做法是先排查是否有泄露,没有泄露的话再根据对象生命周期调优。常规服务建议-Xms和-Xmx设置一致,避免堆自动伸缩带来的性能抖动。

使用容器化部署(Docker等)要设置内存限制。 如果不对容器限内存,JVM可能以为自己可以占用宿主机全部内存,导致被系统OOM Killer杀掉。最好加上类似参数:

BASH

复制

1

docker run -m 2g -e JAVA_OPTS="-Xmx1g"

JVM的-Xmx要略小于容器限制,留一点给元空间和线程栈。

监控必须是主动的。 我见过太多公司直到服务被OOM Kill才想起看日志。其实任何一个成熟项目都应该接上监控:用Prometheus+Grafana采集jvm_memory_used_bytes、jvm_gc_collection_seconds_sum这类指标,设置老年代使用率超过80%时告警,连续N分钟触发就拉群。

还有个小技巧:在启动参数里加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump,这样一旦发生OOM,JVM会自动保存堆转储文件,免得到时候想抓现场却抓不到。这个参数我建议所有上线的Java服务默认都加上,有百利而无一害。

最后说说GC调优。 如果你用的是JDK 11+,优先采用默认的G1收集器,如果堆较小(小于4G),默认的G1表现也不错,不需要额外调整。如果使用CMS(JDK 8及以前),要注意设置-XX:CMSInitiatingOccupancyFraction=75让CMS在老年代占用达到75%时就开始并发清理,避免等到空间耗尽才行动,那会和业务线程竞争CPU,造成卡顿。

最后分享点个人心得

我入行这十几年,内存出问题最多的项目倒不是那些高并发大流量的核心业务,反而是一些不起眼的内部管理系统。比如给财务导Excel的定时任务、给运营生成报表的批处理程序,因为“平时数据量不大”所以没人关注,一旦业务量翻了十倍,立刻炸穿。

这给我最大的教训是:写代码时永远不要假设数据量是固定的。你今天处理1万行没问题,不代表明天处理100万行还扛得住。用集合之前先想清楚,这个集合会无限增长吗?用连接之前先想想,这个流有没有在异常分支中正常关闭?用ThreadLocal之前先想清楚,这个值是否绑定到了被复用的线程上?这些问题想明白了,你写出来的代码基本不会再出现内存泄露。

内存溢出来的突然,内存泄露却熬得人白头发。但只要你手里有jstat、jmap、MAT这些工具,再加上规范的编码习惯,这俩问题也没那么可怕。遇到报错别慌,先看GC日志,再抓dump,顺着引用链一步一步捋,总能定位到真正的根因。希望这篇啰嗦的经验之谈能帮你少走些弯路,少熬几晚通宵。