Skip to content

元数据卡

  • 前置知识:Vol 3 计算机系统(内存布局、栈和堆的概念)、Vol 9 第1章(类型系统)
  • 预计时间:50 分钟
  • 核心难度:进阶
  • 阅读模式:高度专注
  • 可选跳过:如果只关心 Java,"逃逸分析"细节可跳过;"Rust 所有权"属进阶
  • 完成标志:能解释 Java/Python/Rust 三种语言对象在内存中的分配方式;理解 GC 的核心分类

你的进度

你从类型系统的构室离开,向遗迹深处走了一段。墙上的符文从"变量是什么数据"变成了"数据怎么存放"。不同的文明解决这个问题的方式让你感到震撼——它们都想让数据待在应该待的地方,但对"应该"二字的理解完全不同。

你的任务

每个程序都处理数据——但数据存放在哪儿、什么时候创建、什么时候销毁,不同语言的回答天差地别。Java 把对象扔到堆里,GC 扫地;Rust 在编译期就算好了谁什么时候死;Python 什么东西都在堆上,靠引用计数先顶一阵。本章拆解这些选择背后的工程哲学。

本章分层

  • 必读:栈 vs 堆对比、GC 三种基本策略、值类型 vs 引用类型
  • 选读:逃逸分析与栈上分配
  • 进阶:Rust 所有权模型、无 GC 的方案

破局 · 溯源

你在 Java 里写了段看起来人畜无害的代码:

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 语言:

c
void draw() {
    struct Point p = {3, 4};  // p 整个在栈上
    // ...
}

没有 new,没有 mallocp 就是一个 8 字节的栈值。出了函数就自动消失。快,但小——当 Point 太大或者需要超出函数返回值时,栈上放不下,你得手动 malloc

再对比 Python:

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 时就回收。

python
a = []
b = a    # a 的引用计数变成 2
del a    # 引用计数减为 1
del b    # 引用计数减为 0,回收

优点:工作分摊到每次赋值操作,没有"全停顿"。缺点:循环引用泄漏——A 引用 B、B 引用 A,两个计数都是 1,没人能回收。

Python 用一个"分代循环检测器"定期扫描不可达的循环引用。这就是为什么你偶尔能看到 Python 内存不会立即释放。

标记-清除 + 分代收集(Java HotSpot)

JVM 把堆分成三个"代":

  • 新生代:新对象放这里,频繁 GC
  • 老年代:熬过多次 GC 的对象升到老年代
  • 元空间:类的元数据

GC 从根(栈引用、静态变量)出发,标记所有可达对象。没被标记的就是垃圾。

java
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 在编译期分析所有权和生命周期。不需要运行时标记,不需要暂停线程。

rust
fn draw() {
    let p = Box::new(Point { x: 3, y: 4 });
    // p 是 &Point 在栈上,实际 Point 在堆上
    // 函数结束,Box::drop() 自动调用,堆上内存释放
}

关键差别:Rust 没有 GC,但也不是 C 风格的手动 free。它通过所有权规则在编译期确定谁在什么时候释放。规则看起来有点严格,但换来的确定性——你知道每个 free 在哪发生。


逃逸分析与栈上分配

Java 说"对象在堆上"——但 JVM 有一个优化逃逸分析能打破这个原则。

java
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 的"告示牌:遇热即化"——对开发者透明。

对比一下,如果对象逃逸了:

java
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(装箱)在堆上分配了一个对象。

java
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 回收)

java
class User {
    String name;
    int age;
}

void register() {
    User u = new User();  // 堆上分配
    u.name = "Alice";
    u.age = 30;
    // 函数结束,u 没引用了 —— 等待 GC
}

Rust(值语义,编译器决定)

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(所有东西都是对象,堆上分配)

python
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

def register():
    u = User("Alice", 30)  # u(引用)在栈上,User 对象在堆上
    # Python 不需要你管的太多——引用计数自动维护

同一个概念,三种语言的实现策略完全不同。没有一种方案满足"性能最好""编写最方便""内存最确定"。


常见陷阱

  1. "GC 语言不会内存泄漏" —— 泄漏的不是内存本身,而是无用的引用。一个 static List 不断 add 但不 remove,GC 不清理可达对象
  2. "Rust 没有运行时开销" —— 所有权检查是编译期,0 运行时开销;但 BoxRcRefCell 有运行时开销
  3. "栈比堆快" —— 快在分配(指针移动),不是访问(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——为什么每个文明都对"共享"二字给出了不同定义。

Built with VitePress | Software Systems Atlas