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

143 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, golang, 虚拟内存, 内存管理, 操作系统]
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
// 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
// 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` |
## 按需分配的直观验证
```go
// 这两行并不会真正占用 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 那样忍受僵化的内存占用。
## 关联笔记
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/GMP调度模型]]
- [[DEV/GO/GC垃圾回收]]