Go编码时的优化建议 | 青训营笔记

60 阅读4分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 5 天

性能优化的前提是满足正确可靠、清晰简洁等质量因素。性能优化是综合评估,有时候时间效率和空间效率可能对立。这篇笔记根据 Go 语言的相关特性,记录 Go 语言的性能优化建议。

性能表现通过实际数据来衡量,借助 Go 提供的基准性能测试获得实际数据。

Slice预分配内存

尽可能在使用make初始化切片时提供容量信息。

  • 没有提供初始的容量信息:
func NoPreAlloc(size int) {
    // 初始空间大小为 0 , 无预留的空间
    data := make([]int, 0)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}
  • 提供了初始容量信息:
func PreAlloc(size int) {
    // 初始空间大小为 0 , 预留的空间大小为 size
    data := make([]int, 0, size)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}

切片本质是一个数组片段的描述,类似如下:

type slice struct {
    // 指向数组的指针
    array unsafe.Pointer
    // 片段的长度
    len int
    // 片段的容量(不改变内存分配情况下的最大长度)
    cap int
}

当容量已满时,再加入元素就会扩容,扩容需再次申请内存创建新数组且要复制元素到新数组,因此预先提供容量信息可以减少扩容的次数,从而减少内存分配的次数,且可以减少复制元素的时间。

另外,使用slice还要注意大内存未释放的问题。在已有切片基础上创建切片,不会创建新的底层数组:

func GetLastBySlice(origin []int) []int {
    return origin[len(origin) - 2 : ]
}
  • 原切片较大,此函数在原切片的基础上创建小切片。
  • 调用此函数得到的切片使用的底层数组仍然是原来的数组,因此原来的数组仍然被引用,原来较大的内存得不到释放。

使用copy函数即可创建一个新的小内存的数组,原来的大内存即可得到释放:

func GetLastByCopy(origin []int) []int {
    res := make([]int, 2)
    copy(res, origin[len(origin)-2:])
    return res
}

Map预分配内存

同理,Map在创建的时候预分配内存也可以提高性能。

  • 不断向 Map 中添加元素的操作会触发 Map 扩容。
  • 提前分配好空间可以减少扩容次数,减少扩容时的内存拷贝和重新计算哈希值。
  • 因此建议根据实际需求提前预估好所需空间。

字符串拼接

使用strings.Builderbytes.Buffer代替+进行字符串拼接:

func Plus(n int, str string) string {
    s := ""
    for l := 0; l < n; l++ {
        s += str
    }
    return s
}

func StrBuilder(n int, str string) string {
    var builder strings.Builder
    for l := 0; l < n; l++ {
        builder.WriteString(str)
    }
    return builder.String()
}

func ByteBuffer(n int, str string) string {
    // Tips:三种创建结构体对象的方式
    // go 中 new(T) 会为类型为 T 的对象分配已置零的内存空间
	// buf := new(bytes.Buffer)
	// buf := bytes.Buffer{}
    var buf bytes.Buffer
    for l := 0; l < n; l++ {
        buf.WriteString(str)
    }
    return buf.String()
}
  • 字符串在 Go 中是不可变类型,占用内存大小是固定的。
  • 使用+每次都会重新分配内存。
  • strings.Builderbytes.Buffer底层都是[]byte数组,采取内存扩容策略,不用每次拼接都重新分配内存。
  • string.Builder最快,bytes.Buffer稍慢一些,+最慢。
  • Builder稍快一些的原因主要在于最后return xxx.String()的时候逻辑的比Buffer稍快一些。

string.Builderbytes.Buffer在已知字符串长度的情况下,还可以通过预分配进一步提升性能。通过grow进行预分配:

func PreStrBuilder(n int, str string) string {
    var builder strings.Builder
    builder.Grow(n * len(str)) // 预分配
    for l := 0; l < n; l++ {
        builder.WriteString(str)
    }
    return builder.String()
}

func PreByteBuffer(n int, str string) string {
    var buf bytes.Buffer
    buf.Grow(n * len(str))
    for l := 0; l < n; l++ {
        buf.WriteString(str)
    }
    return buf.String()
}

空结构体

  • 使用空结构体可以节省内存。
  • 空结构体struct{}实例不占据任何的内存空间。
  • 可以作为占位符使用,节省资源。

例如:Go 中没有单独的 Set ,要使用 Set 都是通过只使用 Map 的 Key 来实现 Set 的效果,这样就可以使用空结构体作为 Value 的占位符,除了空结构体以外,使用其他类型作为 Value 都会多占据内存空间。

func EmptyStructMap(n int) {
    m := make(map[int]struct{})
    for i := 0; i < n; i++ {
        m[i] = struct{}{}
    }
}

atomic 包

多线(协)程并发安全时,使用atomic包比加锁性能要好很多。

// 使用 atimic 包
type atomicCounter struct {
    i int32
}

func AtomicAddOne(c *atomicCounter) {
    atomic.AddInt32(&c.i, 1)
}

// 使用加锁
type mutexCounter struct {
    i int32
    m sync.Mutex
}

func MutexAddOne(c *mutexCounter) {
    c.m.Lock()
    c.i++
    c.m.Unlock()
}
  • 锁的实现是通过操作系统来实现的,属于系统调用。
  • atomic操作是通过硬件实现的,效率比锁高。
  • sync.Mutex应该用来保护一段逻辑,而不应该仅仅用于保护一个变量。
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}