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
}
10 回复

我已经测量了两种场景的性能,使用append可以少写一行代码。

更多关于Golang中为什么json.Marshal使用append而非copy?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


底层内存是复用的,必须返回一个副本给外部世界。

为什么不直接使用 buf:=e.Bytes() 而要用 buf := append([]byte(nil), e.Bytes()...)

我尝试了 windows-amd64 和 linux-amd64 平台,copy 和 append 的速度大致相同。但在 darwin-arm64 平台上,copy 更快。

你能展示一下你的测量结果吗? 较早的基准测试似乎表明 copyappend 更快,但我不清楚当前的编译器是如何处理这些场景的。

原则上,我认为编译器应该能识别这两种情况,并为它们生成相同的最优机器指令。

只是猜测,但从语义上讲:

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,原因如下:

  1. 动态调整大小:append 允许根据需要动态调整底层字节切片的大小。它会自动管理切片的容量,确保其能够容纳序列化后的 JSON 数据。append 在必要时会增加切片的容量,避免了不必要的内存分配和复制。
  2. 效率:使用 append 避免了不必要的数据复制。如果使用 copy,每当当前容量不足时,就需要将序列化的数据复制到一个具有更大容量的新字节切片中。这种额外的复制操作会产生不必要的开销并降低性能。
  3. 灵活性: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())

关键差异

  1. 单次内存分配 vs 两次内存分配

    • append([]byte(nil), ...):只进行一次堆分配
    • make + copymake进行一次分配,copy进行数据复制
  2. 编译器优化

    // Go编译器会对append([]byte(nil), src...)进行特殊优化
    // 实际上会转换为类似这样的高效实现:
    func optimizedAppend(src []byte) []byte {
        dst := make([]byte, len(src))
        // 编译器内部使用memmove进行内存复制
        return dst
    }
    
  3. 基准测试对比

    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语言"零成本抽象"的理念,在保持代码简洁的同时,通过编译器和运行时优化实现最佳性能。

回到顶部