1249 字
3 分钟
驻留(Interning)
一、为什么需要驻留
编译器在解析源代码时,会遇到大量重复的标识符。一个 Java 项目中 String 这个词可能出现上万次。如果每次都创建新的字符串对象,内存中就躺着上万个内容相同的 String——每个占 40+ 字节(对象头 + char 数组 + 哈希码),加起来几百 KB 的浪费。
更严重的是性能问题。编译器需要频繁比较标识符是否相同:变量查找、类型检查、作用域解析。每次比较都要逐字符扫描两个字符串,O(n) 的开销。在编译大型项目时,字符串比较可能占总编译时间的 10% 以上。
驻留给出的方案是:每个唯一值只在驻留表中存一份,所有引用指向同一个规范实例。比较两个驻留过的字符串,只需要比较指针或整数 ID,O(1) 搞定。
二、现实类比
邮局只存储一份每个邮编的副本,给所有人一个引用。不是每封信都带一份「94105」,它们都指向同一个共享条目。查两封信是否寄往同一个地方,比较引用就行,不用逐字比对邮编。
三、核心思想
驻留将每个唯一值在表中只存储一次,并分发轻量级标识符(符号 ID 或驻留指针)指向规范副本。结构上相等的两个值获得相同的标识符,因此相等性检查变成 O(1) 的指针/整数比较。
flowchart LR
A["intern('hello')"] --> P[符号表]
B["intern('hello')"] --> P
C["intern('world')"] --> P
P -->|"ID: 0"| H["'hello'"]
P -->|"ID: 0(复用)"| H
P -->|"ID: 1"| W["'world'"]
text
intern("hello") → 0 intern("world") → 1 intern("hello") → 0 (复用!)
┌───────────────────────┐
│ Symbol Table │
├────┬──────────────────┤
│ ID │ Value │
├────┼──────────────────┤
│ 0 │ "hello" │
│ 1 │ "world" │
│ 2 │ "foo" │
└────┴──────────────────┘
相等性检查:symbol_a == symbol_b (整数比较, O(1))
而非:strcmp(str_a, str_b) (逐字符扫描, O(n))
| 属性 | 值 | 说明 |
|---|---|---|
| intern() | 首次 O(n),命中时 O(1) 摊销 | 哈希查找 + 存储 |
| 相等性 | O(1) | 整数/指针比较 |
| 内存 | 去重 | 每个唯一值只存储一次 |
| 权衡 | 驻留表单调增长 | 值永不释放,需要淘汰策略 |
驻留表的增长问题:经典驻留表只增不减。编译器处理 10,000 个源文件后,驻留器可能持有 200 万个字符串,占用 500MB——其中大量是一次性标识符,再也不会被引用。生产级驻留器需要作用域策略:按编译单元清除、使用弱引用或只对高频重复的标识符驻留。
四、变体与对比
| 模式 | 与驻留的关系 | 适用场景 |
|---|---|---|
| 享元 | 驻留是实现享元的机制——去重相同的不可变值 | 享元是设计模式,驻留是实现手段 |
| LRU 缓存 | 驻留表可以使用 LRU 淘汰来限制内存使用 | 内存受限的驻留场景 |
| 布隆过滤器 | 布隆过滤器可以在昂贵的驻留表查找前做预检查 | 减少驻留表查找次数 |
| 写时复制 | 驻留共享完全不可变的值;CoW 共享直到修改 | 驻留适合永不修改的值 |
五、多语言实现
5.1 Go 实现
package interning
import "sync"
// Interner 字符串驻留器,返回整数 ID 作为符号type Interner struct { mu sync.RWMutex strToID map[string]int idToStr []string}
func NewInterner() *Interner { return &Interner{ strToID: make(map[string]int), }}
// Intern 驻留字符串,返回符号 ID// 相同字符串始终返回相同 IDfunc (it *Interner) Intern(s string) int { it.mu.RLock() if id, ok := it.strToID[s]; ok { it.mu.RUnlock() return id // 命中:返回已有 ID } it.mu.RUnlock()
it.mu.Lock() defer it.mu.Unlock()
// 双重检查 if id, ok := it.strToID[s]; ok { return id }
id := len(it.idToStr) it.strToID[s] = id it.idToStr = append(it.idToStr, s) return id}
// Resolve 通过 ID 反查原始字符串func (it *Interner) Resolve(id int) (string, bool) { it.mu.RLock() defer it.mu.RUnlock() if id < 0 || id >= len(it.idToStr) { return "", false } return it.idToStr[id], true}
// Size 返回驻留的唯一字符串数量func (it *Interner) Size() int { it.mu.RLock() defer it.mu.RUnlock() return len(it.idToStr)}使用示例——编译器标识符比较:
func compile(sources []string) { interner := interning.NewInterner()
// 所有标识符通过驻留比较 id1 := interner.Intern("String") id2 := interner.Intern("String") // 比较两个标识符:整数比较 O(1) if id1 == id2 { // 同一个符号,无需逐字符比较 }}5.2 TypeScript 实现
class StringInterner { private strToId = new Map<string, number>(); private idToStr: string[] = [];
// 驻留字符串,返回符号 ID intern(s: string): number { const existing = this.strToId.get(s); if (existing !== undefined) return existing;
const id = this.idToStr.length; this.strToId.set(s, id); this.idToStr.push(s); return id; }
// 通过 ID 反查字符串 resolve(id: number): string | undefined { return this.idToStr[id]; }
get size(): number { return this.idToStr.length; }}
// 使用示例:JSON 解析器字段名去重const fieldInterner = new StringInterner();
function parseObject(keys: string[]): Map<number, unknown> { const result = new Map<number, unknown>(); for (const key of keys) { // 重复的字段名(如 "name"、"age")只存储一份 const symbolId = fieldInterner.intern(key); result.set(symbolId, null); } return result;}六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Rust 编译器 rustc | symbol.rs#L24-L79 | Symbol 是 u32 的 newtype——全局驻留表的索引。Rust 编译器中所有标识符都是 Symbol,相等性是一次 u32 比较 |
| CPython | unicodeobject.c#L14416-L14472 | PyUnicode_InternInPlace 通过将字符串存储在全局 dict 中来驻留。所有标识符字符串自动驻留以实现 O(1) 的 dict 查找 |
| V8 引擎 | Internalized Strings | V8 驻留用作属性名的字符串,实现 O(1) 属性查找 |
七、小结
何时使用:
- 编译器和解释器——驻留标识符、关键字和类型描述符以实现快速相等性检查
- 数据库查询引擎——驻留列名、表名以加速查询规划中的比较
- 序列化——驻留 JSON/XML 中重复的字段名以减少内存
- 字符串密集型工作负载——需要比较相同字符串数百万次的系统
何时不用:
- 短生命周期字符串——字符串被快速创建和丢弃,驻留开销大于收益
- 大多数字符串唯一——很少有重复字符串时,驻留表浪费内存而不节省比较
- 有清理需求的内存受限场景——经典驻留表单调增长,考虑弱引用或 LRU 淘汰
- 用户控制的输入——驻留外部输入可能导致驻留表无限增长(DoS 风险)
八、参考资料
- Rust 编译器 Symbol - Rust 编译器用 u32 作为标识符符号,全局驻留表索引
- CPython 字符串驻留 - Python 标识符自动驻留的实现
- Ruby Symbol 与安全 - Ruby Symbol 永不被 GC 的安全风险(CVE-2013-0269)
- LLVM StringPool - LLVM 编译器管线中标识符的驻留字符串池
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
享元(Flyweight)
程序设计 共享不可变的部分,只存储变化的部分——用更少的对象表示更多的数据,游戏引擎和编译器中的经典内存优化。
2
空闲链表(Free List)
程序设计 用链表管理已释放的内存块,O(1) 分配和释放——操作系统内核和游戏引擎的底层分配利器。
3
对象池(Object Pool)
程序设计 预分配一组对象反复使用,避免频繁创建和销毁带来的 GC 压力——游戏引擎、网络框架和高性能服务中的经典优化手段。
4
Slab 分配器(Slab Allocator)
程序设计 固定大小对象池,消除碎片——Linux 内核和 Memcached 的高频对象分配策略
5
写时复制(Copy-on-Write)
程序设计 共享读取,只在修改时才复制——节省内存的延迟优化策略,Linux fork 和 Git 对象模型的基石。






