Golang中为什么json.Marshal使用append而非copy?
Golang中为什么json.Marshal使用append而非copy?
func Marshal(v any) ([]byte, error) {
e := newEncodeState()
defer encodeStatePool.Put(e)
err := e.marshal(v, encOpts{escapeHTML: true})
if err != nil {
return nil, err
}
buf := append([]byte(nil), e.Bytes()...)
return buf, nil
}
我已经测量了两种场景的性能,使用append可以少写一行代码。
更多关于Golang中为什么json.Marshal使用append而非copy?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
底层内存是复用的,必须返回一个副本给外部世界。
为什么不直接使用 buf:=e.Bytes() 而要用 buf := append([]byte(nil), e.Bytes()...)?
你能展示一下你的测量结果吗?
较早的基准测试似乎表明 copy 比 append 更快,但我不清楚当前的编译器是如何处理这些场景的。
原则上,我认为编译器应该能识别这两种情况,并为它们生成相同的最优机器指令。
只是猜测,但从语义上讲:
buf := make([]byte, len(e.Bytes())
_ = copy(buf, e.Bytes())
在调用 make 时必须将 buf 清零,然后 copy 再覆盖它。使用 append 则不需要清零任何内容,直接覆盖元素即可。我本以为编译器能够优化掉那个清零步骤,但也许它没有。或者也许在编写 Marshal 中的那段代码时,编译器还没有这个优化。
我不理解所述理由如何适用于这种情况:
buf := append([]byte(nil), e.Bytes()...)
这将创建一个已知固定大小的全新切片。由于每次都会分配一个新的切片(以及底层数组),因此不存在调整大小的情况。e.Bytes() 的大小在分配时是已知的。由于 e 中的底层数组在返回后会被重用,所有数据都需要被复制——无法进行优化。并且由于它始终是一个字节切片,不需要灵活性,因为它始终是相同类型的数据:一个已知长度的字节切片。
https://play.golang.com/p/G8WNP1kYNi4
goos: windows
goarch: amd64
pkg: hello/bench
cpu: AMD Ryzen 5 PRO 4650G with Radeon Graphics
BenchmarkAppend
BenchmarkAppend-12 1131752 954.6 ns/op
BenchmarkCopy
BenchmarkCopy-12 1281948 966.5 ns/op
PASS
Process finished with the exit code 0
在 Go 语言中,json.Marshal 函数在将数据序列化为 JSON 格式时,出于特定原因使用了 append 而非 copy。让我们来理解这一选择背后的逻辑。
当将数据序列化为 JSON 时,json.Marshal 函数需要构建一个表示 JSON 编码数据的字节切片。这个字节切片的大小会根据被序列化数据的复杂性和大小而变化。由于大小无法预先得知,因此选择使用 append 而不是 copy,原因如下:
- 动态调整大小:
append允许根据需要动态调整底层字节切片的大小。它会自动管理切片的容量,确保其能够容纳序列化后的 JSON 数据。append在必要时会增加切片的容量,避免了不必要的内存分配和复制。 - 效率:使用
append避免了不必要的数据复制。如果使用copy,每当当前容量不足时,就需要将序列化的数据复制到一个具有更大容量的新字节切片中。这种额外的复制操作会产生不必要的开销并降低性能。 - 灵活性:
append在处理不同数据结构和大小方面提供了灵活性。它使得json.Marshal函数能够高效地处理各种类型和大小的数据,而无需对其具体大小或结构做出假设。
在Go语言的json.Marshal实现中,使用append而非copy主要是出于性能和内存分配优化的考虑。以下是具体的技术分析:
性能对比分析
// 使用append的方式(标准库实现)
buf := append([]byte(nil), e.Bytes()...)
// 使用copy的方式
buf := make([]byte, len(e.Bytes()))
copy(buf, e.Bytes())
关键差异
-
单次内存分配 vs 两次内存分配
append([]byte(nil), ...):只进行一次堆分配make + copy:make进行一次分配,copy进行数据复制
-
编译器优化
// Go编译器会对append([]byte(nil), src...)进行特殊优化 // 实际上会转换为类似这样的高效实现: func optimizedAppend(src []byte) []byte { dst := make([]byte, len(src)) // 编译器内部使用memmove进行内存复制 return dst } -
基准测试对比
func BenchmarkAppend(b *testing.B) { src := []byte("test data") for i := 0; i < b.N; i++ { _ = append([]byte(nil), src...) } } func BenchmarkCopy(b *testing.B) { src := []byte("test data") for i := 0; i < b.N; i++ { dst := make([]byte, len(src)) copy(dst, src) } }
实际性能差异
在Go 1.20+版本中,append([]byte(nil), src...)模式:
- 生成更少的汇编指令
- 触发编译器的写屏障消除优化
- 在逃逸分析中表现更好,减少堆分配压力
标准库中的类似模式
这种模式在Go标准库中广泛使用:
// bytes包中的Clone函数实现
func Clone(b []byte) []byte {
if b == nil {
return nil
}
return append([]byte{}, b...)
}
// strings.Builder的String方法
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
内存布局优势
append版本的内存布局更有利于CPU缓存:
- 连续的内存分配和复制操作
- 减少缓存行污染
- 更好的预取性能
这种设计选择体现了Go语言"零成本抽象"的理念,在保持代码简洁的同时,通过编译器和运行时优化实现最佳性能。


