go map实现要比java map好么

1,326 阅读8分钟

go map是常见的数据结构,map底层一般基于开放定址法和分离链接法,前者hash冲突时一般再进行rehash,后者冲突时会在链表中添加元素,go map实现是第二种。

分离链接法的map读写数据,会在对应桶链表中寻找相同的键值,找到就可以返回或更新;更新时找不到可以在链表尾或头添加元素。桶链表如果过长,还可以将其转换为树来提供性能,比如java8 map在链表个数大于8时会转成红黑树,go map实现不是这样的。

接下来先分析go map和go map实现原理,然后比较下二者实现差别及优缺点。

1 go map

go使用make初始化map,底层调用方法makemap函数,其返回指针类型:

// file: runtime/hashmap.go 
func makemap(t *maptype, hint64, h *hmap, bucket unsafe.Pointer) *hmap

注意go map不是线程安全的,如果需要线程安全的map可使用sync.map。当map读写并发时,会报panic,这是由于在读取时会判断hasWriting标志,如果被设置表示当前有写操作,会直接throw异常。

注意,go中每次遍历map元素顺序不是固定的,因为map在遍历时会随机选择一个位置作为起始位置,go为什么要这样做呢?由于go map在rehash是增量进行的,因此读取时数据有可能在老buckets有可能在新buckets,为了防止对使用者造成误解,就一刀切变成了随机从一个位置开始遍历。

1.1 map数据结构

type hmap struct {
    count     int    // 当前map中元素个数
    flags     uint8
    B         uint8   // 桶的2幂次方大小,比如B=8表示桶大小为2^8=256
    noverflow uint16
    hash0     uint32  // hash种子,创建map时初始化

    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer   // 扩容时指向之前的buckets/桶字段
    nevacuate  uintptr // 扩容时已经移到新的map中的bucket数量

    extra *mapextra
}

type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}

hmap中每个bucket是bmap,每个bmap固定存储8个键值对key/value,当单个bucket存储数据超过8个时,会使用hmap.mapextra.overfolow对应的内存空间,如果内存不足会createOverflow创建新的空间。bmap数据结构如下:

type bmap struct {
    tophash [bucketCnt]uint8
}

go中bmap的定义就如上所示,但运行期间其实不仅仅就是上面的定义,为什么这样呢?因为go map支持不同类型的key/value,并且bmap固定存储8个key/value,因此bmap占用的内存空间大小需要在编译期间进行推导,运行期间bmap其他字段的读写都是通过内存指针运算进行的(这也是go map源码可读性不好的原因之一),bmap运行时结构如下:

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

为什么bmap中要定义topbits字段呢?这是为了快速判断hash是否相等的,注意,这里判断相等的不一定真的相等,但是不相等的一定不相等,有点布隆过滤的意思了。

image.png

1.2 读写流程

map的读写方法主要见mapaccess1和mapassign,从mapaccess1签名来看它还有个兄弟mapaccess2,它们分别对应map[key]是否返回ok的操作。

1.2.1 写入操作

当执行map[key]赋值操作时,会将其转换成runtime.mapassign的调用,首先计算hash值,设置map写标志hashWriting,然后通过hash映射某个bucket进行遍历操作,同时计算hash指的top hash,用于在topbits字段快速判断,如果发现top hash相等并且key相等,那么此时就是map更新操作;如果遍历完成(包含遍历overflow数组)未找到对应元素,那么就在遍历过程第一次记录的待插入的空闲(可能是被删除)内存inserti写入元素即可;如果遍历完成也没有空闲空间,会进行newoverflow操作,核心代码如下。

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    hash := t.hasher(key, uintptr(h.hash0)) // 计算hash值
    // Set hashWriting after calling t.hasher, since t.hasher may panic,
    // in which case we have not actually done a write.
    h.flags ^= hashWriting
again:
    // hash映射某个bucket
    bucket := hash & bucketMask(h.B)
    if h.growing() {
        growWork(t, h, bucket)
    }
    // 强转成bmap类型,计算top hash
    b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
    top := tophash(hash)

    var inserti *uint8
    var insertk unsafe.Pointer
    var elem unsafe.Pointer
bucketloop:
    for {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
                // inserti为待插入空闲内存空间
                if isEmpty(b.tophash[i]) && inserti == nil {
                    inserti = &b.tophash[i]
                    insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
                    elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
                }
                if b.tophash[i] == emptyRest { // 数据结束之后不存在数据
                    break bucketloop
                }
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if !t.key.equal(key, k) {
                continue // 不相等记录遍历
            }
            // already have a mapping for key. Update it.
            // 数据存在 更新操作
            if t.needkeyupdate() {
                typedmemmove(t.key, k, key)
            }
            elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
            goto done
        }
        ovf := b.overflow(t) // 遍历下一个overflow数组
        if ovf == nil {
            break
        }
        b = ovf
    }

    if inserti == nil {
        // 遍历完了还未找到空闲空间
        newb := h.newoverflow(t, b)
        inserti = &newb.tophash[0]
        insertk = add(unsafe.Pointer(newb), dataOffset)
        elem = add(insertk, bucketCnt*uintptr(t.keysize))
    }

    typedmemmove(t.key, insertk, key)
    *inserti = top
    h.count++
done:
    h.flags &^= hashWriting
    return elem
}

1.2.2 读取操作

当执行value:=map[key]操作时,go会将其转换为runtime.mapaccess1,hash找bucket和遍历操作同map写入操作一致,这里不在赘述。

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if msanenabled && h != nil {
        msanread(key, t.key.size)
    }
    if h.flags&hashWriting != 0 {
        // 不能同时读写
        throw("concurrent map read and map write")
    }
    hash := t.hasher(key, uintptr(h.hash0))
    m := bucketMask(h.B)
    b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
bucketloop:
    // 遍历overflow
    for ; b != nil; b = b.overflow(t) {
        // 遍历单个bmap结构 最多8个数据
        for i := uintptr(0); i < bucketCnt; i++ {
            // top hash判断,top hash为高8位所数据
            if b.tophash[i] != top {
                if b.tophash[i] == emptyRest {
                        break bucketloop
                }
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey() {
                k = *((*unsafe.Pointer)(k))
            }
            // k相等  找到了数据
            if t.key.equal(key, k) {
                e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
                if t.indirectelem() {
                    e = *((*unsafe.Pointer)(e))
                }
                return e
            }
        }
    }
    // 未找到返回其0值
    return unsafe.Pointer(&zeroVal[0])
}

注意mapaccess2实现和mapaccess1几乎一样,只不过存在多返回个true不存在多返回个false而已。

map的结构是bucket数组 + bmap.key/value数组/链表结构,如果是删除操作,不会真的进行物理删除而是设置删除标志,处于删除状态,下次在进行更新操作时可能会被复用。

随着map数据的增加,性能会越来越慢,当负载因子超过6.5或者使用了过多的overflow溢出桶时会触发rehash,不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign 还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。

根据不同的触发条件会进行不同的扩容操作,如果是溢出桶过多触发扩容,map会进行等量扩容sameSizeGrow,当持续写入并删除数据时,如果未达到阈值会造成缓慢的内存溢出(逻辑删除,并且持续的写入并未复用已删除空间),因此引入sameSizeGrow通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存。

2 java map

Java map即HashMap,Jdk1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。HashMap底层实现是基于数组+链表,数组相当于go map的bucket数组,链表相当于go map的bmap链表(一个bmap存8个key/value对)。

HashMap也是线程不安全的,可使用ConcurrentHashMap来保证线程安全,ConcurrentHashMap的并发锁粒度是bucket的,而go线程安全的sync.Map只有一个锁,相对来说性能不高。

当HashMap写入数据时,也是key hash映射到某个bucket,然后遍历数据,如果不存在创建节点并添加到链表中,如果数据已存在则直接更新。读取数据时流程也基本类似,只不过map中不存在数据是返回null,注意基本类型在Java map中key/value都是其包装类型。

HashMap扩容时也是成倍扩容,和go map不同的是,java是一次性完成rehash,不像go map是增量完成。

3 go map和java map比较

go map和java map实现上来看还是有很多可比较的地方的,如下所示:

  • 底层实现:都是基于哈希表的分离链接法实现,只不过go map是数组+bmap链表(bmap本身包含8个key/value对)
  • 负载因子:go map 6.5,java map 0.75,本质上来讲二者其实是差不多的,因为go中bmap可存储8个kv对(单个bmap可以认为是链表单个节点),6.5/8=0.8,跟0.75也差不多。
  • 扩容机制:go map是增量rehash,每次set操作时可能会移动一些数据,而java map是一次性完成的,这个相对来说go实现性能要好点,但是由于日常开发rehash次数不频繁,因此可能好的不明显。
  • 内存占用:go map一次性申请维度至少bmap大小,而java map可单个key/value申请添加,相对来讲go map有点空间换时间的意思。go map数据删除为逻辑删除并未及时释放内存,不过可能不久就被复用了,而java则是随取随用。
  • 代码可读性:这一点来说java map比较好,go中有很多指针操作,看着有点头大。
  • 性能对比:由于二者底层实现原理类似,因此读写性能不会有数量级别的差距,但是可能会有一些差异。

在日常业务开发中,一般性能问题不会出现在语言或框架上,大都在于IO操作和业务复杂度处理上,相对于框架组件的性能差异(除非有指数级差异且qps很高),我们更应关注业务模块是否内聚、边界划分是否清晰上。

下面看一个go map和java hashmap的示例来看下二者写性能(注意提前初始化较大size的map尽量避免rehash对测试结果的影响):

// go
func main() {
   N := 1000000
   result := int64(0)

   for i := 0; i < 5; i++ {
      m1 := make(map[int]int, N)
      t1 := time.Now()
      for j := 0; j < N; j++ {
         m1[j] = j
      }
      cost := time.Now().Sub(t1).Milliseconds()
      fmt.Printf("%v: put map duration: %d ms\n", i+1, cost)

      result += cost
   }
   fmt.Printf("put map duration: %d ms\n", result/5)
}

// 输出结果
1: put map duration: 91 ms
2: put map duration: 104 ms
3: put map duration: 94 ms
4: put map duration: 85 ms
5: put map duration: 87 ms
put map duration: 92 ms

// java
public static void main(String[] args) {
    int N = 1000000;
    long result = 0;

    for (int i = 0; i < 5; i++) {
        HashMap<Integer, Integer> map = new HashMap<>(N);
        long start = System.currentTimeMillis();
        for (int j = 0; j < N; j++) {
            map.put(j, j);
        }
        long cost = System.currentTimeMillis() - start;
        System.out.printf("%d: put map duration: %d ms\n", i, cost);

        result += cost;
    }

    System.out.printf("put map duration: %d ms\n", result / 5);
}

// 输出结果
0: put map duration: 83 ms
1: put map duration: 82 ms
2: put map duration: 19 ms
3: put map duration: 39 ms
4: put map duration: 18 ms
put map duration: 48 ms

注意由于环境因素导致数据每次数据都会有些许差异,符合预期。

从测试结果来看,java的map性能比go map好点,为什么呢?按说java中还涉及到装箱操作应该更慢一点,难道跟测试环境或go中过多的指针操作有关,具体不太清楚,如果有清楚的小伙伴可以评论告诉我哈,不胜感激~

参考资料:

  1. draveness.me/golang/docs…