《Go 语言高级编程》3.2 unique 包与值规范化

同样的字符串存一万份,内存就白花一万份。Go 1.23 的 unique 包把重复值规范化为同一个 Handle,相等判断从逐字节比较退化成指针比较。本节实测同一字符串的 Handle 指针完全相同、结构体值也能规范化,并给出动态构造字符串场景下 200000 个值的真实内存对比。

3.2 unique 包与值规范化

想象一个多租户服务,每条请求都带一个 tenantID 字符串,日志、指标、限流器里到处都是它。如果同一时刻有一万条请求来自同一个租户,内存里就存了一万份内容完全相同的字符串——每份都有自己的长度、自己的指针、自己的底层数组。

这个问题的经典解法叫 interning(字符串驻留):维护一张表,相同内容只保留一份,需要时返回指向那一份的引用。Java 的 String.intern() 做了几十年,Go 1.23 把它做成了标准库:unique 包。

本节要回答:unique 包怎么用、它真的省内存吗、哪个版本引入?结论:unique.Make 于 Go 1.23 引入(api/go1.23.txt,#62483);相同值的 Handle 可直接用 == 比较且指向同一份数据;实测 200000 个动态构造的重复字符串,规范化后堆内存从约 4702 KB 降到约 1569 KB。

3.2.1 Handle 是什么

unique 包的 API 小得惊人,只有两个导出符号:

func Make[T comparable](v T) Handle[T]
func (h Handle[T]) Value() T

unique.Make(v) 把一个 comparable 的值 v 交给运行时,运行时保证「相同内容的 v 只会保留一份」,并返回一个 Handle[T]。这个 Handle 本身是一个指针大小的值,相同内容的值拿到的是同一个 Handle,所以两个 Handle 的比较可以用 ==——而且这是指针比较,不涉及逐字节比较。

注意约束是 comparable 而不是 string:任何可比较类型都能规范化,包括结构体、数组、整数。

3.2.2 实测:相同值就是同一个指针

package main

import (
	"fmt"
	"unique"
)

type label struct{ k, v string }

func main() {
	h1 := unique.Make("hello-world-abcdefghijklmnop")
	h2 := unique.Make("hello-world-abcdefghijklmnop")
	fmt.Println("strings interned:", h1 == h2)
	fmt.Println("Value round-trip:", h1.Value())
	fmt.Println("pointer identical:",
		fmt.Sprintf("%p", h1.Value()) == fmt.Sprintf("%p", h2.Value()))

	a := unique.Make(label{"env", "prod"})
	b := unique.Make(label{"env", "prod"})
	c := unique.Make(label{"env", "dev"})
	fmt.Println("struct same:", a == b, "different:", a == c)
}

实测输出(GOTOOLCHAIN=go1.27.0 go run .):

strings interned: true
Value round-trip: hello-world-abcdefghijklmnop
pointer identical: true
struct same: true different: false
all equal: true

四个结论:

  • h1 == h2 为 true,相同字符串拿到同一个 Handle;
  • Value() 能取回原始值,round-trip 无损;
  • %p 打印的指针完全相同——底层数据真的只有一份;
  • 结构体 label{"env","prod"} 也被规范化了,a == b 而 a != c。

unique.Make 内部对 Handle 的持有是弱引用语义:只要没有别的 Handle 引用某个值,运行时允许回收它。所以 interning 表不会无限增长——这一点和很多手写 interning 实现(用一个永不清理的 map)不同。

3.2.3 实测:省了多少内存

省不省内存要看场景。第一种场景是「把同一个常量字符串存进切片」——这时省的主要是切片元素本身的头部大小(Handle 8 字节 vs string 头 16 字节):

plain      HeapAlloc delta =   3128 KB
unique     HeapAlloc delta =   1567 KB

第二种场景更接近真实:字符串是动态构造的(比如 fmt.Sprintf 拼出来的),每个都指向独立内存。这时 interning 才真正发挥威力:

const n = 200000
plain = make([]string, n)
for i := range plain {
	plain[i] = fmt.Sprintf("tenant-%d", i%4) // 每次都新建字符串
}
interned = make([]unique.Handle[string], n)
for i := range interned {
	interned[i] = unique.Make(fmt.Sprintf("tenant-%d", i%4))
}

实测输出:

plain      HeapAlloc delta =   4702 KB
unique     HeapAlloc delta =   1569 KB
distinct plain values kept: 200000 interned: 200000
handle equal across calls: true

200000 个值、只有 4 种不同内容:

方案堆内存增量保留的字符串数据
普通 []string约 4702 KB200000 份独立字符串
[]unique.Handle[string]约 1569 KB4 份字符串 + 200000 个 Handle

内存降到约 1/3。注意 handle equal across calls: true——第 0 个和第 4 个元素内容相同(都是 tenant-0),Handle 也相同。

需要诚实说明的一点:fmt.Sprintf 的那 200000 次分配本身是逃不掉的,unique.Make 只保证「规范化后的那一份」被保留。所以上面的 unique 数字里包含了少量临时分配的开销,真实的净收益是「长期驻留的内存」减少,而不是「瞬时分配」减少。

3.2.4 用 Handle 当 map 键

Handle 最有价值的用法之一是当 map 的键。因为 Handle 的相等判断是 8 字节指针比较,而字符串键要逐字节比对,长字符串键的查找会明显更快。基准对比:

var keys = []string{
	"tenant-00000000000000000000000000000042",
	"tenant-00000000000000000000000000000043",
}

func BenchmarkMapStringKey(b *testing.B) {
	m := map[string]int{keys[0]: 1, keys[1]: 2}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = m[keys[0]]
	}
}

func BenchmarkMapHandleKey(b *testing.B) {
	m := map[unique.Handle[string]]int{unique.Make(keys[0]): 1, unique.Make(keys[1]): 2}
	h := unique.Make(keys[0])
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = m[h]
	}
}

实测(Apple M1 Pro,-benchtime=2000000x -count=5):

BenchmarkMapStringKey-10   2000000   12.92 ns/op
BenchmarkMapStringKey-10   2000000    8.19 ns/op
BenchmarkMapStringKey-10   2000000    7.52 ns/op
BenchmarkMapStringKey-10   2000000    7.75 ns/op
BenchmarkMapStringKey-10   2000000    7.70 ns/op
BenchmarkMapHandleKey-10   2000000    2.60 ns/op
BenchmarkMapHandleKey-10   2000000    2.62 ns/op
BenchmarkMapHandleKey-10   2000000    2.64 ns/op
BenchmarkMapHandleKey-10   2000000    2.69 ns/op
BenchmarkMapHandleKey-10   2000000    2.63 ns/op

取稳定后的中位数:

键类型查找耗时说明
string(38 字节)约 7.7 ns/op逐字节比较
unique.Handle[string]约 2.63 ns/op指针比较

约 2.9 倍的差距。这和 1.2 的 Swiss Table 是互补关系:Swiss Table 优化「槽位探测」,unique 优化「键比对本身」——两者叠加,长字符串键的 map 能达到接近整型键的性能。

代价也很明确:你必须为每个键先 unique.Make 一次,而 Make 本身要走一次 interning 表的查找。所以这个优化只在「键会被反复查找很多次」时划算——一次性写入的 map 不值得。另外,Handle 值本身要保持有效(不能是 nil 零值),否则查询会落空。

3.2.5 版本归属

unique 包是 Go 1.23 引入的,api 清单证据明确:

$ grep -ln "^pkg unique," /usr/local/go/api/go1.*.txt
/usr/local/go/api/go1.23.txt
$ grep -h "^pkg unique," api/go1.23.txt
pkg unique, func Make[$0 comparable]($0) Handle[$0] #62483
pkg unique, method (Handle[$0]) Value() $0 #62483
pkg unique, type Handle[$0 comparable] struct #62483
事实结论证据
unique.Make / HandleGo 1.23api/go1.23.txt,#62483
实验开关无从 1.23 起就是正式 API
1.26 / 1.27 行为无变化实测输出与 1.23 语义一致

注意它比本章其它特性都早——unique 是 1.23,而 AddCleanup/weak/mlkem 是 1.24、synctest 是 1.25。做版本规划时别把它们混成一批。

3.2.6 使用场景与陷阱

unique 不是「所有字符串都该 interning」。它的收益和成本都很明确:

适合不适合
高重复率的枚举值、状态码、租户 ID几乎不重复的随机 ID(UUID)
作为 map 键的短字符串,用 Handle 当键更快一次性用完就丢的临时字符串
需要在大量结构体里共享同一份引用只有几十个值的小集合(表本身的开销更显眼)
相等判断频繁的热路径对 GC 停顿极敏感、且值生命周期很短的场景

三条陷阱:

  • Handle[T] 是类型参数化的:unique.Handle[string] 和 unique.Handle[int] 是两个不同的类型,不能混用;
  • Value() 有成本:它要解引用并返回值的副本(对字符串是指针+长度,对结构体是整个值),别在热循环里反复 Value();
  • 值必须 comparable:含 slice、map、func 字段的结构体不能用。

一个实用的模式是:把 Handle[string] 直接用作 map 的键。因为 Handle 的相等判断是指针比较(8 字节)而不是字符串逐字节比较,对长字符串键的 map 查找会有可观加速——这正好和 1.2 的 Swiss Table 形成互补:Swiss Table 优化「槽位探测」,unique 优化「键比对本身」。

最后提醒一个反直觉的点:unique.Make 不会让值「永久存活」。Handle 之间是弱引用关系,当所有 Handle 都不可达时,被 interned 的值也会被回收。所以它不会像手写 map[string]string 缓存那样造成内存泄漏——但反过来,你也不能靠 unique.Make 来「保活」一个值,那需要显式持有 Handle。

3.2.7 小结

  • unique 包于 Go 1.23 引入(api/go1.23.txt,#62483),无实验开关,比本章其它特性都早一个版本。
  • unique.Make 把相同内容的值规范化成同一个 Handle,== 即指针比较,Value() 取回原值。
  • 约束是 comparable,任何可比较类型都能用,不限于字符串。
  • 实测 200000 个动态构造的重复字符串,堆内存从约 4702 KB 降到约 1569 KB。
  • 适合高重复率的短值;对高熵随机 ID 无意义;Handle 持弱引用语义,表不会无限增长。
  • 把 Handle 当 map 键可把长字符串键的查找从约 7.7 ns/op 降到约 2.63 ns/op,前提是键会被反复查询。

一句话判据:值重复率高、且需要频繁比较或长期驻留时,用 unique;值高度随机或一次性使用时,别用。

unique 包虽小(只有两个导出符号),但它把「字符串驻留」这件在 C/C++/Java 里都要手写或调库的事,变成了标准库里一行 unique.Make。对于日志、指标、多租户标签这类高重复场景,它是零依赖、低风险的优化手段。

最后一句话提醒:unique 优化的是「驻留与比较」,不是「分配」。如果你真正想减少的是分配次数,那该看 6 章的内存与 GC 调优;unique 只在你「已经要把同一个值存很多份」时才起作用。

下一节看运行时最底层的两项改进:Green Tea GC 的开关状态与容器感知 GOMAXPROCS——后者我会用一个真实的受限 CPU 容器来验证。

阅读导航:上一节:3.1 testing/synctest 确定性并发测试 · 下一节:3.3 Green Tea GC 与容器感知 GOMAXPROCS 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练