性能优化建议
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
Slice 性能优化建议
预分配内存
Slice 在使用 append 的过程中,可能会发生超出容量的情形,此时就需要重新分配一块更大空间的底层数组,并将新的底层数组的引用传给原切片(如下图),如果在需要频繁进行增加元素的场景中,初始给底层数组分配的内存过小,就会导致多次底层数组的内存重新分配(过程比较慢,影响程序性能)。
我们不妨进行一下预分配内存和不预分配内存的性能测试
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
大内存未能释放
直接在原来大切片上进行操作,截取小切片,由于小切片引用了大切片,它在 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
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
字符串处理
常见字符串拼接方式
- 使用 + 号进行拼接
func Plus(n int, str string) string {
s := ""
for i := 0; i < n; i++ {
s += str
}
return s
}
- 使用. 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()
}
- 使用 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()
}
基准性能测试结果
分析
- 字符串在 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))
}
在字符串处理过程中也可以引入预分配
- 在使用 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()
}
- 在使用 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")
}
}
结构体优化建议
使用空结构体节省内存
- 空结构体 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 包
保证并发安全
- 加锁
- 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)
}
总结
- 避免常见的性能陷阱
- 普通应用代码,不要一味地追求程序的性能
- 越高级的性能优化手段越容易出现问题
- 性能优化需要满足正确可靠、简洁清晰的前提