字符串的转换到底拷不拷贝,取决于结果会不会被修改、会不会逃逸,以及多大。这篇用 unsafe 读字符串头,再逐一验证互转的拷贝与分配行为,最后看一个常见的内存泄漏形态:一个很小的子串拖住整块大字符串。
unsafe.Sizeof(string) = 16;len(s) = 12;头里的 len = 12;StringData(s) == 头里的 ptr?true sub := s[7:10] = "wor":StringData(sub) - StringData(s) = 7 字节(共用同一块内存);len(sub) = 3 strings.Clone(sub):StringData 与 sub 相同?false(拷贝了一份) 两个相同的字符串字面量:StringData 相同?true;运行时拼出来的同内容字符串:相同?false
string 在运行时是一个 {指针, 长度} 头,所以切子串是 O(1) 的,只改指针与长度。代价就是第四节的现象:子串继续引用整块底层内存。字面量放在二进制的只读数据里,相同的字面量会合并成同一份。
b := []byte("abc");s := string(b);改 b[0] = 'X' 之后:b = "Xbc",s = "abc"(s 不变:string(b) 拷贝了)
s := "abc";b := []byte(s);改 b[0] = 'Y' 之后:s = "abc",b = "Ybc"(s 不变:[]byte(s) 拷贝了)
unsafe.String(&b[0], len(b)) 不拷贝:改 b[0] = 'Z' 之后 s = "Zbc"(跟着变了;只在你保证之后不再改 b 时才能用)
unsafe.Slice(unsafe.StringData(s), len(s)) 得到 3 字节的 []byte,cap = 3(指向字符串自己的内存;下面单独试写入)
往 unsafe.Slice(StringData("abc")) 里写 b[0]:rc=2;unexpected fault address 0x… / fatal error: fault / [signal SIGBUS: bus error code=0x… addr=0x… pc=0x…]安全的转换总是拷贝。unsafe.String 与 unsafe.Slice(unsafe.StringData(s), …) 不拷贝,但要自己保证两个条件:不再修改被共享的字节,并且不要写字符串字面量所在的只读内存(上面实测会以 SIGBUS 崩溃)。
b := []byte(long) 结果逃逸(赋给全局) 1 次 b := []byte(long) 不逃逸,只读 len(b) 0 次 b := []byte(long) 不逃逸,读 b[50] 0 次 b := []byte(short) 不逃逸,读 b[5] 0 次 s := string(lb) 结果逃逸(赋给全局) 1 次 s := string(lb100) 不逃逸,读 s[50] 1 次 s := string(sb10) 不逃逸,读 s[5] 0 次 m[string(lb)] 用 []byte 当 map 的键查找 0 次 string(lb) == long 比较 0 次 for range []byte(long) 只遍历 0 次 "x" + string(lb) 拼接(结果赋给全局) 1 次 strings.Clone(long) 1 次 经函数参数(不会被当成常量),不逃逸:[]byte(s) 与 string(b) 各 10 字节:0 次 / 0 次 经函数参数(不会被当成常量),不逃逸:[]byte(s) 与 string(b) 各 32 字节:0 次 / 0 次 经函数参数(不会被当成常量),不逃逸:[]byte(s) 与 string(b) 各 33 字节:0 次 / 1 次 经函数参数(不会被当成常量),不逃逸:[]byte(s) 与 string(b) 各 100 字节:0 次 / 1 次 经函数参数(不会被当成常量),不逃逸:[]byte(s) 与 string(b) 各 1000 字节:0 次 / 1 次
读编译器源码得到一个线索:不逃逸的转换会用栈上的临时缓冲,缓冲大小常量 tmpStringBufSize 是 32(runtime/string.go),实测 string(b) 正好在 32 与 33 字节之间变成 1 次分配。另外三种常见写法零分配:m[string(b)]、string(b) == s、for range []byte(s)——编译器不为它们建立真正的字符串。注意 []byte(s) 在不逃逸时即使 1000 字节也是 0 次,原因见最后一节。
基线 HeapAlloc 0.20 MiB;分配 10 MiB 字符串后只留 big[:5]:HeapAlloc 10.20 MiB(len(keep) = 5,但 10 MiB 仍被 keep 引用) keep = strings.Clone(keep) 之后再 GC:HeapAlloc 0.20 MiB(大块可以回收)
这是真实项目里常见的“泄漏”:从一个大响应、大文件内容里切出一小段长期保存。strings.Clone(或 string([]byte(s[:n])) 之类会拷贝的写法)能让大块在下次 GC 后释放。数字来自 runtime.ReadMemStats 的 HeapAlloc,每次先调用 runtime.GC()。
默认:
main.go:12:18: s does not escape ← func toBytesRead(s string) int { x := []byte(s); return int(x[len(x)/2]) }
main.go:12:46: ([]byte)(s) does not escape ← func toBytesRead(s string) int { x := []byte(s); return int(x[len(x)/2]) }
main.go:12:46: zero-copy string->[]byte conversion ← func toBytesRead(s string) int { x := []byte(s); return int(x[len(x)/2]) }
10 字节:只读版本 0 次,写入版本(x[0] = 1)0 次
32 字节:只读版本 0 次,写入版本(x[0] = 1)0 次
33 字节:只读版本 0 次,写入版本(x[0] = 1)1 次
1000 字节:只读版本 0 次,写入版本(x[0] = 1)1 次
-gcflags=-m -d=zerocopy=0(关掉这项优化):
main.go:12:18: s does not escape ← func toBytesRead(s string) int { x := []byte(s); return int(x[len(x)/2]) }
main.go:12:46: ([]byte)(s) does not escape ← func toBytesRead(s string) int { x := []byte(s); return int(x[len(x)/2]) }
10 字节:只读版本 0 次,写入版本(x[0] = 1)0 次
32 字节:只读版本 0 次,写入版本(x[0] = 1)0 次
33 字节:只读版本 1 次,写入版本(x[0] = 1)1 次
1000 字节:只读版本 1 次,写入版本(x[0] = 1)1 次编译器源码 escape/escape.go 里有这样一段:字符串转 []byte 的结果如果从不被修改(没有 attrMutates),就把它改成直接复用字符串内存的版本,诊断里叫 zero-copy string->[]byte conversion。上面的实验与它吻合:只读版本零分配,一旦写入就回到“≤32 字节用栈缓冲、更大才分配”;-d=zerocopy=0 把这项优化关掉,只读版本 33 字节以上就变成 1 次分配。这个例子里的结果既不修改也不逃逸,条件变化时是否仍然成立我没有逐一验证。