Java中的Hashmap

JDK7與JDK8中HashMap的實現

JDK7中的HashMap

HashMap底層維護一個數組,數組中的每一項都是一個Entryhtml

transient Entry<K,V>[] table;

咱們向 HashMap 中所放置的對象其實是存儲在該數組當中; java

而Map中的key,value則以Entry的形式存放在數組中算法

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

而這個Entry應該放在數組的哪個位置上(這個位置一般稱爲位桶或者hash桶,即hash值相同的Entry會放在同一位置,用鏈表相連),是經過key的hashCode來計算的。shell

final int hash(Object k) {
        int h = 0;
        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

經過hash計算出來的值將會使用indexFor方法找到它應該所在的table下標:數組

static int indexFor(int h, int length) {
        return h & (length-1);
    }

這個方法其實至關於對table.length取模。安全

當兩個key經過hashCode計算相同時,則發生了hash衝突(碰撞),HashMap解決hash衝突的方式是用鏈表。數據結構

當發生hash衝突時,則將存放在數組中的Entry設置爲新值的next(這裏要注意的是,好比A和B都hash後都映射到下標i中,以前已經有A了,當map.put(B)時,將B放到下標i中,A則爲B的next,因此新值存放在數組中,舊值在新值的鏈表上)多線程

示意圖:併發

因此當hash衝突不少時,HashMap退化成鏈表。app

總結一下map.put後的過程:

當向 HashMap 中 put 一對鍵值時,它會根據 key的 hashCode 值計算出一個位置, 該位置就是此對象準備往數組中存放的位置。 

若是該位置沒有對象存在,就將此對象直接放進數組當中;若是該位置已經有對象存在了,則順着此存在的對象的鏈開始尋找(爲了判斷是不是否值相同,map不容許<key,value>鍵值對重複), 若是此鏈上有對象的話,再去使用 equals方法進行比較,若是對此鏈上的每一個對象的 equals 方法比較都爲 false,則將該對象放到數組當中,而後將數組中該位置之前存在的那個對象連接到此對象的後面。 

值得注意的是,當key爲null時,都放到table[0]中

private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

當size大於threshold時,會發生擴容。 threshold等於capacity*load factor

 

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

jdk7中resize,只有當 size>=threshold而且 table中的那個槽中已經有Entry時,纔會發生resize。即有可能雖然size>=threshold,可是必須等到每一個槽都至少有一個Entry時,纔會擴容。還有注意每次resize都會擴大一倍容量

JDK8中的HashMap

一直到JDK7爲止,HashMap的結構都是這麼簡單,基於一個數組以及多個鏈表的實現,hash值衝突的時候,就將對應節點以鏈表的形式存儲。

這樣子的HashMap性能上就抱有必定疑問,若是說成百上千個節點在hash時發生碰撞,存儲一個鏈表中,那麼若是要查找其中一個節點,那就不可避免的花費O(N)的查找時間,這將是多麼大的性能損失。這個問題終於在JDK8中獲得瞭解決。再最壞的狀況下,鏈表查找的時間複雜度爲O(n),而紅黑樹一直是O(logn),這樣會提升HashMap的效率。

JDK7中HashMap採用的是位桶+鏈表的方式,即咱們常說的散列鏈表的方式,而JDK8中採用的是位桶+鏈表/紅黑樹(有關紅黑樹請查看紅黑樹)的方式,也是非線程安全的。當某個位桶的鏈表的長度達到某個閥值的時候,這個鏈表就將轉換成紅黑樹。

JDK8中,當同一個hash值的節點數不小於8時,將再也不以單鏈表的形式存儲了,會被調整成一顆紅黑樹(上圖中null節點沒畫)。這就是JDK7與JDK8中HashMap實現的最大區別。

接下來,咱們來看下JDK8中HashMap的源碼實現。

JDK中Entry的名字變成了Node,緣由是和紅黑樹的實現TreeNode相關聯。

transient Node<K,V>[] table;

當衝突節點數不小於8-1時,轉換成紅黑樹。

static final int TREEIFY_THRESHOLD = 8;

以put方法在JDK8中有了很大的改變

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
 }


final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab;
	Node<K,V> p; 
	int n, i;
	//若是當前map中無數據,執行resize方法。而且返回n
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
	 //若是要插入的鍵值對要存放的這個位置恰好沒有元素,那麼把他封裝成Node對象,放在這個位置上就完事了
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
	//不然的話,說明這上面有元素
        else {
            Node<K,V> e; K k;
	    //若是這個元素的key與要插入的同樣,那麼就替換一下,也完事。
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
	    //1.若是當前節點是TreeNode類型的數據,執行putTreeVal方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
		//仍是遍歷這條鏈子上的數據,跟jdk7沒什麼區別
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
			//2.完成了操做後多作了一件事情,判斷,而且可能執行treeifyBin方法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) //true || --
                    e.value = value;
		   //3.
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
	//判斷閾值,決定是否擴容
        if (++size > threshold)
            resize();
	    //4.
        afterNodeInsertion(evict);
        return null;
    }

treeifyBin()就是將鏈表轉換成紅黑樹。

以前的indefFor()方法消失 了,直接用(tab.length-1)&hash,因此看到這個,表明的就是數組的下角標。

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

 

HashMap線程不安全的體現

爲何說HashMap是線程不安全的呢?它在多線程環境下,會發生什麼狀況呢?

1. resize死循環

咱們都知道HashMap初始容量大小爲16,通常來講,當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,若是超過,須要增大Hash表的尺寸,可是這樣一來,整個Hash表裏的元素都須要被重算一遍。這叫rehash,這個成本至關的大。

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
}

大概看下transfer:

  1. 對索引數組中的元素遍歷
  2. 對鏈表上的每個節點遍歷:用 next 取得要轉移那個元素的下一個,將 e 轉移到新 Hash 表的頭部,使用頭插法插入節點。
  3. 循環2,直到鏈表節點所有轉移
  4. 循環1,直到全部索引數組所有轉移

通過這幾步,咱們會發現轉移的時候是逆序的。假如轉移前鏈表順序是1->2->3,那麼轉移後就會變成3->2->1。這時候就有點頭緒了,死鎖問題不就是由於1->2的同時2->1形成的嗎?因此,HashMap 的死鎖問題就出在這個transfer()函數上。

1.1 單線程 rehash 詳細演示

單線程狀況下,rehash 不會出現任何問題:

  • 假設hash算法就是最簡單的 key mod table.length(也就是數組的長度)。
  • 最上面的是old hash 表,其中的Hash表的 size = 2, 因此 key = 3, 7, 5,在 mod 2之後碰撞發生在 table[1]
  • 接下來的三個步驟是 Hash表 resize 到4,並將全部的 <key,value> 從新rehash到新 Hash 表的過程

如圖所示:

1.2 多線程 rehash 詳細演示

爲了思路更清晰,咱們只將關鍵代碼展現出來

while(null != e) {
    Entry<K,V> next = e.next;
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}
  1. Entry<K,V> next = e.next;——由於是單鏈表,若是要轉移頭指針,必定要保存下一個結點,否則轉移後鏈表就丟了
  2. e.next = newTable[i];——e 要插入到鏈表的頭部,因此要先用 e.next 指向新的 Hash 表第一個元素(爲何不加到新鏈表最後?由於複雜度是 O(N))
  3. newTable[i] = e;——如今新 Hash 表的頭指針仍然指向 e 沒轉移前的第一個元素,因此須要將新 Hash 表的頭指針指向 e
  4. e = next——轉移 e 的下一個結點

假設這裏有兩個線程同時執行了put()操做,並進入了transfer()環節

while(null != e) {
    Entry<K,V> next = e.next; //線程1執行到這裏被調度掛起了
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}

那麼如今的狀態爲:

從上面的圖咱們能夠看到,由於線程1的 e 指向了 key(3),而 next 指向了 key(7),在線程2 rehash 後,就指向了線程2 rehash 後的鏈表。

而後線程1被喚醒了:

  1. 執行e.next = newTable[i],因而 key(3)的 next 指向了線程1的新 Hash 表,由於新 Hash 表爲空,因此e.next = null
  2. 執行newTable[i] = e,因此線程1的新 Hash 表第一個元素指向了線程2新 Hash 表的 key(3)。好了,e 處理完畢。
  3. 執行e = next,將 e 指向 next,因此新的 e 是 key(7)

而後該執行 key(3)的 next 節點 key(7)了:

  1. 如今的 e 節點是 key(7),首先執行Entry<K,V> next = e.next,那麼 next 就是 key(3)了
  2. 執行e.next = newTable[i],因而key(7) 的 next 就成了 key(3)
  3. 執行newTable[i] = e,那麼線程1的新 Hash 表第一個元素變成了 key(7)
  4. 執行e = next,將 e 指向 next,因此新的 e 是 key(3)

這時候的狀態圖爲:

而後又該執行 key(7)的 next 節點 key(3)了:

  1. 如今的 e 節點是 key(3),首先執行Entry<K,V> next = e.next,那麼 next 就是 null
  2. 執行e.next = newTable[i],因而key(3) 的 next 就成了 key(7)
  3. 執行newTable[i] = e,那麼線程1的新 Hash 表第一個元素變成了 key(3)
  4. 執行e = next,將 e 指向 next,因此新的 e 是 key(7)

這時候的狀態如圖所示:

很明顯,環形鏈表出現了!!固然,如今尚未事情,由於下一個節點是 null,因此transfer()就完成了,等put()的其他過程搞定後,HashMap 的底層實現就是線程1的新 Hash 表了。

2. fail-fast

若是在使用迭代器的過程當中有其餘線程修改了map,那麼將拋出ConcurrentModificationException,這就是所謂fail-fast策略。

這個異常意在提醒開發者及早意識到線程安全問題,具體緣由請查看ConcurrentModificationException的緣由以及解決措施

 

順便再記錄一個HashMap的問題:

爲何String, Interger這樣的wrapper類適合做爲鍵? String, Interger這樣的wrapper類做爲HashMap的鍵是再適合不過了,並且String最爲經常使用。由於String是不可變的,也是final的,並且已經重寫了equals()和hashCode()方法了。其餘的wrapper類也有這個特色。不可變性是必要的,由於爲了要計算hashCode(),就要防止鍵值改變,若是鍵值在放入時和獲取時返回不一樣的hashcode的話,那麼就不能從HashMap中找到你想要的對象。不可變性還有其餘的優勢如線程安全。若是你能夠僅僅經過將某個field聲明成final就能保證hashCode是不變的,那麼請這麼作吧。由於獲取對象的時候要用到equals()和hashCode()方法,那麼鍵對象正確的重寫這兩個方法是很是重要的。若是兩個不相等的對象返回不一樣的hashcode的話,那麼碰撞的概率就會小些,這樣就能提升HashMap的性能。

 

Java HashMap的死循環

在淘寶內網裏看到同事發了貼說了一個 CPU 被 100% 的線上故障,而且這個事發生了不少次,緣由是在 Java 語言在併發狀況下使用 HashMap 形成 Race Condition,從而致使死循環。這個事情我四、5 年前也經歷過,原本以爲沒什麼好寫的,由於 Java 的 HashMap 是非線程安全的,因此在併發下必然出現問題。可是,我發現近幾年,不少人都經歷過這個事(在網上查「HashMap Infinite Loop」能夠看到不少人都在說這個事)因此,以爲這個是個廣泛問題,須要寫篇疫苗文章說一下這個事,而且給你們看看一個完美的「Race Condition」是怎麼造成的。

問題的症狀

從前咱們的 Java 代碼由於一些緣由使用了 HashMap 這個東西,可是當時的程序是單線程的,一切都沒有問題。後來,咱們的程序性能有問題,因此須要變成多線程的,因而,變成多線程後到了線上,發現程序常常佔 了 100% 的 CPU,查看堆棧,你會發現程序都 Hang 在了 HashMap.get ()這個方法上了,重啓程序後問題消失。可是過段時間又會來。並且,這個問題在測試環境裏可能很難重現。

咱們簡單的看一下咱們本身的代碼,咱們就知道 HashMap 被多個線程操做。而 Java 的文檔說 HashMap 是非線程安全的,應該用 ConcurrentHashMap。

可是在這裏咱們能夠來研究一下緣由。

Hash 表數據結構

我須要簡單地說一下 HashMap 這個經典的數據結構。

HashMap 一般會用一個指針數組(假設爲 table[])來作分散全部的 key,當一個 key 被加入時,會經過 Hash 算法經過 key 算出這個數組的下標i,而後就把這個插到 table[i]中,若是有兩個不一樣的 key 被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在 table[i]上造成一個鏈表。

咱們知道,若是 table[]的尺寸很小,好比只有 2 個,若是要放進 10 個 keys 的話,那麼碰撞很是頻繁,因而一個O(1) 的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是 Hash 表的缺陷(可參看《Hash Collision DoS 問題》)。

因此,Hash 表的尺寸和容量很是的重要。通常來講,Hash 表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的 thredhold,若是超過,須要增大 Hash 表的尺寸,可是這樣一來,整個 Hash 表裏的無素都須要被重算一遍。這叫 rehash,這個成本至關的大。

相信你們對這個基礎知識已經很熟悉了。

HashMap 的 rehash 源代碼

下面,咱們來看一下 Java 的 HashMap 的源代碼。

Put 一個 Key,Value 對到 Hash 表中:

public V put (K key, V value)
{
    ......
    //算 Hash 值 int hash = hash (key.hashCode ());
    int i = indexFor (hash, table.length);
    //若是該 key 已被插入,則替換掉舊的 value (連接操做) for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals (k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess (this);
            return oldValue;
        }
    }
    modCount++;
    //該 key 不存在,須要增長一個結點     addEntry (hash, key, value, i);
    return null;
}

檢查容量是否超標

void addEntry (int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看當前的 size 是否超過了咱們設定的閾值 threshold,若是超過,須要 resize if (size++ >= threshold)
        resize (2 * table.length);
}

新建一個更大尺寸的 hash 表,而後把數據從老的 Hash 表中遷移到新的 Hash 表中。

void resize (int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //建立一個新的 Hash Table Entry[] newTable = new Entry[newCapacity];
    //將 Old Hash Table 上的數據遷移到 New Hash Table 上     transfer (newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

遷移的源代碼,注意高亮處:

void transfer (Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面這段代碼的意思是:
    //  從 OldTable 裏摘一個元素出來,而後放到 NewTable 中 for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor (e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

 好了,這個代碼算是比較正常的。並且沒有什麼問題。

正常的 ReHash 的過程

畫了個圖作了個演示。

  • 我假設了咱們的 hash 算法就是簡單的用 key mod 一下表的大小(也就是數組的長度)。
  • 最上面的是 old hash 表,其中的 Hash 表的 size=2, 因此 key = 3, 7, 5,在 mod 2 之後都衝突在 table[1]這裏了。
  • 接下來的三個步驟是 Hash 表 resize 成4,而後全部的 從新 rehash 的過程

併發下的 Rehash

1)假設咱們有兩個線程。我用紅色和淺藍色標註了一下。

咱們再回頭看一下咱們的 transfer 代碼中的這個細節:

do {
    Entry<K,V> next = e.next; // <--假設線程一執行到這裏就被調度掛起了 int i = indexFor (e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

而咱們的線程二執行完成了。因而咱們有下面的這個樣子。

注意,由於 Thread1 的 e 指向了 key (3),而 next 指向了 key (7),其在線程二 rehash 後,指向了線程二重組後的鏈表。咱們能夠看到鏈表的順序被反轉後。

 2)線程一被調度回來執行。

  • 先是執行 newTalbe[i] = e;
  • 而後是 e = next,致使了e指向了 key (7),
  • 而下一次循環的 next = e.next 致使了 next 指向了 key (3)

3)一切安好。

線程一接着工做。把 key (7) 摘下來,放到 newTable[i]的第一個,而後把e和 next 往下移

4)環形連接出現。

e.next = newTable[i] 致使  key (3) .next 指向了 key (7)

注意:此時的 key (7) .next 已經指向了 key (3), 環形鏈表就這樣出現了。

 因而,當咱們的線程一調用到,HashTable.get (11) 時,悲劇就出現了——Infinite Loop。

 

其它

有人把這個問題報給了 Sun,不過 Sun 不認爲這個是一個問題。由於 HashMap 原本就不支持併發。要併發就用 ConcurrentHashmap

 http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457

我在這裏把這個事情記錄下來,只是爲了讓你們瞭解並體會一下併發環境下的危險。

 

原文:

JDK7與JDK8中HashMap的實現

談談HashMap線程不安全的體現

Java HashMap的死循環

相關文章
相關標籤/搜索