Post

运行时环境

运行时环境

📖 阅读指南

知识依赖链(建议按此顺序阅读):

1
2
3
4
5
第四章:中间代码生成(三地址码、类型系统)
    └─ 理解"变量"、"过程调用"的编译期表示
            └─ 本章:运行时环境(这些抽象在运行时如何落地)
                    ├─ 5.2 存储管理(核心:栈帧结构与堆分配)
                    └─ 5.3 垃圾回收(核心:可达性与四种 GC 算法)

阅读重点提示

  • 5.2.2 节的活动记录结构调用/返回序列是核心,务必结合 5.5 的图解理解
  • 5.2.3 节的访问链 vs 显示表是嵌套语言(Pascal/ML)的特有难点
  • 5.3 节四种 GC 算法需要横向对比,关注各自解决的问题和引入的新问题
  • 5.4 节 Java 四种引用类型是工程考点

5.1 运行时刻环境概述

问题动机

编译器的工作不止于”翻译代码”——源语言中的各种抽象(变量、函数调用、作用域、对象)在运行时需要一套协议和数据结构来支撑。这套协议就是运行时环境(Runtime Environment)

具体来说,运行时环境负责回答以下问题:

问题对应机制
变量在哪里?(局部 / 全局 / 嵌套)存储分配策略 + 访问机制
函数调用/返回如何工作?调用序列 + 活动记录
参数如何传递?参数传递协议(值传递/引用传递)
动态分配的内存怎么管理?堆管理 + 垃圾回收
如何与操作系统交互?ABI(Application Binary Interface)

关键洞察:编译器生成的代码要正确运行,前端(语义分析)和后端(代码生成)必须就运行时环境的结构达成共识。运行时环境是两者之间的隐性契约。


5.2 存储管理

5.2.1 存储分配基础

内存的三大区域

程序运行时的内存(虚拟地址空间)分为三个区域,各有不同的生命期管理策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
┌──────────────────┐
│      堆(Heap)   │  ← 向高地址增长(malloc/new)
│        ↓         │
│   (空闲区域)    │
│        ↑         │
│      栈(Stack)  │  ← 向低地址增长(函数调用)
├──────────────────┤
│   静态/全局区     │  ← 编译时分配,程序全生命期
├──────────────────┤
│      代码区       │  ← 只读,存放可执行指令
└──────────────────┘
低地址
区域内容大小确定时机生命期
代码区可执行目标代码编译时程序全生命期
静态区全局变量、常量、编译器辅助数据(如 GC 信息表)编译时程序全生命期
函数调用的局部变量、参数、返回地址运行时(LIFO 管理)函数调用期间
动态分配的对象(new/malloc运行时(显式/GC 管理)不定(直到回收)

三种分配策略

① 静态分配:大小编译时已知,分配在固定地址(静态区)。C 语言的全局变量、static 局部变量均属此类。

② 栈式动态分配:与过程调用/返回同步管理(LIFO)。局部变量的生命期 = 过程的生命期。优点:分配/释放代价极低(仅移动栈指针)。

③ 堆动态分配:数据生命期不确定,可能超过创建它的过程。需要显式管理(C/C++)或垃圾回收(Java/Python/Go)。

内存对齐与补白(Padding)

1
2
3
4
5
6
7
8
struct S {
    char  a;   // 1 字节,offset = 0
    // [3字节补白]  ← 因为 int 需要 4 字节对齐
    int   b;   // 4 字节,offset = 4
    char  c;   // 1 字节,offset = 8
    // [7字节补白]  ← 因为 double 需要 8 字节对齐
    double d;  // 8 字节,offset = 16
};             // 总大小 = 24 字节(非 1+4+1+8=14)

工程影响:结构体字段顺序影响内存占用。将大字段放前面可以减少补白(如 double, int, char 只需 16 字节)。


5.2.2 栈式分配

5.2.2.1 活动树

为什么用栈管理过程调用?

过程调用天然具有时间嵌套性:后调用的过程必然先返回(LIFO)。这与栈的语义完全吻合。

活动树(Activation Tree):用树结构表示程序运行期间所有过程活动的调用关系:

  • 每个节点 = 一次过程活动(注意:同一过程的递归调用是不同的活动)
  • 根节点 = main 的活动
  • 子节点从左到右 = 被调用顺序
  • 关键约束:子节点必须在其右兄弟开始前结束(LIFO 保证)
1
2
3
4
5
6
7
8
9
10
11
示例程序:main 调用 p,p 调用 q,p 还调用 r

活动树:
        main
        /
       p
      / \
     q   r       ← q 必须在 r 开始前结束

调用序列(前序遍历):main → p → q → r
返回序列(后序遍历):q → r → p → main

关键洞察:任意时刻,控制栈中所有活动记录的序列 = 活动树中从根到当前活动节点的路径。这是栈正确性的直观证明。

5.2.2.2 活动记录(栈帧)

每个活跃的过程活动在栈中对应一个活动记录(Activation Record / Stack Frame)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
一个完整的活动记录布局(从低地址到高地址):

  ┌─────────────────────┐ ← 调用者的 top_sp 处
  │   实在参数          │  调用者传入的参数值
  ├─────────────────────┤
  │   返回值            │  被调用者将结果放这里
  ├─────────────────────┤
  │   控制链            │  → 指向调用者的活动记录(用于恢复栈帧)
  ├─────────────────────┤
  │   访问链            │  → 指向外层过程的活动记录(嵌套语言需要)
  ├─────────────────────┤
  │   机器状态          │  保存的寄存器 + 返回地址(eip/PC)
  ├─────────────────────┤  ← top_sp 指向此处(定长字段末端)
  │   局部数据          │  本次活动的局部变量(大小编译期已知)
  ├─────────────────────┤
  │   临时值            │  表达式求值的中间结果
  └─────────────────────┘
  │   变长数据          │  变长数组等(top 指向此区域末端)

两个关键指针

指针含义用途
top_sp指向定长字段末端以固定偏移访问定长数据(局部变量、参数等)
top指向实际栈顶标识下一个活动记录起始位置

为什么需要两个指针? 当活动记录中存在变长数据(如 C99 VLA)时,top_sp 固定不变(定长部分),top 随变长数据移动。定长数据的访问始终用 top_sp + 编译期已知偏移,保证高效。

各字段放置原则

  • 传递的参数靠近调用者记录(便于调用者在自己的帧中准备参数)
  • 固定长度字段(控制链、访问链、寄存器状态)在中间
  • 大小未知的字段(变长数组)放末尾,在 top_sp 以外

5.2.2.3 调用/返回代码序列

调用一个过程涉及调用者(Caller)被调用者(Callee)双方的协作。

调用代码序列(Calling Sequence)

1
2
3
4
5
6
7
8
9
10
11
12
调用者负责:
  1. 计算所有实在参数的值
  2. 在被调用者的活动记录区域写入:
     - 实在参数
     - 返回地址(调用指令的下一条指令地址)
     - 保存当前 top_sp(控制链)
  3. 增加 top_sp,跳转到被调用者代码

被调用者负责:
  4. 保存调用者的寄存器状态(机器状态字段)
  5. 初始化局部变量
  6. 开始执行函数体

返回代码序列(Return Sequence)

1
2
3
4
5
6
7
8
被调用者负责:
  1. 将返回值写入指定位置
  2. 从机器状态字段恢复 top_sp 和寄存器
  3. 跳转到返回地址

调用者负责:
  4. 从返回值位置取出结果(若有)
  5. 继续执行调用点后的代码

工程原则:尽量把代码放在被调用者中,而非调用者中。原因:同一函数可能在 n 个调用点被调用,若代码在调用者,则每个调用点都生成一份,代码膨胀 n 倍;被调用者的代码只有一份。

这也是为什么 ABI 通常规定”被调用者保存寄存器”(callee-saved registers)——由被调用者统一保存/恢复,节省调用者代码量。

5.2.2.4 栈中的变长数据

场景:C99 的变长数组(Variable Length Array, VLA)——大小在运行时确定。

1
2
3
4
void f(int n) {
    int a[n];   // VLA:大小运行时才知道
    ...
}

处理方式:VLA 生命期局限于函数活动期 → 可以放在栈上,但不属于活动记录的固定部分,位于 top_sp 以外(top 指针以内)。

1
2
3
4
5
6
7
8
函数 f 的栈布局:

  ┌─────────────────────┐
  │  固定部分(参数n、  │ ← top_sp 在此
  │  控制链、局部变量等)│
  ├─────────────────────┤
  │  int a[n](VLA)    │ ← 运行时确定大小,紧跟固定部分之上
  └─────────────────────┘ ← top 在此

访问 VLA 中的元素:需要先通过指针(存放在固定部分)间接寻址,不能用常数偏移。


5.2.3 非局部数据的访问

无嵌套过程(C 语言模型)

C 语言不允许函数嵌套定义,因此只有两种变量:

  • 局部变量:在本函数的活动记录中,top_sp + 编译期已知偏移 直接访问
  • 全局变量:在静态区,地址编译时已知,直接访问

函数作为参数传递时,只需传入代码起始地址(函数指针),无需额外的环境信息。

嵌套声明过程(Pascal / ML / Ada 模型)

当语言允许函数嵌套定义,内层函数需要访问外层函数的局部变量。

1
2
3
4
5
6
7
8
9
procedure outer;
    var x: integer;
    procedure inner;
    begin
        x := x + 1;   (* inner 访问 outer 的局部变量 x *)
    end;
begin
    inner;
end;

问题inner 执行时,outer 的活动记录在栈中的哪里?

⚠️ 反直觉点inner 的访问链不一定指向调用者的活动记录(控制链),而是指向词法上的外层过程的活动记录。两者在递归等场景下不同!

嵌套深度(Nesting Depth):编译时静态确定的概念。

1
2
不嵌套在任何过程中的顶层过程:深度 = 1
嵌套在深度 i 的过程中定义的过程:深度 = i + 1

每个活动记录中增加一个”访问链”指针,指向词法上直接外层过程的最近一次活动记录。

访问外层变量的步骤

设当前过程深度为 np,被访问变量 x 声明在深度 nq 的过程中(nq < np):

  1. 从当前活动记录出发,沿访问链走 np - nq 步,到达深度 nq 的活动记录
  2. 以 x 的编译期已知偏移访问 x
1
2
3
4
5
6
7
8
9
10
示例(深度差 = 2):

当前记录(深度3)
    │ 访问链
    ↓
中间记录(深度2)
    │ 访问链
    ↓
目标记录(深度1)
    └─ x 在此处(偏移量编译期已知)

访问链的维护规则

场景如何设置被调用者的访问链
p 直接定义在 q 内(深度 p = 深度 q + 1)→ 指向当前(q 的)活动记录
递归调用(p 调用自身)→ 等于当前活动记录的访问链(指向同一外层)
p 定义在 r 中,通过 q(嵌套在 r 中)传递调用→ 指向栈中最顶部的 r 的活动记录

传递过程指针参数时:参数必须是(代码地址, 访问链)的二元组——即闭包(Closure)的雏形。

访问链的代价:访问深度差为 k 的变量需要 k 次指针跳转,代价为 O(k)。

方案二:显示表(Display)

问题动机:访问链方案在深度差大时开销线性增长,能否做到 O(1)?

核心思想:用一个全局数组 d[]d[i] 始终指向栈中最近一次活动的深度为 i 的过程的活动记录

访问深度 i 的变量:直接取 d[i],再加变量偏移,一次间接寻址,O(1)。

维护方式

1
2
3
4
5
6
调用深度为 np 的过程 p 时:
  1. 在 p 的活动记录中保存 d[np] 的旧值
  2. 将 d[np] 设为当前(p 的)活动记录地址

从 p 返回时:
  3. 从 p 的活动记录中恢复 d[np] 的旧值

访问链 vs 显示表对比

方案访问深度差为 k 的变量维护开销(每次调用/返回)内存开销
访问链O(k)(k 次指针跳转)O(1)(设置一个指针)每帧一个指针
显示表O(1)(直接查数组)O(1)(保存/恢复一个槽)全局数组(最大深度大小)

工程现实:现代语言(Go、Rust、Python)对闭包的实现通常用堆上分配的环境(Environment),而非显示表。显示表是编译器教材中的经典方案,工业上更多见于静态作用域的嵌套语言(Pascal 时代)。


5.2.4 堆管理

为什么需要堆?

栈上的数据生命期与函数调用绑定——函数返回时栈帧消失。但有些数据需要活得比创建它的函数更长

1
2
3
4
5
int* create_array(int n) {
    int* arr = malloc(n * sizeof(int));  // 必须放堆上!
    return arr;                           // 函数返回后 arr 仍然有效
}
// 若 arr 在栈上,函数返回后栈帧销毁,返回的指针成悬空指针!

堆分配的评价维度

  • 空间效率:减少碎片(Fragmentation),提高内存利用率
  • 时间效率:分配/释放操作要快
  • 程序局部性:相关数据在内存中靠近,减少缓存/TLB 缺失

堆碎片问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
初始堆(32字节):
[||||||||||||||||||||||||||||||||]  全部空闲

分配 8、4、8 字节后:
[AAAAAAAA BBBB CCCCCCCC         ]
 ↑8字节   ↑4字节 ↑8字节   ↑12字节空闲

释放 B(4字节)后:
[AAAAAAAA #### CCCCCCCC         ]
 ↑8字节   ↑4字节空洞 ↑8字节  ↑12字节空闲

现在申请 8 字节:
  - 中间 4 字节洞太小
  - 右边 12 字节空闲可满足
  - 但如果右边没有12字节,虽然总空闲=16,也无法满足8字节请求
                 ↑ 这就是外部碎片(External Fragmentation)

两种基本分配策略

策略思想优点缺点
Best-Fit选能容纳请求的最小空闲块保留大块供大请求使用搜索慢;产生大量极小碎片
First-Fit第一个能容纳的空闲块分配快;大块保留在后端前端碎片化严重

容器化堆管理(GNU C 方案)

按尺寸分档,同规格的空闲块放同一个”容器(bin)”:

1
2
3
4
5
6
7
8
9
10
容器分档:
  16B 容器: [□□□□□...]
  24B 容器: [□□□□□...]
  32B 容器: [□□□□□...]
  ...
  512B 容器: [□□□...]
  >512B: 按对数划分

分配时:找对应容器取一个空闲块
不足时:从"荒野块(Wilderness Chunk)"中分割

优点:分配常数时间;同规格块组合良好,减少内部碎片。

空闲块接合(Coalescing)

回收时将相邻空闲块合并成更大块,防止堆越来越碎。

关键技术:边界标记(Boundary Tag)

每个存储块的头部和尾部都存放该块的元数据:

1
2
3
4
5
块结构:
┌──────────────┬────────────────────────────────┬──────────────┐
│ 头(Header) │         有效数据区             │ 尾(Footer) │
│  size | free │                                │  size | free │
└──────────────┴────────────────────────────────┴──────────────┘

检查邻块是否空闲(O(1)):

  • 检查左邻块:读取当前块头部地址 - 4(上一块的尾部)
  • 检查右邻块:读取当前块头部地址 + size(下一块的头部)

双向链接的空闲列表:在每个空闲块的有效数据区内嵌入前向/后向指针,维护所有空闲块的双链表,O(1) 插入删除。

手工内存管理的两大危险

① 内存泄漏(Memory Leak):分配了堆内存,但在不再使用后忘记释放。

1
2
3
4
5
void leak() {
    char* buf = malloc(1024);
    if (error) return;  // 忘记 free(buf)!
    free(buf);
}

长期运行的程序(服务器等)因持续泄漏最终 OOM(Out of Memory)。

② 悬空指针(Dangling Pointer):释放内存后仍然持有指向该内存的指针。

1
2
3
int* p = malloc(sizeof(int));
free(p);
*p = 42;   // 未定义行为!该内存可能已被重新分配给别人

手工管理的缓解模式

  • 对象所有权(Object Ownership):每个对象只有一个所有者,只有所有者能删除;Rust 语言将此作为编译期强制规则
  • 引用计数(Reference Counting):计数归 0 时自动释放;C++ shared_ptr 的实现思路
  • 基于区域的分配(Region-based Allocation):同生命期的对象放同一区域,整块一起释放;Apache HTTP Server 的 apr_pool 即此思路

5.3 垃圾回收

5.3.1 基础概念:可达性与根集

什么是垃圾?

严格定义:程序无法再访问到的对象(不可达对象)。

广义定义:程序不会再使用的对象(但这在一般情况下不可计算——相当于停机问题)。

工程折中:GC 只能回收不可达的对象,而不是”不再需要”的对象。这意味着即使在 GC 语言中,也可能发生”逻辑内存泄漏”(对象可达但永远不会使用,如全局 HashMap 中积累的僵尸条目)。

根集(Root Set)

定义:不需要通过任何指针解引用就能直接访问的对象集合。

典型的根集来源:

  • 全局变量(静态区中的引用类型字段)
  • 当前栈上的局部变量和参数
  • CPU 寄存器中的引用

可达性的传播

1
2
3
4
5
根集对象都是可达的
    ↓ 若可达对象 A 的某字段存放了对象 B 的引用
B 也是可达的
    ↓ 递归传播
所有通过引用链从根集可到达的对象都是可达的

关键性质:一旦不可达,永远不会再变成可达(在正确的程序中)。

改变可达对象集合的三类操作

操作效果
对象分配新对象出现,加入可达集(通常初始分配在根集可达处)
引用赋值 u = vu 原来指向的对象可能失去一个引用,v 指向的对象多一个引用
过程返回局部变量从根集消失,被局部变量唯一引用的对象变不可达

GC 的前提条件

GC 要求语言类型安全(Type-Safe):运行时能判断任意内存位置是否存放的是指针。

为什么 C/C++ 不能做精确 GC? C 中可以将指针转换为整数再转回来,GC 无法区分”恰好等于某对象地址的整数”和”真正的指针”,因此只能做保守 GC(Conservative GC)——把所有看起来像指针的值都当指针处理,可能导致垃圾无法被回收。


5.3.2 引用计数

机制

每个堆对象附带一个引用计数字段,记录当前有多少个指针指向该对象:

操作计数变化
对象被分配计数 = 1
新引用指向该对象(赋值、参数传递等)计数 + 1
引用消失(赋值覆盖、局部变量出作用域等)计数 - 1
计数变为 0立即删除对象,并递归减少其所有字段指向对象的计数

引用赋值 u = v 的完整操作

1
2
3
4
5
旧对象 = u 指向的对象
1. 旧对象.count -= 1
2. 若 旧对象.count == 0:递归删除旧对象
3. u = v(更新指针)
4. v 指向的对象.count += 1

注意:必须先减再赋值,否则若 u == v,会先把计数减到 0 导致误删。

优缺点分析

优点

  • 对象变垃圾时立即回收,内存利用率高
  • 无需停顿(Stop-the-World),适合实时性要求高的场景
  • 回收工作均摊到每次引用操作,避免 GC 长暂停

缺点

① 无法回收循环引用(致命缺陷)

1
2
3
4
5
6
7
8
9
对象 A 和 B 互相引用,形成环:
  A.ref = B
  B.ref = A
  
当程序不再持有 A、B 时,根集中指向 A/B 的引用消失,但:
  A.count = 1(B 还引用 A)
  B.count = 1(A 还引用 B)
  
计数永远不为 0 → 永远不会被回收 → 内存泄漏!

CPython 解决方案:主要用引用计数,同时有一个循环检测器(gc 模块)处理循环引用。

② 运行时开销大:每次指针操作(赋值、函数调用传参、返回)都需要更新计数,对引用密集型代码性能影响显著。

③ 多线程问题:计数的增减必须是原子操作(CAS),开销进一步增加。


5.3.3 基于跟踪的回收

基本思路:不在垃圾产生时立即回收,而是周期性运行(通常在堆空间不足时触发),从根集出发遍历所有可达对象,回收其余的不可达对象。

对象的四种状态

状态含义
空闲(Free)不含活跃对象,可被分配
未被访问(Unreached)当前 GC 轮次尚未访问(默认假设不可达)
待扫描(Scanned)已确认可达,但其内部的引用字段尚未追踪
已扫描(Unscanned)已确认可达,且内部所有引用都已追踪完毕

GC 结束后:所有对象要么是已扫描(可达,保留),要么是空闲(不可达,回收)。

5.3.3.1 标记-清扫(Mark-Sweep GC)

两个阶段

Phase 1:标记(Mark)

从根集出发,做图遍历(BFS 或 DFS),将所有可达对象标记:

1
2
3
4
5
6
7
8
初始化:将所有对象状态设为 Unreached
将根集中的所有对象加入工作队列(状态→Scanned)

while 工作队列非空:
    取出一个对象 o(Scanned → Unscanned)
    for o 的每个引用字段 p:
        if p 指向的对象是 Unreached:
            将 p 指向的对象状态设为 Scanned,加入工作队列

Phase 2:清扫(Sweep)

遍历整个堆,处理每个对象:

  • Unscanned(已标记,可达)→ 清除标记,保留
  • Unreached(未标记,不可达)→ 回收,加入空闲列表
1
2
3
4
5
6
7
8
9
10
代价分析(堆大小 H 字节,可达数据 R 字节):
  标记代价 ∝ R(只遍历可达对象)
  清扫代价 ∝ H(遍历整个堆)
  单次 GC 总代价:C(GC) = a·R + b·H

  每回收一个字节的代价:
  C(per_byte) = (a·R + b·H) / (H - R)

  当 H >> R 时(堆很大,垃圾很多):C(per_byte) ≈ b
  当 H ≈ R 时(堆几乎满),代价急剧升高 → 需要扩堆

Baker 优化:维护 Unreached 链表,清扫时只扫描该列表而非整个堆,减少清扫代价。

缺点

  • Stop-the-World:GC 期间整个应用暂停(对延迟敏感的应用不可接受)
  • 堆碎片:存活对象位置不变,碎片依然存在
  • 清扫代价与堆大小成正比,堆越大停顿越长

5.3.3.2 标记-压缩(Mark-Compact GC)

动机:Mark-Sweep 不移动对象,碎片问题无法解决。

三个阶段

1
2
3
4
5
6
7
8
9
10
Phase 1:标记(同 Mark-Sweep)

Phase 2:计算新位置
  从堆最低地址开始,依次为每个"存活对象"计算新地址
  (想象把所有存活对象从左到右紧密排列)
  用哈希表 NewLocation[old_addr] = new_addr 记录映射

Phase 3:移动对象并更新所有引用
  将每个存活对象从 old_addr 拷贝到 new_addr
  遍历所有存活对象的引用字段,用 NewLocation 更新指针

效果

1
2
3
4
5
6
7
GC 前(碎片化):
  [AAAA #### BBBB #### #### CCCC ####]
       空洞       空洞  空洞       空洞

GC 后(压缩):
  [AAAA BBBB CCCC                    ]
                   ↑ 单一连续空闲区域

优点:消除所有碎片,空闲空间成为单一连续块,分配变为 O(1) 的指针碰撞(bump pointer)。

缺点:需要三遍扫描堆;更新所有引用意味着 GC 期间所有指针临时失效,实现复杂。

5.3.3.3 拷贝回收(Copying GC / Semispace GC)

核心思想:用空间换时间——将堆分为两个等大的半空间(From / To),GC 时只拷贝存活对象。

流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
正常分配:
  程序在 From 空间中用"指针碰撞"顺序分配对象
  From_ptr ──→ 对象A 对象B 对象C ... 对象N │ 空闲空间

From 空间满,触发 GC:
  1. 从根集出发,遍历所有可达对象
  2. 将每个可达对象拷贝到 To 空间(按遍历顺序,消除碎片)
  3. 在原对象位置留下"转发指针(Forwarding Pointer)"指向新地址
  4. 更新根集中的所有引用

GC 完成,交换角色:
  原 From 空间:全部视为空闲(所有对象已复制走或是垃圾)
  原 To 空间:成为新的 From 空间,继续分配

Cheney 算法(拷贝 GC 的经典实现,无需递归栈):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在 To 空间维护两个指针:
  scan:指向下一个待扫描(追踪其引用字段)的对象
  free:指向下一个空闲位置(新对象拷贝到此处)

初始化:将根集对象拷贝到 To 空间,设 scan = To 开始,free = 根集拷贝结束后的位置

while scan < free:
    取出 scan 处的对象 o
    for o 的每个引用字段 r:
        if r 指向的 From 空间对象没有转发指针:
            将该对象拷贝到 free 处,free += 对象大小
            在原处设转发指针
        r = 转发指针指向的 To 空间新地址
    scan += o 的大小

完成:scan == free,所有可达对象都在 To 空间

优点

  • 无碎片(分配始终是指针碰撞,O(1))
  • 遍历可达对象时直接移动,兼具标记和压缩
  • 改善程序局部性(相关对象按访问顺序排列在 To 空间)

缺点

  • 内存利用率只有 50%(一半空间永远闲置)
  • 每轮 GC 需遍历所有存活对象(即使垃圾极少)

5.3.3.4 分代回收(Generational GC)

动机(弱代假设,Weak Generational Hypothesis)

1
2
3
4
5
实验观测:大多数对象"朝生暮死"——
  > 80% 的对象在分配后极短时间内变垃圾
  < 20% 的对象活得较长(老年对象)

推论:对年轻代频繁做 GC,每次 GC 的代价小且回收量大,效率高

结构

1
2
3
4
5
6
7
8
9
10
11
堆分为多个"代(Generation)":

  新生代(Young Generation):对象新生,GC 频繁
  ┌──────────────────────────────────────┐
  │  Eden Space  │  S0(Survivor 0)  │  S1(Survivor 1)  │
  └──────────────────────────────────────┘
  
  老年代(Old / Tenured Generation):存活多轮的老对象,GC 不频繁
  ┌──────────────────────────────────────┐
  │                                      │
  └──────────────────────────────────────┘

执行流程(以 JVM G1/HotSpot 为例)

1
2
3
4
5
6
7
1. 新对象在 Eden 空间分配
2. Eden 满 → Minor GC(只回收新生代):
   - 存活对象从 Eden + S0 拷贝到 S1
   - 清空 Eden 和 S0
   - S0/S1 角色互换
3. 多次 Minor GC 后仍存活的对象"晋升"到老年代(age 阈值,默认 15)
4. 老年代满 → Major GC / Full GC(回收整个堆,代价高)

关键问题:老年代引用新生代怎么处理?

Minor GC 只扫描新生代,但老年代中的对象可能引用新生代对象——若忽略这些引用,存活对象会被误回收。

解决方案:记忆集(Remembered Set)

记录老年代中所有指向新生代的引用。Minor GC 时,记忆集中的引用也作为根集的一部分处理。

1
2
3
4
5
老年代对象 O.field = 新生代对象 Y

记忆集 remembered_set = { O.field 的地址 }

Minor GC 根集 = 正常根集 ∪ { O.field 指向的对象 Y }

维护记忆集的方式:写屏障(Write Barrier)——每次引用赋值时,检查是否是从老年代到新生代的跨代引用,如是则更新记忆集。


5.4 Java GC 设计(引用类型)

Java 通过四种引用强度精细控制对象的生命周期与 GC 行为:

引用类型GC 时机典型用途
强引用(Strong Reference)普通 Object obj = new ...有强引用存在时永不回收所有普通对象
软引用(Soft Reference)SoftReference<T>内存不足时才回收缓存(允许 GC 降级)
弱引用(Weak Reference)WeakReference<T>正常 GC 时即回收(不论内存是否充足)短期缓存、WeakHashMap
虚引用(Phantom Reference)PhantomReference<T>正常 GC 时回收,但回收前加入 ReferenceQueue跟踪 GC 时机、资源清理

生存时间排序:强引用 » 软引用 > 弱引用 ≈ 虚引用

软引用使用场景:图片缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, SoftReference<Image>> imageCache = new HashMap<>();

Image getImage(String url) {
    SoftReference<Image> ref = imageCache.get(url);
    if (ref != null) {
        Image img = ref.get();   // 可能返回 null(已被 GC 回收)
        if (img != null) return img;
    }
    // 缓存失效,重新加载
    Image img = loadFromNetwork(url);
    imageCache.put(url, new SoftReference<>(img));
    return img;
}

内存充足时图片驻留缓存;内存不足时 GC 自动回收,避免 OOM。

虚引用的特殊性

虚引用对对象的生命周期没有任何影响(持有虚引用等于没有引用),但对象被 GC 回收之前,JVM 会将该虚引用加入关联的 ReferenceQueue

1
2
3
4
5
6
7
8
9
10
11
12
ReferenceQueue<Resource> queue = new ReferenceQueue<>();
PhantomReference<Resource> phantomRef = 
    new PhantomReference<>(new Resource(), queue);

// 在另一个线程中监听队列
Thread cleaner = new Thread(() -> {
    while (true) {
        Reference<?> ref = queue.remove();   // 阻塞等待
        // 收到通知:对应的 Resource 对象即将/已被 GC 回收
        // 在此执行清理工作(如关闭文件句柄、释放 native 资源)
    }
});

Java 9+ 的 Cleaner API:基于虚引用封装,是 finalize() 的现代替代品。finalize() 因执行时机不确定、性能差、安全问题已在 Java 18 中弃用。


5.5 运行时环境图解(完整示例)

main 调用 fun(a, b) 为例,追踪完整的栈帧生命周期。

三个关键寄存器(x86 模型)

寄存器全称作用
eipInstruction Pointer当前执行指令的地址
ebpBase Pointer当前栈帧的底部(基址)
espStack Pointer当前栈顶(最新压栈位置)

完整执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
初始状态:
  代码区:main 代码、fun 代码
  静态区:全局变量 m = 10
  动态区(栈):空

Step 1:main 建立自己的栈帧
  push ebp        // 保存调用者(OS)的 ebp
  mov ebp, esp    // ebp 指向 main 栈底
  sub esp, 8      // 为局部变量 i, j 分配空间

  局部变量初始化:i = 4, j = 5

Step 2:调用 fun(i, j) 前的准备
  C 调用约定:参数**逆序**入栈
  push j          // 先压 j=5
  push i          // 再压 i=4
  call fun        // ① 将返回地址(call 下一条指令)push 入栈
                  // ② eip 跳转到 fun 代码

Step 3:fun 建立自己的栈帧
  push ebp        // 保存 main 的 ebp
  mov ebp, esp    // ebp 指向 fun 栈底
  sub esp, 4      // 为局部变量 c 分配空间

此时栈(从高到低):
  ┌──────────────┐
  │    j = 5     │  ebp + 12(main 传入的第二个参数)
  ├──────────────┤
  │    i = 4     │  ebp + 8 (main 传入的第一个参数)
  ├──────────────┤
  │  返回地址    │  ebp + 4
  ├──────────────┤
  │  main 的 ebp │  ebp + 0  ← fun 的 ebp 指向此处
  ├──────────────┤
  │    c = ?     │  ebp - 4  ← fun 的局部变量
  └──────────────┘  ← esp

Step 4:fun 执行计算
  mov eax, [ebp+8]    // 读取参数 i(= a)
  add eax, [ebp+12]   // 加上参数 j(= b)
  mov [ebp-4], eax    // 结果存入 c
  mov eax, [ebp-4]    // 返回值放 eax(C 调用约定)

Step 5:fun 返回
  mov esp, ebp        // 恢复 esp(释放局部变量)
  pop ebp             // 恢复 main 的 ebp
  ret                 // pop 返回地址 → eip,跳回 main

Step 6:main 获取返回值
  add esp, 8          // 清除压栈的两个参数(调用者清栈,cdecl 约定)
  mov [m], eax        // 将 fun 的返回值(eax)存入全局变量 m

参数逆序入栈的原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C 支持可变参数(如 printf)。最左边的参数(格式字符串)必须在最高地址
(最先被弹出/访问),所以最后一个压栈,因此参数逆序入栈。

  printf("%d %d", a, b)
  
  栈(从高到低):
  ┌──────────────┐
  │    b         │  ebp + 16
  ├──────────────┤
  │    a         │  ebp + 12
  ├──────────────┤
  │  "%d %d"    │  ebp + 8   ← 格式字符串,第一个参数,最靠近调用者帧
  ├──────────────┤
  │  返回地址    │  ebp + 4
  └──────────────┘

附录

常见误区对照表

误区正确理解
“活动树的子节点从左到右是调用顺序,说明右边的孩子更早执行”相反:左边先执行(先调用),右边后执行
“控制链和访问链一样,都指向调用者”控制链指向调用者(动态),访问链指向词法外层(静态),两者在递归等场景下不同
“显示表比访问链一定更快”显示表访问变量是 O(1),但维护时每次调用/返回要保存恢复一个槽;访问链维护 O(1) 但访问 O(k)。深度差小时差异不大
“引用计数能回收所有垃圾”引用计数无法回收循环引用形成的垃圾孤岛,这是其根本缺陷
“Mark-Sweep 的标记代价与堆大小成正比”标记只遍历可达对象,代价 ∝ R(可达数据量);清扫才与堆大小 H 成正比
“拷贝 GC 内存浪费,所以比 Mark-Sweep 差”拷贝 GC 消除碎片、分配极快(指针碰撞),GC 时间只与存活对象相关,垃圾多时反而高效
“分代 GC 只扫描年轻代,所以不需要考虑老年代的引用”老年代对年轻代的引用必须通过记忆集纳入 Minor GC 的根集,否则会误回收存活对象
“软引用在内存充足时永远不会被回收”JVM 规范没有严格定义,不同 JVM 实现可能不同;实践上 HotSpot 用 -XX:SoftRefLRUPolicyMSPerMB 控制软引用存活时间
“虚引用可以用来延迟对象的 GC”虚引用对对象生命周期没有任何保护作用,GC 不受其影响

关键概念速查表

概念一句话定义所在章节
活动记录(栈帧)一次过程调用在栈中的数据结构,含参数/返回地址/局部变量等5.2.2.2
活动树表示程序运行期间所有过程活动调用关系的树,前序=调用序5.2.2.1
控制链活动记录中指向调用者活动记录的指针(动态链接)5.2.2.2
访问链活动记录中指向词法外层过程最近活动记录的指针(静态链接)5.2.3
top_sp指向顶层活动记录定长字段末端,用固定偏移访问定长数据5.2.2.2
top指向实际栈顶(下一个活动记录起始位置)5.2.2.4
显示表数组 d[],d[i] 指向深度 i 最顶层活动记录,O(1) 非局部变量访问5.2.3
嵌套深度编译时静态确定的过程嵌套层次5.2.3
边界标记每个存储块两端存放 size 和 free/used 位,支持 O(1) 邻块接合5.2.4
Best-Fit选最小能容纳的空闲块,保留大块但搜索慢5.2.4
First-Fit选第一个能容纳的空闲块,分配快但前端碎片多5.2.4
根集无需指针解引用可直接访问的对象集合(全局变量、栈变量等)5.3.1
可达性从根集出发通过引用链可访问到的对象属性5.3.1
引用计数每对象附带引用数,为 0 时立即回收;缺陷:循环引用5.3.2
Mark-Sweep标记可达对象,清扫不可达;简单但有碎片,Stop-the-World5.3.3.1
Mark-Compact标记后压缩存活对象,消除碎片;需更新所有引用5.3.3.2
Copying GC堆分两半,GC 时拷贝存活对象到另一半;无碎片,内存利用率 50%5.3.3.3
Generational GC按年龄分代,年轻代频繁 GC;依赖”大多数对象短命”假设5.3.3.4
记忆集记录老年代到年轻代的跨代引用,供 Minor GC 使用5.3.3.4
写屏障引用赋值时的 hook,用于维护记忆集等 GC 数据结构5.3.3.4
强引用有引用存在时对象永不回收(Java 普通引用)5.4
软引用内存不足时才回收,适合缓存(Java SoftReference)5.4
弱引用正常 GC 时即回收(Java WeakReference)5.4
虚引用最弱引用,用于监听 GC 事件(Java PhantomReference + ReferenceQueue)5.4

本章知识依赖图(ASCII 树)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
前置知识
├── 过程调用语义(函数如何传参/返回)
├── 作用域规则(词法作用域 vs 动态作用域)
└── 内存模型基础(地址空间、字节寻址)

本章内容
├── 5.2 存储管理
│   ├── 5.2.1 三大区域(代码/静态/动态)
│   │   ├── 静态分配(全局变量)
│   │   ├── 栈式分配(过程调用)
│   │   └── 堆分配(动态对象)
│   │
│   ├── 5.2.2 栈式分配
│   │   ├── 活动树(调用关系的树状表示)
│   │   ├── 活动记录(栈帧结构,7个字段)
│   │   ├── 调用/返回代码序列(调用者+被调用者分工)
│   │   └── 变长数据(top vs top_sp 双指针)
│   │
│   ├── 5.2.3 非局部数据访问
│   │   ├── 无嵌套(C):静态区 + 偏移
│   │   └── 嵌套过程(Pascal):
│   │       ├── 访问链(O(深度差),维护简单)
│   │       └── 显示表(O(1),维护略复杂)
│   │
│   └── 5.2.4 堆管理
│       ├── Best-Fit / First-Fit 分配策略
│       ├── 容器化管理(GNU C)
│       ├── 边界标记 + 双链表(O(1) 接合)
│       └── 手工管理问题(泄露/悬空指针)
│           └── 缓解:所有权 / 引用计数 / 区域分配
│
└── 5.3 垃圾回收
    ├── 5.3.1 基础:根集 + 可达性
    ├── 5.3.2 引用计数(即时回收,循环引用缺陷)
    └── 5.3.3 基于跟踪(四种算法)
        ├── Mark-Sweep(简单,有碎片,Stop-the-World)
        ├── Mark-Compact(无碎片,更新引用开销大)
        ├── Copying GC(无碎片,50%内存利用率)
        └── Generational GC(优化短命对象,记忆集+写屏障)

工程连接
├── JVM:G1/ZGC/Shenandoah(并发分代 GC)
├── Python:引用计数 + 循环检测器
├── Go:三色标记-清扫(并发,低停顿)
├── Rust:所有权 + 借用检查(编译期解决,无 GC)
└── Linux/glibc:ptmalloc(容器化堆,per-thread arena)
This post is licensed under CC BY 4.0 by the author.