集合框架——HashMap實現原理詳解

 

目錄

一、HashMap實現原理

1、底層數據結構

2、put方法:

3、擴容resize()方法

4、刪除remove()方法:

二、關於HashMap的幾個問題

1、爲什麼HashMap不是線程安全的

2、HashMap爲什麼不直接使用hashCode()處理後的哈希值直接作爲table的下標?

3、HashMap在JDK1.7和JDK1.8中有哪些不同?

4、爲什麼HashMap中String、Integer這樣的包裝類適合作爲Key?我們能否使用任何類作爲Map的key?如果能需要注意哪些問題

5、在HashMap的查找(get)操作中,首先利用hash值定位bucket位置,然後在鏈表中遍歷,比較key是否相等確定節點。那麼爲什麼不兩次都用hash值進行比較呢?(認爲不同對象的hash值不同)


一、HashMap實現原理

1、底層數據結構

hashMap是map接口下一個重要的實現類,基於數組+鏈表+紅黑樹的數據結構(如下圖),通過hashCode來定位bucket(桶)的位置,可以大大提升查詢效率。

HashMap中幾個重要的參數如下:

initialCapacity:初始容量只是創建哈希表時的容量,容量是哈希表中能夠容納的節點(node)數。
loadFactor:負載因子是在容量自動增加之前允許哈希表得到滿足的度量。 
threshold:容量閾值,負載因數和當前容量的乘積,當在散列表中的條目的數量超過了threshold,哈希表需要進行擴容(resize),其中的元素需要被被重新散列到新的數據結構中。
size:當前節點(node)數

通過hashMap的put(插入)和get(查詢)兩個方法可以瞭解到hashMap的實現原理。由於插入(put)時需要先根據key查找是否已經存在當前key的節點,需要對map進行查詢,因此get的思路和put相似,不再贅述。

2、put方法:

當我們要插入一個節點,首先要計算key的hash值,根據這個hash值在數組中尋址,如果對應的bucket是空的(null),那麼構造node節點並放入對應的bucket,如果對應的bucket已經存在節點元素,也就是發生了hash衝突,那麼就需要逐個比較鏈表(或者紅黑樹)的節點,如果節點的key和查詢的key相等,那麼將value替換oldValue;當記錄的數量(size)超過了閾值(負載因數和初始容量的乘積),哈希表需要進行擴容(resize)。

具體代碼和註釋如下:

//onlyIfAbsent如果爲true,那麼只有插入不存在的key-value對,如果key已經對應了value則插入失敗
//evict如果爲真,則table處於creation模式
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0) 
            n = (tab = resize()).length;//table在構造函數中是沒有初始化的,而是在put中檢測到table==null則使用resize進行初始化
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
//如果tab數組中hash值對應的bucket是空的(null),則構造key-value節點放入
        else {//如果不爲空,則進行以下操作
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
//比較tab數組中hash值對應的bucket的key和查詢的key是否相同,如果相同則定位到該節點
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果該節點是紅黑樹的節點node,那麼使用樹的方法來定位對應節點
            else {//如果以上都不是,說明說鏈表的節點,或者不存在這樣的key,因此下面循環的在鏈表中查詢是否有對應的key
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);//如果沒有帶查詢的key,那麼將key-value構造成鏈表節點放入鏈表尾部
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);//如果查詢技術binCount超過8,說明新增節點導致鏈表超過8,應該轉換爲紅黑樹結構
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;//如果在鏈表中找到查詢key對應的節點,那麼此時的e已經定位到該節點,跳出循環
                    p = e;
                }
            }
//以上代碼已經完成節點的查找並定位到e(插入)節點,接下來就是用value替換oldValue
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);//該方法在HashMap中爲空,是用於linkedHashMap的
                return oldValue;
            }
        }
        ++modCount;//用於迭代器的fail-fast機制
        if (++size > threshold)
            resize();//檢測添加節點後是否超過閾值,如果是則需要擴容
        afterNodeInsertion(evict);
        return null;
    }

3、擴容resize()方法

每次擴容都是原數組長度的2倍

那麼爲什麼要2倍擴容呢?從resize()的實現中可以看出,因爲2倍擴容可以不再計算hash值對應的bucket位置,擴容後,原下標j處的鏈表被分爲兩個部分,一個部分仍在下標j的bucket中,另一部分在(j+oldCap)下標的bucket中,這樣可以大大的提高效率

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首先用一個數組來保存原map
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {//當原table不爲空
            if (oldCap >= MAXIMUM_CAPACITY) {
                //如果原table的長度超過MAXIMUM_CAPACITY,那麼無法擴容,返回原table
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //否則,table數組長度*2,map容量閾值*2
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //如果原始table爲空,說明map的table還未初始化,玉石對table進行初始化
        else if (oldThr > 0) 
        // 如果threshold大於0,則將容量初始化爲原threshold大小,threshold一定是2的
        //冪次方(通過tableSizeFor(initialCapacity)方法實現)
            newCap = oldThr;
        else {               // 如果threshold等於0,則使用默認值,並計算相應的threshold
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//構造一個newCap大小的新數組
        table = newTab;
        if (oldTab != null) {
        //如果原table不爲空,那麼逐個的將元素計算hash值,並放入新的table中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { 
                    // 注意:鏈表要保持原來的順序
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //由於是2倍擴容,因此原table下得鏈表會被分成兩部分,一部分仍在原bucket的位置j,
                            //另一部分在下標(j+oldCap)的bucket位置,因此可以先分別得到高低兩個鏈表,
                            //然後將兩個鏈表放入對應的bucket中
                            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);
                        //分別將兩個鏈表放入對應的bucket中
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

4、刪除remove()方法:

HashMap的remove方法很簡單,只要根據key找到對應的節點,然後通過鏈表的操作就可以實現節點刪除

//matchValue爲true表示只有key和value都匹配才刪除節點
//movable爲false表示刪除node節點時不移動其它節點
final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        //……一系列通過key來定位節點node的代碼,同get方法
        //下面是刪除定位到的節點node的代碼
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)//如果node就在tab數組中,也就是鏈表的開頭,那麼把該位置換上node.next即可
                    tab[index] = node.next;
                else//如果node在鏈表或紅黑樹中,那麼將p鏈接到node.next就完成節點刪除
                    p.next = node.next;
                ++modCount;
                --size;//節點數量減少
                afterNodeRemoval(node);//早HashMap中是空方法,用於LinkedHashMap的雙向鏈表的節點處理
                return node;
            }
        }
        return null;
    }

二、關於HashMap的幾個問題

1、爲什麼HashMap不是線程安全的

 有兩個操作時線程不安全的,分別是put和resize操作:

put操作:導致的多線程數據不一致

首先,從put方法不是同步方法,那麼我們假設有兩個線程A和B分別要插入d和e兩個節點,並且其hash值都定位到了0下標的bucket中,那麼他們獲取到c節點並要將他們自己鏈接到c節點後。

線程A首先計算d節點所要落到的桶的索引座標,然後獲取到該桶裏面的c節點,此時線程A的時間片用完了,而此時線程B被調度得以執行,和線程A一樣執行,只不過線程B成功將d節點插到了桶裏面,那麼當線程B成功插入之後,線程A再次被調度運行時,它依然持有過期的c節點但是它對此一無所知,以至於它認爲它應該這樣做,如此一來就覆蓋了線程B插入的記錄,這樣線程B插入的記錄就憑空消失了,造成了數據不一致的行爲。

 

resize操作:get操作可能因爲resize而引起死循環(cpu100%)

在JDK1.7中,resize()方法實現如下:可以看出JDK1.7中,由於擴容後的節點是從鏈表頭插入的,因此會導致節點順序顛倒

void resize(int newCapacity) {   //傳入新的容量
Entry[] oldTable = table;    //引用擴容前的Entry數組
int oldCapacity = oldTable.length;         
if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
    threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
    return;
}
Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
table = newTable;                           //HashMap的table屬性引用新的Entry數組
threshold = (int)(newCapacity * loadFactor);//修改閾值
}

void transfer(Entry[] newTable) {
Entry[] src = table;                   //src引用了舊的Entry數組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
     Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
     if (e != null) {
         src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
         do {
              Entry<K,V> next = e.next;
              int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
             e.next = newTable[i]; //標記[1]
             newTable[i] = e;      //將元素放在數組上
             e = next;             //訪問下一個Entry鏈上的元素
         } while (e != null);
     }
   }
}

可以看出tranfer()方法是擴容的關鍵,實現了節點元素的移動,如果我們的map中有3個節點[3,A],[7,B],[5,C],並且這三個entry都落到了第二個bucket裏面。

現在假設有兩個線程同時執行resize()方法:

  1. 線程thread1執行到了transfer方法的Entry next = e.next這一句,然後時間片用完了,此時的e = [3,A], next = [7,B]。

  2. 線程thread2被調度執行並且順利完成了resize操作,需要注意的是,此時的[7,B]的next爲[3,A]。

  3. 此時線程thread1重新被調度運行,此時的thread1持有的引用是已經被thread2 resize之後的結果。線程thread1首先將[3,A]遷移到新的數組上,因爲線程thread1中e=next的next是[7,B],也就是[7,B]被鏈接到了[3,A]的後面,而通過thread2的resize之後,[7,B]的next變爲了[3,A],此時,[3,A]和[7,B]形成了環形鏈表,在get的時候,如果get的key的桶索引和[3,A]和[7,B]一樣,那麼就會陷入死循環

那麼在JDK1.7中,可以看出由於是採用鏈表頭部插入的方式會導致循環鏈表,而JDK1.8中採用了尾部插入的方法,因此已經不存在resize()導致的線程不安全問題。

2、HashMap爲什麼不直接使用hashCode()處理後的哈希值直接作爲table的下標?

hashCode()方法返回的是int整數類型,其範圍爲-(2 ^ 31)~(2 ^ 31 - 1),約有40億個映射空間,而HashMap的容量範圍是在16(初始化默認值)~2 ^ 30,HashMap通常情況下是取不到最大值的,並且設備上也難以提供這麼多的存儲空間,從而導致通過hashCode()計算出的哈希值可能不在數組大小範圍內,進而無法匹配存儲位置;

HashMap中是怎麼解決的?

  • HashMap自己實現了自己的hash()方法,通過兩次擾動使得它自己的哈希值高低位自行進行異或運算,降低哈希碰撞概率也使得數據分佈更平均;

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 在保證數組長度爲2的冪次方的時候,使用hash()運算之後的值與運算(&)(數組長度 - 1)來獲取數組下標的方式進行存儲,這樣一來是比取餘操作更加有效率,二來也是因爲只有當數組長度爲2的冪次方時,h&(length-1)纔等價於h%length,三來解決了“哈希值與數組大小範圍不匹配”的問題;

3、HashMap在JDK1.7和JDK1.8中有哪些不同?

  • 數據結構

在JDK1.7中,HashMap還是基於數組+鏈表的實現。

在JDK1.8以後,HashMap的底層數據結構改成了數組+鏈表+紅黑樹的實現,在鏈表的節點大於8時,鏈表轉爲紅黑樹。

*由於紅黑樹是平衡多叉樹,因此查找開銷爲O(log n),但是要求作爲key的對象必須正確的實現了Compare接口,如果沒有實現Compare接口,或者實現得不正確(比方說所有Compare方法都返回0),那JDK1.8的HashMap其實還是慢於JDK1.7的

  • 節點類型

在JDK1.7中,節點是Entry類型。

在JDK1.8中,節點是Node類型。(便於紅黑樹的轉換)

  • 初始化方式

在JDK1.7中,單獨使用函數inflateTable()進行table數組的初始化

在JDK1.8中,最開始並不對table進行初始化,而是直接集成到了擴容函數resize()中,在put時如果table爲null,調用resize()進行初始化。

  • hash值計算方式

在JDK1.7中,擾動處理爲9次擾動 ,其中包括4次位運算 + 5次異或運算。

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

在JDK1.8中,擾動處理爲2次擾動 ,其中包括1次位運算 + 1次異或運算。

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

擾動的目的: 保證最終獲取的存儲位置儘量分佈均勻。

爲什麼JDK1.8中是2次擾動:兩次就夠了,已經達到了高位低位同時參與運算的目的;

  • 插入數據方式

在JDK1.7中,頭插法(先講原位置的數據移到後1位,再插入數據到該位置)

在JDK1.8中,尾插法(直接插入到鏈表尾部/紅黑樹)

  • 擴容方式

在JDK1.7中,resize的核心方法是transfer,其中使用的是頭插法,會導致循環鏈表,在get方法中會導致死循環。並且在在JDK1.7中,元素都需要根據取模算法重新計算在數組中的下標。

在JDK1.8以後,由於是2倍擴容,可以得到新的index要麼不變,要麼是舊index+oldCapacity,不用重新計算下標,效率大幅提升。並且是從鏈表(紅黑樹)頭部開始遍歷,並將節點分別順序放到高、低兩個鏈表中,然後將鏈表頭部鏈接到數組的相應bucket中,因此不再存在線程不安全的問題。

4、爲什麼HashMap中String、Integer這樣的包裝類適合作爲Key?我們能否使用任何類作爲Map的key?如果能需要注意哪些問題

String、Integer這樣的包裝類適合作爲Key的原因:

  • 都是final類型,即不可變性,保證key的不可更改性,不會存在獲取hash值不同的情況
  • 內部已重寫了equals()hashCode()等方法,遵守了HashMap內部的規範(不清楚可以去上面看看putValue的過程),不容易出現Hash值計算錯誤的情況;

用自己定義的類做key需要注意什麼?重寫hashCode()equals()方法

  • 重寫hashCode()是因爲需要計算存儲數據的存儲位置,需要注意不要試圖從散列碼計算中排除掉一個對象的關鍵部分來提高性能,這樣雖然能更快但可能會導致更多的Hash碰撞;
  • 重寫equals()方法,需要遵守自反性、對稱性、傳遞性、一致性以及對於任何非null的引用值x,x.equals(null)必須返回false的這幾個特性,目的是爲了保證key在哈希表中的唯一性

5、在HashMap的查找(get)操作中,首先利用hash值定位bucket位置,然後在鏈表中遍歷,比較key是否相等確定節點。那麼爲什麼不兩次都用hash值進行比較呢?(認爲不同對象的hash值不同)

其實這裏也可以用hashCode來進行第二次比較,只是看用戶對相等的定義是什麼,如果認爲key的hashCode相等時認爲key相等,那麼就可以用hash值做第二次比較

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章