ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

堆内存与栈内存深度解析:从原理到堆外内存实战

堆内存与栈内存深度解析:从原理到堆外内存实战 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的栈帧已经存在里面存着x3、y4。执行add(x, y)时先把y、x按调用约定压栈参数压栈顺序取决于编译器和调用约定x86-64 下更多是用寄存器传参这里为便于理解简化为压栈随后把add函数的返回地址压栈。CPU 跳转到add函数入口add为自己开辟栈帧分配sum的空间。add执行完毕返回值放入约定的寄存器通常是rax然后根据栈帧中保存的返回地址跳回main。main的栈帧接管控制权add的栈帧空间被回收——注意是逻辑回收内存里的旧数据还在只是栈顶指针已经越过它了。整个过程就像一摞便签纸最上面的便签属于当前正在执行的函数写完撕掉露出下面一张继续写。整洁、高效、几乎零成本。2.2 栈溢出长什么样为什么递归会摧毁栈栈的空间是有限的。线程创建时系统会分配一块固定大小的栈空间比如 Java 默认的线程栈通常是 512KB 到 1MBC 程序主线程栈一般是 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对应freenew对应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 ListOrder 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/OFileChannel、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 像温度计一样不断上升。排查手段相对受限NMTNative Memory Tracking需要启动时加-XX:NativeMemoryTrackingdetail可以看 JVM 自身占用的各类原生内存pmap可以看进程地址空间分布perf配合 eBPF 可以追踪原生内存的 malloc 分配点但调试成本明显更高。这里分享一个我经历过的排查案例基于常见实践的总结一个网关服务上线后运行约一周进程 RSS 从 2GB 涨到 20GB但jstat -gcutil显示堆占用一直稳定在 60% 左右Full GC 几乎没有。最后加-XX:NativeMemoryTrackingsummary观察发现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但容器内存只有 6GBJVM 的堆和其他非堆内存会挤压操作系统的工作集。最终系统被迫将不活跃页面交换到 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,formatb,fileheap.hprof刷一份堆快照用 MAT 或 VisualVM 分析支配树Leak Suspects找持有大对象但未释放的根路径。如果堆快照太大可以先jmap -histo:live看粗粒度对象统计。堆外问题启动时加-XX:NativeMemoryTrackingsummary用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连续采集两个时间点做对比分析定位增长速度最快的对象类型。这篇文章写到这里我想起自己刚入行时觉得内存调优是专家才需要做的事写业务代码根本不需要关心。后来被一个生产事故教育过一轮才明白堆与栈的协作逻辑就像一栋大楼的承重结构平时你感觉不到它但一旦出问题就是结构性的大事。理解这两根支柱不是为了背八股而是为了在系统真正出问题时你能第一时间知道该往哪看。希望这篇文章能帮你省下一点摸索时间。
返回列表