容器--Map之HashMap源碼解析

本文參考:https://blog.csdn.net/m0_37914588/article/details/82287191

HashMap

HashMap是基於哈希表實現的,每個元素都是一個鍵值對(key-value)。

HashMap在JDK1.7和JDK1.8中變化比較大,在JDK1.7中,HashMap的底層是採用以個Entry數組來存儲數據,當發生hash衝突時,採用鏈地址法(即鏈表存儲相同hashcode的元素)來解決。而在JDK1.8的HashMap使用一個Node數組來存儲數據,但當存儲元素的鏈表超過閾值之後,會將鏈表轉換爲紅黑樹,查詢時間從O(n)轉換爲O(logn)。

接下來就讓我們進入HashMap的源碼吧(以下源碼爲1.8)!

首先我們來看看HashMap的屬性

//數組的初始化容量-數值必須時2的冪
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//最大容量,可以使用一個帶參數的構造函數來隱式的改變容量大小,但必須時2的冪且小於等於1<<30
static final int MAXIMUM_CAPACITY = 1 << 30;

//負載因子初始值
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//使用樹(而不是列表)來設置bin計數閾值。當向至少具有這麼多節點的bin添加元素時,bin將轉換爲樹。該值必須大於2,並且應該至少爲8,以便與刪除樹時關於轉換回普通桶的假設相匹配收縮。
static final int TREEIFY_THRESHOLD = 8;

//當桶(bucket)上的結點數小於該值是應當樹轉鏈表
static final int UNTREEIFY_THRESHOLD = 6;

//桶中結構轉化爲紅黑樹時對應的table的最小值
static final int MIN_TREEIFY_CAPACITY = 64;

//tables數組,在必要時會重新調整大小,但長度總是2的冪
transient Node<K,V>[] table;

//保存緩存的entrySet()。注意,使用了AbstractMap字段用於keySet()和values()。
transient Set<Map.Entry<K,V>> entrySet;

//在該map中映射的key-value對數量
transient int size;

//這個HashMap在結構上被修改的次數結構修改是指改變HashMap中映射的數量或修改其內部結構的次數(例如,rehash)。此字段用於使HashMap集合視圖上的迭代器快速失效。(見ConcurrentModificationException)。
transient int modCount;

//要下一次調整大小的臨界值(capacity * load factor)
int threshold;

//哈希表的加載因子
final float loadFactor;

//內部類Node,用於存放元素
static class Node<K,V> implements Map.Entry<K,V> {
        final int 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;
        }
}

很多人不知道這個負載因子的變量是幹什麼用的,負載因子表示一個散列表的使用程度。有這樣一個公式:initailCapacity*loadFactor=HashMap的容量。可以看出,當自定義容量不變的時候,負載因子越大,散列表在該容量下可以裝填的元素越多,但是由此會導致鏈表更長,索引的效率降低。如果負載因子過小,則會導致散列表中的元素很少,對空間造成浪費。

接下來是HashMap的構造函數。

構造函數
HashMap()

構造一個空的 HashMap ,默認初始容量(16)和默認負載係數(0.75)。

HashMap(int initialCapacity)

構造一個空的 HashMap具有指定的初始容量和默認負載因子(0.75)。

HashMap(int initialCapacity, float loadFactor)

構造一個空的 HashMap具有指定的初始容量和負載因子。

HashMap(Map<? extends K,? extends V> m)

構造一個新的 HashMap與指定的相同的映射 Map 。

源碼如下

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }
 
 
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
 
 
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

可以看出,HashMap提供了一個修改初始化容量和負載因子的構造方法,這樣我們可以根據不同的使用環境,來決定我們HashMap的負載因子應該爲多少。

接下來看看HashMap一些常用的方法。

返回類型 方法名(參數)
void clear()

從這張地圖中刪除所有的映射。

Object clone()

返回此 HashMap實例的淺拷貝:鍵和值本身不被克隆。

boolean containsKey(Object key)

如果此映射包含指定鍵的映射,則返回 true 。

void forEach(BiConsumer<? super K,? super V> action)

對此映射中的每個條目執行給定的操作,直到所有條目都被處理或操作引發異常。

V get(Object key)

返回到指定鍵所映射的值,或 null如果此映射包含該鍵的映射。

boolean isEmpty()

如果此地圖不包含鍵值映射,則返回 true 。

Set<K> keySet()

返回此地圖中包含的鍵的Set視圖。

V put(K key, V value)

將指定的值與此映射中的指定鍵相關聯。

void putAll(Map<? extends K,? extends V> m)

將指定地圖的所有映射覆制到此地圖。

V remove(Object key)

從該地圖中刪除指定鍵的映射(如果存在)。

boolean remove(Object key, Object value)

僅當指定的密鑰當前映射到指定的值時刪除該條目。

V replace(K key, V value)

只有當目標映射到某個值時,才能替換指定鍵的條目。

void replaceAll(BiFunction<? super K,? super V,? extends V> function)

將每個條目的值替換爲對該條目調用給定函數的結果,直到所有條目都被處理或該函數拋出異常。

int size()

返回此地圖中鍵值映射的數量。

 

我們通過put()和remove()兩個最常用的方法來了解一下HashMap的增刪過程。

在看這兩個方法的源碼之前,我們需要先來看看HashMap是如何計算哈希值的。

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

put()方法

    public V put(K key, V value) {
        //四個參數,第一個hash值,第四個參數表示如果該key存在值,如果爲null的話,則插入新的value,最後一個參數,在hashMap中沒有用,可以不用管,使用默認的即可*
        return putVal(hash(key), key, value, false, true);
    }
 
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //tab 哈希數組,p 該哈希桶的首節點,n hashMap的長度,i 計算出的數組下標
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //獲取長度並進行擴容,使用的是懶加載,table一開始是沒有加載的,等put後纔開始加載
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //如果計算出的該哈希桶的位置沒有值,則把新插入的key-value放到此處,此處就算沒有插入成功,也就是發生哈希衝突時也會把哈希桶的首節點賦予p
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //發生哈希衝突的幾種情況
        else {
            // e 臨時節點的作用, k 存放該當前節點的key 
            Node<K,V> e; K k;
            //第一種,插入的key-value的hash值,key都與當前節點的相等,e = p,則表示爲首節點
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //第二種,hash值不等於首節點,判斷該p是否屬於紅黑樹的節點
            else if (p instanceof TreeNode)
                //爲紅黑樹的節點,則在紅黑樹中進行添加,如果該節點已經存在,則返回該節點(不爲null),該值很重要,用來判斷put操作是否成功,如果添加成功返回null
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //第三種,hash值不等於首節點,不爲紅黑樹的節點,則爲鏈表的節點
            else {
                //遍歷該鏈表
                for (int binCount = 0; ; ++binCount) {
                    //如果找到尾部,則表明添加的key-value沒有重複,在尾部進行添加
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //判斷是否要轉換爲紅黑樹結構
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果鏈表中有重複的key,e則爲當前重複的節點,結束循環
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //有重複的key,則用待插入值進行覆蓋,返回舊值。
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //到了此步驟,則表明待插入的key-value是沒有key的重複,因爲插入成功e節點的值爲null
        //修改次數+1
        ++modCount;
        //實際長度+1,判斷是否大於臨界值,大於則擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        //添加成功
        return null;
    }

插入過程可以總結爲一下幾個步驟:

  1. 計算索引
  2. 檢查是否hash衝突,不發生則直接插入
  3. 發生hash衝突後,判斷插入方式,是鏈表插入還是紅黑樹插入
  4. 如果key重複,覆蓋值
  5. 計算容量,是否進行擴容

HashMap計算索引的方式爲:hash&(length-1)。

HashMap的擴容機制

    final Node<K,V>[] resize() {
        //把沒插入之前的哈希數組做oldTal
        Node<K,V>[] oldTab = table;
        //old的長度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //old的臨界值
        int oldThr = threshold;
        //初始化new的長度和臨界值
        int newCap, newThr = 0;
        //oldCap > 0也就是說不是首次初始化,因爲hashMap用的是懶加載
        if (oldCap > 0) {
            //大於最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
                //臨界值爲整數的最大值
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //標記##,其它情況,擴容兩倍,並且擴容後的長度要小於最大值,old長度也要大於16
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //臨界值也擴容爲old的臨界值2倍
                newThr = oldThr << 1; 
        }
        //如果oldCap<0,但是已經初始化了,像把元素刪除完之後的情況,那麼它的臨界值肯定還存在,如果是首次初始化,它的臨界值則爲0
        
        else if (oldThr > 0) 
            newCap = oldThr;
        //首次初始化,給與默認的值
        else {               
            newCap = DEFAULT_INITIAL_CAPACITY;
            //臨界值等於容量*加載因子
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //此處的if爲上面標記##的補充,也就是初始化時容量小於默認值16的,此時newThr沒有賦值
        if (newThr == 0) {
            //new的臨界值
            float ft = (float)newCap * loadFactor;
            //判斷是否new容量是否大於最大值,臨界值是否大於最大值
            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];
        //賦予當前的table
        table = newTab;
        //此處自然是把old中的元素,遍歷到new中
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                //臨時變量
                Node<K,V> e;
                //當前哈希桶的位置值不爲null,也就是數組下標處有值,因爲有值表示可能會發生衝突
                if ((e = oldTab[j]) != null) {
                    //把已經賦值之後的變量置位null,當然是爲了好回收,釋放內存
                    oldTab[j] = null;
                    //如果下標處的節點沒有下一個元素
                    if (e.next == null)
                        //把該變量的值存入newCap中,e.hash & (newCap - 1)並不等於j
                        newTab[e.hash & (newCap - 1)] = e;
                    //該節點爲紅黑樹結構,也就是存在哈希衝突,該哈希桶中有多個元素
                    else if (e instanceof TreeNode)
                        //把此樹進行轉移到newCap中
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { 
                        //此處表示爲鏈表結構,同樣把鏈表轉移到newCap中,就是把鏈表遍歷後,把值轉過去,在置位null
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        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) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        //返回擴容後的hashMap
        return newTab;
    }

可能源碼看起來很喫力,但是耐心看下去就會發現其實也沒有那麼難。在這麼長的代碼裏,我們其實需要關注的就是HashMap具體的擴容方式:newThr = oldThr << 1,也就是將原來的容量乘2。那麼爲什麼偏偏是乘2呢?

乘2可以保證擴容後的容量爲偶數,且方便於擴容後重新分桶的操作。上邊講過HashMap計算索引的方式是hash&(length-1)。如果length爲奇數,就會導致length-1轉換爲二進制後末位爲0,從而使得奇數索引沒有用到,浪費了一半的內存空間。

那麼它又是如何方便分桶的呢?假設我們桶的長度爲4,擴容後爲8。那麼我們進行重新分桶時,原先桶0的元素只會分配到新的桶0和桶4,原桶1只會分配到新的桶1和桶5,以此類推。減少了重新分桶的hash衝突,肯定方便重新分桶呀。

擴容的時機也是一個知識點。什麼時候進行擴容呢,HashMap中規定了一個閾值(閾值=容量*負載因子,初始閾值爲16*0.75=12),當HashMap中存儲的元素超過這個閾值時,HashMap就會採取一個擴容操作。

remove()

    public V remove(Object key) {
        //臨時變量
        Node<K,V> e;
        //調用removeNode(hash(key), key, null, false, true)進行刪除,第三個value爲null,表示,把key的節點直接都刪除了,不需要用到值,如果設爲值,則還需要去進行查找操作
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    
    //第一參數爲哈希值,第二個爲key,第三個value,第四個爲是爲true的話,則表示刪除它key對應的value,不刪除key,第四個如果爲false,則表示刪除後,不移動節點
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        //tab 哈希數組,p 數組下標的節點,n 長度,index 當前數組下標
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        //哈希數組不爲null,且長度大於0,然後獲得到要刪除key的節點所在是數組下標位置
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            //nodee 存儲要刪除的節點,e 臨時變量,k 當前節點的key,v 當前節點的value
            Node<K,V> node = null, e; K k; V v;
            //如果數組下標的節點正好是要刪除的節點,把值賦給臨時變量node
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            //也就是要刪除的節點,在鏈表或者紅黑樹上,先判斷是否爲紅黑樹的節點
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    //遍歷紅黑樹,找到該節點並返回
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else { //表示爲鏈表節點,一樣的遍歷找到該節點
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        /**注意,如果進入了鏈表中的遍歷,那麼此處的p不再是數組下標的節點,而是要刪除結點的上一個結點**/
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            //找到要刪除的節點後,判斷!matchValue,我們正常的remove刪除,!matchValue都爲true
            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)
                    tab[index] = node.next;
                else /**爲鏈表結構,刪除的節點在鏈表中,把要刪除的下一個結點設爲上一個結點的下一個節點**/
                    p.next = node.next;
                //修改計數器
                ++modCount;
                //長度減一
                --size;
                //此方法在hashMap中是爲了讓子類去實現,主要是對刪除結點後的鏈表關係進行處理
                afterNodeRemoval(node);
                //返回刪除的節點
                return node;
            }
        }
        //返回null則表示沒有該節點,刪除失敗
        return null;
    }

remove()的過程其實與其他容器的差不多,就是尋址、刪除。在這裏就不多講。

鏈表轉紅黑樹的時機

鏈表轉紅黑樹的時機由兩個變量決定TREEIFY_THRESHOLD(默認爲8)和MIN_TREEIFY_CAPACITY(默認爲64)當HashMap中元素的個數小於MIN_TREEIFY_CAPACITY時,HashMap優先採用擴容的方式,而不是採用樹化。當元素個數超過了6MIN_TREEIFY_CAPACITY的值後,我們通過TREEIFY_THRESHOLD來決定是否樹化,當一個鏈表的長度超過TREEIFY_THRESHOLD後,就會將鏈表轉化爲紅黑樹。

HashMap的特點

  1. HashMap的key不可重複,key和value允許爲null
  2. HashMap是非線程安全的,只能在單線程的環境下使用,多線程環境需要外部加鎖

 

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