go数据结构

156 阅读12分钟

数组(array)

  • 数组的组成存储的数据类型、存储的数据量

  • 初始化数组的方法

// 指定大小
arr := [10]int
// 通过 [...]int
arr := [...]int
// 定义数据会在 编译过程中:生成中间代码阶段,通过walk函数替换成 NewArrAy 方法
  • 底层创建数组的方法
// cmd/compile/internal/types/type.go:NewArray
func NewArray(elem *Type, bound int64) *Type {
  if bound < 0 {
    Fatalf("NewArray: invalid bound %v", bound)
  }
  t := New(TARRAY)
  t.Extra = &Array{Elem: elem, Bound: bound}
  t.SetNotInHeap(elem.NotInHeap())
  return t
}
// 该方法是在 go的编译过程中的--生成中间代码阶段去使用的walk函数去将定义的数组去替换成NewArray的方法,具体的编译过程会在go的编译过程具体分析的
// 通过这个方法去创建 Array的结构体
  • 数组存在的问题:数组定义时就被确定好了数量,不能去动态扩容,所以被使用的没有切片(slice)的场景多

切片(slice)

  • 创建
// 直接创建
slice := []int{1, 2, 3}
// make 创建
slice := make([]int, 0, 10) // 最后一个参数是初始化容量cap,初始化这个容量也是优化的一个重要操作
// 通过数组创建
arr := [5]int{0,1,2,3,4}
slice := arr[1:3]
// 注意:通过数组创建切片,切片的容量cap问题,cap = 切片截取数组的开始到数组结束的数量

image_5quue1swpAbx7JEbY4TgLL.png

  • 底层创建切片的方法
// cmd/compile/internal/types/type.go:NewSlice
func NewSlice(elem *Type) *Type {
  if t := elem.Cache.slice; t != nil {
    if t.Elem() != elem {
      Fatalf("elem mismatch")
    }
    return t
  }
  t := New(TSLICE)
  t.Extra = Slice{Elem: elem}
  elem.Cache.slice = t
  return t
}
// 该方法和NewArray一样,在编译过程中是一样的
  • 切片的扩容
1.扩容原理:slice在追加元素时,会判断slice的cap(容量空间),如果cap没有超出,则正常追加元素,如果超出cap,则分配一个更大空间的slice,再将原来的slice放入新的slice,然后返回新的slice,可以通过打印追加后的slice内存地址,证明这个步骤

2.扩容规则:
  cap < 1024时,新slice扩充成原来的2倍
  cap >= 1024时,新slice扩充成原来的1.25被
  注意:有的时候,按照上面的规则,算出的新的cap和系统计算的cap不一致,这个原因是内存对齐导致的,内存对齐优化的一个手段,下面会对其讲解
  
 3.使用append()向Slice添加一个元素的实现步骤如下:
   假如Slice容量够用,则将新元素追加进去,Slice.len++,返回原Slice
   原Slice容量不够,则将Slice先扩容,扩容后得到新Slice
   将新元素追加进新Slice,Slice.len++,返回新的Slice

内存对齐

  • 原理:系统在读取数据时,是一块一块的读取内存,64位每次读取8个字节,32位每次读取4个字节,内存对齐就是将同一个数据的字节放到同一块,读取的时候只需要做判断的操作就好,从而实现优化

image_bZo4snw7UgCS7VfW8aZxFb.png

  • 不对齐的时候:

image_kJiv1eDTCKz7VVUE5mHJWF.png

map

  • 以下代码会执行会出现什么情况?
package main
import "fmt"
type Stu struct {
  Name string
}
func main() {
  s := make(map[string]Stu)
  s["stu"] = Stu{"wgp"}
  s["stu"].Name = "go"
  fmt.Println(s)
}
// 这个代码会报错,因为s["stu"] = Stu{"wgp"}只是值的拷贝,也就是赋值之后,这个只是只读的状态,不能赋值
// 只需要改成  s := make(map[string]*Stu) s["stu"] = &Stu{"wgp"} 就好
package main
import "fmt"

type Stu struct {
  Name string
}
func main() {
  s := make(map[string]*Stu)
  stus := []Stu{
    {"shineyork"},
    {"sixstaredu"},
    {"go"},
  }
  for _, v := range stus {
    s[v.Name] = &v
  }
  for k, v := range s {
    fmt.Println(k, " => ", v.Name)
  }
}
// 执行结果:
shineyork  =>  go
sixstaredu  =>  go
go  =>  go
// 出现这种情况是:s是引用地址,在用for _, v := range stus 对s进行赋值时,指向的都是同一个内存地址
// 解决方法:
package main
import "fmt"

type Stu struct {
  Name string
}
func main() {
  s := make(map[string]*Stu)
  stus := []Stu{
    {"shineyork"},
    {"sixstaredu"},
    {"go"},
  }

  // 更改这个代码
  for i := 0; i < len(stus); i++ {
    s[stus[i].Name] = &stus[i]
  }

  for k, v := range s {
    fmt.Println(k, " => ", v.Name)
  }
}

  • map的数据结构:哈希表和搜索树

    • 哈希表
    哈希表:用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高
    
    • 哈希表存在的问题
    哈希表一般会出现一些碰撞的问题,就是当key分配到一个捅(bucket)时,桶内没有位置可以存储(桶一般存8个key/value),导致出现碰撞问题,解决的方式有两种:链表法、开放地址法
    链表法将一个bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。(主要,具体在后面还是要分析)
    开放地址法则是碰撞发生后,通过一定的规律,在数组的后面挑选“空位”,用来放置新的 key
    
    • 搜索树:搜索树法一般采用自平衡搜索树,包括:AVL 树,红黑树
  • map的内存结构模型

    • 在go中map主要使用的是哈希表,并且使用链表法解决哈希碰撞的问题,在一个哈希表里面存在多个哈希节点(即为桶,bucket),每个节点保存一对或者多对key/value,源码在runtime/map.go:hmap
    type hmap struct {
      count int // 元素个数,调用 len(map) 时,直接返回此值
      flags uint8
      B uint8 // buckets 的对数 log_2
      noverflow uint16 // overflow 的 bucket 近似数
      hash0 uint32 // 计算 key 的哈希的时候会传入哈希函数
      buckets unsafe.Pointer // 指向 buckets 数组,大小为 2^B 如果元素个数为0,就为 nil
      oldbuckets unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
      nevacuate uintptr // 指示扩容进度,小于此地址的 buckets 迁移完成
      extra *mapextra
    }
    

    image_5cxymDrFE32Zz4UHAD82QJ.png

    • bucket的数据结构(桶)
    // Bucket数据结构由 runtime/map.go:bmap 定义,需要值得注意的是 data 与overflow这两个元素并不是在结构体中显示定义的,而是直接通过指针运算进行访问的;
    // 每个bucket可以存储8个键值对
    type bmap struct {
      tophash [bucketCnt]uint8 // 是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配。
      -data byte[1] // 存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省字节对齐带来的空间浪费。
      -overflow *bmap // 指针指向的是下一个bucket,据此将所有冲突的键连接起来。
    }
    

    image_5tXveRYfEST55pwfbFxk4x.png

    • map的内存模型图

    image_cvVuvEeqrxpUgCosAhKHJV.png

  • map的初始化与读写

    • 初始化
    1.执行 make(map[int]int, 10)之后,会创建 hmap的结构体
    2.生成一个哈希因子hash0,在构建的时候创建,为哈希函数结构引入随机性
    3.根据上面的10计算出B的值(这个B是hmap结构体中定义),而10算出的B=1(算法会在下面讲解)
    4.根据B算出bucket(桶)数量
      B < 4, bucket_num = 2^B
      B >= 4, bucket_num = 2^B + 2^(B-4) 标准桶 + 溢出桶
    
    • B的值是怎么结算的?
    make(map[int]int, 10),通过10计算,这个10代表的是创建的map容量是10,就是证明bucket要存10个以上的数据,一个bucket可以存8个,也就是需要2个bucket,已知 bucket = 2^B = 2,所以 B = 1
    
    • map的读写
    当我需要对make元素进行写入的时候,执行m[“name”] =“wgp” 写入数据如下为执行流程:
    1. 结合hash0(哈希因子) 和 键name生成哈希值 0110110111010001011010
    2. 获取哈希值的后8位,根据B的值来计算key存在于具体的桶的位置
    

    image_hxLj9EehTivUY4zYitusZM.png

    Key经过计算后得到二级制数据即可确定具体存在哪个bmap中,而这里我们需要注意;tophash获取的是数据的 前8为数值 确定key,而确定哪个bmap则是看B的数值;比如B为5则 获取后 5为数值确定bmap
    

    image_cVnG1JB8HmCmE8AG4tZDmm.png

    image_6yLEymJUApTpfGTqEPM7wK.png

  • map的扩容

    • 链表冲突问题
    由于一个或者多个key分配到一个bmap(bucket)中时,bmap已经满了,不能再存值,这个就是冲突,利用链表法解决这个问题
    由于每个bmap可以存放8个键值对,所以同一个bmap存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bmap连接起来。
    

    image_7qgwam5HKBS98jFSe2i827.png

    哈希链表对key的查找方式;在确定是在那个bmap之后下一步就会在该bmap中遍历查找,内部的tophash/key是否相等,没有就会根据overflow跳到下一个bmap中进行同样的遍历操作;直到全部查找没有元素或者匹配到key为止;
    
    • 负载/装载因子
    Bucket中存在overflow 指定下一个bucket,视为当前bucket溢出;事实上哈希冲突会降低存取效率;在go中采用负载因子来衡量哈希冲突情况
    负载因子 = 元素数量 / bucket数量
    哈希表需要将负载因子控制在合适大小,超过其范围就会进行rehash即重新组织
    * 哈希因子过小,说明空间利用率低
    * 哈希因子过大,说明冲突严重,存取效率低
    每个哈希表的实现对负载因子设置不一样,go的范围是6.5才会触发rehash;
    
    • Go的map扩容条件
    1. 负载因子 > 6.5 --- 增量扩容
    2. 使用了太多的溢出桶 overflow > 2^15 也就是超过32768 ---等量扩容
    
    增量扩容:简单来说就是增加更多的bucket来存储数据
    等量扩容:是对bucket中整体的key/value进行调整优化空间
    
    • 增量扩容
    当负载因子过大时,就新建一个bucket(bmap);而新的bucket长度为原有的2倍,然后会把之前的数据迁移到 新的bucket中;
    考虑到如果kv有百万千万的时候需要的时间很多,Go采用了逐步搬迁的策略,即每次访问map的时候会触发一次搬迁,每次搬迁2个kv
    hmap数据结构中oldbuckets成员指身原bmap[],而buckets指向了新申请的bmap[]。新的键值对被插入新的bucket中。后续对map的访问操作会触发迁移,
    将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets
    

    image_qxyoJiWgzmoBLYv7VrAFso.png

    • 等量扩容
    所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bmap的使用率更高,进而保证更快的存取。
    在极端场景下,比如不断地增删,而键值对正好集中在一小部分的bmap ,这样会造成overflow的bmap数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,如下图所示:
    overflow的bucket中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率
    

    image_avf5KrnZcQx7z2i53bvR7b.png

slice和map的线程安全问题

  • slice的线程安全问题

    • 如下为示例代码,slice运用append进行元素的添加;需要注意的是会存在线程不安全的问题
    func main() {
      fmt.Println(goSlice(5))
    }
    func goSlice(index int) []int {
      s := make([]int, 0)
      for i := 0; i < index; i++ {
        go func(i int) {
          s = append(s, i)
        }(i)
      }
      time.Sleep(1e9)
      return s
    }
    // 正确输出
    // [0 1 2 3 4 5 6 9 7 8]
    // 每次运行结果都不同,并且都不正确
    // [4 7 5 9]
    // [2 0 1 5 3]
    
    以上代码出现问题的两种情况:
      情况1:线程不安全的情况主要是在内存的扩容,在底层中对于slice在容量不足的时候会执行扩容的操作,去申请内存;
      情况2:存在的线程不安全主要是在存在有空闲的空间的时候可以存放元素的时候另一个协程也关注到这个空间,两个协程同时写入到同一个空间的时候就会出现竞争;最终只有一个写入
    
    • 解决的方案就是利用sync的锁机制
    package main
    
    import (
      "fmt"
      "sync"
      "time"
    )
    
    var mu sync.Mutex
    
    func main() {
      fmt.Println(goSlice(10))
    }
    func goSlice(index int) []int {
      s := make([]int, 0)
      for i := 0; i < index; i++ {
        go func(i int) {
          mu.Lock()
          defer mu.Unlock()
          s = append(s, i)
        }(i)
      }
      time.Sleep(1e9)
      return s
    }
    
  • map的线程安全问题

    • 如下为示例代码,map利用协程测试在并发的情况下的操作,需要注意的是map的 线程不安全会出现报错的现象
    package main
    
    import (
      "fmt"
      "time"
    )
    
    func main() {
      fmt.Println(goMap(10))
    }
    func goMap(index int) map[int]interface{} {
      m := make(map[int]interface{})
      for i := 0; i < index; i++ {
        // fatal error: concurrent map writes
        go func(i int) {
          m[i] = i
        }(i)
      }
      time.Sleep(1e9)
      return m
    }
    
    // Map在进行查找、赋值、遍历、删除的操作的时候都会对hamp.flags进行标记,如果发现有标记则直接panic;go不对map做线程安全处理主要是因为考虑性能因为有些场景可以不用考虑这个话题
    // Go这样的设计,主要是在goroutine操作时,可能会因为并发的情况造成混乱,相关的程序也可能会发生不可预知的问题
    
    
    • 解决map线程不安全问题的两种方式:1.是运用sync.RWMutex 2. 是运用sync.Map
    // 1.运用sync.RWMutex
    package main
    
    import (
      "fmt"
      "sync"
      "time"
    )
    
    var mux sync.RWMutex
    
    func main() {
      fmt.Println(goMap(10))
    }
    func goMap(index int) map[int]interface{} {
      m := make(map[int]interface{})
      for i := 0; i < index; i++ {
        // fatal error: concurrent map writes
        go func(i int) {
          mux.Lock()
          defer mux.Unlock()
          m[i] = i
        }(i)
      }
      time.Sleep(1e9)
      return m
    }
    
    
    // 2.运用sync.Map
    package main
    
    import (
      "fmt"
      "sync"
      "time"
    )
    
    
    func main() {
      m := goMap(10)
      // 遍历map
      m.Range(func(key, value interface{}) bool {
        fmt.Println(key, value)
        return true
      })
    }
    func goMap(index int) sync.Map {
      var m sync.Map
      for i := 0; i < index; i++ {
        // fatal error: concurrent map writes
        go func(i int) {
          m.Store(i, i) // 向map加值
        }(i)
      }
      time.Sleep(1e9)
      return m
    }
    
  • sync.Map分析

    • 理解
    Go语言元素map并不是线程安全的,而程序中对它进行并发读写操作的时候,需要加锁。而go源码提供了sync.map则是一种并发安全的map;
    sync.map 对 map 的读写,不需要加锁。通过空间换时间的方式,使用 read 和 dirty两个 map 来进行读写分离,降低锁时间来提高效率
    
    • 源码展示 sync/map.go中
    type Map struct {
      mu Mutex
      // 会冗余数据,只读;用于并发安全的访问[空间换时间]
      read atomic.Value // readOnly
      // 实际数据存储,使用非线程安全的map存储数据
      dirty map[interface{}]*entry
      // 记录上次更新read之后,从read读取key失败的次数
      misses int
    }
    type readOnly struct {
      m map[interface{}]*entry
      amended bool // true if the dirty map contains some key not in m.
    }
    type entry struct {
      p unsafe.Pointer // *interface{}
    }
    
    • 源码解析
    Map.read实际是readOnly这个结构体,只读的数据结构体,因为只读所以不会有写冲突,而readOnly包含了map的一部分数据,用于并发安全的访问
    
    Map.dirty数据包含当前的map包含的key,包含最新的key,注意在dirty和readOnly.m中虽然存在冗余但是它们的value都是指向同一个指针变量*entry
    
    Entry是实际用于存储value的结构体,里面的p实际是一个*interface{},也就是entry实际保存的是指向value的指针;
    
    
    

    image_jqncRtqfK4u7K9xSdCMHnt.png

    image_hwP9bAXA4SyfWypJz5PW42.png

    • Sync.Map整体的优化有如下几点
    1. 空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。
    2. map只保存key和对应的value的指针,这样可以并发的读写map, 实际更新指向
    3. value的指针再通过基于CAS的无锁atomic。
    4. 使用只读数据(read),避免读写冲突
    5. 动态调整,miss次数多了之后,将dirty数据提升为read。
    6. 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据。
    7. 优先从read读取、更新、删除,因为对read的读取不需要锁
    
  • slice和map的小细节:都是指针传递(址拷贝)