1. 为什么过了这么多年,还是想写一篇关于堆内存与栈内存的文章
前阵子帮一个团队排查线上服务频繁 Full GC 的问题,聊着聊着发现一个很有意思的现象:很多写了好几年业务代码的同学,一说到堆内存和栈内存,能说出"栈存局部变量,堆存对象"这种教科书答案,但真让他们对着一个调用栈分析内存去向时,却常常把方向搞反。更别说被问到"堆外内存"的时候,基本都是一脸茫然。
我后来想想,这其实不怪他们。堆和栈的问题,往往散落在编译原理、操作系统、JVM 源码、性能调优各个角落,很少有文章把这两兄弟放在一起讲透。但内存体系恰恰是程序性能的底层底盘——线上 OOM、服务假死、GC 停顿、网络框架的零拷贝,追根溯源全都绕不开堆与栈。所以这篇文章我想从实际工程视角,把堆内存与栈内存这两根核心支柱彻底拆开揉碎,再结合一个很多人没搞清楚的概念:堆外内存。无论你是刚入门的学生,还是写了好几年 Java、C++、Go 的老兵,都应该能从里面找到点有价值的东西。
1.1 一个反直觉的问题:栈真的比堆快吗
几乎每个面试者都会背一句"栈比堆快"。但你要是追问一句"快在哪儿",大多数人就卡住了。其实栈本身并没有比堆更高的访问速度,真正拉开差距的是分配与释放的机制成本。
栈的分配,本质上就是移动一下栈顶指针。sub rsp, 0x20一条指令,局部变量的空间就划出来了;函数返回时再把指针加回去,内存立即回收。整个过程不涉及查找空闲块、不涉及锁竞争、不需要与操作系统交互。而堆分配则不同,分配器需要在空闲链表或空闲树里找到一块大小合适的区域,如果线程多了还要考虑锁竞争,分配完还要维护元数据。这一来一回,栈的分配成本可能只有堆的几十分之一甚至更低。
加上现代 CPU 的缓存机制,栈空间因为总是被高频访问,热数据基本都留在 L1/L2 Cache 里;而堆对象分布散乱,访问时缓存命中率天然吃亏。所以"栈快"的本质,是分配机制简单 + 缓存局部性好,而不是栈这种存储介质本身有什么魔法。理解这一点,后面讲逃逸分析、栈上分配时,你才知道 JVM 到底在优化什么。
1.2 先把定义边界说清楚,避免概念打架
在展开之前,我想先把术语边界划清楚,因为不同语境下"堆"和"栈"的含义容易混。
- 本文说的栈,指的是线程私有的调用栈(Call Stack),也叫运行时栈。它在程序启动时被创建,由操作系统或运行时环境分配一段连续内存。函数调用时压栈帧,返回时弹栈帧。
- 本文说的堆,指的是动态内存分配区(Heap),由分配器管理,生命周期由程序员(或垃圾回收器)控制。所有线程共享同一个堆。
- 特别注意:数据结构里的"堆"(大顶堆、小顶堆)和内存管理里的"堆"完全是两回事,只是英文都叫 heap。本文只讨论内存管理。
另外还需要提醒一点:不同语言对这个体系的封装程度差异巨大。C/C++ 里栈和堆是直接暴露给开发者的,你可以精确控制;Java 里栈和堆由 JVM 统一管理,开发者只能通过参数间接干预;Go 则更特殊,goroutine 的栈可以动态扩缩,堆上对象的回收又由 GC 负责。但无论哪门语言,底层的内存体系骨架都是一样的。我下文主要以 Java 和 C 为例展开,必要时用 Go 做对比。
2. 栈内存:程序执行的隐形调度员
2.1 一次函数调用,栈上到底发生了什么
栈的核心职责是支撑函数调用。每次调用一个函数,运行时都会在栈上压入一个"栈帧"(Stack Frame),里面装着这个函数运行所需的全部临时状态。一个典型的栈帧至少包含四类东西:
- 返回地址:函数执行完后,CPU 该回到哪里继续执行。
- 实参数据:调用方传入的参数值,或者指向参数的引用。
- 局部变量:函数内部声明的临时变量。
- 保存的寄存器状态:调用者模式下的寄存器快照,用于恢复现场。
用一个最简单的 C 程序来感受一下:
int add(int a, int b) { int sum = a + b; return sum; } int main() { int x = 3; int y = 4; int result = add(x, y); return 0; }当main调用add时,栈上的变化大致是:
main的栈帧已经存在,里面存着x=3、y=4。- 执行
add(x, y)时,先把y、x按调用约定压栈(参数压栈顺序取决于编译器和调用约定,x86-64 下更多是用寄存器传参,这里为便于理解简化为压栈),随后把add函数的返回地址压栈。 - CPU 跳转到
add函数入口,add为自己开辟栈帧,分配sum的空间。 add执行完毕,返回值放入约定的寄存器(通常是rax),然后根据栈帧中保存的返回地址跳回main。main的栈帧接管控制权,add的栈帧空间被回收——注意,是"逻辑回收",内存里的旧数据还在,只是栈顶指针已经越过它了。
整个过程就像一摞便签纸,最上面的便签属于当前正在执行的函数,写完撕掉,露出下面一张继续写。整洁、高效、几乎零成本。
2.2 栈溢出长什么样,为什么递归会摧毁栈
栈的空间是有限的。线程创建时,系统会分配一块固定大小的栈空间,比如 Java 默认的线程栈通常是 512KB 到 1MB,C 程序主线程栈一般是 8MB 级别。一旦栈帧压入的总深度超过这个上限,就会出现栈溢出(Stack Overflow)。
最常见的触发场景就是无限递归。每次递归调用压入一个栈帧,每个栈帧哪怕只有几十字节,1MB 的栈也撑不过几万层递归。比如这段代码:
public class StackOverflowDemo { public static int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); // 递归调用 } public static void main(String[] args) { System.out.println(fib(100000)); // 直接栈溢出 } }用-Xss512k启动时,大概递归到几千层就会抛出StackOverflowError。这里我想特别强调一个实操中经常被忽略的点:一旦抛出 StackOverflowError,线程栈可能已经处于损坏状态,继续在这个线程上执行任何代码都可能产生更奇怪的问题。所以正确的处理方式是立即记录日志并终止该线程,而不是 try-catch 吞掉错误继续跑。
我在实际工作中遇到过一个典型案例:一个报表服务用递归方式解析深层嵌套的 JSON。测试数据只有几层深度,一切正常;上线后客户导入了一条深度达数百层的结构,服务直接栈溢出。后来我把递归解析改成显式栈(用 Java 的ArrayDeque模拟深度优先遍历),问题立刻消失,而且内存占用更可控。这个经验值得记住:遇到深层嵌套的数据结构,优先想到用堆代替栈,而不是一味调大栈空间。
2.3 栈上分配与逃逸分析:栈的隐藏优化
除了存放局部变量和函数调用信息,栈在现代运行时里还有一个隐藏职责——承接那些本可以在栈上分配的对象。这就是 JVM 的"逃逸分析"(Escape Analysis)加"标量替换"。
原理不复杂:如果编译器能证明一个对象不会被传递到当前方法之外,也不会被其他线程访问,那么这个对象就可以不真正分配到堆上,而是拆散成多个基础字段直接在栈上分配。比如:
public class Point { int x; int y; } public static int sum() { Point p = new Point(3, 4); // p 只在方法内使用,没有逃逸 return p.x + p.y; }HotSpot JVM 经过逃逸分析后,发现p完全没有逃逸出sum方法,于是不会在堆上创建Point对象,而是直接在栈上分配两个整型x和y。这样既避免了堆分配的开销,又减轻了 GC 压力。
但这只是一种"编译优化",不是语言层面的保证。在 C++ 中,栈上分配是直接语法层面的行为:MyClass obj;就是栈对象,离开作用域自动析构;而new MyClass()才是堆对象,需要手动delete。Java 开发者没有这种显式控制权,只能依赖 JIT 的逃逸分析结果。这也是为什么 Java 代码里大量创建短生命周期的小对象,实际性能并没有想象中那么差——因为 JIT 在幕后帮你做了栈上分配。
Go 语言也类似,编译器通过逃逸分析决定变量放在栈还是堆。go build -gcflags="-m"可以查看逃逸分析结果。如果你发现原本以为在栈上的变量逃逸到了堆,就要考虑是不是传了指针给外部、或者闭包捕获了变量。
3. 堆内存:动态生命周期的自由市场
3.1 为什么必须有堆:栈解决不了的两类问题
如果所有内存都能在栈上优雅地分配释放,那堆压根没有存在的必要。但现实中有两类问题,栈完全招架不住。
第一类:需要跨函数存活的数据。一个函数创建一个对象,返回给调用方继续使用,这个对象的生命周期超出了当前栈帧。如果把它放在栈上,函数返回时栈帧回收,对象就没了。你当然可以通过返回值拷贝,但对象如果很大、或者涉及动态大小(数组、列表、字符串),拷贝的成本就不可接受了。
第二类:大小无法在编译期确定的数据。栈帧的大小在编译期就基本确定了(变长数组除外)。但程序的真实运行场景里,一个用户上传的文件、一个网络包、一个查询结果集,大小都是运行时才决定的。堆允许你在运行时动态申请任意大小的内存。
所以堆存在的本质逻辑是:用稍高的管理成本,换取灵活性和生命周期控制权。
3.2 分配器在忙什么:碎片问题与分配策略
堆内存不像栈那样有一个"顶指针"可以随手移动。堆里散落着各种大小不一、存活时间不一的对象,分配器的职责就是在这些碎片中找到合适的空闲块。这里就引出了两个经典问题:外部碎片和内部碎片。
- 外部碎片:空闲内存总体积足够,但被切成了很多小块,找不到一块连续的大块来满足分配请求。
- 内部碎片:分配器为了对齐或减少元数据开销,给了你一块比你请求更大的内存,多余部分被浪费。
不同分配器的策略差异很大。早期的 dlmalloc 用空闲链表,按大小分类;现代 C/C++ 的 glibc malloc 采用多线程本地缓存加主分配区的结构;Google 的 tcmalloc、jemalloc 则更进一步,用线程本地缓存(Thread Cache)大幅减少锁竞争。JVM 的堆内部也有复杂的分配逻辑,新生代对象通常在 Eden 区用"碰撞指针"(Bump-the-Pointer)分配——因为 Eden 是连续空白的,只需要移动指针就能完成分配,非常快。这也是为什么 Java 中大部分对象分配并不慢的原因之一。
有一个漫威级别的比喻我经常拿来用:栈像一个整理好的书架,你只看最上面那本书,拿完放回最上面;堆像一个开放式的仓库,各种货物随便堆,你需要时去翻找合适的空位,经常翻乱了还得请人(GC)来重新整理。
3.3 堆管理的两条技术路线:GC 与手动释放
堆上对象用完了,怎么把空间还回去?业界形成了两条路线。
路线一:程序员手动释放(C/C++)。malloc对应free,new对应delete。优势是精确可控、没有 GC 停顿;劣势是人为失误代价极高——忘了释放就是内存泄漏,释放了又用就是悬垂指针(Dangling Pointer),就可能出现 use-after-free 漏洞。著名的心脏病(Heartbleed)漏洞本质就是缓冲区没检查边界加上内存管理错误,这种事故就是手动内存管理的巨大风险。
路线二:垃圾回收(Java、Go、C# 等)。运行时自动识别并回收不再可达的对象。基础思路其实就三类:
- 引用计数(Reference Counting):每个对象维护被引用次数,归零即回收。实现简单,但难以处理循环引用,例如 Python 需要额外的标记清除算法兜底。
- 标记-清除(Mark-Sweep):从 GC Roots 出发遍历对象图,可达的标记,不可达的清除。会产生碎片,所以一般配合标记-整理(Compact)或复制算法使用。
- 分代收集(Generational Collection):基于"大部分对象朝生夕灭"的统计规律,把堆分成新生代、老年代,不同区域用不同回收算法。这是 HotSpot 等主流 JVM 的看家本领。
我个人观点是:GC 解决了一大批人心智负担,但并不是万能的。GC 做得再好,程序员的"逻辑泄漏"(对象已经没用但还被一个全局缓存引用着)依然能让堆占比居高不下。所以不管走哪条路线,理解堆上对象的生命周期,永远是写程序的基础功。
4. 一次方法调用,看堆与栈如何携手完成协作
4.1 一段真实代码的内存旅程
理论说了那么多,不如看一段完整代码在内存里如何跑通。就拿随手写的一个 Java 方法举例:
public class OrderService { private static final List<Order> CACHE = new ArrayList<>(); // 堆上的静态对象 public Order createOrder(String userId, int amount) { User user = findUser(userId); // user 引用在栈上,User 对象在堆上 Order order = new Order(user, amount); // order 引用在栈上,Order 对象在堆上 CACHE.add(order); // order 逃逸进了堆上的静态列表 return order; // 引用通过栈上的返回值返回给调用方 } }一次createOrder调用,内存是这样协作的:
createOrder栈帧入当前线程的调用栈,栈帧里存放参数userId、amount、以及局部变量user、order的引用(8 字节,具体大小与 JVM 压缩指针有关)。findUser方法被调用,它的栈帧压栈。执行过程中在堆上创建了一个User对象,假设 JDK 17 开启压缩指针后对象头 12 字节加字段对齐后总共 24 字节。findUser返回时把User对象的堆地址放入返回值载体,findUser栈帧弹出。- 回到
createOrder,栈上的user引用指向堆里那个User对象。接着new Order(...)在堆上再开辟一块空间,存下Order对象头、字段user引用、字段amount等。 CACHE.add(order)把order引用存进堆上的静态ArrayList内部数组里。此时Order对象的引用已经"逃逸"到了更广的作用域,它绝不能待在栈上。return order把地址复制到上一个栈帧的返回值位置,createOrder栈帧弹出,局部变量user、order的引用空间收回——但堆上的User、Order对象依然存活,因为它们被CACHE和调用方变量引用着。
整个流程已经能看出堆和栈的分工模式了:栈负责顺序、临时、自动化的任务调度;堆负责动态、共享、长生命周期的数据驻留。栈上的引用是通向堆的"门牌号",栈帧是函数的"临时办公室"。
4.2 从协作关系看常见故障的定位思路
理解协作关系,最大的实际价值是遇到线上内存故障时,你能第一眼判断问题出在哪个环节。
- 栈溢出:表现为大量
StackOverflowError或本机栈溢出崩溃。特征非常明确,堆内存占用通常还正常,GC 也没有压力。优先看调用深度、递归逻辑、线程栈大小。 - 堆内存溢出:
OutOfMemoryError: Java heap space或者 C++ 的bad_alloc。特征是堆占满、GC 持续回收不到内存。优先查大对象、缓存无上限、内存泄漏点。 - 堆外内存问题:GC 正常、堆占用不高,但服务进程的物理内存(RSS)却持续上涨,最后被操作系统 OOM Kill。这类问题最隐蔽,后面我会在讲堆外内存时专门展开。
还有一种常见但容易被误解的现象:明明只是创建了一个大数组,却抛了OutOfMemoryError: Java heap space,去查堆发现内存还很宽裕。这很可能不是堆不够,而是没有连续的堆内存。JVM 堆虽然在逻辑上连续,但在碎片化严重时,一个超大数组仍然可能找不到连续区域。这时加大堆不一定是第一选择,先需要看是否有大量存活对象导致老年代碎片化。
4.3 局部变量到底在堆还是栈:一个经典的认知陷�阱
很多人觉得"局部变量就是栈上的",这个说法不完全错,但不准确,而且会误导你排查内存问题。
准确的说法是:局部变量的引用(地址)保存在栈上,但如果这个变量指向的对象是 new 出来的,对象本体一定在堆上。比如:
public void demo() { byte[] buffer = new byte[1024 * 1024 * 100]; // 100MB 的数组 // 栈上只有一个8字节引用,堆上躺着100MB数据 }这段代码里,"分配了一个 100MB 的局部变量"这句话严格说是不对的。栈上只是分配了一个引用,真正的 100MB 在堆上。这也是很多内存泄漏排查时最难转过弯的地方——你调用了一个方法,方法里创建了大数组,方法返回后"变量"不在了,但堆里的对象如果没有其他引用,依然要等 GC 来回收,不会立即消失。如果在循环里不断调用类似方法,堆占用会在短时间内迅速飙升,直到触发 GC。
5. 堆外内存:容易被忽略的第三支柱
5.1 什么是堆外内存,为什么会火起来
最近这些年,"堆外内存"这个词在 Java 社区越来越热。所谓堆外,就是指不受 GC 管理、不在 Java 堆内分配的内存,由操作系统直接分配。Java 中常见的堆外内存来源包括:
ByteBuffer.allocateDirect(capacity)分配的 DirectByteBuffer 底层用的是堆外内存。Unsafe.allocateMemory/safe.allocateMemory,直接绕开 Java 对象体系去申请原生内存。- 基于 JNI 调用的 C/C++ 库自己分配的内存,比如 JDBC 驱动里的 MySQL C Client 可能分配的内存。
- Netty、RocketMQ、Kafka 等高性能中间件,大量使用堆外内存做缓冲。
为什么要用堆外内存?最核心的动机有三个:
第一,减少 GC 压力。堆内大数组(比如 64MB 的缓冲)对 GC 是巨大负担,尤其是老年代收集时,扫描大对象要花不少时间。把缓冲区放到堆外,GC 完全感知不到,可以显著降低 Full GC 停顿。你想想,一个 16GB 堆的 Java 服务,如果把网络收发缓冲区全部放在堆内,每次 Full GC 都要扫描这些大对象,停顿时间很难看。
第二,避免拷贝带来的性能损耗。网络 I/O(FileChannel、SocketChannel)在读写时,如果是堆内的byte[],通常要先复制到堆外的原生缓冲区,再做系统调用;如果用 DirectBuffer,就直接从堆外内存发起 I/O,省了一次内存复制。Netty 的"零拷贝"特性,一部分就是靠堆外内存换来的。
第三,突破堆大小限制。有些场景下,整个机器的物理内存很多,但你不想把这部分内存纳入 GC 管理,也不想受-Xmx约束。堆外内存由操作系统分配,可以超过 JVM 堆上限。
5.2 堆外内存使用要付出的代价
堆外内存这么好,为什么不是所有程序都全员堆外?因为它有非常明显的代价。
最麻烦的是生命周期管理。堆外内存不归 GC 管,直白点说:没人帮你自动释放,一旦你忘了清理,这块内存就泄漏了。Java 里 DirectByteBuffer 虽然有一个Cleaner机制,在 GC 回收 DirectByteBuffer 对象时尝试释放底层的堆外内存,但这个触发时机不可控。如果大量 DirectByteBuffer 对象被长期引用,堆外内存就会持续增长。
其次,堆外内存的分配成本比堆内高。每次向操作系统申请内存都要走系统调用,还要考虑对齐等细节,所以实际工程中几乎都采用池化的方式,比如 Netty 的PooledByteBufAllocator,提前分配一块大的堆外内存池,需要时从中取用,用完归还。
第三个坑是泄漏难以察觉。堆内泄漏,你至少能看到堆占用上涨,能通过jmap -histo:live看对象分布。堆外泄漏,JVM 的堆指标一切正常,但操作系统的top、free里,进程的 RSS 像温度计一样不断上升。排查手段相对受限:NMT(Native Memory Tracking,需要启动时加-XX:NativeMemoryTracking=detail)可以看 JVM 自身占用的各类原生内存;pmap可以看进程地址空间分布;perf配合 eBPF 可以追踪原生内存的 malloc 分配点,但调试成本明显更高。
这里分享一个我经历过的排查案例(基于常见实践的总结):一个网关服务上线后,运行约一周,进程 RSS 从 2GB 涨到 20GB,但jstat -gcutil显示堆占用一直稳定在 60% 左右,Full GC 几乎没有。最后加-XX:NativeMemoryTracking=summary观察,发现Internal和Other部分内存暴涨,配合pmap看到大量 64MB 左右的内存块。进一步排查发现是业务代码里频繁调用ByteBuffer.allocateDirect分配临时缓冲区,导致堆外内存碎片化 + 部分缓冲区未被 Cleaner 及时回收。修复方案很简单:复用堆外缓冲区,或者改用池化的 NettyByteBuf,问题随即消失。
5.3 什么场景该用堆外,什么场景不该用
以我个人的经验,决策可以这么判断:
| 场景 | 建议 | 原因 |
|---|---|---|
| 网络收发频繁、追求高吞吐 | 用堆外 | 减少拷贝、减少 GC 扫描 |
| 大块数据做 I/O 缓冲(文件读写、网络读写) | 用堆外 | 避免复制,长生命周期缓冲 |
| 高频创建小块临时缓冲区 | 别用堆外 | 分配成本高,且泄漏风险大 |
| 普通业务对象、业务数据 | 坚决用堆内 | 风险低、可观测性好 |
| 缓存、索引等长生命周期内存结构 | 视规模而定 | 如果几百 MB 内,堆内够用;若达到 GB 级且 GC 压力大,再考虑堆外 |
还有一个更重要的提醒:堆外内存的量级必须做限制,不能无限增长。JVM 启动参数里-XX:MaxDirectMemorySize可以限制 DirectBuffer 的上限,默认值与堆大小有关(通常是堆大小)。如果你的应用大量使用堆外,一定要显式设置这个值并监控它的使用率。
6. 调优实践中得来的经验:几个容易翻车的细节
6.1 别一上来就调栈大小
很多人遇到 StackOverflowError 的第一反应是把-Xss调大。这个做法不是不行,但需要想清楚背后的代价。线程栈是线程私有的,调大-Xss意味着每个线程都要多占内存。举个例子:-Xss512k时,1000 个线程占用约 500MB 虚拟内存;改成-Xss2m,就是 2GB。在线程数本来就多的服务上,这会显著推高物理内存压力,甚至导致操作系统层面内存不足。
所以我的建议是:先看递归逻辑本身能不能改。用循环替代递归、用显式栈模拟递归、限制递归深度,通常比调大栈空间更健康。只有在你确信调用深度确实需要那么大,且线程数受控的情况下,才考虑这一步。Go 的 goroutine 一开始栈只有 2KB,按需扩容,所以疯狂递归的问题比 Java、C++ 轻得多,但也不是无限膨胀——注意 goroutine 栈上限默认 1GB,仍然可能被runtime: goroutine stack exceeds终止。
6.2 堆大小不是越大越好
调优 JVM 时,很多人第一句话是"把 -Xmx 调到 8GB、16GB",仿佛堆越大越好。真相恰恰相反,堆过大可能引入更严重的停顿。原因有两点:
第一,GC 时间跟存活对象数量和堆大小相关。堆越大,发生 Full GC 时,标记-清除需要遍历的对象图可能更大(虽然新生代和老年代算法有差异),停顿时间可能变长。G1 在大堆上表现比 CMS 好,但调优不成仍然可能产生长时间的mixed GC停顿。
第二,物理内存不足会引发换页。如果你把-Xmx设成 8GB,但容器内存只有 6GB,JVM 的堆和其他非堆内存会挤压操作系统的工作集。最终系统被迫将不活跃页面交换到 swap,出现诡异的"假死"现象——CPU 不高、GC 没动静、但请求卡住超时,很可能就是物理内存不够导致换页频繁。
容器环境尤其要小心。K8s 里如果只设置 JVM 的-Xmx而不管容器内存上限,JVM 很可能会申请超过容器限制的内存,然后被 OOM Kill。正确做法是结合容器内存上限设置-Xmx,同时关注-XX:MaxRAMPercentage这类参数。我在实践中一般会把-Xmx控制在容器内存的 60%-70%,其余留给线程栈、Metaspace、堆外内存和系统缓冲。
6.3 排查内存问题的推荐路线与一个常见误区
最后给一套我个人比较顺手的排查路线,按优先级排列:
- 先确认是堆内还是堆外。
jstat -gcutil看堆使用率和 GC 次数;top看进程 RSS。两个指标都高,大概率是堆内;堆正常但 RSS 飙升,重点查堆外。 - 堆内问题:
jmap -dump:live,format=b,file=heap.hprof刷一份堆快照,用 MAT 或 VisualVM 分析支配树(Leak Suspects),找持有大对象但未释放的根路径。如果堆快照太大,可以先jmap -histo:live看粗粒度对象统计。 - 堆外问题:启动时加
-XX:NativeMemoryTracking=summary,用jcmd <pid> VM.native_memory summary对比多个时间点;再用pmap -x <pid>看地址空间的增长区域;进一步可以用 gdb、eBPF 等工具去追踪 malloc 调用栈。 - GC 频繁问题:
jstat -gcutil <pid> 1000持续观察年轻代、老年代变化,必要时加-Xlog:gc*(JDK 9+)看 GC 日志。如果是 CMS/G1 频繁 Full GC,先看看是老年代碎片还是内存泄漏。
还有一个非常常见的误区:收集堆快照的时机不对。很多人等 OOM 之后才去 dump,但 OOM 发生时很多对象已经被 GC 回收过,现场已经被污染;而且 dump 大堆本身会雪上加霜。更靠谱的做法是在启动参数里加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,让 JVM 在 OOM 的第一时间自动落盘。如果怀疑内存增长有规律,还可以用jcmd手动触发 dump,连续采集两个时间点做对比分析,定位增长速度最快的对象类型。
这篇文章写到这里,我想起自己刚入行时,觉得内存调优是"专家才需要做的事",写业务代码根本不需要关心。后来被一个生产事故教育过一轮,才明白:堆与栈的协作逻辑,就像一栋大楼的承重结构,平时你感觉不到它,但一旦出问题,就是结构性的大事。理解这两根支柱,不是为了背八股,而是为了在系统真正出问题时,你能第一时间知道该往哪看。希望这篇文章能帮你省下一点摸索时间。