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 KB | 200000 份独立字符串 |
[]unique.Handle[string] | 约 1569 KB | 4 份字符串 + 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 / Handle | Go 1.23 | api/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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。