HashMap從認識到源碼分析

image

Map

Map在開發過程當中使用頻率很高的數據結構,Map是Key-value鍵值對映射的抽象接口,該映射不包括重複的鍵,既一個鍵對應一個值。HashMapHashTableConcurrentHashMap都是Java Collection Framework的重要成員。Map接口提供三種collection視圖,容許以鍵集(keySet())、值集(values())或鍵-值映射關係集(entrySet())的形式查看某個映射的內容。 node

HasH表

咱們知道數組的儲存方式是在內存上分配固定的連續的空間,尋址速度快(查詢速度快),時間複雜度爲O(1),可是在插入、刪除元素時候須要移動數組的元素,因此插入、刪除時候速度慢,時間複雜度爲O(n)。鏈表的存儲方式在內存上是不連續的,每一個元素都保存着下個元素的內存地址,經過這個地址找到下個元素,因此鏈表在查詢的時候速度慢,時間複雜度爲O(n),在插入和刪除的時候速度快,時間複雜度爲O(1)
若是咱們想要一個數據結構既查詢速度快,插入和刪除速度也要快,那咱們應該怎麼作呢?這時哈希(Hash)表就應時而生了,經過哈希函數計算出在哈希表中指定的儲存位置(注意這裏的儲存位置是在表中的位置,並非內存的地址),稱爲哈希地址,而後將值儲存在這個哈希地址上,而後經過就能夠直接操做到,查詢、插入、刪除等操做時間複雜度都是O(1)
既然是鍵經過哈希函數計算出儲存位置,那麼哈希函數的好壞直接影響到哈希表的操做效率,如會出現浪費儲存空間、出現大量衝突(即不一樣的鍵計算出來的儲存位置同樣)。數組

哈希函數能夠將任意長度的輸入映射成固定長度的輸出,也就是哈希地址 哈希衝突是不可避免的,經常使用的哈希衝突解決辦法有如下2種方法。安全

  1. 鏈地址法(拉鍊法)
    採用數組和鏈表結合的方法,對哈希表中每一個哈希地址創建一個線性表,將哈希地址相同的數據儲存在線性表中,並將鏈表的頭指針保存在數組中,哈希地址、鍵、值等信息通常保存在鏈表節點中。通常經過哈希地址計算出數組的下標,將哈希值相同的保存在下標相同的數組中的。拉鍊法適合常常進行插入、刪除操做的狀況。
  2. 開放定址法
    開放定址法也稱線性探測法,基本思想是:將哈希表T[0...m-1]當作是個循環向量,若初始探測地址爲d,則最長的探測路徑爲:d,d+i,d+2i,...,m-1。即探測時候從地址d開始,首先探測T[d],若是T[d]發生哈希衝突則繼續探測下一個T[d+1]...直到探測到T[m-1]爲止,i爲自定義的常數。開放定址法很容易產生堆聚現象,所謂堆聚現象就是哈希表中的數據連成一片,在加入新元素的時候就容易產生哈希衝突。
  3. 拉鍊法和開放定址比較
    拉鍊法:處理衝突簡單,無堆聚現象,同時鏈表插入、刪除操做簡單,因此拉鍊法適合常常進行插入、刪除操做的狀況。
    開放定址法:爲了減小衝突,要求**負載因子(裝填因子)**較小,當節點規模較大時候會浪費不少空間。且開放定址法在刪除節點的時候,不能簡單的將節點所在的空間置爲空,不然將截斷在它以後的節點的查找路徑,這是由於各類開放定址法中,空地址單元都是查找失敗的條件。所以在進行刪除節點操做的時候,須要使用邏輯刪除,即在被刪除的節點上作刪除標記。

負載因子 = 填入哈希表中的元素個數 / 哈希表的數組長度bash

HashMap

數據結構

HashMap採用上述的拉鍊法解決哈希衝突.HashMap是非線程安全的,容許鍵、值爲null,不保證有序(好比插入的順序),也不保證順序不隨時間變化(哈希表加倍擴容後,數據會有遷移)。
咱們建立個HashMap運行看看數據結構

HashMap<String, Integer> map = new HashMap();
map.put("語文", 1);
map.put("數學", 2);
map.put("英語", 3);
map.put("歷史", 4);
map.put("政治", 5);
map.put("地理", 6);
map.put("生物", 7);
map.put("化學", 8);
複製代碼

經過圖能夠看到HashMap並非按照插入順序存儲的(無序的)。
接下來咱們看看HashMap的 數據結構
HashMap有幾個重要的成員變量, tablesizethresholdloadFactormodCount

  • table:是一個Entry[]數組類型,而Entry其實是一個單向鏈表,哈希表的鍵值對都是儲存在Entry數組中,每一個Entry對應一個哈希地址,這裏的Entry即常說的桶
  • size:是HashMap的大小,爲保存的鍵值對的數量
  • DEFAULT_INITIAL_CAPACITY:HashMap默認容量(數組的大小) 默認爲16
  • MAXIMUM_CAPACITY:HashMap的最大容量(2的30),若是傳入的容量大於這個值,則被最大容量替換
  • threshold:是HashMap的閾值,用於判斷是否須要調整HashMap的容量。threshold=容量*負載因子,當HashMap中儲存的鍵值對數量到達threshold時,HashMap就會將容量加倍的擴容
  • loadFactor:即負載因子
  • modCount:用來實現快速失敗(fail-fast)機制

快速失敗機制:對於線程不安全(注意是線程不安全的集合纔有這個機制)的集合對象的迭代器,若是在使用迭代器的過程當中有其餘的線程修改了集合對象的結構或者元素數量,那麼迭代馬上結束,迭代器將拋出ConcurrentModificationExceptionapp

構造函數

HashMap有4個構造函數,以下:函數

//無參構造函數,負載因子爲默認的0.75,HashMap的容量(數組大小)默認容量爲16
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//指定HashMap容量大小的構造函數 負載因子爲默認的0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//指定HashMap容量大小和負載因子的構造函數
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);
}

//包含子Map的構造函數,負載因子爲默認的0.75
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
複製代碼

爲何負載因子默認是0.75?按照官方給出的解釋是,當負載因子爲0.75時候,Entry單鏈表的長度幾乎不可能超過8(到達8的機率是0.00000006),做用就是讓Entry單鏈表的長度儘可能小,讓HashMap的查詢效率儘量高。ui

因爲當HashMap的大小(即size)大於初始容量(capacity)時候,HashMap就會擴大一倍,因爲不少時候並不須要擴大這麼多,因此當咱們知道咱們的數據的大小的時候,就能夠在HashMap初始化的時候指定容量(數組大小)。
須要注意的是,咱們指定的容量必須是2的冪次方,即便咱們傳入的容量不是2的冪次方,源碼中也會將容量轉成2的冪次方,好比咱們傳入的是5,最終的容量是8。this

爲何容量必定要是2的冪次方?由於HashMap是數組+單鏈表的結構,咱們但願元素的存放的更均勻,最理想的狀態是每一個Entry中只存放一個元素,這樣在查詢的時候效率最高。那怎麼才能均勻的存放呢?咱們首先想到的是取模運算 哈希地址%容量大小,SUN的大師們的想法和咱們的也同樣,只不過他們使用位運算來實現這個運算(位運算效率高),爲了使位運算和取模運算結果同樣,即hash & (capacity - 1) == hash % capacity,容量(Capacity)的大小就必須爲2的冪次方。spa

put方法

在JDK1.8以前hashMap的插入是在鏈表的頭部插入的,本文分析的是JDK1.8源碼,是在鏈表的尾部插入的。

  1. 根據鍵(key)的hashCode()計算出當前鍵值對的哈希地址,用於定位鍵值對在HashMap數組中存儲的下標
  2. 判斷table是否初始化,沒有初始化則調用resize()table初始化容量,以及threshold的值
  3. 根據**table數組長度和哈希地址作&運算(i = (n - 1) & hash)**計算出該key對應的table數組索引,若是對應的數組索引位置沒有值,則調用newNode(hash, key, value, null)方法,爲該鍵值對建立節點。

這裏思考個問題,當table數組長度變化後,是否是取到的值就不正確了?後面給出分析。這裏簡單分析下爲何不是直接按照哈希地址作數組下標,而是用table數組長度和哈希地址作&運算(i = (n - 1) & hash)(由於數組的大小是2的冪次方,因此這個運算等效於mod 數組大小的運算)計算數組下標,由於哈希地址可能超過數組大小,還有就是爲了讓鍵值對更均勻的分佈的在各個桶(鏈表)中,也由於容量會變因此各個桶(鏈表)中的節點的哈希地址並非相同的,相同的哈希地址也可能分到不一樣的下標。

  1. 若是根據哈希地址計算出該key對應的table數組索引有節點,且節點的鍵key和傳入的鍵key相等,哈希地址和傳入的哈希地址也相等,則將對應的節點引用賦值給e
  2. 若是根據哈希地址計算出該key對應的table數組索引有節點,且節點的哈希地址和傳入的哈希地址同樣,可是節點的鍵key和傳入的鍵key不相等,則遍歷鏈表,若是遍歷過程當中找到節點的鍵key和傳入的鍵key相等,哈希地址和傳入的哈希地址也相等,則將對應的value值更新。不然調用newNode(hash, key, value, null)方法,爲該鍵值對建立節點添加到鏈表尾部,若是追加節點後的鏈表長度 >= 8,則轉爲紅黑樹
  3. 若是e不爲空,且onlyIfAbsenttrue則不會覆蓋相同key和相同哈希地址的value
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

//若是參數onlyIfAbsent是true,那麼不會覆蓋相同key的值value。若是evict是false。那麼表示是在初始化時調用的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //tab存放 當前的哈希桶, p用做臨時鏈表節點  
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //若是當前哈希表是空的,表明是初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        //那麼直接去擴容哈希表,而且將擴容後的哈希桶長度賦值給n
        n = (tab = resize()).length;
    //若是當前index的節點是空的,表示沒有發生哈希碰撞。 直接構建一個新節點Node,掛載在index處便可。
    //這裏再囉嗦一下,數組下標index 是利用 哈希地址 & 哈希桶的長度-1,替代模運算
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {//不然 發生了哈希衝突。
        //e
        Node<K,V> e; K k;
        //若是哈希值相等,key也相等,則是覆蓋value操做
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;//將當前節點引用賦值給e
        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);
                    //若是追加節點後,鏈表數量》=8,則轉化爲紅黑樹
                    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;
            }
        }
        //若是e不是null,說明有須要覆蓋的節點,
        if (e != null) { // existing mapping for key
            //則覆蓋節點值,並返回原oldValue
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //這是一個空實現的函數,用做LinkedHashMap重寫使用。
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //若是執行到了這裏,說明插入了一個新的節點,因此會修改modCount,以及返回null。

    //修改modCount
    ++modCount;
    //更新size,並判斷是否須要擴容。
    if (++size > threshold)
        resize();
    //這是一個空實現的函數,用做LinkedHashMap重寫使用。
    afterNodeInsertion(evict);
    return null;
}
複製代碼

hashCode()是Object類的一個方法,hashCode()方法返回對象的hash code,這個方法是爲了更好的支持hash表,好比Set、HashTable、HashMap等。hashCode()的做用:若是用equals去比較的話,若是存在1000個元素,你new一個新的元素出來,須要去調用1000次equals去逐個和它們比較是不是同一個對象,這樣會大大下降效率。ashcode其實是返回對象的存儲地址,若是這個位置上沒有元素,就把元素直接存儲在上面,若是這個位置上已經存在元素,這個時候纔去調用equal方法與新元素進行比較,相同的話就不存了,散列到其餘地址上。

get方法

  1. table不爲空,且table的長度大於0,且根據鍵keyhashCode()計算出哈希地址,再根據桶的數量-1和哈希地址作&運算計算出數組的下標,該下標下不爲空(即存有鏈表頭指針)則繼續往下進行,不然返回null
  2. 若是和第一個節點的哈希地址、鍵key都相同,則返回第一個節點。
  3. 若是第一個節點的下個節點不爲空,則繼續,若是第一個節點爲樹的節點,則執行getTreeNode(hash, key),在樹中尋找節點,而且返回。不然遍歷鏈表,找到鍵key、哈希地址同樣的則返回此節點。
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // 若是索引到的第一個Node,key 和 hash值都和傳遞進來的參數相等,則返回該Node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) { //若是索引到的第一個Node 不符合要求,循環變量它的下一個節點。
            if (first instanceof TreeNode) // 在樹中get
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {// 在鏈表中get
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
複製代碼

remove方法

  1. table不爲空,且table的長度大於0,且根據鍵keyhashCode()計算出哈希地址,再根據哈希地址計算出數組的下標,該下標下不爲空(即存有鏈表頭指針)則繼續往下進行,不然執行6`。
  2. 若是哈希地址、鍵key同樣,則將對應的節點引用賦值給node,而後執行4。不然執行3。
  3. 若是爲樹,則執行getTreeNode(hash, key)在樹中尋找節點而且返回,不然遍歷鏈表,找到鍵key、哈希地址同樣的節點而後將對應的節點引用賦值給node,而後執行4,不然執行6。
  4. 若是節點node不爲空(即查詢到鍵key對應的節點),且當matchValuefalse的時候或者value也相等的時候,則執行5,不然執行6。
  5. 若是節點爲樹,則調用removeTreeNode(this, tab, movable)移除相應的節點。不然在鏈表中移除相應的節點,
  6. 返回null
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    // p 是待刪除節點的前置節點
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //若是哈希表不爲空,則根據hash值算出的index下 有節點的話。
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //node是待刪除節點
        Node<K,V> node = null, e; K k; V v;
        //若是鏈表頭的就是須要刪除的節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;//將待刪除節點引用賦給node
        else if ((e = p.next) != null) {//不然循環遍歷 找到待刪除節點,賦值給node
            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 = e;
                } while ((e = e.next) != null);
            }
        }
        //若是有待刪除節點node,  且 matchValue爲false,或者值也相等
        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 ==  p,說明是鏈表頭是待刪除節點
                tab[index] = node.next;
            else//不然待刪除節點在表中間
                p.next = node.next;
            ++modCount;//修改modCount
            --size;//修改size
            afterNodeRemoval(node);//LinkedHashMap回調函數
            return node;
        }
    }
    return null;
}
複製代碼

containsKey方法

若是存在指定的鍵key,返回true,不然返回false。
containsKey方法調用的get調用的方法同樣的方法,參考get方法的解析。

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}
複製代碼

哈希表的初始化和加倍擴容resize方法

分析resize方法,咱們就能夠知道爲何哈希表的容量變化後,仍然能取到正確的值

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //若是哈希表是空的 則將舊容量置爲0,不然置爲舊哈希表的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //舊的哈希表的閾值
        int oldThr = threshold;
        //新的哈希表的容量和閾值 都置爲0
        int newCap, newThr = 0;
        //若是舊的容量大於0 即不是第一次初始化 是擴容操做
        if (oldCap > 0) {
            //舊的容量是否大於2的30次冪方(容量的最大值)
            if (oldCap >= MAXIMUM_CAPACITY) {
                //閾值設置爲Integer的最大值
                threshold = Integer.MAX_VALUE;
                //返回舊的哈希表(舊的哈希表已經到最大的容量了,不能繼續擴容 因此返回)
                return oldTab;
            }
            //新的哈希表容量的=舊的容量<<1,即新的容量=舊的2倍,若是新的容量小於2的30次冪方(容量的最大值) 且 舊的容量大於等於默認的容量(16)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //新的哈希表的閾值=舊的哈希表的閾值<<1,既即新的閾值=舊的2倍 擴容table
                newThr = oldThr << 1; // double threshold
        }
        //第一次初始化,若是舊的閾值>0
        即HashMap是以傳入容量大小或者傳入容量大小、負載因子的構造函數進行初始化的,閾值thr
        eshlod已經在構造函數初始化過了,因此閾值在這裏大於0
        else if (oldThr > 0) // initial capacity was placed in threshold
            //新的容量=舊的閾值
            newCap = oldThr;
        //若是是以無參構造函數進行初始化的,則
        新的容量大小=默認的容量大小,新的閾值=默認的負載因子*默認的容量大小
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //新的閾值=0,即執行的是上面的else if (oldThr >
        0)(使用帶參數的構造函數初始化),是使用帶參數的構造函數進行的初始化,而且計算出新的
        閾值
        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];
        table = newTab;
        //重點看這部分,若是舊的哈希表不爲空
        if (oldTab != null) {
            //遍歷舊的哈希表,
            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 { // preserve order
                        //將舊的哈希表的節點所有從新定位,好比舊的哈希表容量是16,有一個
                        值a放在數組下標爲0上,如今新的哈希表容量是32,從新定位後值a就被重
                        新定位到下標爲32上,即新的哈希表的下標爲32儲存值a,簡單來講就是新
                        的下標=舊的哈希表的下標+新的哈希表的容量,正是由於這個節點的遷移,
                       因此咱們在hashMapputget操做的時候,在哈希表容量變化後仍讓取到正確
                       的值,可是也由於這個遷移操做,會消耗不少資源,因此儘可能在建立HashMa
                       p的時候就估計哈希表的容量,儘可能不要讓他加倍擴容。這裏的遷移也都是
                       運用的位運算,因此在初始化的時候,桶的數量必須是2冪次方,才能保證
                       位運算和取模運算結果同樣。
                        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;
                        }
                    }
                }
            }
        }
        return newTab;
    }
複製代碼

咱們能夠運行個例子,調試看看。

HashMap<String, Integer> map = new HashMap();
for (int i = 1; i <= 24; i ++) {
    map.put(String.valueOf(i), i);
}
for (int i = 25; i <= 80; i ++) {
    map.put(String.valueOf(i), i);
}
複製代碼

咱們以無參構造函數(即哈希表容量默認是16,負載因子默認是0.75)new一個HashMap,而後調試看看

運行第一個 for循環,看到 11保存的下標爲0, 12保存的下標是1
在繼續運行第二個 for,發現下標爲0的變成了44,下標爲1的變成了45
那咱們的11和12保存在哪了?能夠發現11和12到了下標爲3二、33上,即當執行第二個 for的時候哈希表發生了擴容,而後節點都遷移了,新的下標=舊的下標+新的哈希表的容量

參考資料

Java HashMap工做原理及實現
Map 綜述(一):徹頭徹尾理解 HashMap

原文地址:https://ddnd.cn/2019/03/07/jdk1.8-hashmap/

相關文章
相關標籤/搜索