字节青训营笔记 day3(2)

117 阅读4分钟

性能优化建议

Benchmark 工具

性能表现需要实际数据来衡量,Go 语言提供了支持基准性能测试的 benckmark 工具,下面是使用 Benchmark 进行基准性能测试的小例子:

fib.go


func Fib(n int) int {
    if n < 2 {
        return n
    }
    
    return Fib(n-1) + Fib(n-2)
}

fib_test.go

func BenchmarkFib10(b *testing.B) {
    // run the Fib function b.N times
    for i := 0; i < b.N; i++ {
        Fib(10)
    }
}

go test -bench=. -benchmem

image.png

Slice 性能优化建议

预分配内存

Slice 在使用 append 的过程中,可能会发生超出容量的情形,此时就需要重新分配一块更大空间的底层数组,并将新的底层数组的引用传给原切片(如下图),如果在需要频繁进行增加元素的场景中,初始给底层数组分配的内存过小,就会导致多次底层数组的内存重新分配(过程比较慢,影响程序性能)。

image.png

我们不妨进行一下预分配内存和不预分配内存的性能测试

alloc.go

func NoPreAlloc(size int) {
    data := make([]int, 0)
    
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}

func PreAlloc(size int) {
    data := make([]int, 0, size)
    
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}

alloc_test.go


func BenchmarkNoPreAlloc8(b *testing.B) {
    for i := 0; i < b.N; i++ {
        NoPreAlloc(8)
    }
}


func BenchmarkPreAlloc8(b *testing.B) {
    for i := 0; i < b.N; i++ {
        PreAlloc(8)
    }
}

go test -v -bench=. -benchmem

image.png

大内存未能释放

直接在原来大切片上进行操作,截取小切片,由于小切片引用了大切片,它在 GC 时导致大切片无法得到释放。基准性能测试如下:

getSlice.go

package main

// 直接在原来大切片上进行操作,截取小切片,由于小切片引用了大切片,它在 GC 时无法得到释放(比如复用 1000 个字节大小的切片新建一个 2 个空间大小的切片,在使用期间 998 个空间闲置,但也无法被 GC 回收,造成很大浪费)
func GetLastBySlice(origin []int) []int {
	return origin[len(origin)-2:]
}

// 根据需要的切片大小重新分配一块空间,然后将原切片的数据拷贝过来,新切片并没有持有元切片的引用,不会影响大切片的回收
func GetLastByCopy(origin []int) []int {
	result := make([]int, 2)

	copy(result, origin[len(origin)-2:])
	return result
}

上述两个函数的作用是一样的,取 origin 切片的最后 2 个元素。

  • 第一个函数直接在原切片基础上进行切片。
  • 第二个函数创建了一个新的切片,将 origin 的最后两个元素拷贝到新切片上,然后返回新切片。

测试用的辅助函数


func generateWithCap(n int) []int {
        // 设置随机种子
	rand.Seed(time.Now().UnixNano())
        // 分配一个容量大小为 n 的切片
	nums := make([]int, 0, n)
        
        // 切片每个位置初始化
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

// 打印内存占用信息
func printMem(t *testing.T) {
        // 测试辅助函数
	t.Helper()
        // 运行时内存状态信息的结构体
	var rtm runtime.MemStats
        // 将内存状态信息填充到结构体中
	runtime.ReadMemStats(&rtm)
        
        // rtm.Alloc 表示程序分配的总内存,单位为 B(类型为 float64)/1024. 表示浮点数除法,B KB MB 进制是 2 的 10 次方(1024)
	t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}

getSlice_test.go

package main

import (
	"testing"
)


func testLastChars(t *testing.T, f func([]int) []int) {
    // Helper将调用函数标记为测试Helper函数。打印文件和行信息时,将跳过该函数。
	t.Helper()
	ans := make([][]int, 0)
	for k := 0; k < 100; k++ {
		origin := generateWithCap(128 * 1024) // 1M
		ans = append(ans, f(origin))
	}
	printMem(t)
	_ = ans
}

func TestLastCharsBySlice(t *testing.T) {
	testLastChars(t, GetLastBySlice)
}

func TestLastCharsByCopy(t *testing.T) {
	testLastChars(t, GetLastByCopy)
}

终端输入:

go test -run=. -v

image.png

map 性能优化建议

预分配内存

分析

  • 不断向 map 中添加元素的操作会触发 map 的扩容
  • 提前分配好空间可以减少内存拷贝和 Rehash 的消耗
  • 建议根据实际需求提前预估号需要的空间

基准测试如下:

m.go

func NoPreAlloc(size int) {
    data := make(map[int]int)
    
    for i := 0; i < size; i++ {
        data[i] = i
    }
}

func PreAlloc(size int) {
    data := make(map[int]int, size)
    
    for i := 0; i < size; i++ {
        data[i] = i
    }
}

m_test.go

func BenchmarkNoPreAlloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		NoPreAlloc(100)
	}
}

func BenchmarkPreAlloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		PreAlloc(100)
	}
}

go test -v -bench=. -benchmem

image.png

字符串处理

常见字符串拼接方式

  1. 使用 + 号进行拼接
func Plus(n int, str string) string {
    s := ""
    for i := 0; i < n; i++ {
        s += str
    }
    
    return s
}
  1. 使用. strings.Builder 进行处理
func StrBuilder(n int, str string) string {
    var builder strings.Builder
    
    for i := 0; i < n; i++ {
        builder.WriteString(str)
    }
    
    return builder.String()
}

  1. 使用 bytes.Buffer 进行处理
func ByteBuffer(n int, str string) string {
    buf := new(bytes.Buffer)
    
    for i := 0; i < n; i++ {
        buf.WriteString(str)
    }
    
    return buf.String()
}

基准性能测试结果

image.png

分析

  • 字符串在 Go 语言中是不可变类型,占用内存大小是固定的
  • 使用 + 每次都会重新分配内存
  • strings.Builder, bytes.Buffer 底层都是 []byte 数组
  • 内存扩容策略,不需要每次拼接重新分配内存

为什么 strings.Builder 比 bytes.Buffer 还要更快一些?

  • bytes.Buffer 转化为字符串时重新申请了一块空间
  • strings.Builder 直接将底层的 []byte 转换成了字符串类型返回
// To build strings more efficiently, see the strings.Builder type.
func (b *Buffer) String() string {
    if b == nil {
        // special case, useful int debugging
        return "<nil>"
    }
    
    return string(b.buf[b.off:])
}

// string returns the acumulated string
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

在字符串处理过程中也可以引入预分配

  1. 在使用 strings.Builder 处理字符串时,引入预分配
func PreStrBuilder(n int, str string) string {
    var builder strings.Builder
    
    builder.Grow(n * len(str))
    for i := 0; i < n; i++ {
        builder.WriteString(str)
    }
    
    return builder.String()
}

  1. 在使用 bytes.Buffer 处理字符串时,引入预分配
func PreByteBuffer(n int, str string) string {
    buf := new(bytes.Buffer)
    
    buf.Grow(n * len(str))
    
    for i := 0; i < n; i++ {
        buf.WriteString(str)
    }
    
    return buf.String()
}

string_test.go

package main

import "testing"

func BenchmarkPlus(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Plus(8, "hello world")
	}
}

func BenchmarkStringBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		StrBuilder(8, "hello world")
	}
}

func BenchmarkByteBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		ByteBuffer(8, "hello world")
	}
}

func BenchmarkPreStringBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		PreStrBuilder(8, "hello world")
	}
}

func BenchmarkPreByteBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		PreByteBuffer(8, "hello world")
	}
}

image.png

结构体优化建议

使用空结构体节省内存

  • 空结构体 struct{} 实例不占据任何的内存空间
  • 可作为各种场景下的占位符使用
  • 实现 Set 可以使用空结构体(只需要用到 map 键,不需要值,即使将 map 的值设置为 bool 类型,也会多占据一个字节空间,而使用空结构体则不会)

代码实例

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


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

atomic 包

保证并发安全

  1. 加锁
  2. atomic

sync 和 atomic 的区别

  • 锁的实现是通过操作系统来实现,属于系统调用
  • atomic 操作是通过硬件实现,效率比锁高
  • sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用 atomic.Value,因为它能够承载一个 interface{}

代码示例

type mutexCounter struct {
    i int32
    m sync.Mutex
}

func MutexAddOne(c *mutexCounter) {
    c.m.Lock()
    c.i++
    c.m.Unlock()
}
type atomicCounter struct {
    i int32
}

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

image.png

总结

  • 避免常见的性能陷阱
  • 普通应用代码,不要一味地追求程序的性能
  • 越高级的性能优化手段越容易出现问题
  • 性能优化需要满足正确可靠、简洁清晰的前提