Java HashMap和Go map原始碼對比

ryzencool發表於2018-12-01

前言

在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

相關文章