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

本文首發於cdream的我的博客,點擊得到更好的閱讀體驗!html

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

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

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

[TOC]git

如下是本文的結構,可根據須要跳轉到相應位置,沒必要按順序閱讀!程序員

image-20181121193553067

1、預備知識

時間複雜度

時間複雜度用來度量算法的運行時間,記做: T(n) = O(f(n))。它表示隨着輸入 n 的增大,算法執行須要的時間的增加速度能夠用 f(n) 來描述。漸進時間複雜度用大寫O來表示,因此也被稱爲大 O表示法。github

經常使用的時間複雜度比較:算法

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)。

<div class="note info"><p style="color: #EE82EE">數組與鏈表的根據指定值查詢時間複雜度都是O(1),但數組更快</p>1. <i>鏈表須要在遍歷時,須要比數組更多的尋址操做</i> 2. <i>CPU緩存會把一片連續的內存空間讀入,由於數組結構是連續的內存地址,因此數組所有或者部分元素被連續存在CPU緩存裏面,而鏈表則不會</i></div>

紅黑樹

是一種自平衡二叉查找樹,能夠在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我的趙四,錢五,孫六要裝進去,咱們定義一個規則,按姓氏在百家姓中的順序除以四獲得的餘數做爲索引放入四個位置。

image-20181118161709369

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

image-20181118174832759

若是又來一我的叫週日,根據咱們的規則,週日也要落在1的位置上,此時就產生了衝突。這裏咱們使用單獨鏈表法處理,把周天放在索引爲一的位置和趙四構成鏈表(新元素是放在鏈表前端仍是後端徹底是取決於怎麼方便)。

散列表查找是就是先找到桶的位置,再遍歷查找須要的數據。若是散列函數設計的很差,全部的元素都落在一個桶裏那效率就特別低,和單鏈表隨機訪問沒什麼區別。

基本位運算

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

瞭解更多:

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

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

image-20181118180344514

jdk8

image-20181118180418238

速度

查詢與修改

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

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

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

增長與刪除

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


3、源碼分析

基本常量

// 默認初始長度,即主幹數組的長度,若是建立對象時沒有給長度,默認是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;

<div class="note warning"><p>CAPACITY是 HashMap容量(主幹數組長度),size是鍵值對個數</p></div>

基本成員變量

// 主幹數組
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);
}

<div class="note warning">在構造方法中,並無對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold從新計算。</div>

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

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

image-20181119222138595

剩下的你們腦補,最後算出來就是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);
}

<div class="note info"><p>小結</p>1.<i>構造函數會建立一個容量(主幹數組長度)大於等於initialCapacity的最小的2的冪長度的HashMap</i><br/></br/>2.<i>負載因子能夠自定義</i><br/></br/>3.<i>多數構造方法中並無初始化table,table初始化的過程是在put方法中完成的</i></div>

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);
}

image-20181120190453468

<div class="note primary"><p style="color:blue;font-weight: bold">可能小夥伴有疑惑,好好的hashCode()非弄個亦或運算幹啥?</p><li>這是由於找key的位置時,`(n - 1) & hash`是table的索引,n的長度不夠大時,只和hashCode()的低16位有關,這樣發生衝突的機率就變高。</li><br/><li>爲減小這種影響,設計者權衡了speed, utility, and quality,將高16位與低16位異或來減小這種影響。設計者考慮到如今的hashCode分佈的已經很不錯了,並且當發生較大碰撞時也用樹形存儲下降了衝突。僅僅異或一下,既減小了系統的開銷,也不會形成的由於高16位沒有參與下標的計算(table長度比較小時)而引發的碰撞。</li></div>

舉個例子:下圖就是table.length爲16時的計算狀況,若是沒有亦或運算就只和低4位有關,這樣就會加大沖突的機率。

image-20181120191018657

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;

複製數據部分

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

image-20181120221414380

正常來說,向新數組複製元素時須要從新計算位置,如今有了這個規律,就能夠這樣作:

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

判斷x是否爲0,e.hash & oldCap能夠完成,返回結果是0,表明x處是0,位置不用改變,不然改變位置

image-20181120223547843

//建立新的數組
	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中

<div class="note warning">實際上是先拼完鏈表才裝進桶裏的,這裏只是方便描述,說成是一個一個過去</div>

image-20181120225308182

至此,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;
}

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

image-20181121185854156

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

4、平常使用注意事項

  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是不一樣的,因此沒法找到想同的桶啊!桶都找不到去哪裏找值哈哈!

5、總結

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


本文參考:

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