曹大带我学 Go之从 Map 的 Extra 字段谈起

开发 后端
对于这些 overflow 的 bucket,在 hmap 结构体和 bmap 结构体里分别有一个 extra.overflow 和 overflow 字段指向它们。

[[416124]]

本文转载自微信公众号「码农桃花源」,作者小X 。转载本文请联系码农桃花源公众号。

你好,我是小X。

曹大最近开 Go 课程了,小X 正在和曹大学 Go。

这个系列会讲一些从课程中学到的让人醍醐灌顶的东西,拨云见日,带你重新认识 Go。

熟悉 map 结构体的读者应该知道,hmap 由很多 bmap(bucket) 构成,每个 bmap 都保存了 8 个 key/value 对:

hmap

有时落在同一个 bmap 中的 key/value 太多了,超过了 8 个,就会由溢出 bmap 来承接,即 overflow bmap(后面我们叫它 bucket)。溢出的 bucket 和原来的 bucket 形成一个“拉链”。

对于这些 overflow 的 bucket,在 hmap 结构体和 bmap 结构体里分别有一个 extra.overflow 和 overflow 字段指向它们。

如果我们仔细看 mapextra 结构体里对 overflow 字段的注释,会发现这里有“文章”。

  1. type mapextra struct { 
  2.  overflow    *[]*bmap 
  3.  oldoverflow *[]*bmap 
  4.  
  5.  nextOverflow *bmap 

其中 overflow 这个字段上面有一大段注释,我们来看看前两行:

  1. // If both key and elem do not contain pointers and are inline, then we mark bucket 
  2. // type as containing no pointers. This avoids scanning such maps. 

意思是如果 map 的 key 和 value 都不包含指针的话,在 GC 期间就可以避免对它的扫描。在 map 非常大(几百万个 key)的场景下,能提升不少性能。

那具体是怎么实现“不扫描”的呢?

我们知道,bmap 这个结构体里有一个 overflow 指针,它指向溢出的 bucket。因为它是一个指针,所以 GC 的时候肯定要扫描它,也就要扫描所有的 bmap。

而当 map 的 key/value 都是非指针类型的话,扫描是可以避免的,直接标记整个 map 的颜色(三色标记法)就行了,不用去扫描每个 bmap 的 overflow 指针。

但是溢出的 bucket 总是可能存在的,这和 key/value 的类型无关。

于是就利用 hmap 里的 extra 结构体的 overflow 指针来 “hold” 这些 overflow 的 bucket,并把 bmap 结构体的 overflow 指针类型变成一个 unitptr 类型(这些是在编译期干的)。于是整个 bmap 就完全没有指针了,也就不会在 GC 期间被扫描。

  1. overflow    *[]*bmap 

另一方面,当 GC 在扫描 hmap 时,通过 extra.overflow 这条路径(指针)就可以将 overflow 的 bucket 正常标记成黑色,从而不会被 GC 错误地回收。

当我们知道上面这些原理后,就可以利用它来对一些场景进行性能优化:

  1. map[string]int -> map[[12]byte]int 

因为 string 底层有指针,所以当 string 作为 map 的 key 时,GC 阶段会扫描整个 map;而数组 [12]byte 是一个值类型,不会被 GC 扫描。

我们用两种方法来验证优化效果。

主动触发 GC

这里的测试代码来自文章《尽量不要在大 map 中保存指针》[1]:

  1. func MapWithPointer() { 
  2.     const N = 10000000 
  3.     m := make(map[string]string) 
  4.     for i := 0; i < N; i++ { 
  5.         n := strconv.Itoa(i) 
  6.         m[n] = n 
  7.     } 
  8.     now := time.Now() 
  9.     runtime.GC()      
  10.     fmt.Printf("With a map of strings, GC took: %s\n"time.Since(now)) 
  11.  
  12.     // 引用一下防止被 GC 回收掉 
  13.     _ = m["0"
  14.  
  15. func MapWithoutPointer() { 
  16.     const N = 10000000 
  17.     m := make(map[int]int
  18.     for i := 0; i < N; i++ { 
  19.         str := strconv.Itoa(i) 
  20.         // hash string to int 
  21.         n, _ := strconv.Atoi(str) 
  22.         m[n] = n 
  23.     } 
  24.     now := time.Now() 
  25.     runtime.GC() 
  26.     fmt.Printf("With a map of int, GC took: %s\n"time.Since(now)) 
  27.  
  28.     _ = m[0] 
  29.  
  30. func TestMapWithPointer(t *testing.T) { 
  31.     MapWithPointer() 
  32.  
  33. func TestMapWithoutPointer(t *testing.T) { 
  34.     MapWithoutPointer() 

直接用了 2 个不同类型的 map:前者 key 和 value 都是 string 类型,后者 key 和 value 都是 int 类型。整个 map 大小为 1kw。

测试结果:

  1. === RUN   TestMapWithPointer 
  2. With a map of strings, GC took: 150.078ms 
  3. --- PASS: TestMapWithPointer (4.22s) 
  4. === RUN   TestMapWithoutPointer 
  5. With a map of int, GC took: 4.9581ms 
  6. --- PASS: TestMapWithoutPointer (2.33s) 
  7. PASS 

于是验证了 string 相对于 int 这种值类型对 GC 的消耗更大。正如这篇文章的标题所说:

Go语言使用 map 时尽量不要在 big map 中保存指针。

用 pprof 看对象数

第二种方式就是直接开个 pprof 来看 heap profile。这次我们将 string 类型的 key 优化成数组类型:

  1. package main 
  2.  
  3. import ( 
  4.  "fmt" 
  5.  "io" 
  6.  "net/http" 
  7.  _ "net/http/pprof" 
  8.  
  9. // var m = map[[12]byte]int{} 
  10. var m = map[string]int{} 
  11.  
  12. func init()  { 
  13.  for i := 0; i < 1000000; i++ { 
  14.   // var arr [12]byte 
  15.   // copy(arr[:], fmt.Sprint(i)) 
  16.   // m[arr] = i 
  17.  
  18.   m[fmt.Sprint(i)] = i 
  19.  } 
  20.  
  21. func sayHello(wr http.ResponseWriter, r *http.Request) { 
  22.  io.WriteString(wr ,"hello"
  23.  
  24. func main() { 
  25.  http.HandleFunc("/", sayHello) 
  26.  err := http.ListenAndServe(":8000", nil) 
  27.  if err != nil { 
  28.   fmt.Println(err) 
  29.  } 

注意,去掉代码里的注释即可将 key 从 string 优化成数组类型。

直接在 init 里构建 map,然后开 pprof 看 profile:

key 为 string

key 为数组

对象数从 33w 下降到 1.5w,效果非常明显。

map 的 key 和 value 要不要在 GC 里扫描,和类型是有关的。数组类型是个值类型,string 底层也是指针。

不过要注意,key/value 大于 128B 的时候,会退化成指针类型。

那么问题来了,什么是指针类型呢?**所有显式 *T 以及内部有 pointer 的对像都是指针类型。

——来自董神的 map 优化文章

关于超过 128 字节的情况,源码里也有说明:

  1. // Maximum key or elem size to keep inline (instead of mallocing per element). 
  2. maxKeySize  = 128 
  3. maxElemSize = 128 

总结

当 map 的 key/value 是非指针类型时,GC 不会对所有的 bucket 进行扫描。如果线上服务使用了一个超大的 map ,会因此提升性能。

为了不让 overflow 的 bucket 被 GC 错误地回收掉,在 hmap 里用 extra.overflow 指针指向它,从而在三色标记里将其标记为黑色。

如果你用了 key 是 string 类型的 map,并且恰好这些 string 是定长的,那么就可以用 key 为数组类型的 map 来优化它。

通过主动调用 GC 以及开 pprof 都可观察优化效果。

好了,这就是今天全部的内容了~ 我是小X,我们下期再见~

欢迎关注曹大的 TechPaper 以及码农桃花源~

参考资料

[1]《尽量不要在大 map 中保存指针》: https://www.jianshu.com/p/5903323a7110

 

责任编辑:武晓燕 来源: 码农桃花源
相关推荐

2021-06-10 09:00:32

Go底层代码

2021-06-07 10:47:02

GoGoexit函数

2021-06-01 09:27:53

Ast Go语言

2021-07-15 08:58:15

指定配置项Go

2021-05-20 08:59:47

Go调度本质

2021-05-27 08:59:09

Go汇编命令

2022-01-05 08:56:20

Go火焰图编程

2017-04-25 16:45:11

2022-11-02 08:36:35

ArgoAIOPS

2022-10-13 08:32:44

手机故障IO

2022-10-31 07:33:05

Javafor循环

2020-06-28 13:51:03

哈希map结构

2023-03-02 08:13:53

Oracle共享池监控

2022-04-06 08:58:39

归并排序Go算法

2022-09-30 07:32:48

循环while循环体

2022-11-26 00:34:57

数组Java程序

2022-10-30 10:14:43

Java循环语句

2018-02-07 17:32:54

情感分析

2012-05-10 17:21:49

三星Tizen

2017-07-03 13:53:17

大数据大数据平台数据治理
点赞
收藏

51CTO技术栈公众号