高质量编程以及编码规范 | 青训营

100 阅读7分钟

高质量编程以及编码规范

性能建议

Slice:尽可能在使用make()初始化切片时提供容量信息,这样就可以避免底层的内存分配次数,

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

在Go语言中,

  • 切片的本质是一个数组片段的描述

    • 包括数组指针
    • 片段的长度
    • 片段的容量(不改变内存分配情况下的最大长度)
  • 切片操作并不复制切片指向的元素
  • 创建一个新的切片会复用原来的切片的底层数组
 type slice struct{
 array unsafe.Pointer
 len int
 cap int
 }

当我们在切片中添加一个或者多个元素的时候,可能会发生以下两种操作的其中一种。

  • 当原来切片容量足够的时候,直接将需要添加到切片的内容追加到原来切片的后面

image-20230728175329218.png

  • 当原来切片容量不够的时候,就需要先将原来的切片扩容,扩容之后再将需要添加到切片中的内容题添加到切片后面。但是,这种操作就比较消耗时间了,因为我们需要1.底层数组扩容。2.将需要追加的内容追加到新的切片后面。(切片具体的扩容方式在上一篇笔记当中)

image-20230728175342595.png

另一个陷阱:大内存未释放

  • 在已有切片基础上创建切片,不会创建新的底层数组

    比如说原始的切片较大,有一个切片从原始的切片上截取了个别的比较短的切片,这就会导致原来的底层数组依然在内存当中得不到释放。

    因此,我们可以使用copy来代替原来的切片操作

     func GetLastBySlice(origin []int) []int {
     return origin[len(origin)-2:]
     }
     // 用这种方式的话就会节约很大一部分内存
     func GetLastByCopy(origin []int) []int {
     result := make([]int22)
     copy(result, origin[len(origin)-2:])
     return result
     }
    

同理,map如果预分配的话,其性能会好很多。

如果不断向map中添加元素的操作就会触发map的扩容。

提前分配好空间的话可以减少内存拷贝和ReHash的消耗

建议根据实际需求提前预估好需要的空间

map

具体的map的底层实现可以看这篇文章[Go map的数据结构和源码扩容分析. (一个桶最多放8个元素,如果超过了的话,那就使用链表法增加一个overflow桶)。从下图中可以看出go 中解决哈希冲突的链表法,链表上的每一个元素是一个 bucket。go map 的实现里面,一个 bucket 可以存放 8 个键值对

image-20230729102609127.png

在 go 中,map 在实际存储的元素数量超过 mapbucket 总数量的 6.5 倍的时候(也就是平均每个 bucket 中的元素个数大于 6.5 的时候),会进行扩容, 这个 6.5 是实现 map 的那个开发者经过实验计算出来的比较合适的数,这个 6.5 被称为负载因子。

说到map扩容,那就了解一下go语言中map的底层扩容机制

在Go中,当map遇到冲突的时候,使用的是拉链法,(也就是用一个链表来保存hashkey相同的值)

提一嘴java的,在java中也是采用了这种方式,并且含有两种策略,当数组小于64的时候对数组扩容,当链表大于8的时候,那就将链表转化为红黑树以求得更快的速度。

哈希扩容

当元素数量变多时,会导致碰撞变多,那么bmap里面的值就会变多,查找效率会变低,所以到一定程度时就需要扩容。需要一个指标衡量,那就是负载因子。

go的负载因子规则是 locadFactor := count / bucketCount

即元素数量除以桶的数量,默认是6.5.(Java 的HashMap的负载因子是0.75,所以Go相对于java来说,更倾向于用空间换时间)

go中map的扩容方式有两种:增量扩容等量扩容

增量扩容:在Hash表中总元素个数过多的扩容,这种扩容发生在当相同hashkey过多的时候,那么链表就会很长,当链表过长的时候元素的查找速度就会变慢,(元素过多了,链表又是依次访问,那就跟数组挨个遍历一样了)。那么,为了避免这种情况发生,一般的哈希表设计会在元素超过一定数量的时候,对哈希表扩容,这样一来,那些哈希冲突的key就可以相对均匀的分布在hash表中,从而避免了很多哈希冲突情况下导致的查找效率低下的问题。

go map 设计与实现(参考)

扩容之后的容量为原来的两倍

等量扩容

我们知道,在哈希冲突的时候,会建立链表来保存键冲突的元素,但是我们删除那些哈希冲突的键的时候,并不会对删除元素的内存进行释放, 如果每次删除都释放的话,在我们频繁插入跟删除的时候,效率就非常低下了。 因为插入一个元素就分配内存,删除一个元素就释放内存(分配内存和释放内存都是相对耗时的操作)。 而这样的结果是,保存冲突键的链表上,有很多空的元素,这样就会导致冲突的时候,查找键的效率降低,因为要遍历很多空的键。

删除的时候不释放,那什么时候会释放呢?哈希冲突的元素很多都被删除的时候,在 go 里面,map 会判断就算没有超过负载因子的情况下, 如果冲突链表占用的空间过大的话,也会进行扩容。但这里说的扩容其实并不是真正意义上的扩容,只是 map 的实现里面,使用的函数是同一个函数。

具体实现方式是,分配跟原哈希表相同大小的空间,然后将旧哈希表的数据迁移到新的哈希表。 这样迁移之后,对于哈希冲突链表上的那些元素,只会迁移非空的元素,最终结果就是,扩容之后,哈希冲突链表上的元素更加紧凑,在查找冲突的键的时候会更加高效。

go map 设计与实现(参考)

strings

在Go中,进行字符串拼接的方式有三种,第一种是直接拼接,第二种是使用strings.Builder第三种是使用bytes.Buffer的形式进行拼接。

image-20230729103121067.png 后两种时间相对来说要快一些,最快的还是strings.Builder。在Go中字符串的大小是不变的,(类似于java)。

使用+ 每次都会重新分配内存。strings.Builder,bytes.Buffer底层都是[]yte数组,有内存扩容策略,不需要每次拼接都重新分配内存。

空结构体

使用空结构体节省内存。

  • 空结构体struct{}实例不占据任何的内存空间

  • 可作为各种场景下的占位符使用

    • 节省资源
    • 空结构体本身具有很强的语义,这里不需要任何值,仅作为占位符
 func emptyStructMap(){
 m := make(map[int]struct{})
 }

使用atomic包

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

使用这个包可以实现原子操作,线程安全,比加锁的方式花费的时间更少。

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