Go 内存模型

思考一下,为什么 Go 语言只提供了 array,slice 和 map 给我们用,为什么没有提供 栈,队列,堆等?

怎样高效的处理数据 ? 怎样写出清晰易懂的代码? 怎样才能把代码所引发的开销给强调出来?

CPU 缓存

CPU 一般有三级缓存和主内存。l1 > l2 > l3 > 主内存,访问速度是越来越慢的,内存空间是越来越大。

一个缓存行的长度,访问缓存行中的其他元素,基本是免费的。昂贵的是访问一个新的缓存,只有我们访问更少的缓存行时,我们的程序会更高效。

当下的高速缓存行是 32 或 64 字节宽,具体取决于硬件。硬件喜欢沿着缓存线线性遍历数据和指令。

切片:直达缓存友好的性能

数组

应该培养一种特质,知道自己正在做什么,搞清楚为什么这样做,这样做的道理和效果,这样可以做出更好的设计决策。

Go 语言中数组的各个元素地址是连续的,更符合 CPU 预测,使用数组的程序会更快。

CPU 有三级缓存,为了更快,当你读取数据的时候,它会预测你接下来要读什么,提前放入缓存中。

1
2
3
4
// 指针语义遍历
for i := range arr {
  total += arr[i]
}
1
2
3
4
// 值语义遍历
for _,v := range arr{
  total += v
}

切片

这将是你的首选数据结构。除非你真能确定自己遇到特殊情况,需要用到链表等结构,否则还是应该优先考虑使用切片。而且要尽可能使用由值构成的切片,而不是由指针构成的切片。

使用 make 创建,这是 Go 语言内置函数,让我们可以创建出 3 种引用类型。

目前我们用到的类型分钟为两种:

  • 内置类型
  • 结构体类型(用户自定义)
  • 引用类型 ( slice / map / channel / 函数 / 接口 )。这是带有指针的数据结构,如果将该类型变量设置零值,相当于 nil,好比指针设置为 nil。字符串实际很接近于引用类型,但是零值是空串,而不是 nil,所以不能归类到引用类型。

slice 跟 字符串的结构有点像,但是 slice 比 字符串多一个 cap 字段,表示切片容量。

长度和容量有什么区别呢?

长度表示从当前位置开始,可能访问多少个元素。

容量表示将来有可能增加到多少个元素,为考虑以后的扩充而设计。

如果访问超过切片长度的内容,会发生 runtime error。

1
2
3
4
5
type slice struct{
  array unsafe.Pointer
  len int
  cap int
}

这个结构作为函数参数传递时,非常高效。 数组会将每一个元素拷贝,而切片只会拷贝这样一个结构,占用 24 字节。切片本来就应该采用值语义操作,本来就应该留在使用它的那个栈里。

1
2
3
func inspectSlice(slice []string) {
  fmt.Printf("length[%d] capacity[%d]\n", len(slice),cap(slice))
}

切片结构中 array 是数组指针,意味着通过修改切片会产生副作用。

nil slice,empty slice 与 nil

Go 语言可以通过 nil 与 empty 来表达式不同的意思 你可以返回零值切片来表示错误 empty 切片可以表示顺利,但是没有数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func TestNilSlice(t *testing.T) {
	// 声明未赋值的零值切片  slice == nil
	var nilSlice []string
	require.Nil(t, nilSlice)

	// 声明赋值的空切片  slice != nil
	emptySlice := []string{}
	require.NotNil(t, emptySlice)

	require.Nil(t, nilSliceF())
	require.NotNil(t, emptySliceF())
}

func nilSliceF() []string {
	return nil
}
func emptySliceF() []string {
	return make([]string, 0)
}

零分配的空白结构体

可以根据这样的结构体,创建上百万个值,但不会引发任何分配。

因为在运行时环境里面,有一个 8 字节的固定值,它就像一个全局变量一样,可以让这种空白的结构体引用。所以无论有多少个空白结构体,它们都可以指向这同一个地址。

比如 nil slice 里面的指针,就是指向这个空白结构体。

1
var emptyStruct struct{}

扩容

如果提前知道需要多少容量,在创建时指定,这能减少需要扩容时的内存分配。这是非常高效的!! 即想要写出高效的代码,避免使用 append函数,直接操作对应位置的元素。

但有可能我们并不知道到底需要操作多少数据,可能是 0 或更多,没办法提前把数组分配出来,即便分配了也可能浪费。为了尽量降低程序消耗的资源量,我们可能要稍微损耗一点性能,这样做,跟真正有可能影响性能的地方相比,这种损耗算不了什么。这时,只好从 nil slice 开始。

1
var data []string

通过 append 扩容,将新数据添加到切片的尾部。注意其调用方式,这个函数的 API 用的是值语义,这是很好的设计。这样的 API 可以叫做通过 值语义 进行修改的 API。它通过对我们传入的切片值复制,在副本上做修改,并返回。这很重要,将修改效果隔离起来,无副作用。这是一种优雅,安全而又清晰的做法。

1
data = append(data, "value")

append 会导致内存泄漏吗?

如果资源不释放,在内存里滚雪球慢慢增大,这就是内存泄漏了。可以通过 Go trace 工具检查,检查 GC 后内存是否并没有减少,而是增大。

典型的内存泄漏:

  • 是否每个 goroutine 无法自行终止,持有的引用无法释放。
  • map 只添加数据,因未清理而一直膨胀。
  • 某些 API 用完后需要主动 close,但却忘记调用。
  • append 的返回值未赋给充当参数的变量,原有支持结构的引用数量未清零。

当切片的 len 和 cap 相同时,此时调用 append 会创建一个容量翻倍的支撑数组,然后将原数组和参数复制过去,让切片的指针指向新的数组。

扩容规则是什么?

查看源码 ,注意要区分 1.18 版本 和 小于其的版本

1.18 开始

  • 所需容量大于容量翻倍,预估容量等于所需容量
  • 否则
    • < 256 时,2倍扩容
    • 从 2 倍扩容到 1.25 倍平滑过度扩容,公式: newcap += (newcap + 3*256) / 4
  • 最后,匹配内存规格,公式为 8 * (2*x),x 为递增变量。

<= 1.17 以前是,仅作为了解。

  • 所需容量大于容量翻倍,预估容量等于所需容量
  • 否则
    • < 1024 时,双倍扩容
    • >= 1024 时,1.25 倍扩容
  • 最后,匹配内存规格。

切片的高效操作

1
2
slice1 := make([]string,5,8)
slice2 := slice1[2:2+2] // 从 2 开始,取两个值

上面的写法,可以高效的创建出新的切片值,而且新值和原值可以高效的共享同一个支撑数组。这意味着需要分配在堆上面的至多只有原来的那个支撑数组。

注意,通过 append 对 slice2 添加元素,会影响到 slice1。因为上面提到的是同一个支撑数组。

1
slice2 := slice1[2:4:4] // 从 2 开始,取两个值。容量为 2。 

在创建新切片值时,可以指定容量与长度相等,此时使用 append 会创建一个新的数组,并将旧数组值复制过去,这样就消除了上面的副作用。三下标制作切片的方法,让你既可以在尾部追加元素,同时又不会影响使用原来那个支持数组的其它切片。这样当然很棒,但有时候,可能需要自己复制,当然还是应该尽量少用复制操作。

Go 语言提供了内置函数copy ,它能够将源切片中的元素复制到目标切片。

1
2
slice3 := make([]string,len(slice1))
copy(slice3,slice1)

函数式编程

如果不注意防范副作用,那么可能会产生想当危险的结果。通过指针操纵切片时,修改的是切片的支持数组所在的内存,这当然会让程序写起来困难一些。假如使用函数式编程,就不用担心这种问题了。

函数式编程,所有内容都通过值语义来操纵,每一段代码每一个函数,操纵的都是它自己的那份数据。可是这样,就不能根据需要把程序速度优化到最快,这提现了 Go 语言的一项优势,它让我们自己决定,是操纵数值还是指针。

必须注意,采用指针语义操作,必须当心这种做法在修改数据时,是否会引发什么问题。

1
2
3
4
5
6
// Bad
likes := make([]int,3)
u1 := &likes[1]
// 控制 u1 指针来修改数据
// 如果 likes 发生 append 扩容,u1 修改是旧数据,且因为旧数组被引用,而无法释放。
// 必须反复检查调用 append 函数的地方,避免支撑数组发生替换,进而给程序带来副作用

字符串和切片

字符串归根结底还是由一系列字节组成,最底层是字节,中间一层叫做代码点,每个代码点都可以当做一个 32 位(4 字节)值,在代码点上方是字符层。代码点有可能1 个字节就能表示,也可能需要 4 个字节才能表示。

  • 汉字需要 3 个字节
  • 英文字母需要 1 个字节。

字符串可以用 for range 来遍历,遍历的是代码点,string(v) 可以将代码点转为字符。值遍历 v 是 rune 类型,这个类型并不是真正的类型,而是 int32 的别名,实际上,byte 类型是 uint8 的别名。

对字符串切片操作,要小心不同字符的长度。可以转为[]rune 切片来处理。

for range

for range 是值语义,也就是遍历拷贝的副本。在遍历中操作切片不影响遍历。这样的写法一个切片发生变化,不会影响到使用另一个切片所涉及的程序。

1
2
3
4
// 值语义,不影响
for _,v := range slice1 {
  slice1 = slice1[:2]
}

是指针语义时,它访问的就是原来的切片,如下面的代码,可能会发生数组下标溢出。

1
2
3
for i := range slice1{
  slice1 = slice1[:2]
}

Licensed under CC BY-NC-SA 4.0
本文阅读量 次, 总访问量 ,总访客数
Built with Hugo .   Theme Stack designed by Jimmy