1. 程式人生 > >超詳細的HashMap解析(jdk1.8)

超詳細的HashMap解析(jdk1.8)

有關 過程 註解 rem tcl 性能 log 詳細 內容

目錄

  • 一、預備知識
    • 時間復雜度
    • 基本數據結構
    • 基本位運算
  • 二、HashMap實現原理
    • 結構
    • 速度
  • 三、源碼分析
    • 基本常量
    • 基本成員變量
    • 構造方法
    • put方法
    • remove
  • 四、日常使用註意事項
  • 五、總結

本文首發於cdream的個人博客,點擊獲得更好的閱讀體驗!

歡迎轉載,轉載請註明出處。

本文是我在學習 java集合過程中,針對HashMap的一篇總結文章。由於博主是非科班出身程序員,在學習HashMap原理時遇到了很多困難,所以如果你和博主一樣,數據結構基礎也不紮實甚至是沒有基礎,這篇文章可能也非常適合你!

註:本文基於jdk1.8,不包括紅黑樹部分分析

以下是本文的結構,可根據需要跳轉到相應位置,不必按順序閱讀!

技術分享圖片

一、預備知識

時間復雜度

時間復雜度用來度量算法的運行時間,記作: T(n) = O(f(n))。它表示隨著輸入 n 的增大,算法執行需要的時間的增長速度可以用 f(n) 來描述。漸進時間復雜度用大寫O來表示,所以也被稱為大 O表示法。

常用的時間復雜度比較:

O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)

從左到右,算法執行效率逐漸下降,

了解更多:

  1. 十分鐘搞定時間復雜度(算法的時間復雜度)
  2. 一套圖 徹底明白了“時間復雜度”

基本數據結構

HashMap主幹是哈希表,這之前先了解一下其他幾種數據結構的性能。

數組

采用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間復雜度為 O(1);通過給定值進行查找(順序查找),需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為 O(n);對於有序數組,可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為 O(logn);對於指定位置的插入刪除操作,涉及到數組元素的移動,其平均復雜度為 O(n);另外修改和查找實現復雜度相同。

鏈表

不是按線性的順序存儲數據,而是在每一個節點裏存到下一個節點的指針(Pointer)。對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間復雜度為 O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為 O(n)。

數組與鏈表的根據指定值查詢時間復雜度都是O(1),但數組更快

1. 鏈表需要在遍歷時,需要比數組更多的尋址操作 2. CPU緩存會把一片連續的內存空間讀入,因為數組結構是連續的內存地址,所以數組全部或者部分元素被連續存在CPU緩存裏面,而鏈表則不會

紅黑樹

是一種自平衡二叉查找樹,可以在O(log n)時間內做查找,插入和刪除,jdk8之後HashMap桶內鏈表長度超過樹化閥值且總長度超過最小樹化容量後會將鏈表轉換為紅黑樹。

散列表

散列表(Hash table,也叫哈希表)是一種根據 key-value進行訪問的數據結構。在散列表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1)。哈希表是 HashMap主幹,所以在分析 HashMap前要先詳細了解一下哈希表。

散列表(Hash table),是根據鍵(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它通過計算一個關於鍵值的函數f(x),將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數f(x)稱做散列函數,存放記錄的數組稱做散列表。

散列函數(Hash function),經散列函數映象到地址集合中任何一個地址的概率是相等的,則稱此類散列函數為均勻散列函數(Uniform Hash function),散列函數的設計至關重要,好的散列函數會盡可能地保證 計算簡單散列地址分布均勻。散列函數構造方法包括直接定址法,隨機數法,除留余數法等。

沖突(Collision):對不同的Key可能得到同一散列地址,即k1≠ k2,而 f(k1)=f(k2),再好的散列函數也無法避免沖突。產生沖突就要進行處理,通常處理沖突的方法有開發定址法,單獨鏈表法,雙散列,再散列等。在java中使用的是單獨鏈表法

舉個例子:

假設有個數組長度為4數組(每一個位置都叫桶bucket),這裏有3個人趙四,錢五,孫六要裝進去,我們定義一個規則,按姓氏在百家姓中的順序除以四得到的余數作為索引放入四個位置。

技術分享圖片

當前存放三個人記錄的數組就是散列表,我們定義的規則就是散列函數,根據散列函數進行映射的過程叫做散列過程。

技術分享圖片

如果又來一個人叫周日,根據我們的規則,周日也要落在1的位置上,此時就產生了沖突。這裏我們使用單獨鏈表法處理,把周天放在索引為一的位置和趙四構成鏈表(新元素是放在鏈表前端還是後端完全是取決於怎麽方便)。

散列表查找是就是先找到桶的位置,再遍歷查找需要的數據。如果散列函數設計的不好,所有的元素都落在一個桶裏那效率就特別低,和單鏈表隨機訪問沒什麽區別。

基本位運算

運算符 計算方式
與 & 只有兩個數同一位都是1才會返回1
或 l 兩個數同一位只要存在一個1就是1
異或 ^ 兩個數同一位不能相同才為1
左移 << 所有位置左移,低位補0
右移 >> 正數:所有位置右移,高位補0
負數:寫出補碼(符號位不變,其余位置取反,然後加1),所有位置右移高位補1,然後再獲取原碼(符號位不變,其余位置取反,然後加1)
無符號右移 >> 無論正負高位補0

了解更多:

  1. 負數的帶符號和不帶符號的右移運算
  2. 位運算符詳解

二、HashMap實現原理

結構

Node是 HashMap的靜態內部,HashMap主幹是一個Node數組,Node是HashMap的最基本組成單位。

// HashMap的主幹數組
transient Node<K,V>[] table;

Node

static class Node<K,V> implements Map.Entry<K,V> {
    // 這個節點所在位置的hash值
    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;
        }
    
    …………(其他get(),set()等方法省略)
        
}

在jdk8之前HashMap是數組加鏈表的形式實現,但是在1.8之後為提高哈希沖突後鏈表的查詢速度,當桶內鏈表長度超過樹化閥值且總長度超過最小樹化容量後會將鏈表轉換為紅黑樹。

jdk7

技術分享圖片

jdk8

技術分享圖片

速度

查詢與修改

先用散列函數對鍵進行散列,沒有沖突的情況下查詢是下標查詢,時間復雜度是 O(1),速度很快。

存在哈希沖突的情況,需要對鏈表/紅黑樹進行遍歷,equals比對查詢。

性能上,考慮是鏈表/紅黑樹上的元素越是越好,越均勻越好;此外HashMap主幹未必越長越好,會有用不到的桶浪費空間。

增加與刪除

由於查詢速度快,而桶裏用鏈表/紅黑樹實現,所以添加和刪除效率也很高。HashMap會在size超過閥值後進行調整大下(resize),所以根據具體情況提前給HashMap一個合適的初始長度是個不錯的習慣。


三、源碼分析

基本常量

// 默認初始長度,即主幹數組的長度,如果創建對象時沒有給長度,默認是16
// 在明確知道元素個數的情況下,初始化時建議可以把容量設置成expectedSize / 0.75F + 1.0F (guava)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量,HashMap最大容量是2^30
// 因為int範圍是-2^31——2^31-1,但32位2進制最高位是符號位,所以最大是2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默認負載因子,默認是0.75,創建對象時可以自定義
// 可以用於計算擴容閥值transient Node<K,V>[] table;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 樹化閥值
// 當某一個桶中鏈表長度超過8時會轉化為紅黑樹
static final int TREEIFY_THRESHOLD = 8;

// 去樹化閥值
// 當一個桶裏紅黑樹總結點數小於6時,會轉化為鏈表
static final int UNTREEIFY_THRESHOLD = 6;

// 最小樹化容量
// 樹化的另一個條件,只有主幹數組長度大於64才進行樹化
static final int MIN_TREEIFY_CAPACITY = 64;

CAPACITY是 HashMap容量(主幹數組長度),size是鍵值對個數

基本成員變量

// 主幹數組
transient Node<K,V>[] table;

// 遍歷時經常用到
transient Set<Map.Entry<K,V>> entrySet;

// HashMap中Node的總個數
transient int size;

// 用於快速失敗,HashMap是非線程安全的,在叠代過程中,如果結構發生改變,會拋出ConcurrentModificationException
transient int modCount;

// 閥值,是否調整主幹數組長度的指標
// 一般由capacity(主幹數組長度) * loadFactor計算,超過範圍會取最大容量MAXIMUM_CAPACITY
int threshold;

//負載因子,數組的填充量,計算閥值使用,上面有個默認的0.75F
final float loadFactor;

構造方法

主要構造方法

HashMap有四種構造方法,這裏只說最核心的一個,只說傳入初始容量和負載因子這種。

/**
 * 核心構造方法
 * @param  initialCapacity 初始化容量
 * @param  loadFactor      負載因子
 */
public HashMap(int initialCapacity, float loadFactor) {
    // 初始容量小於0,拋異常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 如果大於了最大容量,就轉成最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 負載因子小於0或是個非法數字(除數為0這種),拋異常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    // 這裏初始容量賦值給了閥值,後面會用到
    this.threshold = tableSizeFor(initialCapacity);
}
在構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。

tableSizeFor(initialCapacity)方法是用來計算初始容量的,HashMap容量並不是傳多少就是多少,而一定是2的次冪。這個方法會返回一個比給定容量大的最小2的次冪的數。

舉個例子:如果你給了9,比9大的最小2的次冪是16(2^4);如果你給個27,比27大的最小的2的次冪是32(2^5)。

static final int tableSizeFor(int cap) {
    // 先是將cap減1,否則,如果cap是2的次冪,例如16,計算結果就是32,是我們需要的容量的2倍
    int n = cap - 1;
    // 這裏是先將n無符號右移,再與n進行或運算並賦值給n 
    // 這樣好理解一點 =>  n = n | (n >>> 1)
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 如果n<0返回1
    // 如果n>最大容量返回最大容量
    // 否則返回 n+1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

解釋:

n=0

經過幾次次無符號右移還是0,最後返回n+1是1

n>0

下面這個圖演示前三步移動的過程

技術分享圖片

剩下的大家腦補,最後算出來就是32位以內最高位那個1後面跟的都是1,然後n≠1的情況下會加個1,就是我們要的結果,這裏結果是2^8,原來那個顯然是大於2^7的一個數。看完這個過程是不是覺得"妙啊!",我也覺得這個算法好機智,哈哈。

其他構造方法

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

以上幾個構造函數都沒有直接的創建一個切實存在的數組,他們都是在為創建數組需要的一些參數做初始化,table的初始化被推遲到了put方法中,所以這幾個構造函數中並沒有被初始化的屬性都會在實際初始化數組的時候用默認值替換。

這個構造函數有put過程,table已經完成初始化

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

小結

1.構造函數會創建一個容量(主幹數組長度)大於等於initialCapacity的最小的2的冪長度的HashMap

2.負載因子可以自定義

3.多數構造方法中並沒有初始化table,table初始化的過程是在put方法中完成的

put方法

put

put方法是一個重點方法,這裏有 HashMap初始化,數據在 HashMap中是如何儲存的,什麽情況下鏈表會轉換為紅黑樹等內容,需要仔細研究。

public V put(K key, V value) {
    //這裏繼續調用putVal方法
    return putVal(hash(key), key, value, false, true);
}

putVal

putVal是final修飾的方法,子類 LinkedHashMap也是用的這各方法,evict(看下面的的第5個參數)就是給 LinkedHashMap使用的,HashMap中並沒有什麽用。

/**
 * 真正進行插入操作的方法,
 * hash 傳入key的哈希值
 * onlyIfAbsent 如果該值是true,如果存在值就不會進行修改操作
 * evict LinekdHashMap尾操作使用,這裏暫無用途
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /**********初始化********/
    
    // 如果table長度是0或table是null會調整一次大小
    // 這時tab會指向調整大下後的Node<K,V>[](主幹數組)
    // n被賦值為新數組長度
    
    // 如果沒有調整大小,tab指向table
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    /********開始查找鍵的位置,並存儲value*******/
    
    // i = (n - 1) & hash這個是獲取key應該在哪個桶裏,下面詳說
    // 這裏將p指向當前key所需要的那個桶
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果空桶,也就是無哈希沖突的情況,直接丟個Node進去。
        // 此時的tab就是table
        tab[i] = newNode(hash, key, value, null);
    //存在沖突,開始尋找我們要找的節點
    else {
        Node<K,V> e; K k;
        // 判斷第一個節點是不是我們找的
        // 此時k儲存了 p.key
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // hash系值相等,key值相等,定位完成,是修改操作
            // e來儲存p這個節點,一會修改
            e = p;
        // 判斷是否是紅黑樹節點
        else if (p instanceof TreeNode)
            // 是紅黑樹節點,存在就返回那個節點,不存在就返回null
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 最終,是鏈表了,開始對鏈表遍歷查找
        else {
           
            for (int binCount = 0; ; ++binCount) {
                // 上面知道第一個接點不是我們要的,直接獲取下一個,並儲存給e
                // 下一個是空,直接丟個Node在這裏,然後p.next指向這裏
                // 這裏下一個節點地址給了e
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // !大於樹化閥值,開始樹化
                    // 註意-1是因為binCount是索引而不是長度
                    // 其實此時鏈表長度已經是7+1(索引) + 1(新進來的Node)
                    // 已經大於樹化閥值8,也就是說鏈表長度為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
            V oldValue = e.value;
            // 給e新值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 這個是LinkedHashMap用的,HashMap裏是個空實現
            afterNodeAccess(e);
            // 修改就會把舊值返回去
            return oldValue;
        }
    }
    
    /*********修改完成的後續操作**********/
    // 修改次數加1
    ++modCount;
    // 如果size大於閥值,會執行resize()方法調整大小
    if (++size > threshold)
        resize();
    // 這個是給LinkedHashMap用的,HashMap裏也是個空實現
    afterNodeInsertion(evict);
    // 添加成功返回null
    return null;
}

hash

再來看一下hash()這個方法吧。

static final int hash(Object key) {
    int h;  
    // key是null就返回0,key不是null就先取hashCode()然後與這個hashCode()無符號右移進行亦或運算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

技術分享圖片

可能小夥伴有疑惑,好好的hashCode()非弄個亦或運算幹啥?

  • 這是因為找key的位置時,`(n - 1) & hash`是table的索引,n的長度不夠大時,只和hashCode()的低16位有關,這樣發生沖突的概率就變高。

  • 為減少這種影響,設計者權衡了speed, utility, and quality,將高16位與低16位異或來減少這種影響。設計者考慮到現在的hashCode分布的已經很不錯了,而且當發生較大碰撞時也用樹形存儲降低了沖突。僅僅異或一下,既減少了系統的開銷,也不會造成的因為高16位沒有參與下標的計算(table長度比較小時)而引起的碰撞。
  • 舉個例子:下圖就是table.length為16時的計算情況,如果沒有亦或運算就只和低4位有關,這樣就會加大沖突的概率。

    技術分享圖片

    resize

    這也是一個很重要的方法,主要包括兩部分,第一部分是根據size是否超過閥值判斷是否需要進行擴容,第二部分是擴容後將原Node[]中數據復制到擴容後的Node[]中

    擴容部分

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        // 原容量,table為null返回0,否則返回table長度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 原閥值
        int oldThr = threshold;
        // 新容量,新閥值
        int newCap, newThr = 0;
        // table已經初始化
        if (oldCap > 0) {
            // 容量已經超過最大容量,直接返回去
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 2倍擴容後小於最大容量,並且原容量大於默認初始化容量(我還沒想清楚為什麽要大於默認初始容量)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 閥值加倍
                newThr = oldThr << 1; // double threshold
        }
        // 原數組容量為0,未初始化,單閥值不為0
        // 也就是構造方法裏threshold = tableSizeFor(initialCapacity)這個步驟
        else if (oldThr > 0) 
            newCap = oldThr;
        // 啥都沒有,默認構造
        else {               
            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;

    復制數據部分

    看源碼前,先看下面這個圖

    技術分享圖片

    正常來講,向新數組復制元素時需要重新計算位置,現在有了這個規律,就可以這樣做:

    • x=0不改變位置
    • x≠0原位置+原數組長度獲取新位置

    判斷x是否為0,e.hash & oldCap可以完成,返回結果是0,代表x處是0,位置不用改變,否則改變位置

    技術分享圖片

        //創建新的數組
        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;
                    // x獲取桶的第一個節點
                    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裏
                                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;
        }

    復制過程,a過去,假設計算後位置不邊,進到i,此時i為null,a進去後即是head,又是tail

    然後循環,到b,假設計算後還是i,i中已經有a,所以b直接丟到a後面,a任是head,單tail已經變成了b

    以此類推,a,b,c,d都會放在i,j中

    其實是先拼完鏈表才裝進桶裏的,這裏只是方便描述,說成是一個一個過去

    技術分享圖片

    至此,put方法已經說完了,重點是putVal,hash和resize三個方法,如果不理解可以看本文結尾的參考文獻,因為不同的人思維方式,表達方式都不同,說不定換一種表述方式就能理解了。

    remove

    remove就是先找到節點位置,然後移除,核心方法是removeNode()

    public V remove(Object key) {
        Node<K,V> e;
        // 調用removeNode,如果移除成功返回原值,否則返回null
        return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
    }

    removeNode

    /**
     * value remove方法重載時使用,只有同時匹配key-value時移除該節點
     * matchValue,為true時才會同時匹配key-value進行刪除
     * movable 刪除節點後是否改變紅黑樹的結構,般都為true只有在iterator的時候才為false
     */
    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;
        // 1.原數組不為null 2. 原數組長度大於0 3.key數組中的位置不為空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            // 聲明兩個節點node,e
            Node<K,V> node = null, e; K k; V v;
            // 第一個節點就我們要找的節點
            
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // 先給node,在下面刪掉
                node = p;
            else if ((e = p.next) != null) {
                // 如果是紅黑樹,獲取該接點並給node
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                // 如果是鏈表,循環遍歷
                else {
                    do {
                        // 如果是要找的節點就把這個節點給node
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        // 不是把節點給p記錄,繼續檢查下一個節點
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            /**********刪除節點的部分*********/
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                // 如果是紅黑樹節點,使用removeTreeNode移除
                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;
                // 修改次數再加1
                ++modCount;
                // 長度 -1
                --size;
                // 給LinkedList使用,這裏沒啥用
                afterNodeRemoval(node);
                // 刪除的值返回去
                return node;
            }
        }
        // 根本沒有這個鍵
        return null;
    }

    大致過程就是這個樣子的~~,勉強看吧沒畫圖天賦!

    技術分享圖片

    源碼分析到這裏就結束,看了這幾個方法,只要不是紅黑樹的部分,看起來就很沒那麽困難了。

    四、日常使用註意事項

    1. 在可以明確HashMap長度的情況下,最好給HashMap一個初始容量

      看完上面原碼後,會發現HashMap使用過程中會出現resize()操作,會涉及到哈希表的重建,這是一個比較消耗資源的操作,如果在明確長度的情況下,能給定合適的容量就可以減少甚至避免擴容操作。

      阿裏巴巴開發手冊給出如下公式:

      {% cq %}
      initialCapacity = (需要存儲的元素個數 / 負載因子) + 1。
      {% endcq %}

      註意負載因子(即loader factor)默認為0.75, 如果暫時無法確定初始值大小,請設置為16(即默認值)

      在guava中其實也使用這個公式,並且guava提供下面這個方法來創建HashMap:

      {% note info %}Map<String, String> map = Maps.newHashMapWithExpectedSize(10){% endnote %}

      其實這個公式是來自putAll()方法,感興趣的小夥伴可以去看一下。

    2. 重寫equals方法是一定要重寫hashCode方法

      老生長談了,這裏主要針對key是對象的情況,舉個例子:

      class Person{
          int idCard;
          String name;
      
          public Person(int idCard, String name) {
              this.idCard = idCard;
              this.name = name;
          }
          @Override
          public boolean equals(Object o) {
              if (this == o) {
                  return true;
              }
              if (o == null || getClass() != o.getClass()){
                  return false;
              }
              Person person = (Person) o;
              //兩個對象是否等值,通過idCard來確定
              return this.idCard == person.idCard;
          }
          @Test
          public void test(){
              Map map = new HashMap();
              Person p1 = new Person(1234,"小白");
              map.put(p1,"哈哈哈哈");
              Person p2 = new Person(1234,"小白");
              map.get(p2);
          }

      當用person來做key時,顯然,如果在hashcode不重寫的情況下,使用p2是無法獲得需要的內容的,因為兩個對象用來找桶的hashcode是不同的,所以無法找到想同的桶啊!桶都找不到去哪裏找值哈哈!

    五、總結

    本文記錄了我學習HashMap的全過程,包括預備知識、實現原理、源碼分析、註意事項等幾個部分,對我這個沒數據結構基礎的人來說收獲真的很大,希望對各位讀者也有一定的幫助!存在問題希望大家指正!


    本文參考:

    1. HashMap實現原理及源碼分析
    2. 揭秘 HashMap 實現原理(Java 8)
    3. HashMap源碼註解 之 靜態工具方法hash()、tableSizeFor()(四)
    4. Think in Java 第四版 第8章 集合部分
    5. Java 1.8中HashMap的resize()方法擴容部分的理解
    6. 關於HashMap容量的初始化,還有這麽多學問

    超詳細的HashMap解析(jdk1.8)