深入理解内存管理

在《软件性能之 CPU》和《软件性能之 IO》中,我们分别从计算和数据流转的角度探讨了性能问题。但有一个话题始终贯穿其中却未曾正面交锋 ------ 内存。CPU 缓存命中需要理解内存布局,IO 篇的 Page Cache 本质上是内存的调度,零拷贝是在减少内存搬运,GC 架构是在自动管理内存生命周期。

内存就像空气一样无处不在,以至于我们常常忽略它的存在,直到出了问题 ------ 内存泄漏、OOM、性能抖动 ------ 才发现对它的理解远远不够。

本文试图从操作系统到应用层,把内存管理这条链路完整地串一遍。才浅笔拙,难以穷尽,大家姑妄看之。


从物理内存开始

最早期的计算机,程序直接操作物理地址。你用地址 0x1000,我也用地址 0x1000,两个程序一起跑就互相踩踏。每个程序都要自己记住哪些地址是空闲的,哪些已经被占用了,稍有不慎就是灾难。

那怎么办?操作系统给出的方案是:给每个进程一个假象 ------ 你独占了整个内存空间。这就是虚拟内存。

每个进程拥有自己独立的虚拟地址空间,A 进程无法访问 B 进程的内存,互不干扰。编译器和链接器也不用操心程序会被加载到物理内存的哪个位置,因为每个进程都从相同的虚拟地址开始。更妙的是,物理内存只有 16 GB,但操作系统可以给每个进程都承诺一个 128 TB 的虚拟地址空间(64 位系统),反正大部分地址你也用不到。

虚拟地址怎么变成物理地址?操作系统维护了一张页表(Page Table),记录虚拟页到物理页的映射关系。CPU 内部的 MMU 在每次内存访问时自动查表翻译。为了加速这个翻译过程,CPU 还有一个 TLB 缓存最近用过的映射条目,命中 TLB 只需要 1 个时钟周期,未命中则要走多级页表查找,代价高出两个数量级。你在代码里写的每一个指针、每一次数组访问,背后都经历了这个翻译过程,只是快到你感知不到而已。

有了虚拟内存,操作系统为每个进程规划了一个标准的地址空间布局:

代码段加载后几乎不变,标记为只读防止误写。数据段和 BSS 段存放全局变量,生命周期贯穿整个进程。真正有意思的是剩下两块 ------ 栈和堆,它们代表了两种截然不同的内存管理哲学,下面很快会展开。

到这里,进程隔离和地址统一的问题解决了。但虚拟内存引入了一个新的问题:操作系统什么时候真正分配物理内存?


操作系统的 "空头支票"

答案可能出乎很多人意料:当你调用 malloc 申请了 1 GB 内存,操作系统并没有真的给你准备好 1 GB 的物理内存。它只是在你的虚拟地址空间里标记了一段区域:"这块地址是合法的,你可以用"。仅此而已。

真正的物理内存分配发生在你第一次访问这块地址的时候。CPU 去查页表,发现这个虚拟页没有对应的物理页,触发一个缺页中断(Page Fault)。操作系统接管控制权,分配一个物理页框,建立映射关系,然后把控制权还给程序。整个过程对程序透明,但会有微秒级的延迟。

这就是操作系统的 overcommit 策略 ------ 先答应再说。好处是内存利用率极高,你申请了 1 GB 但只用了 10 MB,操作系统只需要付出 10 MB 的物理内存代价。坏处是当所有进程真的同时要兑现承诺时,物理内存可能不够用。

物理内存不够了怎么办?操作系统有两条路:

  • Swap:把不活跃的内存页换到磁盘上,腾出物理内存给急需的进程,代价是下次访问被换出的页时延迟从微秒级飙升到毫秒级。
  • OOM Killer:直接杀掉占用内存最多的进程,简单粗暴但有效。

在生产环境中,Swap 几乎是被禁用的。Redis、Elasticsearch 这类内存密集型服务,宁可被 OOM 杀掉重启,也不愿意忍受 Swap 带来的不可预期的延迟抖动 ------ 一旦开始 Swap,服务的响应时间会从毫秒级劣化到秒级,对于在线服务来说等同于宕机。

好,到这里操作系统解决了物理内存的管理问题:虚拟内存做隔离,缺页中断做按需分配,Swap/OOM 做兜底。但这套机制是给所有进程共享的,每次向操作系统申请内存都意味着一次系统调用 ------ 用户态到内核态的切换。如果一个程序每秒要分配释放几十万次小块内存,每次都陷入内核,性能将惨不忍睹。

在讨论用户态怎么解决这个问题之前,先回头把刚才地址空间布局里最有意思的两块 ------ 栈和堆 ------ 拎出来看看。


栈与堆:两种生命周期的哲学

栈和堆都是用来存放动态数据的,但它们的管理方式天差地别。这个差别不是实现细节,而是根本的设计取舍,理解它是理解后面一切的基础。

栈的本质是一个由 CPU 寄存器(SP,Stack Pointer)指向的连续内存区域,函数调用时 SP 向下移动预留空间,函数返回时 SP 向上移动回收空间。整个分配和回收过程就是一条汇编指令,没有任何簿记开销。更妙的是,栈上的内存访问模式高度规律,几乎都命中 CPU 缓存,性能相比堆分配有数量级的差距。

但栈有两个硬约束:​生命周期必须严格 LIFO ​(先进后出),不能跨函数持有;​空间有限​,Linux 默认 8 MB,Windows 默认 1 MB,超了就是栈溢出。为什么有这个限制?因为每个线程都要独占一段连续的栈空间,如果栈可以任意大,开几千个线程系统就崩了。后面我们会看到,Go 的 goroutine 为什么能开百万级,答案就藏在它对栈的重新设计里。

堆则没有这些限制。你在任何地方 malloc,在任何地方 free,内存就一直活着。灵活性是堆的优势,也是它所有问题的源头 ------ 既然生命周期不再由语法结构决定,那谁来决定?怎么决定?释放早了就是悬垂指针,释放晚了就是内存泄漏,不释放就是资源耗尽。本文后半部分几乎所有的讨论,都是在回答这一个问题。

一个经验法则是:​能在栈上的绝不上堆​。这不是风格问题,是性能问题。后面讲到 Golang 的逃逸分析,会看到编译器是如何自动帮你做这个判断的。

栈的问题由语言和编译器包办,不需要程序员操心。真正让人头疼的是堆 ------ 而堆的管理,就落到了 malloc 头上。


malloc:用户态的内存池

很多人以为调用 malloc 正在向操作系统申请内存。事实并非如此。malloc 是 C 标准库(glibc)提供的用户态函数,它在你的进程内部维护了一个内存池​ ------ 池子里有货就直接切一块给你,池子空了才会通过系统调用找操作系统补货。

先看补货的过程。操作系统把内存批发给进程,只有两种渠道,正好对应前面地址空间布局里的两块区域:

  • brk/sbrk :推动一个叫 "堆顶" 的指针,让进程的 "堆" 区域向高地址生长一段。这个接口的形态决定了堆只能是一整块从低到高单向生长的连续区域 ------ 没有 "多个堆",也没法从堆的中间开一个口子。优点是快、管理简单;缺点是归还也只能从堆顶往下收,中间腾出的空闲空间下不来。
  • mmap :在堆之外的地址空间里单独映射一块匿名内存,和 brk 管的堆井水不犯河水。用完 munmap 能直接把这块区域从进程地址空间里摘掉,立即归还操作系统,不会像 brk 那样被困在堆里。

glibc 的策略很自然:小于 128 KB 的分配走 brk,大于 128 KB 的走 mmap。小块内存通常生命周期短、复用频繁,留在堆里反复切分最划算;大块内存生命周期明确,用完就走,走 mmap 能干脆还掉,避免在堆里挖一个大坑堵死后续的归还。

但不管走哪条路,都有两重代价。第一重是系统调用的开销 ------ 陷入内核、切换上下文、更新页表,一次 brk/mmap 少则几百纳秒,多则几微秒。第二重是粒度错配 ------ brk 和 mmap 都是操作系统级别的接口,而操作系统管理内存的最小单位是 "页"(通常 4 KB)。你要 20 字节它也得拨一整页过来;brk 推进堆顶也是按页对齐的,不可能只推 20 字节。

于是问题就清楚了:如果每次 malloc 都直接调 brk/mmap 去问操作系统要,一来每秒几万次的调用频率会被系统调用开销拖垮,二来 20 字节的需求要整页来凑,绝大部分空间都浪费在了单次分配里。

malloc 的核心价值由此浮现:在进程内部做一层缓存和调度。 一次性向操作系统批发一大块(比如 132 KB),在用户态自己切成 8 字节、37 字节、512 字节这些零散大小分给程序用;free 回来的内存也不急着还操作系统,而是留在池子里,下次 malloc 优先从池子里分。几万次的 malloc/free 被压缩成有限的几次 brk/mmap,系统调用和页对齐带来的浪费一起被摊薄。这和 IO 篇讨论的 Page Cache 是同一个思路 ------ 在频繁交互的两层之间加一层缓存,用空间换时间。

那问题来了:用户态的这个 "内存池",内部是怎么管理的?


分配器的设计取舍

glibc 的 malloc 实现叫 ptmalloc2,它面对的核心问题是:大小不一的内存块,频繁地分配和释放,怎么管理才能又快又省?

在展开之前,必须先澄清一个常被混为一谈的概念 ------ ​碎片​。碎片其实有两种,它们的成因和对策完全不同:

  • 内部碎片:你申请了 20 字节,分配器给了你 32 字节,多出来的 12 字节你用不上也释放不了。这是为了对齐和分类管理付出的代价 ------ 分配器不可能为每一个精确大小都维护一个链表,只能按固定的 size class(8、16、32、64...)来切块,申请的大小向上取整到最近的 class。class 划分越细,内部碎片越小,但管理开销越大。
  • 外部碎片:空闲内存总量有 100 MB,但被已分配的块切成无数小段,最大的连续空闲段只有 1 MB。这时候你想分配 10 MB,分配器就傻眼了 ------ 有空间但给不出来。外部碎片是分配和释放顺序不规则导致的,和分配器的设计策略密切相关。

这两种碎片是分配器设计的核心矛盾:要减少内部碎片,就要精细化 size class,但会加剧外部碎片(不同 size class 的块不能阻碍互换);要减少外部碎片,就要合并相邻空闲块,但合并后再切分又会引入内部碎片。不同分配器、不同语言,本质上都是在这个矛盾上选择了不同的权衡点。

回到 ptmalloc2。思路其实和现实中的仓库管理一样 ------ 螺丝钉和发动机不会放在同一个货架上。ptmalloc2 将空闲内存块按大小分成几类:

  • Fast Bins:小于 64 字节的块,后进先出,分配释放极快,不做合并。就像便利店的零食货架,拿了就走。
  • Small Bins:精确大小匹配,每个 bin 里的块大小相同。
  • Large Bins:大块内存,按大小范围管理,分配时需要遍历查找最合适的块。
  • Unsorted Bin:刚释放的块先扔到这里,延迟分类。下次分配时再决定它该去哪个 bin。

这解决了 "又快又省" 的问题。但还有一个问题:多线程。如果所有线程都抢同一把锁来分配内存,性能会急剧下降。ptmalloc2 引入了 Arena(分配区)的概念,每个线程可以有自己的 Arena,各自管理各自的内存池,大幅减少锁竞争。

到这里,分配的问题基本解决了。那释放呢?free 之后内存去哪了?

答案是:大概率还在你的进程里。

当你 free 一块内存时,ptmalloc2 会根据大小放入对应的 bin,检查相邻的块是否也是空闲的,如果是则合并成更大的块。只有当合并后的块位于堆顶且足够大时,才可能通过 brk 收缩堆顶归还操作系统。通过 mmap 分配的大块内存,free 时直接 munmap 归还。

注意这个条件:必须是堆顶的块。 如果堆顶有一个还在使用的小块,即使下面有大量空闲内存,堆也无法收缩。

这就是为什么长时间运行的服务,内存占用往往会缓慢增长 ------ 不一定是内存泄漏,可能只是碎片化导致的无法归还。你经常看到一个进程的 RSS(常驻内存)只增不减,即使你已经 free 了大量内存,它们大概率还在进程手里,等着被下一次 malloc 复用。

ptmalloc2 是个通用方案,但不同的场景有不同的痛点。高并发短连接服务(大量线程频繁分配释放小对象),锁竞争是主要矛盾;长时间运行的有状态服务,内存碎片是主要矛盾。所以出现了不同的分配器:

  • **tcmalloc (Google)**:把 Thread-Caching 做到了极致。每个线程有自己的本地缓存(ThreadCache),小对象的分配和释放完全在线程本地完成,无需加锁。只有当本地缓存不够或太多时,才和中心缓存交互。高并发场景下性能远超 ptmalloc2。
  • **jemalloc (Facebook/FreeBSD)**:在碎片控制上做得更好。多个 Arena 配合精细的 Size Class 划分和 Slab 分配策略,在长时间运行的服务中能保持更低的碎片率。Redis 就使用 jemalloc。

分配器的选择本质上是在分配速度、内存碎片、多线程扩展性三者之间做权衡。没有银弹,只有适合的场景。


分配器是怎么 "起步" 的

这里还有一个容易被忽略但很重要的问题:程序刚启动时,内存池里一无所有,第一次 ​malloc ​的内存从哪里来?分配器需要预先准备多大的家底?

ptmalloc2 的答案是:几乎什么都不准备。 进程启动时主 Arena 为空,第一次调用 malloc 时才通过 brk 推进堆顶,拿一小块(通常是 132 KB 左右)回来切分使用。后续不够了再继续推,用一次要一次。线程第一次调用 malloc 时才通过 mmap 给它分配一个线程 Arena(默认 64 MB 的虚拟地址空间,但还是那句话 ------ 虚拟地址空间的 overcommit,真正的物理内存要等写入时缺页中断才分配)。

tcmalloc 和 jemalloc 也是类似的思路:ThreadCache/Arena 都是懒加载的,线程第一次分配时才创建。size class 的空闲链表一开始全是空的,需要哪个 class 的块,就去上一级(CentralCache / mheap)要,上一级没有就再向上要,最终由操作系统通过 mmap 满足。

这种 "用多少要多少" 的策略,带来的好处是进程启动快、空闲内存占用低。代价是运行期会有一系列 "膨胀抖动" ------ 每次缓存层级耗尽要向上申请时,都是一次较慢的路径,可能伴随系统调用。所以生产环境的长生命周期服务通常会在预热阶段跑一轮满负载流量,把各级缓存撑起来,把冷启动的抖动提前消化掉。

这个设计看起来天经地义,但之后我们会看到,Java 选择了完全相反的路线 ------ ​启动时就把堆全部划好,运行时不再向操作系统要​。

到这里,从操作系统到用户态分配器,内存的分配和释放机制已经比较清楚了。但还有一个根本问题没有解决:谁来决定什么时候释放?

不同的编程语言对这个问题给出了截然不同的答案。下面我们沿着 "信任程度" 这条线依次看过去 ------ 从完全信任程序员的 C,到完全不信任程序员的 Rust,再到干脆不让程序员操心的 Java 和 Go。


C:程序员自己管

在 C 语言里,答案干脆利落 ------ 程序员自己管。mallocfree,一来一回,清清楚楚。但这恰恰是最大的问题 ------ 人会犯错。忘了 free 是内存泄漏,free 两次是未定义行为,用了已经 free 的指针是悬垂指针。这三座大山压垮了无数 C 程序员。更麻烦的是,这些错误在编译期完全看不出来,往往在生产环境跑几天几周后才以最诡异的方式爆发。

C 的方案信任程序员,但程序员并不总是值得信任。后续的语言演进,基本都在回答同一个问题:能不能让编译器或运行时帮忙分担这份责任?


C++:编译器帮你插入 free

C++ 没有抛弃手动管理,而是找到了一个巧妙的切入点:既然忘记释放是最大的问题,那能不能让编译器在合适的时机自动插入释放代码?

C++ 的答案是 RAII(Resource Acquisition Is Initialization) ------ 把资源的生命周期绑定到对象的生命周期上。核心依赖 C++ 的一条铁律:栈上的对象在离开作用域时,编译器一定会调用它的析构函数。 只要在析构函数里写上释放逻辑,程序员就不需要记得手动 free,编译器会在每个作用域出口自动插入析构调用。

基于这条规则,C++ 设计了两种智能指针,对应两种所有权模型:

  • unique_ptr :解决的是最简单的情况 ------ 一块内存只有一个主人。它本质上就是一个包装了裸指针的对象,析构函数里写了一行 delete。独占意味着禁止拷贝(否则两个指针析构同一块内存就是 double free),只允许通过 std::move 转移所有权。整个机制没有任何运行时开销,生成的机器码和手写 new/delete 完全一样。
  • shared_ptr :解决的是 "多个地方需要持有同一个对象" 的场景。核心机制是引用计数 :每个被管理的对象旁边附带一个计数器,拷贝时加一,销毁时减一,计数归零的那一刻自动释放 ------ 不需要任何人显式调用 delete,最后一个离开的 shared_ptr 会自动释放内存。但代价比 unique_ptr 高得多:引用计数的增减必须是原子操作(多线程安全),每次拷贝和销毁都有开销。更棘手的是循环引用 ------ A 持有 B 的 shared_ptr, B 也持有 A 的 shared_ptr,两个计数永远不会归零,内存就泄漏了。C++ 用 weak_ptr(不增加计数的 "观察者")来打破循环,但需要程序员自己识别哪些引用会形成环,编译器帮不了你。

这是一种 "编译期的半自动管理" ------ 编译器帮你在正确的时机插入析构调用,但所有权的设计仍然需要程序员自己思考。虽然比起手动 malloc/free 已经好了太多,但依然无法完全杜绝问题。

而对于极致性能的场景(游戏引擎、高频交易),C++ 程序员甚至会绕过 malloc,自己实现内存池:预先分配一大块内存,自己管理分配和回收,指针偏移即可完成分配,控制碎片问题。代价是需要自己实现一套分配逻辑,其中的复杂度并不低。

C++ 的方案本质上是:信任程序员,但给他更好的工具。 那如果我们不信任程序员呢?


Rust:编译器替你做决定

Rust 看着 C/C++ 几十年来在内存安全上的挣扎,提出了一个激进的方案:既然人会犯错,那就不让人有犯错的机会。

Rust 的所有权系统用三条规则在编译期就消灭了内存安全问题,简单而言:每个值有且只有一个所有者;值在所有者离开作用域时自动释放;值可以被借用,但要么有多个不可变借用,要么只有一个可变借用。违反这些规则?编译器直接报错,代码根本编译不过去。

rust 复制代码
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;          // 所有权转移给s2
    // println!("{}", s1); // 编译错误!s1已经无效
    println!("{}", s2);    // 正常
}   // s2离开作用域,内存自动释放

没有 GC,没有运行时开销(Box 场景下),内存安全由编译器静态保证。代价是编译器会拒绝很多 "看起来没问题但可能有问题" 的代码 ------ 和编译器搏斗是 Rust 新手的日常,所有权的设计也比写 malloc/free 更费脑子。

从本质上讲,Rust 和 C++ 的智能指针解决的是同一个问题,但 Rust 更彻底 ------ C++ 的智能指针是 "建议你这么做",Rust 的所有权是 "强制你必须这么做"。C++ 允许你在任何时候退回到裸指针,Rust 则需要显式标记 unsafe 才能绕过检查。

C++ 和 Rust 的方案有一个共同点:所有权的正确性在编译期就已经确定,不需要像 GC 那样在运行时扫描堆去猜谁还活着。 这里要澄清一个常见的误解 ------ 并不是说完全没有运行时开销。unique_ptr 和 Rust 的 Box 确实是零开销的,编译器在合适的位置插入析构调用即可;但 shared_ptr 和 Rust 的 Rc/Arc 依然依赖运行时的引用计数,每次赋值都要原子地增减计数器,计数归零时触发释放。只不过这种开销是​局部的、可预测的、和对象一一对应的​,而不像 GC 那样要停下整个世界去扫描堆。

换个角度说:C++/Rust 的运行时开销随着每次引用操作分摊掉了,而 GC 的开销集中爆发在某个时刻。这是两种截然不同的成本模型。

那如果我们连 "思考所有权、标注借用" 这点心智成本都不想付出呢?


Java:运行时帮你兜底

Java 走了一条和前面三种都不同的路线:程序员只管 new,不管 delete。 内存的回收完全交给垃圾回收器(GC)在运行时自动处理。这极大地降低了编程的心智负担,也是 Java 能在企业级开发中大行其道的重要原因。

但 GC 不是免费的午餐。它要回答一个根本问题:哪些对象还活着,哪些已经死了?

答案是从一组必然活着的对象(GC Roots,比如栈上的引用、静态变量)出发,沿着引用关系一层层追踪,所有能被追踪到的对象都是活的,追踪不到的就是死的。这个过程就是​标记 ​。标记完成之后,死对象占用的空间需要回收 ------ 这就是​清除​。

到这里看起来问题解决了,但仔细想想就会发现麻烦。标记-清除之后的堆长什么样?死对象被回收了,但留下了一个个空洞 ------ 这不就是我们在 malloc 那一节反复讨论的外部碎片吗?总空闲空间可能很大,但被切成无数小段,下次要分配一个稍大的对象就找不到连续空间。ptmalloc2 通过合并相邻空闲块来缓解,但只要分配释放的顺序不规则,碎片永远会残留一部分。

JVM GC 选择了一条更激进的路线 ------ ​整理​(Compact)。既然新分配出来的对象会让碎片更严重,那干脆把所有存活对象搬到堆的一端,另一端就是一整块干净的连续空闲内存,下次分配直接从这一端向外推进即可,连 "找合适的空闲块" 这个步骤都省了。

这是 GC 相比 malloc 的一个根本不同:​malloc 不能移动已分配的内存 ​(你持有的指针会失效),但 GC 可以 ------ 因为所有引用都在 JVM 的掌控之下,移动对象后把所有指向它的引用一并更新就行了。这也是为什么 Java 从来不暴露 "对象地址" 给你,只给你引用。引用是个逻辑概念,背后映射到哪个物理地址由 JVM 说了算。

标记、清除、整理这三步构成了 GC 的基本动作。真实的 GC 算法是这三步的不同组合和优化:

  • 标记-清除(不整理):速度快,但会留下碎片。老年代的 CMS 就是这个策略。
  • 标记-整理:消除碎片,但移动对象代价高。老年代的默认选择。
  • 复制算法:把活对象从一块区域复制到另一块空白区域,复制完原区域整个清空 ------ 本质上是把 "标记存活 + 整理" 合成了一步。但需要一半空间闲置做复制目标,很浪费,所以只适合存活率低的场景。

"存活率低的场景" 在哪?这就引出了 JVM GC 的另一块基石 ------ ​分代假说​:大部分对象朝生夕灭。一个 HTTP 请求处理过程中创建的临时对象,请求结束就没用了。基于这个假设,JVM 把堆分成年轻代和老年代,两代用完全不同的算法:

  • 年轻代:存活率极低,每次 GC 下来活着的对象寥寥无几,用复制算法最划算 ------ 只需要复制这一点点存活对象,剩下的空间扫都不用扫直接清零。代价是要留出一部分空间(Survivor)做复制目标,但因为存活率低,浪费的空间其实不多。
  • 老年代:存活率高,复制的代价太大(要复制的对象太多),换用标记-整理。

分代带来的收益不止于此:年轻代 GC 只扫年轻代,不用动老年代,扫描范围小速度就快,大部分 GC 都能在年轻代内部解决,老年代 GC(Full GC)只在必要时触发。这是 JVM 整个 GC 体系能跑起来的前提。

聊到这里,正好可以回答一个几乎每个 Java 初学者都遇到过的问题:为什么跑 Java 一定要写 -Xmx4g,而跑 Go、C、Python 从来不需要指定堆大小?

答案藏在前面这套 GC 机制里。Java 的堆从来不是 "用多少要多少" 的 ------ JVM 启动的那一刻,就会向操作系统申请一块连续的虚拟地址空间 作为堆,大小就是你指定的 -Xmx。为什么必须这么做?

  • 整理算法要求连续空间。复制算法要把存活对象从 From 区搬到 To 区,这两个区必须在同一块连续地址里;标记-整理要把对象压到堆的一端,"一端"的前提是堆本身有明确的起点和终点。如果堆是东一块西一块的离散区域,这些动作根本无从谈起。
  • 分代划分要求固定边界。年轻代占堆的几分之几,Eden 和 Survivor 怎么分,Old Gen 留多少 ------ 这些比例都建立在 "堆总大小已知" 的基础上。边界一旦模糊,分代假说就没法落地。
  • 指针压缩要求堆基址稳定。为了让 64 位 JVM 在小堆场景下用 32 位指针省内存(配合 8 字节对齐,最多能压到 32 GB),堆必须有一个固定的基地址,所有对象地址都是相对于基址的偏移。堆一动,压缩指针全部失效。
  • 卡表/Remember Set 等辅助结构要求堆大小已知。跨代引用追踪是按堆地址建立位图索引的,堆多大,位图就多大,启动时必须一次性分配好。

所以 -Xmx 不是 JVM 设计者的任性,而是​分代 + 整理这套 GC 算法的硬前提​。你选择了这套算法,就必须接受 "先把场地划好再开业"。

那不给 -Xmx 会怎样?JVM 会按照默认策略(一般是物理内存的 1/4)猜一个值。但在容器环境里这个猜测会出大事 ------ 老版本 JVM 不认识 cgroup 的内存限制,看到的是宿主机的总内存,堆按宿主机的 1/4 划分,结果容器限制是 2 GB 但 JVM 觉得自己能用 16 GB,一跑就 OOM 被 kill。这也是为什么容器里的 Java 服务必须显式指定 -Xmx,或者使用较新 JVM 的 -XX:+UseContainerSupport

这还只是 "必须指定大小" 的约束。Java 的堆一旦划定,即使你实际只用了 10%,剩下的虚拟地址空间也被 JVM 占着;而且为了避免运行时向 OS 扩张堆导致的不确定性,生产环境通常还会把 -Xms(初始大小)直接设成和 -Xmx 相等 ------ ​启动时就把整个堆的物理内存也一次性申请下来 ​。一个 -Xmx4g 的 Java 进程,哪怕啥业务都没跑,RSS 里也会躺着好几 GB。

反观 ptmalloc2、tcmalloc 这些分配器,按需扩张,有多少用多少,自然不需要也不可能让用户指定 "最大堆大小" ------ 因为它们压根就没有 "堆总大小" 这个概念。至于 Go 为什么也不需要,留到下一节再说。

到这里标记、清除、整理、分代都解决了,但还剩一个头疼的问题 ------ ​**STW (Stop The World)**​。GC 做这些事情的时候,业务线程必须暂停:标记要遍历引用关系,业务线程在改引用会导致漏标;整理要移动对象,业务线程正访问这个对象就会出事。早期的 Serial GC 需要暂停数秒,Parallel GC 缩短到数百毫秒,对在线服务都是致命的。

JVM 的 GC 演进史基本就是一部缩短 STW 的历史。CMS(Concurrent Mark Sweep)让标记阶段和业务线程并发执行,STW 降到几十毫秒;G1 把堆分成一个个 Region,每次只整理一部分 Region,实现可预测的停顿;ZGC 引入染色指针和读屏障,让整理阶段也能和业务并发,STW 压到亚毫秒级。这些算法的细节各有千秋,但核心思路一致:能并发做的事情绝不 STW,必须 STW 的事情尽量快。

但低 STW 并不是没有代价。GC 越是 "和业务并发",就越要解决一个棘手的问题:业务线程在 GC 过程中改了引用关系怎么办?GC 刚标记完 A 不可达,业务线程转手让某个可达对象又引用了 A,这就漏标了。这个问题的解决方案涉及到写屏障 ------ 在业务线程的每次引用赋值上插一段代码通知 GC 重新检查 ------ 具体的机制我们留到下一节 Golang 的部分讲,那里的 GC 对这套机制的依赖更纯粹。

最后还有一个被整理机制隐藏起来的副作用:​对象会被移动​。这对纯 Java 代码没影响,JVM 会把所有引用自动更新。但对需要和操作系统交互的场景就是灾难 ------ 还记得 IO 篇中讨论的 DirectBuffer 吗?当你调用 send 发送数据时,内核需要一个稳定的内存地址来读取数据。如果这块内存随时可能被 GC 搬走,内核读到的就是垃圾数据。所以 Java 不得不引入堆外内存(DirectBuffer),这块内存不受 GC 管理、不会被移动,但需要程序员自己控制生命周期 ------ 兜兜转转,又回到了手动管理的老路上。

Java 的方案是:用运行时开销换取开发效率,用 GC 停顿换取内存安全,用额外的堆外内存来弥补 GC 移动对象的副作用。 三层权衡叠加在一起,造就了 JVM 庞大的 GC 调参空间。


Golang:把分配器和 GC 一起重新设计

Golang 的思路是:与其在 GC 算法上死磕 STW,不如从源头减少 GC 的工作量 ------ 能不进堆的对象,就别进堆。

Golang 的编译器会做逃逸分析:如果一个变量不会逃逸出当前函数的作用域,就直接分配在栈上,函数返回时自动回收,完全不需要 GC 介入。只有真正需要在函数间共享的对象才会分配到堆上。

go 复制代码
func createUser() *User {
    u := &User{Name: "test"}  // 逃逸到堆上,因为返回了指针
    return u
}

func processUser() {
    u := User{Name: "test"}   // 分配在栈上,函数结束自动回收
    fmt.Println(u.Name)
}

go build -gcflags="-m" 就能看到编译器的逃逸分析结果。Go 程序员写代码时会本能地注意这一点 ------ 小对象尽量值传递,避免不必要的指针,就是为了让对象留在栈上。逃逸判断规则大致是:返回局部变量的指针必然逃逸(函数返回后栈已回收,指针指向的地址无效);被闭包捕获的变量通常逃逸(闭包的生命周期可能超过当前函数);传给 interface 类型的值通常逃逸(interface 在运行时可能被任意持有);太大的对象(比如大数组)也会逃逸(栈空间有限,放不下);编译期无法确定大小的对象会逃逸(栈帧大小必须编译期确定)。其他情况下,变量就安安静静地分配在栈上,函数返回时自动回收,完全不需要 GC 介入。

对于确实需要进堆的对象,Golang 的分配器借鉴了 tcmalloc 的思路,构建了三级缓存结构:mcache(每个 P 私有,分配小对象无需加锁) → mcentral(共享的中心缓存) → mheap(全局堆,最终通过 mmap 向操作系统要内存)。小对象的分配路径极短,从当前 P 的 mcache 中取一块合适大小的内存,指针偏移即可,甚至比 C 的 malloc 还快。

回到上一节留下的那个问题 ------ Go 为什么不用像 Java 那样指定 -Xmx? 答案已经呼之欲出了。Go 的堆不是一块预先划好的连续空间,而是由一个个 64 MB 的 arena 拼起来的:mheap 需要的时候向操作系统 mmap 一块 arena,用完一块再申请下一块。arena 之间在虚拟地址空间上甚至不一定连续,Go 的运行时通过一张 arena 索引表来管理它们。因为没有整理、没有分代、没有指针压缩,Go 完全不需要堆是连续的,也不需要堆总大小已知 ------ 这些本来就是 Java GC 算法的硬前提,Go 既然不走那套路线,自然也就没有这些约束。

所以 Java 的预分配和 Go 的按需扩张,不是两种风格的选择,而是两套 GC 算法各自必然的结果。 我们可以这么理解:Java 是先圈好一块操场再让孩子们进来跑,Go 是孩子们跑到哪里操场就铺到哪里。两种都能跑步,但圈操场的方案必须提前决定操场多大,铺操场的方案则永远不需要。代价也各自清晰 ------ Java 的内存占用是 "声明即占用",Go 的内存占用是 "实际用了多少才占多少",但运行时要不断处理 arena 扩张的开销。

到这里还只是分配端的优化,真正有意思的是 GC 端的取舍。上一节我们看到 Java 的 GC 绕不开标记-清除-整理三阶段,其中整理是代价最高的那一步。Golang 在这里做了一个和 Java 截然相反的选择 ------ ​干脆不做整理​。

那不整理怎么解决外部碎片?答案在前面讲分配器时已经埋了伏笔 ------ ​mcache 的 size class​。Go 的堆从一开始就是按 size class 切分的,所有 16 字节的对象在一组 page 里,所有 32 字节的对象在另一组 page 里。死掉的对象空出来的槽位,下次分配相同大小的对象时可以精准填回去,根本不会出现 "总空间够但找不到连续块" 的外部碎片问题。

本质上,Go 用分配时的精细分类 换掉了​回收时的整理成本​。这和 Java 的思路形成鲜明对比:Java 允许分配时随意切块,依赖 GC 的整理阶段来事后纠正;Go 在分配时就把块切得整整齐齐,让事后根本不需要整理。代价是有一定的内部碎片(申请 20 字节给 32 字节),以及大对象的分配会略慢(需要在 heap 层面找连续 page)。

不整理的好处还有另一面 ------ ​对象的物理地址永远不变​。这意味着 Go 不需要像 Java 那样搞堆外内存。cgo 调用、syscall 传入 buffer、mmap 映射,所有跨运行时边界的操作都可以直接用堆上对象的地址,不用担心 GC 把它搬走。这是运行时设计上的巨大简化。

不整理的代价是什么?只能用​标记-清除​,不能用复制算法(复制算法本身就是整理)。而既然不用复制算法,分代就没有了意义 ------ Java 的分代之所以高效,一半靠的是 "存活率低" + "复制算法" 的组合拳,Go 不用复制算法,分代带来的收益就大打折扣了。况且逃逸分析已经把大部分短命对象留在栈上,堆上剩下的对象存活率本身就比 Java 高,朝生夕灭的分代假说效果有限。所以 Go 直接不分代,每次 GC 扫描整个堆。

不分代 + 不整理 + 并发执行,这套组合对一件事的要求极高 ------ ​并发标记的正确性​。这就要说到 Java 节里埋下的那个问题:GC 在标记的时候,业务线程还在不停地改引用,怎么保证不漏标?

Go 用的是​三色标记法​:每个对象三种颜色 ------ 白色(未访问,暂定死亡)、灰色(已访问,但它的引用还没处理完)、黑色(自己和所有引用都处理完了)。GC 从根对象出发,把根染灰,不断从灰色集合里取对象,把它的直接引用染灰,自己染黑。最后所有黑色的就是活的,剩下的白色全部回收。

并发的问题在哪?考虑这个场景:GC 刚扫完 A(染黑),正准备扫 C(灰色),此时业务线程插了一脚 ------ 切断了 C 对 B 的引用,同时让 A 引用了 B。GC 继续扫 C,没找到 B;又不会回头扫黑色的 A。结果 B 明明还活着,却被当成白色回收了。

这就是经典的漏标问题。核心条件是:一个白色对象被黑色对象引用,同时所有指向它的灰色引用都断了。只要破坏这两个条件之一,漏标就不会发生。

Go 的解法是​写屏障 ​。编译器会在每一处指针赋值操作旁插入一小段检查代码,但这段代码只在 GC 标记阶段才真正生效 ------ 平时执行到这里只是检查一个标志位然后跳过,几乎没有开销。一旦 GC 开始标记,标志位翻转,写屏障激活:每次指针赋值时,Go 的混合写屏障会把 "被赋值的对象" 和 "原来的对象" 都染灰,保证它们在本轮 GC 里一定会被扫描到,宁可错标也不漏标。错标的代价只是这一轮多扫点东西,下一轮 GC 再处理;漏标的代价是活对象被错误回收,程序崩溃。两害相权取其轻。

至于 Go 为什么能开百万级 goroutine,而 Java 线程最多开几千个?答案在 Go 对栈的重新设计上。

Java 线程的栈是 OS 级别的,创建时操作系统直接给你一段连续的虚拟地址空间,默认 1 MB。1 MB 在 64 位地址空间里不算什么,但每个线程都要独占这么一段,而且栈大小一旦分配就不能改 ------ 因为栈上任何变量的地址都是相对于栈底的偏移,栈一挪,所有指针全废。所以 Java 要开一万个线程,光虚拟地址就要吃掉 10 GB,再加上线程调度本身是内核态的开销,几千个线程差不多就是极限。

Go 的 goroutine 栈是运行时自己管的,初始只分配 2 KB,远小于一个内存页。函数调用时检查栈是否够用,不够就整个栈搬家 ------ 分配一块更大的新栈,把旧栈内容复制过去,扫描所有指向旧栈的指针并更新。听起来代价很大,但发生频率很低,而且收益是单机百万级并发。

为什么 Go 敢搬栈而 Java 不敢?因为 Go 的运行时掌控了所有的 goroutine 栈,知道每个栈帧的结构,能精确找到所有需要更新的指针 ------ 这和 GC 能移动堆对象的前提是一样的,都是 "引用的精确可知"。OS 线程做不到这一点,因为 OS 不了解你的程序数据结构,栈里的每一个字节它都得当成不透明的内存对待。

到这里 Go 的内存故事基本讲完了:能在栈上的绝不去堆上(逃逸分析);栈本身按需伸缩不预留空间(动态栈);必须去堆上的用 size class 从源头规避碎片(不需要整理);回收时用三色标记 + 写屏障实现并发 GC(STW 控制在微秒级)。代价是吞吐量略低(不分代导致每次全堆扫描)和一定的内部碎片(size class 对齐),但对 Go 的目标场景 ------ 高并发服务端 ------ 这是完全划算的交易。


Python/JavaScript:简单至上

如果说 Java 和 Golang 还在性能和安全之间精心权衡,Python 和 JavaScript 则代表了另一种取向:开发效率优先,运行效率让步。

Python 的内存管理以引用计数为主。每个对象内部维护一个计数器,引用增加计数加一,引用减少计数减一,计数归零立即释放。回收及时,实现简单。但引用计数有一个致命缺陷 ------ 循环引用:

python 复制代码
a = []
b = []
a.append(b)  # a引用b
b.append(a)  # b引用a
del a
del b
# 两个对象互相引用,计数永远不会归零

所以 Python 额外引入了一个循环引用检测器,定期扫描对象图,找出这些互相引用但外部已经不可达的对象群,统一回收。

JavaScript(以 V8 引擎为例)则采用了类似 Java 的分代 GC 策略:新生代用 Scavenge 算法快速复制存活对象,老生代用标记-清除-整理。V8 的 GC 也在不断演进,引入了并发标记和增量标记来减少主线程的停顿。

这两种语言的内存管理对程序员几乎完全透明。代价是运行时的内存占用和 CPU 开销都比较高,但对于它们的目标场景(Web 开发、脚本、数据处理),这个代价完全可以接受。


总结

回顾整个讨论过程,可以发现一条清晰的主线:每一层都在为上一层的 "不够用" 补课,每一次补课又引入新的代价。

  • 操作系统:用虚拟内存解决多进程共用物理内存的冲突,代价是引入了页表翻译和缺页中断。
  • 分配器:用户态内存池解决系统调用太贵的问题,代价是要自己管理碎片 ------ 于是有了 size class、Arena、多种分配器在 "速度、碎片、并发" 三角里各自取舍。
  • 语言运行时 :围绕 "谁来决定释放" 这一个问题,不同语言走向了截然不同的道路 ------ C 把责任全交给程序员,C++ 和 Rust 让编译器在编译期承担一部分,Java 和 Go 把它彻底交给运行时。而运行时这条路又进一步分化:Java 选了 "预先划好场地 + 整理 + 分代" 的路线,换来了分代假说的高效,但必须接受 -Xmx、STW、DirectBuffer 这些配套代价;Go 选了 "按需扩张 +size class+ 不整理" 的路线,换来了栈伸缩和并发标记的简洁,但放弃了分代的吞吐红利。Python/JS 则干脆用引用计数优先照顾开发效率。

越往底层越自由越危险,越往上层越安全越有开销。选择哪一层的抽象、承担哪一种代价,取决于你的场景更靠近 "性能-安全-开发效率" 这个不可能三角的哪个顶点。

内存管理是计算机科学中少有的 "每一层都在重新发明轮子" 的领域。操作系统有自己的页面分配器,C 库有自己的 malloc,语言运行时有自己的对象分配器,应用层还可能有自己的内存池。每一层都觉得上一层不够好,要自己再管一遍。这种层层叠叠的抽象,既是软件工程复杂性的缩影,也是追求极致性能的必然代价。理解每一层在做什么、为什么这么做,方能在这座抽象的高塔中有法可依。


更多内容,欢迎浏览:persional-site

相关推荐
风骏时光牛马1 小时前
JSON常见踩坑问题与实战避坑案例代码
前端
YAwu111 小时前
从 TodoList 看 React + TypeScript 类型实践
前端·javascript
喵了几个咪1 小时前
基于 Flutter 的 Headless CMS 全平台前端架构:技术解析与二次开发导引
前端·flutter·架构
lantian1 小时前
TypeScript 模块系统核心原理:从ESM到CJS,彻底搞懂模块格式与解析逻辑
前端·typescript·ecmascript 6
Lear1 小时前
CSR、SSR、SSG 到底怎么选?一文讲透现代前端三大渲染模式
前端
এ慕ོ冬℘゜1 小时前
前端分页组件完整实现:样式 + 交互 + 逻辑全优化
前端·交互
Ajie'Blog1 小时前
Claude Opus 4.8 发布:Claude Code 能不能接住复杂项目
服务器·前端·javascript·人工智能·ai编程
San813_LDD1 小时前
[后端开发]GET/POST_带参/不带参
前端·后端·计算机网络·json
问心无愧05131 小时前
ctf show web入门101
android·前端·笔记