JDK8源碼分析之HashMap

一、什麼是HashMap

顧名思義,HashMap就是基於哈希表的Map實現。哈希表(散列)是一種數據結構,其主幹是數組,但存儲方式是利用哈希函數將元素映射到數組(哈希表)中的位置,通過下標一次定位。

根據哈希表的定義,當我們需要查找過添加某個元素時,通過哈希函數即可快速定位,理想情況下每一個元素都會通過哈希函數一映射唯一的地址,但這只是理想情況。

哈希衝突

事實上,由於數據的無限和地址的有限,無論怎麼設計哈希函數,都無法保證不同的元素可以映射到不同的地址。當兩個不同的元素通過哈希函數計算得到了相同的值,此時就發生了哈希衝突。HashMap解決哈希衝突採用了鏈地址法,將哈希值相同的元素構成一個鏈表,head放在哈希表中,當鏈表達到一定長度時又將鏈表轉換爲紅黑樹,也就是說HashMap採用的是數組+鏈表+紅黑樹的結構,如下圖所示。

有了以上基礎認知,我們以JDK8爲例,看看Java是如何實現HashMap的。

二、源碼分析

HashMap的源碼接近2000行,以下只選取核心的代碼進行分析。

//初始化容量,默認16,且必須是2的次冪(有原因)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//鏈表轉爲tree結構的閾值,當前索引位置添加的元素超過該值,則轉爲tree存儲
static final int TREEIFY_THRESHOLD = 8;
//樹轉爲鏈表的閾值
static final int UNTREEIFY_THRESHOLD = 6;
//tree的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
//節點數組
transient Node<K,V>[] table;
//map中所有節點個數
transient int size;
//map在結構上的修改次數,用於迭代過程中的fail-fast機制,保證線程安全問題
transient int modCount;

以上是HashMap類的重要參數及其默認值,其中加載因子表示的是table中元素的填滿程度,比如默認0.75,則會認爲當table的元素個數爲16*0.75 = 12時,map已滿,需要擴容。

//HashMap的節點類
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//這裏的hash是key的特殊的hash
        final K key;
        V value;
        Node<K,V> next;
        //節點的構造函數
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        //節點的Hash值
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        //節點相等的條件
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

以上是HashMap的數據節點的類,很容易理解,下面看看HashMap是如何存儲數據的。

//hash:key的hash值,onlyIfAbsent:默認fasle,即key相同時覆蓋原來的value,evict在hashmap無作用
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
//tab以數組的結構存儲節點,p爲tab上的某一點,n爲tab數組的長度,i爲數組索引
        Node<K,V>[] tab; Node<K,V> p; int n, i;
//當table爲null或者長度爲0時,需要通過resize將tab初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
//將key的hash和數組長度-1進行與操作,作爲該元素所在哈希表的下標,這麼做的原因後面會分析
        if ((p = tab[i = (n - 1) & hash]) == null) //如果爲空,則說明沒有數據,直接插入
            tab[i] = newNode(hash, key, value, null);
        else {//該點有數據,則分情況
            Node<K,V> e; K k;
            //該點的key和要插入的key相同時,即hash和值都相同,直接賦值給e,後續統一處理
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)//如果當前節點是樹節點,按樹的方式存儲
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//遍歷當前索引的鏈表,
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {//將新增的節點插入鏈表尾部
                        p.next = newNode(hash, key, value, null);
                     //如果插入後當前鏈表的個數超過閾值-1,即下一個插入將到達閾值,則轉爲樹結構
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;//如果沒到達閾值,則插完就退出遍歷
                    }//鏈表中某節點和新增節點的key相同,e已是當前節點,則直接退出,
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;//繼續鏈表的下一節點
                }
            }
            //經過上面幾種情況,當e不爲null時,則說明存在相同的key,需要以下處理
            if (e != null) { // existing mapping for key
                V oldValue = e.value;//保存舊value
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;//改爲新value
                afterNodeAccess(e);//空函數,linkedhashmap後續操作
                return oldValue;//返回舊value
            }
        }
        //插入新key-value的後續處理,注意覆蓋舊元素時已直接return,不算插入新節點
        ++modCount;//增加修改次數,此變量是爲了線程安全問題,迭代時檢查此變量
        if (++size > threshold)//超過節點閾值需要擴容
            resize();
        afterNodeInsertion(evict);//linkedhashmap後續操作
        return null;//插入成功返回null
    }

以上是put的源碼,每一行做了簡要的註釋,下面解釋幾個本人理解上有難度的點。

  1. 計算元素在數組的下標
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

    在這之前先看一下計算可以key的hash的方法。
     

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

    根據源碼,下標計算有三步,先計算key的hashcode,再將其低16位與高16位進行異或操作,對數組長度取模運算。這樣做的原因有兩個。一是如果直接用int型的hashcode,那麼哈希表的索引範圍將在-2^{31}2^{31}-1共幾十億,遠遠大於可用內存,但僅僅用低16位進行取模運算會增加hash的衝突率,因此加入了高位特徵,將低16位與高16位進行異或(也稱擾動函數),既減小了空間,又能保證hash儘可能散列。最後爲什麼要和n-1與操作呢?因爲取餘(%)操作中如果除數是2的冪次則等價於與其除數減一的與(&)操作,且二進制運算效率比%高.在數組長度n爲2的冪次條件下,n-1的二進制就是當前位變爲0,低位全爲1,比如8(1000)->7(0111) ,而取餘就是爲計算低位的值,因此,hash(key)&(n-1) = hash(key) % n。所以n必須爲2的冪次,每次擴容乘2。

  2. 判斷當前節點是樹節點

    else if (p instanceof TreeNode)//如果當前節點是樹節點,按樹的方式存儲
         e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

    p是以Node類型聲明的,怎麼會是TreeNode的實例且強制轉換爲TreeNode了呢。原來在後面插入新節點後需要轉化爲樹結構時,將該鏈表上的所節點都轉爲了TreeNode類型,因此後面可以做到將父類Node強制轉化爲子類TreeNode。

  3. 如何將鏈表轉爲紅黑樹treeifBin以及resize擴容機制,後續會分析。

  4. 擴容:當插入新節點時,判斷當前size是否超過閾值,超過的話先擴容再插入

                   1.新建一個新的Node數組,容量爲原來的2倍。

                   2.將舊數組複製到新數組,由於擴容了一倍,所以n左移了一位,n-1也高了一位,而高的這一位剛好可以將原鏈表上的節點散列開,如果某節點hash值的高位爲1,則新的index = 原index+原n,若爲0則index = 原index。代替rehash操作

     5.併發引發的問題:

                     1.7:擴容時,尾插,環形

                      1.8:put時,A判斷了index沒有元素,準備插入,掛起,B判斷沒有元素後插入,掛起,A繼續,則直接插到index上,導致B的丟失數據。size++也不安全。

 

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