6.1 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-04-24 10:00 |
虚拟内存:Go 与其他语言的差异
概述
虚拟内存本身是操作系统提供的抽象,所有语言共享同一套底层机制(页表、MMU、page fault、swap)。但不同语言在如何与虚拟内存交互、谁来管理分配上有显著差异。理解这些差异,能帮助你更好地把握 Go 内存管理的独特之处。
思考:既然虚拟内存是 OS 的特性,那语言的"内存管理"到底在管什么?——语言管的是从虚拟内存到对象之间的分配策略,而虚拟内存只是舞台背景。
内存分配"控制权"的归属
不同语言的内存分配器层级不同:
| 语言 | 分配链路 | 谁决定物理内存何时分配? |
|---|---|---|
| C/C++ | 程序 → malloc/new → 系统库 → OS mmap/brk |
程序员 + malloc 库实现 |
| Go | 程序 → Go malloc → mcache→mcentral→mheap → mmap |
Go 运行时(自己当"二道贩子") |
| Java | 程序 → JVM 堆管理器 → mmap 一大块 → 对象分配 |
JVM(启动时 mmap 一大块,不主动归还) |
| Python | 程序 → CPython pymalloc → malloc |
CPython(小对象 pymalloc,大对象直接 malloc) |
Go 的独特之处:自己当二道贩子
C 语言直接调系统 malloc(底层可能调 mmap 或 brk),而 Go 运行时自己实现了从 OS 到对象的全套分配逻辑:
Go 的分配链路:
程序 malloc → Go mcache → mcentral → mheap → mmap(64MB) → OS
C 的分配链路:
程序 malloc → glibc/tcmalloc → mmap/brk → OS
Go 先 mmap 一大块虚拟内存(默认 64MB),再自己管理 span 怎么分片。好处是不需要依赖系统 malloc 的行为,每一块内存的分配和回收都在 Go 自己的掌控之中。
栈 vs 堆:谁来决定?
这是 Go 和很多语言最大的区别:
// Go:编译器在编译期做逃逸分析,自动决定
x := 10 // 可能留在栈上(零 GC 开销)
p := &x // 逃逸到堆(GC 管理)
// Java:几乎全部对象都在堆上,栈上只有引用
Object o = new Object(); // new 的一定在堆上
// C:完全由程序员决定
int x = 10; // 栈(默认)
int *p = malloc(4); // 堆,不 free 就泄漏
Go 打破了"局部变量在栈、动态分配在堆"的传统分工——编译器会根据生命周期自动决定,程序员几乎不用操心。
思考:如果你能交给编译器做决定,为什么不是所有语言都这么做?——答案在传统语言没有 GC,一旦变量逃逸到栈外就是 UB(未定义行为),所以只能让用户显式控制。
GC 与虚拟内存的协作
| 语言 | GC 策略 | 和虚拟内存的关系 |
|---|---|---|
| Go | 并发三色标记-复制 | GC 后把不用的页 munmap 还给 OS,降低 RSS |
| Java | CMS/G1/ZGC 等 | 通常不主动还内存给 OS(除非特殊配置) |
| C/C++ | 无 GC | 程序员 free 后,库可能还回 OS,也可能留着下次用 |
Go 的"弹性"行为
当 GC 回收了大量内存后,Go 运行时会把空闲的页通过 munmap 直接还给操作系统,让 RSS 降下来:
// Go 进程的内存占用是弹性的
func main() {
data := make([]byte, 100<<20) // 100MB
_ = data // RSS 涨
data = nil
runtime.GC() // RSS 回落!munmap 还给了 OS
}
这意味着 Go 进程的内存占用随 workload 动态变化——忙的时候涨,闲的时候退。而 Java 的 JVM 通常会"拿了就不还",吃内存比较"霸道"。
内存增长模式对比
Go: [||||||||||||][ ][||||| ]
↑ mmap 64MB → GC 后 munmap 还一部分 → 再次增长
Java: [|||||||||||||||||||||||||||||||||||][ ]
↑ 启动时 mmap 一大块,几乎只增不减
C: [||][ ][|||||||][ ]
↑ malloc/free 自由穿插,取决于程序逻辑
Go 介于 Java 的"霸道"和 C 的"随意"之间——有自动增长的策略,但也有弹性收缩的机制。
大对象处理差异
Go 对 >32KB 的对象有特殊处理:跳过 mcache/mcentral,直接从 mheap 分配。这是因为大对象不值得放进细粒度池里管理。
| 语言 | 大对象处理 |
|---|---|
| Go | >32KB 直接走 mheap,跳过中间层 |
| Java | G1 GC 把大对象放在 "humongous regions" |
| C | glibc 对大对象通常直接 mmap |
按需分配的直观验证
// 这两行并不会真正占用 1GB 物理内存
data := make([]byte, 1<<30) // 1GB 虚拟地址空间
// 直到你真正写入那一刻,操作系统才开始分配物理页
data[0] = 1 // 触发 page fault,分配第一页(4KB)
data[1024*1024] = 1 // 触发第二页
// ps 看 RSS 可能只有几 KB
你 make 了 1GB,但实际物理内存只用了几 KB。这就是虚拟内存的 Lazy Allocation 机制——你承诺要用多大,操作系统实际只给多少。
思考:既然物理内存没真正分配,那为什么
make([]byte, 1<<30)还是会让进程变慢?——因为建立页表条目本身是有开销的,而且一旦触发 page fault,性能会有瞬时抖动。
总结
| 维度 | Go 的特色 |
|---|---|
| 分配器 | 自己实现从 mmap 到对象的全链路,不依赖系统 malloc |
| 栈/堆决策 | 编译期逃逸分析自动决定,程序员专注正确性 |
| 内存弹性 | GC 后主动 munmap 还给 OS,RSS 可回落 |
| 并发友好 | mcache 每 P 一份无锁缓存,天然配合 GMP 模型 |
| 按需分配 | 和所有语言一样利用虚拟内存,但 Go 自己的分配策略更细粒度 |
虚拟内存本身没变,但 Go 把它用得更"自动化"了——你不需要像 C 那样担心泄漏,也不需要像 Java 那样忍受僵化的内存占用。