Java HashMap和Go map源码对比

3,541 阅读4分钟

前言

在Java中,hashmap的实现十分的精妙,而且不断的在优化中,尤其是1.8引入了红黑树并且优化了扩容,而在Go中map是作为关键字的存在,这篇文章的目的就是通过分析两者的源码来比较他们的异同,两者都是非线程安全的

Java(HashMap)

hashmap中的桶是一个如下的数组

transient Node<K,V>[] table;

这是Node的结构,因为具有next引用,所以很显然是一个链表的结构,数组中存的可以理解为头指针

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; 
        final K key;
        V value;
        Node<K,V> next; // 链表的下一个Node的引用	
}

很显然,hash表中,只有hash的碰撞概率越小,map的存取效率越高,但是如果桶过大的话,又会浪费空间,那Java种是怎么来优化的的呢,分析java源码的时候一般从构造函数开始,从构造函数中可以找到这样几个相关的重要属性

  • threadshold:决定是否扩容的临界值,超过这个临界值,就会把桶的长度扩容为2倍,等于length*loadFactor
  • loadFactor:负载因子,元素在长度中的占比,超过这个占比,长度会扩容2倍
  • size:元素的数量

Jdk源码中loadFactor的默认是0.75,一般不用修改,如果你认为map中元素增长的速度很快那就可以缩小,如果内存紧张可以放大

无论如何,第一步需要做的都是取到key的hash,源码如下

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里通过高位运算和取模运算获取到了hash的值,因为&运算取模的效率要高于%运算,而且桶的长度稳定在2的n次方,这种方法在Jdk中很常见,包括ArrayDeque等包中也是使用这种方法来提高效率

扩容(resize)

桶是一个数组,数组是不能自动扩容的,只能用一个新的数组来存储原来的数组,java7中扩容每次都要计算新的hash值,java8中利用按位与&运算来实现了扩容后,重新hash,提高了效率,十分精妙

do {
    next = e.next;
    if ((e.hash & oldCap) == 0) { 
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {                   
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {    // 按位与结果为0的时候位位置不变
    loTail.next = null;
    newTab[j] = loHead; 
}
if (hiTail != null) {   // 按位与结果为1的时候原来的位置移动原来长度
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

Java的hashmap不是线程安全的,在多线程的环境下应该使用concurrenthashmap来代替hashmap

Go(关键字map)

最新的Go1.11版本中map的实现从原来的runtime中hmap.go,迁移到了runtime包中的map.go中,想要查看源码可以去github上clone一份,首先通过注释我们就可以了解很多关于map的实现的原理

  1. map是一个hash表,数据是存放在一个桶数组里的,每个桶都含有8个k/v,通过key的高8位来提高查询的效率
  2. go中的扩容和java中有很大的不同,oldbucket不会像java中立马转移到新的bucket中,只有当访问到该bucket的时候才会使用growWork方法来进行迁移,随着访问 最终会完成所有的迁移
  3. loadFactor默认的是6.5,这个结果按照注释来讲是很多次实践之后比较好的结果

map的核心是hmap结构

type hmap struct {
	count     int //元素个数
	flags     uint8
	B         uint8  
	noverflow uint16 
	hash0     uint32 
	buckets    unsafe.Pointer // buckets数组指针
	oldbuckets unsafe.Pointer // 扩容时复制数组指针
	nevacuate  uintptr        // 已经迁移的数量
	extra *mapextra 
}

还有一个重要的结构是bmap

type bmap struct {
	tophash [bucketCnt]uint8
}

从bmap中的注释可以看出来,tophash存储了8个key的hash的高八位,这样在查询的时候更快,从注释中看

Followed by bucketCnt keys and then bucketCnt values.

Followed by an overflow pointer.

bmap结构中还有两个需要通过指针运算才能访问的结构体,一个是key0/key1/key2/key3/val0/val1/val2/val3这样的结构,这种结构可以节省空间,最后是一个overflow的指针,最后就形成了一个类似于java中的结构,由此可以看出也是使用拉链法来解决地址冲突的

扩容

go中的扩容和java中有很大的区别,他首先会创建一个新的两倍长度的数组替换掉原来的数组,然后oldbucket会添加原来的元素,然后只有当访问到当前key所在的bucket的时候才会调用growWork方法进行重新hash去迁移原来的元素。这样做的优点就是能够在扩容的时候不用因为复制整个数组而阻塞很长的时间,在redis中的map也是使用这样的方式来避免阻塞很长的时间

同样 Go中的map也不是协程安全的,如果想要协程安全,有三种方案

  • 使用官方博客上的封装map和rwlock的方式
  • 使用第三方的一个利用分段锁实现的线程安全的map
  • 使用sync.Map