元数据卡
- 前置知识:Vol 3 计算机系统(内存布局、栈和堆的概念)、Vol 9 第1章(类型系统)
- 预计时间:50 分钟
- 核心难度:进阶
- 阅读模式:高度专注
- 可选跳过:如果只关心 Java,"逃逸分析"细节可跳过;"Rust 所有权"属进阶
- 完成标志:能解释 Java/Python/Rust 三种语言对象在内存中的分配方式;理解 GC 的核心分类
你的进度
你从类型系统的构室离开,向遗迹深处走了一段。墙上的符文从"变量是什么数据"变成了"数据怎么存放"。不同的文明解决这个问题的方式让你感到震撼——它们都想让数据待在应该待的地方,但对"应该"二字的理解完全不同。
你的任务
每个程序都处理数据——但数据存放在哪儿、什么时候创建、什么时候销毁,不同语言的回答天差地别。Java 把对象扔到堆里,GC 扫地;Rust 在编译期就算好了谁什么时候死;Python 什么东西都在堆上,靠引用计数先顶一阵。本章拆解这些选择背后的工程哲学。
本章分层
- 必读:栈 vs 堆对比、GC 三种基本策略、值类型 vs 引用类型
- 选读:逃逸分析与栈上分配
- 进阶:Rust 所有权模型、无 GC 的方案
破局 · 溯源
你在 Java 里写了段看起来人畜无害的代码:
public class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
void draw() {
Point p = new Point(3, 4); // p 在哪里?
// ...
}p(引用)在栈上,new Point(3, 4)(实际对象)在堆上。这是大多数 GC 语言的标准答案。
对比一下 C 语言:
void draw() {
struct Point p = {3, 4}; // p 整个在栈上
// ...
}没有 new,没有 malloc,p 就是一个 8 字节的栈值。出了函数就自动消失。快,但小——当 Point 太大或者需要超出函数返回值时,栈上放不下,你得手动 malloc。
再对比 Python:
def draw():
p = (3, 4) # 元组,堆上分配Python 的每个对象(包括整数和元组)都在堆上分配。整数 3 和 4 也是对象——每个整数默认 28 个字节(Python 3 中 sys.getsizeof(3) 返回 28)。
同一个 Point(3,4),三个语言花的内存天差地别。
栈 vs 堆:一次决择
| 栈 | 堆 | |
|---|---|---|
| 分配速度 | 移动栈指针,O(1) | 找空闲块,O(n) 起 |
| 释放 | 函数返回自动出栈 | 显式 free 或 GC |
| 大小 | 有限(通常 MB 级),编译期已知 | 很大(GB 级),运行时动态 |
| 线程安全 | 天然线程私有 | 需要同步 |
| 适用场景 | 局部变量、小对象、编译期大小已知 | 大对象、动态大小、跨函数生命周期 |
为什么 Java 默认放堆里? 因为 GC 需要统一管理,如果有些对象在栈上,GC 无法统一追踪。类型擦除导致 JVM 也不知道 T 实际多大,所以不能像 C 那样在栈上放未知大小的数据。
为什么 C 可以放栈上? 编译期结构体大小已知,sizeof(struct Point) 是一个常数。
GC 的三种基本策略
不同的语言设计者选择了不同的清扫方式:
引用计数(Python, Swift, Objective-C)
每个对象维护一个计数器——有多少引用指向我。为 0 时就回收。
a = []
b = a # a 的引用计数变成 2
del a # 引用计数减为 1
del b # 引用计数减为 0,回收优点:工作分摊到每次赋值操作,没有"全停顿"。缺点:循环引用泄漏——A 引用 B、B 引用 A,两个计数都是 1,没人能回收。
Python 用一个"分代循环检测器"定期扫描不可达的循环引用。这就是为什么你偶尔能看到 Python 内存不会立即释放。
标记-清除 + 分代收集(Java HotSpot)
JVM 把堆分成三个"代":
- 新生代:新对象放这里,频繁 GC
- 老年代:熬过多次 GC 的对象升到老年代
- 元空间:类的元数据
GC 从根(栈引用、静态变量)出发,标记所有可达对象。没被标记的就是垃圾。
void example() {
Point p = new Point(3, 4); // (1) 新生代分配
Point q = p; // (2) p 和 q 都引用同一个对象
q = new Point(5, 6); // (3) 旧的 Point(3,4) 不可达 —— GC 标记
}
// 函数返回后,p 和 q 从栈上消失
// 两个 Point 对象如果没有其他引用,标记为垃圾优点:能处理循环引用。缺点:一次完整的 GC(Full GC)会导致"Stop the World"——所有线程暂停,等 GC 扫完再继续。
无 GC(Rust)
Rust 在编译期分析所有权和生命周期。不需要运行时标记,不需要暂停线程。
fn draw() {
let p = Box::new(Point { x: 3, y: 4 });
// p 是 &Point 在栈上,实际 Point 在堆上
// 函数结束,Box::drop() 自动调用,堆上内存释放
}关键差别:Rust 没有 GC,但也不是 C 风格的手动 free。它通过所有权规则在编译期确定谁在什么时候释放。规则看起来有点严格,但换来的确定性——你知道每个 free 在哪发生。
逃逸分析与栈上分配
Java 说"对象在堆上"——但 JVM 有一个优化逃逸分析能打破这个原则。
void draw() {
Point p = new Point(3, 4);
int sum = p.x + p.y;
// p 没有"逃逸"出 draw 函数
}JVM 分析发现:p 只在 draw() 内部使用,没有被返回、没有被放入全局变量、没有被传给其他线程。于是它在栈上分配 Point 对象——就像 C 的局部结构体一样。
javac 编译成字节码仍然是 new,但 JIT 编译器在运行时识别了逃逸模式,对热代码做优化。这就是 JVM 的"告示牌:遇热即化"——对开发者透明。
对比一下,如果对象逃逸了:
Point globalPoint;
void store(Point p) {
globalPoint = p; // p 逃逸了 —— 必须堆上分配
}逃逸分析无法在编译期静态完成的语言,比如 Python,就几乎不做这件事——因为函数可以动态修改、参数类型可以变化,编译器没法确定引用关系。
值类型 vs 引用类型
| 值类型 | 引用类型 | |
|---|---|---|
| 数据位置 | 栈上(或内嵌在其他对象中) | 堆上,栈上只有引用 |
| 赋值语义 | 复制整个值 | 复制引用(两个引用指向同一个对象) |
| null 值 | 通常不能为 null | 可以为 null |
| 代表语言 | int, struct (C#, Rust) | Object, class (Java, Python) |
Java 的原始类型 int, long, boolean 是值类型——int a = 5; int b = a 复制了 5 本身。但 Integer 是引用类型,Integer a = 5(装箱)在堆上分配了一个对象。
int a = 5;
int b = a;
b = 10;
System.out.println(a); // 5 —— 不受影响
Integer aBox = 5;
Integer bBox = aBox;
bBox = 10;
System.out.println(aBox); // 5 —— 不受影响,但 bBox 成了新对象输出一样,但底层天差地别:int 改了就是一个栈值的改写;Integer 改了是新建一个 Integer 对象。
为什么 Java 不把所有类型做成值类型? JVM 的 GC 基于引用追踪。如果一个值在栈上,GC 无法把它标记为"可达"。此外,值类型的大小在编译期必须已知——这在类型擦除的泛型中做不到。Project Valhalla(Java 值的未来)就是为了解决这个问题。
Rust 反其道而行之:默认就是值类型。你写 let p = Point { x: 3, y: 4 },整个 Point 在栈上(或者内嵌在父结构中)。如果要堆上分配,你必须显式用 Box::new()。
多语言对比:创建一个"用户"对象
Java(堆上分配,引用传递,GC 回收):
class User {
String name;
int age;
}
void register() {
User u = new User(); // 堆上分配
u.name = "Alice";
u.age = 30;
// 函数结束,u 没引用了 —— 等待 GC
}Rust(值语义,编译器决定):
struct User {
name: String, // String 内部是堆分配
age: u32, // 栈上
}
fn register() {
let u = User {
name: String::from("Alice"),
age: 30,
};
// u 本身在栈上(a name 指针 + age);
// name 的字符串数据在堆上。
// 函数结束,drop 两个字段,栈和堆都释放。
}Python(所有东西都是对象,堆上分配):
class User:
def __init__(self, name, age):
self.name = name
self.age = age
def register():
u = User("Alice", 30) # u(引用)在栈上,User 对象在堆上
# Python 不需要你管的太多——引用计数自动维护同一个概念,三种语言的实现策略完全不同。没有一种方案满足"性能最好""编写最方便""内存最确定"。
常见陷阱
- "GC 语言不会内存泄漏" —— 泄漏的不是内存本身,而是无用的引用。一个 static List 不断 add 但不 remove,GC 不清理可达对象
- "Rust 没有运行时开销" —— 所有权检查是编译期,0 运行时开销;但
Box、Rc、RefCell有运行时开销 - "栈比堆快" —— 快在分配(指针移动),不是访问(CPU cache 效果取决于数据大小和访问模式)
通关挑战
- 热身:画一张表,对比 Java / C++ / Python / Rust 在以下维度:默认分配位置、GC 策略、值/引用类型
- 动手:运行
java -XX:+PrintGCDetails YourProgram,观察 GC 日志,识别 Minor GC 和 Full GC 的区别 - 观察:用
sys.getsizeof()在 Python 中查看不同对象的内存大小——一个 int、一个 str、一个 list
旅人笔记
内存管理是语言设计最大的单点抉择——选择 GC 就得接受停顿,选择引用计数就得处理循环引用,选择所有权就得接受学习曲线。没有免费的午餐,只有不同的交换。
→ 下一站预告
一个程序跑起来,内存有了,线程也有了。多个线程之间怎么协作?遗迹第三扇门——并发模型对比:Actor、CSP、STM——为什么每个文明都对"共享"二字给出了不同定义。