跳转到内容

第 5 章 · 值与引用、相等性、内存与垃圾回收

解决什么问题:赋值、传参时到底复制了数据还是只复制了一个指向,两个东西“相等”是指内容一样还是就是同一个,不用的内存又由谁收回。

四种语言的态度:Python 和 JS/TS 里对象一律按引用共享,C# 让你在 struct 和 class 之间选,Go 默认按值复制、指针写在明处;四种语言都靠垃圾回收收内存,算法各不相同。

游戏里有一个坐标点 (1, 2) 和一份分数列表 [1, 2]。程序先复制坐标点,把副本的横坐标改成 10,看原点变没变;然后给分数列表起一个别名,往别名里加一个 99,看原列表变没变;最后用另一种方式造一份内容同样是 [1, 2] 的列表,分别回答“两份列表内容相等吗”和“两份列表是同一个对象吗”。四种语言的输出一字不差,背后的机制各不相同。

赋值有两种含义。值语义(value semantics)是把数据本身复制一份,从此两边各管各的;引用语义(reference semantics)是只复制一个指向数据的引用(reference),两个变量看到的是同一份数据,一边改了另一边立刻看得见。第 4 章讲过“变量是名字还是存储”,下图看两种语义在内存里的样子。

值类型与引用类型的内存布局

看左右两栏赋值之后箭头的数目:左栏 moved = origin 把 X、Y 两个字段整块复制到新的位置,此后改 moved 碰不到 origin;右栏 scores 和 alias 两个变量里放的都是引用,两条箭头指向堆上同一个列表对象。

常有人说“值类型在栈(stack)上,引用类型在堆(heap)上”,这只说对了一半。栈随函数调用分配、随返回释放,不用回收;堆上的对象寿命不跟着函数走,要有人负责释放。放在哪里由编译器和运行时决定,不由语法决定:C# 的 struct 做了类的字段就跟着对象住在堆上;Go 的局部变量被返回了地址,编译器会把它挪到堆上。语义说的是“赋值时复制什么”,位置是实现细节。

相等性(equality)也有两种含义。内容相等(value equality)问的是两份数据的内容是否一样;引用相等(reference equality),也叫同一性(identity),问的是两个引用是否指向同一个对象。语言要决定 == 默认表示哪一种,以及用户定义的类型怎样改写它。

手动释放(C 的 free)早了留下悬空指针,忘了就泄漏。垃圾回收(garbage collection,GC)把这件事交给运行时,主要有两类做法。引用计数(reference counting)给每个对象记一个“有几个引用指向我”的计数,减到 0 立刻释放:回收及时,但每次赋值都要改计数,两个对象互相引用形成循环引用(reference cycle)时计数永远不为 0,收不回来。追踪式回收(tracing GC)从全局变量、栈上的变量这些根(root)出发,顺着引用找出所有还能到达的对象,其余的都是垃圾:标记清除(mark-sweep)原地释放垃圾,会留下碎片;复制(copying)把活对象搬到另一块空间,顺便整理干净;标记整理(mark-compact)标记之后把活对象往一端挪。追踪式回收天然能处理循环引用,代价是扫描时要停下程序或与它配合,停顿(pause)和吞吐量(throughput)此消彼长。

分代回收:新生代与老年代

分代(generational)回收建立在一个经验观察上:大多数对象刚创建不久就死了。图里所有新对象都从最上面进入新生代,新生代小,回收频繁而且很快;活过回收的对象晋升到老年代,老年代很少回收。每次只扫一小块内存,就能收回大部分垃圾。

Python 只有引用语义,而且是原生的:整数、字符串、列表、用户写的类,一律是堆上的对象,变量里放的永远是引用。没有用户自定义的值类型,“复制”就是造一个新对象。

import copy
from dataclasses import dataclass, replace
@dataclass
class Point:
x: int
y: int
def yes_no(flag: bool) -> str:
return "是" if flag else "否"
origin = Point(1, 2)
moved = replace(origin) # 新建一个 Point,字段逐个复制
moved.x = 10
print(f"改副本后原点:({origin.x}, {origin.y})")
scores = [1, 2]
alias = scores # 别名:两个名字指向同一个列表对象
alias.append(99)
print(f"改别名后原列表:{scores}")
a = [1, 2]
b = copy.deepcopy(a) # 另造一个内容相同的新列表
print(f"两份列表内容相等:{yes_no(a == b)}") # == 调用 __eq__,逐个比较元素
print(f"两份列表是同一对象:{yes_no(a is b)}") # is 比较对象身份
# 输出:
# 改副本后原点:(1, 2)
# 改别名后原列表:[1, 2, 99]
# 两份列表内容相等:是
# 两份列表是同一对象:否

运行:python points.py。replace(origin) 用原对象的字段值调用构造函数,得到一个新的 Point;alias = scores 不造任何新东西,只让第二个名字指向同一个列表。== 调用对象的 __eq__ 方法:列表的 __eq__ 逐个比较元素,@dataclass 也会自动生成一个按字段比较的 __eq__;普通的类不写 __eq__ 时,== 退回到比较同一性。is 不能改写,它只比较两个引用是不是同一个对象。

copy.copy 只复制最外一层,新列表里的元素和旧列表是同一批对象;copy.deepcopy 递归复制每一层,并用备忘字典记住复制过的对象,遇到循环引用也不会死循环。

底层怎么实现。 CPython 里每个对象开头都有一个对象头,存着引用计数 ob_refcnt 和指向类型的指针;一个整数 1 也是这样一个结构体,列表里存的是一排指向这些结构体的指针。小对象由 CPython 自己的分配器 pymalloc 从大块内存里切出来。

回收分两层。主力是引用计数:赋值、传参、离开作用域都会增减计数,减到 0 立刻释放。补充的是循环垃圾收集器(cyclic GC,gc 模块),只跟踪列表、字典、类实例这类容器对象,定期找出彼此引用却已从外部不可达的对象一起释放;这个收集器是分代的。3.14.0 到 3.14.4 曾把它改成增量式、只分新老两代,以缩短最长停顿,但因为生产环境里内存压力明显上升,3.14.5 起退回了 3.13 的分代收集器。

  • 小整数缓存让 is 偶然成立:CPython 预先建好 -5 到 256 的整数对象并反复复用,int("256") is int("256") 为真,换成 257 就为假。比较数值和字符串一律用 ==;对字面量用 is 时,3.8 起编译器会给出 SyntaxWarning。
  • grid = [[0] * 3] * 3 造出的三行是同一个列表对象,grid[0][0] = 1 之后三行的第一个元素都变成 1。外层的 * 复制的是引用,应该写 [[0] * 3 for _ in range(3)]。
  • 引用计数的“及时释放”遇到循环就失效:互相引用的对象要等循环收集器下一次运行才释放,其中持有的文件或连接也跟着晚关。资源要靠 with 关闭。

JavaScript 原生区分两类值,原始值(primitive,数字、字符串、布尔、bigint、symbol、undefined、null)按值使用,对象(包括数组、函数)按引用共享。用户不能定义新的原始类型。TypeScript 在这方面什么也没加。

import { isDeepStrictEqual } from "node:util";
type Point = { x: number; y: number };
const yesNo = (flag: boolean): string => (flag ? "是" : "否");
const show = (list: number[]): string => `[${list.join(", ")}]`;
const origin: Point = { x: 1, y: 2 };
const moved = { ...origin }; // 展开语法:新建一个对象,浅复制各属性
moved.x = 10;
console.log(`改副本后原点:(${origin.x}, ${origin.y})`);
const scores = [1, 2];
const alias = scores; // 别名:两个变量里放着同一个数组的引用
alias.push(99);
console.log(`改别名后原列表:${show(scores)}`);
const a = [1, 2];
const b = structuredClone(a); // 深复制出一个新数组
console.log(`两份列表内容相等:${yesNo(isDeepStrictEqual(a, b))}`); // Node 标准库的深比较
console.log(`两份列表是同一对象:${yesNo(a === b)}`); // === 对对象只比较引用
// 输出:
// 改副本后原点:(1, 2)
// 改别名后原列表:[1, 2, 99]
// 两份列表内容相等:是
// 两份列表是同一对象:否

运行:tsx points.ts。展开语法新建一个对象,把 origin 的每个自有属性抄一遍,所以 moved 是新对象;const alias = scores 只复制引用。=== 用在两个对象上时只问是不是同一个对象,两个内容相同的数组比出来是 false;用在原始值上才比较值。

语言本身没有深比较。本例用的 isDeepStrictEqual 来自 Node 的 node:util 模块,浏览器里没有;也可以自己写比较函数,比较 JSON.stringify 的结果则会因属性顺序不同而误判。structuredClone 是 HTML 规范定义、Node 17 起也提供的全局函数,能处理循环引用、Map、Date,遇到函数则直接抛错。

底层怎么实现。 V8 里一个 JS 值通常是一个带标记的机器字:小整数(Smi)直接编码在这个字里,不占堆;其余的数字、字符串和所有对象都在堆上,变量里放的是指针。“按值”是语义上的说法:长字符串赋值时并不复制字符,因为它不可变,共享也安全。

V8 的垃圾回收项目叫 Orinoco,是分代的追踪式回收。新生代分成两半,新对象在其中一半里顺序分配,回收时由 Scavenger 把活对象复制到另一半,活过新生代回收的对象晋升到老生代;老生代用标记清除,碎片多时再整理(mark-compact)。标记大多在后台线程并发进行,整理由多个线程并行完成,主线程只短暂停顿。

  • 展开语法是浅复制:const b = { ...a } 之后,b.pos 和 a.pos 仍是同一个嵌套对象,改 b.pos.x 会改到 a。嵌套的数据要么逐层展开,要么用 structuredClone。
  • structuredClone 不保留原型:复制一个 class 的实例,得到的是普通对象,方法都没了,instanceof 也变成 false。
  • Set 和 Map 的键按同一性比较:往 Set 里放两个内容相同的 { x: 1, y: 2 } 会得到两个元素,用对象当 Map 的键时,只有拿着原来那个引用才查得到。

C# 原生同时支持两种语义,由类型的声明决定:struct(以及 int、double、DateTime 这些内置类型)是值类型(value type),class、数组、字符串是引用类型(reference type)。record 再在两者之上加了编译器生成的按值比较。

using System;
using System.Collections.Generic;
using System.Linq;
var origin = new Point(1, 2);
var moved = origin; // struct 赋值:整块复制
moved.X = 10;
Console.WriteLine($"改副本后原点:({origin.X}, {origin.Y})");
var scores = new List<int> { 1, 2 };
var alias = scores; // class 赋值:只复制引用
alias.Add(99);
Console.WriteLine($"改别名后原列表:[{string.Join(", ", scores)}]");
var a = new List<int> { 1, 2 };
var b = new List<int>(a); // 另造一个内容相同的新列表
Console.WriteLine($"两份列表内容相等:{YesNo(a.SequenceEqual(b))}"); // 逐个比较元素
Console.WriteLine($"两份列表是同一对象:{YesNo(ReferenceEquals(a, b))}"); // 比较引用
static string YesNo(bool flag) => flag ? "是" : "否";
struct Point(int x, int y)
{
public int X = x;
public int Y = y;
}
// 输出:
// 改副本后原点:(1, 2)
// 改别名后原列表:[1, 2, 99]
// 两份列表内容相等:是
// 两份列表是同一对象:否

运行:dotnet run points.cs。Point 是 struct,var moved = origin 把两个字段整块复制;List<int> 是 class,var alias = scores 只复制引用。a.SequenceEqual(b) 是 LINQ 提供的逐元素比较,ReferenceEquals 只比较引用。List<int> 没有改写 Equals,a == b 也是比较引用。

默认的相等性随类型种类而变。class 的 Equals 默认比较引用;struct 的 Equals 默认逐字段比较(字段里有引用时可能要靠反射,很慢),而 == 根本没有定义,origin == moved 编译不过。record class 和 record struct 由编译器生成按属性比较的 Equals、GetHashCode 和 ==,还能用 with 复制出改了一处的新实例。深复制没有内置方法,要自己写,或者通过序列化来回一遍。

底层怎么实现。 值类型的数据内联在它所在的地方:局部变量里的 struct 放在栈帧或寄存器里,做类的字段时嵌在对象内部,放进数组时数组里就是一排紧挨着的 struct。引用类型的对象都在托管堆上。把值类型赋给 object 或接口类型时,运行时在堆上分配一个盒子,把值复制进去,这叫装箱(boxing)。近几个版本的 JIT 也开始做逃逸分析(escape analysis),.NET 10 能把一部分不会逃出方法的小对象直接放在栈上。

.NET 的 GC 是分代的追踪式回收,分 0、1、2 三代:新对象进第 0 代,活过一次回收升到第 1 代,再活下来升到第 2 代。回收会移动对象来整理内存,所以要把托管对象的地址交给本机代码时必须用 fixed 把它钉住。不小于 85000 字节的对象直接进大对象堆(large object heap,LOH),它在逻辑上属于第 2 代,默认只清除不整理,因为搬动大块内存太贵。第 2 代回收可以在后台线程上和程序并发进行。

  • 可变 struct 放在 List<T> 里,points[0].X = 10 直接编译不过(CS1612),因为索引器返回的是副本;改写成 var p = points[0]; p.X = 10; 能编译,改的却是局部副本,列表里的元素纹丝不动。数组的元素是变量,arr[0].X = 10 才真正改到。
  • 装箱之后比较的是引用:object a = 1; object b = 1; 时 a == b 为假,因为两个盒子是两个对象;a.Equals(b) 为真。
  • 很大的 struct 每次赋值、传参都要整块复制;放在 readonly 字段里时,调用它的非只读方法还会触发编译器生成的防御性复制(defensive copy)。所以 struct 宜小而不可变。

Go 原生是值语义:赋值和传参一律复制,结构体、数组都是整块复制;想共享就显式取指针 &x,用 *p 访问。切片、映射、通道看起来像引用类型,其实是带指针的小结构,复制之后背后的数据仍然共享。

package main
import (
"fmt"
"slices"
"strconv"
"strings"
)
type Point struct{ X, Y int }
func yesNo(flag bool) string {
if flag {
return "是"
}
return "否"
}
func show(list []int) string {
parts := make([]string, len(list))
for i, v := range list {
parts[i] = strconv.Itoa(v)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func main() {
origin := Point{1, 2}
moved := origin // 结构体赋值:整块复制
moved.X = 10
fmt.Printf("改副本后原点:(%d, %d)\n", origin.X, origin.Y)
scores := []int{1, 2}
alias := &scores // 别名要用指针;直接复制切片只复制切片头
*alias = append(*alias, 99)
fmt.Printf("改别名后原列表:%s\n", show(scores))
a := []int{1, 2}
b := slices.Clone(a) // 新的底层数组,内容相同
fmt.Printf("两份列表内容相等:%s\n", yesNo(slices.Equal(a, b)))
fmt.Printf("两份列表是同一对象:%s\n", yesNo(&a[0] == &b[0])) // 比较底层数组首元素的地址
}
// 输出:
// 改副本后原点:(1, 2)
// 改别名后原列表:[1, 2, 99]
// 两份列表内容相等:是
// 两份列表是同一对象:否

运行:go run points.go。moved := origin 复制了整个结构体。分数列表这一步和另外三种语言不同:写 alias := scores 再 alias = append(alias, 99),原来的 scores 长度仍是 2,打印出来还是 [1, 2],因为 alias 只是切片头的拷贝。要让别名的修改对原变量可见,就得像代码里这样用指针 &scores。slices.Equal 逐元素比较;切片本身不能用 == 比较(只能和 nil 比),本例改为比较两个底层数组首元素的地址。

所有字段都可比较的结构体能直接用 == 逐字段比较;含切片、映射或函数字段的就编译不过,要用 reflect.DeepEqual 或自己写。标准库的 slices.Clone、maps.Clone 都是浅复制,深复制没有通用的内置方法。

底层怎么实现。 切片头是三个机器字:指向底层数组的指针、长度、容量;字符串头是指针加长度;映射和通道的变量里只是一个指向运行时内部结构的指针。复制它们很便宜,两份指向同一块数据。

Go 切片头与 append

看三个切片头各自指向哪个数组(s := make([]int, 2, 3)):s 和 t 指向同一个数组 A,只是长度不同;append(t, 4) 时容量 3 已经用满,运行时分配了容量为 6 的新数组 B 并把元素复制过去,u 从此和前两个不再共享。

变量放在栈上还是堆上,由编译器的逃逸分析决定:返回局部变量的地址完全合法,编译器发现它活得比函数长,就分配到堆上;go build -gcflags=-m 能看到哪些变量“移到了堆上”。垃圾回收是并发的三色标记清除(concurrent tri-color mark-sweep):标记和程序同时运行,靠写屏障(write barrier)记下标记期间被改动的指针,只在开始和结束时短暂停顿。它不分代,也不移动对象。GOGC(默认 100)表示堆增长到上次回收后存活量的约两倍时启动下一轮,GOMEMLIMIT(Go 1.19 起)给出一个总内存的软上限。Go 1.26 起默认启用的 Green Tea 改进了标记阶段扫描小对象的方式,基本做法不变。

  • append 可能共享,也可能不共享底层数组:图里 s 还有一格空余容量时,x := append(s, 9) 会把 9 写进数组 A 的第三格,悄悄把 t[2] 从 3 改成 9;容量用满时又会分配新数组,两边从此互不影响。从别人传来的切片派生新切片时先 slices.Clone,或者用 s[:len(s):len(s)] 把容量截到长度。
  • 小切片拖住大数组:从一个 100 MB 的数组里切出 buf[:10] 长期保存,整个数组都因为还被引用而无法回收。只要一小段就复制出来再存。
  • 映射里的结构体值不可寻址:m["a"].X = 1 编译不过,要整个取出、修改、再存回去,或者把映射的值改成指针。
维度 Python JS/TS C# Go
支持程度 原生,只有引用语义 原生,原始值按值、对象按引用 原生,值类型与引用类型并存 原生,值语义 + 显式指针
实现机制 对象头 + 引用计数 带标记的值 + 堆对象 内联的值类型 + 托管堆 逃逸分析决定栈或堆
用户自定义值类型 没有 没有 struct、record struct 任何结构体、数组
默认相等性 == 调 __eq__,未定义时比同一性 === 对对象比引用 class 比引用,record 比内容 可比较的结构体逐字段比,切片不能 ==
深拷贝方式 copy.deepcopy structuredClone 自己写或序列化 自己写
GC 算法 引用计数 + 分代循环收集器 分代:新生代复制,老生代标记清除加整理 分代标记整理,三代 + 大对象堆 并发三色标记清除,不分代
能否手动控制内存 基本不能,只有 gc.collect();del 只删名字 不能,只能少分配 stackalloc、Span<T>、ArrayPool<T>、unsafe 显式指针、sync.Pool,GC 参数

差别最大的是“用户自定义值类型”一行:Python 和 JS 里不存在,“复制”只能靠造新对象;C# 和 Go 里赋值本身就会复制。

Python 和 JavaScript 都把“写起来简单”放在前面:“一切都是对象、变量都是引用”让语言只需要一套规则,解释器也只处理一种东西:指向对象的指针。代价是几乎每个值都要上堆,一个整数也带着对象头,计算密集时分配和回收很费时间;没有值类型,“不小心共享了同一个对象”也成了常见错误,浅复制、别名、[[0] * 3] * 3 都是它的变体。

C# 设计时就看到了 Java 不能自定义值类型的性能代价,所以保留了值类型:坐标、颜色、金额这类小数据用 struct,放进数组时紧挨着排列,不用为每个元素分配一个对象,也不给 GC 增加负担。代价是两套规则并存,程序员必须知道手里的类型是哪一种,装箱、可变 struct 的副本这些坑都由此而来。

Go 面向的是大规模的服务端程序,设计者希望数据布局像 C 一样可预测:结构体就是一块连续内存,数组就是一排结构体,赋值就是复制。共享必须写出 &,读代码就看得出哪里会被别人改。切片和映射为了不复制大块数据做成了带指针的小结构,于是 Go 也有自己的共享陷阱,append 共享与否取决于容量。

GC 的选择跟着各自的主要场景走。CPython 用引用计数,适合交互脚本和大量调用 C 扩展的程序;代价是每次赋值都要改计数,这也是 CPython 长期需要 GIL 保护计数的原因之一。V8 要保证页面不卡顿,所以把回收切成小段、尽量放到后台线程。.NET 的服务端程序看重吞吐量,分代加整理让分配只是移动一个指针。Go 的服务看重尾延迟(tail latency),选择了不移动对象的并发标记清除,停顿很短,代价是 GC 占用一部分 CPU、堆要多留余量,GOGC 和 GOMEMLIMIT 就是在内存和 CPU 之间取舍的旋钮。

Rust 既不用 GC,也不让程序员手动释放,而是靠所有权(ownership):每个值同一时刻只有一个所有者,所有者离开作用域时值被自动释放。赋值默认是移动(move),移动之后旧变量不能再用;需要临时使用时借用(borrow),编译器的借用检查器保证同一时刻要么有多个只读引用,要么只有一个可写引用。

let scores = vec![1, 2];
let alias = scores; // 所有权移给 alias
alias.len(); // 可以
// scores.len(); // 编译错误:scores 已经被移走

需要共享所有权时,Rust 提供引用计数的 Rc<T>,同样收不回循环引用,要用 Weak<T> 打破循环。

  • 先想清楚要哪种相等:比较内容用 Python 的 ==、C# 的 record 或 SequenceEqual、Go 的 slices.Equal,JS 需要自己写或借用 Node 的 isDeepStrictEqual;只有确实要判断“是不是同一个对象”时才用 is、ReferenceEquals 或比较指针。
  • 小而不可变的数据优先做成值:C# 用 readonly record struct,Go 用普通结构体值,Python 用冻结的 dataclass,JS 用不再修改的对象字面量,复制还是共享都不会出错。
  • 遇到 GC 压力先看分配:减少短命对象、复用缓冲区,比调 GC 参数更有效。真到了 Go 的 GOGC、.NET 的服务器 GC 这一步,要用实际负载测量。

写一个可变的点类型,字段 X、Y 都可以改。造一个装着两个点 (1, 2)、(3, 4) 的列表,先用最顺手的写法把第一个点的 X 改成 10,打印第一个点;再改成真正生效的写法,再打印一次。预期两行输出是 直接改取出的元素:(1, 2) 和 正确修改之后:(10, 2)。用 C# 和 Go 各实现一遍。

提示:C# 里用 struct 和 List<T>,先试试 points[0].X = 10 能不能编译,再试试先取出到变量里改。Go 里用结构体切片,试试在 for … range 循环里改循环变量。两种语言各有几种正确写法,它们分别改的是哪一块内存?

  • 值语义复制数据,引用语义复制引用;放在栈上还是堆上由编译器和运行时决定。
  • 相等有两种:内容相等和同一性。Python 的 == 与 is、JS 的 ===、C# 的 Equals 与 ReferenceEquals、Go 的 == 与指针比较。
  • Python 和 JS 复制要显式造新对象,浅复制的嵌套部分仍然共享;C# 的 struct 和 Go 的结构体赋值即复制,Go 的切片只复制头部,append 是否共享取决于容量。
  • 垃圾回收有引用计数和追踪式两类:CPython 用引用计数加分代循环收集器,V8 和 .NET 用分代回收,Go 用不分代的并发标记清除;选择背后是停顿、吞吐量和内存之间的取舍。