This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/内存分配与逃逸分析/虚拟内存与各语言对比.md
T

6.1 KiB
Raw Blame History

tags, create time
tags create time
go
golang
虚拟内存
内存管理
操作系统
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 那样忍受僵化的内存占用。

关联笔记