詳解 HashMap 數(shù)據(jù)結(jié)構(gòu)

可以搜索微信公眾號【Jet 與編程】查看更多精彩文章

原文發(fā)布于自己的博客平臺【http://www.jetchen.cn/analysis-hashmap/


HashMap 這個數(shù)據(jù)結(jié)構(gòu),不管是日常開發(fā),還是求職面試诈皿,它始終都是所有 Java 程序員繞不開的宿命,所以還是決定寫篇文章來詳細(xì)剖析下 HashMap 這個數(shù)據(jù)結(jié)構(gòu),探探期間到底有多少奧秘演熟。


背景

很早的時候就想寫點關(guān)于數(shù)據(jù)結(jié)構(gòu)方面的文章,時隔多年,終于決定正式開始提筆了偏化,那就先從最熱門的 HashMap 開始吧。

HashMap 是 Java 程序中使用率最高的數(shù)據(jù)結(jié)構(gòu)之一镐侯,其主要用于處理鍵值對這種數(shù)據(jù)結(jié)構(gòu)侦讨。而且在 JDK 1.8 中對底層的實現(xiàn)進(jìn)行了優(yōu)化,比如引入了紅黑樹苟翻、優(yōu)化了擴(kuò)容機(jī)制等韵卤。

本文主要是基于 JDK 最常用的 1.8 版本來介紹,詳細(xì)分析了幾個最重要的參數(shù)和方法崇猫,比如索引的計算沈条、數(shù)組的擴(kuò)容、put() 方法等诅炉,末尾也會稍微對比下 1.8 和 1.7 版本之間的差異蜡歹。

簡介

特點

HashMap 繼承自 Map,有如下特點:

  1. 存儲 key - value 類型結(jié)構(gòu)涕烧,數(shù)據(jù)類型不限制
  2. 根據(jù) key 的 hashcode 值進(jìn)行存儲數(shù)據(jù)
  3. 最多只允許一條記錄的鍵(key)為 null(對 value 值不約束)
  4. 它是無序的(其實一見 hash 我們便知道了)
  5. 查詢效率很高
  6. 它是線程不安全的(要線程安全月而,可以使用 Collections 的 synchronizedMap,或者使用更加推薦的 ConcurrentHashMap)

它還有一些常見的兄弟姐妹议纯,比如 LinkedHashMap父款、TreeMap、Hashtable瞻凤,本文就不進(jìn)行對比介紹了憨攒。

基本結(jié)構(gòu)

HashMap 的結(jié)構(gòu),是數(shù)組+鏈表+紅黑樹的結(jié)構(gòu)阀参,草圖可以見下圖浓恶。

紅黑樹是在 JDK1.8 版本才引入的,目的是加快鏈表的查詢效率

HashMap 數(shù)據(jù)結(jié)構(gòu)草圖

從上圖可看出结笨,HashMap 底層是一個哈希桶數(shù)組,名為 table,數(shù)組內(nèi)存儲的是基于 Node 類型的數(shù)據(jù)炕吸,所以伐憾,這個 Node 甚為關(guān)鍵,下文會詳解赫模。

然后同一個數(shù)組所以的位置可以存儲多個 Node树肃,并以鏈表或紅黑樹的形式來實現(xiàn),所以很容易猜到瀑罗,既然是鏈表胸嘴,那么每個 Node 必然會記錄下一個 Node。但是如果鏈表很長斩祭,那查詢效率便會降低劣像,所以自 JDK1.8 開始便引入了紅黑樹,即當(dāng)鏈表長度超過 8 的時候摧玫,鏈表便會轉(zhuǎn)為紅黑樹耳奕,另外,當(dāng)鏈表長度小于 6 的時候诬像,會從紅黑樹轉(zhuǎn)為鏈表屋群。

鏈地址法

HashMap 是使用哈希表來存儲數(shù)據(jù)的。哈希表為了解決沖突坏挠,一般有兩種方案:開放地址法鏈地址法芍躏。

開放地址法:哈希完后如果有沖突,則按照某種規(guī)則找到空位插入

HashMap 采用的便是 鏈地址法降狠,即在數(shù)組的每個索引處都是一個鏈表結(jié)構(gòu)对竣,這樣就可以有效解決 hash 沖突。

當(dāng)兩個 key 的 hash 值相同時喊熟,則會將他們至于數(shù)組的同一個位置處柏肪,并以鏈表的形式呈現(xiàn)。

但是如果大部分的數(shù)據(jù)都集中在了數(shù)組中的同一索引處芥牌,而其余索引處的數(shù)據(jù)比較少烦味,即分配不均衡,則說明哈希碰撞較多壁拉。

所以為了提高 HashMap 的存取效率谬俄,我們需要將數(shù)據(jù)盡可能多地分散到數(shù)組中去,即減少哈希碰撞弃理,為了達(dá)到這一目的溃论,最直接的方案便是改善 hash 算法,其次是擴(kuò)大哈希桶數(shù)組的大卸徊(擴(kuò)容)钥勋,在下文會詳細(xì)介紹炬转。

字段和屬性

一些默認(rèn)參數(shù)

// 默認(rèn)的初始容量為 16 (PS:aka 應(yīng)該是 as know as)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量(容量不夠時需要擴(kuò)容)
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默認(rèn)的負(fù)載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 鏈表長度為 8 的時候會轉(zhuǎn)為紅黑樹
static final int TREEIFY_THRESHOLD = 8;

// 長度為 6 的時候會從紅黑樹轉(zhuǎn)為鏈表
static final int UNTREEIFY_THRESHOLD = 6;

// 只有桶內(nèi)數(shù)據(jù)量大于 64 的時候才會允許轉(zhuǎn)紅黑樹
static final int MIN_TREEIFY_CAPACITY = 64;

初始容量是 16,可以擴(kuò)容算灸,但是擴(kuò)容之后的容量扼劈,也是 2 的冪次方,比如 32菲驴、64荐吵,why?這里面涉及到很多巧妙的設(shè)計赊瞬,下文介紹 resize() 方法的時候會詳細(xì)介紹先煎。

另外,我們解釋下 MIN_TREEIFY_CAPACITY巧涧,雖然說當(dāng)鏈表長度大于 8 的時候薯蝎,鏈表會轉(zhuǎn)為紅黑樹,但是也是需要滿足桶內(nèi)存儲的數(shù)據(jù)量大于上述這個參數(shù)的值褒侧,否則不僅不會轉(zhuǎn)紅黑樹良风,反而會進(jìn)行擴(kuò)容操作。

比如下面這段代碼是判斷是否要將鏈表轉(zhuǎn)為紅黑樹闷供,乍看烟央,只是將鏈表長度和 UNTREEIFY_THRESHOLD 進(jìn)行對比,其實不然歪脏,點開 treeifyBin(tab, hash) 這個方法疑俭,我們便可以看到,如果此時桶數(shù)組內(nèi)的數(shù)據(jù)量小于 MIN_TREEIFY_CAPACITY婿失,則不會將鏈表轉(zhuǎn)紅黑樹钞艇,而是進(jìn)行擴(kuò)容操作,見下圖:

鏈表轉(zhuǎn)紅黑樹

一些重要的字段

// Map 中存儲的數(shù)據(jù)量豪硅,即 key-value 鍵值對的數(shù)量
transient int size;

// HashMap 內(nèi)部結(jié)構(gòu)發(fā)生變化的次數(shù)哩照,即新增、刪除數(shù)據(jù)的時候都會記錄懒浮,
// 注意:修改某個 key 的值飘弧,并不會改變這個 modCount
transient int modCount;

// 重點,代表最多能容納的數(shù)據(jù)量
// 即最多能容納的 key-value 鍵值對的數(shù)量
int threshold;

// 負(fù)載因子砚著,默認(rèn)為 0.75
// 注意次伶,這個值是可以大于 1 的
final float loadFactor;

其中有兩個參數(shù)需要注意一下,一個是 threshold稽穆,還有一個是 loadFactor冠王。

threshold 代表最多能容納的 Node 數(shù)量,一般 threshold = length * loadFactor舌镶,也就是說要想 HashMap 能夠存儲更多的數(shù)據(jù)(即獲得較大的 threshold)柱彻,有兩種方案豪娜,一種是擴(kuò)容(即增大數(shù)組長度 length),另一種便是增大負(fù)載因子绒疗。

threshold 和數(shù)組長度不是一回事哦

0.75 這個默認(rèn)的負(fù)載因子的值是基于時間和空間考慮而得的一個比較平衡的點侵歇,所以負(fù)載因子我們一般不去調(diào)整,除非有特殊的需求:

  1. 比如 以空間換時間吓蘑,意思是如果內(nèi)存比較大,并且需要有較高的存取效率坟冲,則可以適當(dāng)降低負(fù)載因子磨镶,這樣做的話,就會減小哈希碰撞的概率健提。
  2. 再比如 以時間換空間琳猫,意思是如果內(nèi)存比較小,并且接受適當(dāng)減小存取效率私痹,則可以適當(dāng)調(diào)大負(fù)載因子脐嫂,哪怕大于 1,這樣做的話紊遵,就會增大哈希碰撞的概率账千。

關(guān)于 0.75 這個負(fù)載因子的詳細(xì)的解釋,需要建立數(shù)學(xué)模型來分析暗膜,由于鄙人才疏學(xué)淺匀奏,暫不進(jìn)行討論。

Node<K,V>

HashMap 底層是一個 Node[] table学搜,所以 Node 是一個很重要的數(shù)據(jù)結(jié)構(gòu)娃善。

Node 實現(xiàn)了 Entry 接口,所以瑞佩,Node 本質(zhì)上就是一個 Key-Value 數(shù)據(jù)結(jié)構(gòu)聚磺。

static class Node<K,V> implements Map.Entry<K,V> {
    // key 的 hash 值
    final int hash;
    final K key;
    V value;
    // 記錄下一個 Node
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {...}
    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }
    public final int hashCode() {...}
    public final V setValue(V newValue) {...}
    public final boolean equals(Object o) {...}
}

構(gòu)造函數(shù)

總共有四個構(gòu)造函數(shù),依次來講解下炬丸。

構(gòu)造函數(shù)列表

HashMap()

構(gòu)造一個空的 HashMap瘫寝,初始容量為 16,負(fù)載因子為 0.75

// 構(gòu)造一個空的 HashMap御雕,初始容量為 16矢沿,負(fù)載因子為默認(rèn)值 0.75
public HashMap() {    
    this.loadFactor = DEFAULT_LOAD_FACTOR;  // all other fields defaulted
}

HashMap(int initialCapacity)

構(gòu)造一個空的 HashMap,并指定初始化容量酸纲,負(fù)載因子為默認(rèn)的 0.75捣鲸。

構(gòu)造函數(shù)內(nèi)部會調(diào)用下文緊接著講到的第三種構(gòu)造函數(shù)。

// 構(gòu)造一個空的 HashMap闽坡,并指定初始化容量栽惶,負(fù)載因子采用默認(rèn)的 0.75
public HashMap(int initialCapacity) {    
    // 調(diào)用另一個構(gòu)造函數(shù)
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

HashMap(int initialCapacity, float loadFactor)

構(gòu)造一個空的 HashMap愁溜,并指定初始化容量,指定負(fù)載因子外厂。

// 構(gòu)造一個空的 HashMap冕象,并指定初始化容量,指定負(fù)載因子
public HashMap(int initialCapacity, float loadFactor) {
    // 初始容量不為負(fù)數(shù)
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +  initialCapacity);
    // 初始容量大于最大值時汁蝶,則取最大值
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 負(fù)載因子不能小于 0渐扮,并且必須是數(shù)字,否則拋異常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
    
    // 最大容量 MAXIMUM_CAPACITY 為 1 << 30
}

構(gòu)造函數(shù)中會進(jìn)行一系列的參數(shù)判斷掖棉,并且會進(jìn)行初始化操作墓律。

  1. 如果初始容量小于 0,或者負(fù)載因子小于 0 或不為數(shù)字時幔亥,會拋出 IllegalArgumentException 異常耻讽。
  2. 如果初始容量大于最大值(2^30),則會使用最大容量帕棉。
  3. 設(shè)置 threshold针肥,直接調(diào)用 tableSizeFor() 方法,該方法會返回一個大于等于指定容量的 2 的冪次方的整數(shù)香伴,例如傳入 6慰枕,則會返回 8。

tableSizeFor() 方法的詳細(xì)解釋會放到下文來講解瞒窒。

另外捺僻,直接將得到的值賦給 threshold,難道不是應(yīng)該是這樣的操作嗎崇裁?this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;, 其實不然匕坯,我們再看一眼源碼,會發(fā)現(xiàn)初始化的動作放在了 put 操作中拔稳。

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

構(gòu)造一個非空的 HashMap葛峻,保證初始化容量能夠完全容下傳進(jìn)來的 Map,另外巴比,負(fù)載因子使用的是默認(rèn)值 0.75术奖。

// 構(gòu)造一個非空的 HashMap,指定了默認(rèn)的負(fù)載因子 0.75
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 將 Map 中的 key-value 賦值到新的 Map 中去
    putMapEntries(m, false);
}

putMapEntries() 方法是將傳遞進(jìn)來的 Map 中的數(shù)據(jù)全都存入到當(dāng)前的 HashMap 中去轻绞,方法的詳解見下文采记。

關(guān)鍵方法

tableSizeFor(int cap)

顧名思義,初始化桶數(shù)組的大小政勃。

該方法的作用是返回一個大于等于傳入的參數(shù)的數(shù)值唧龄,并且返回值會滿足以下兩點:

  1. 返回值是 2 的冪次方
  2. 返回值是最接近傳入的參數(shù)的值

比如:傳入 5,則返回 8奸远;傳入 8既棺,則返回 8讽挟;

這個方法的設(shè)計是很令人驚嘆的,十分巧妙丸冕,除了敬仰還是敬仰耽梅。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

2 的冪次方有個特點,就是它的字節(jié)碼除了最高位是 1胖烛,其余全是 0眼姐。

比如 2,字節(jié)碼為:10洪己,最高位為 1妥凳,其余為 0

再比如16,字節(jié)碼為:10000答捕,最高位為 1,其余為 0

所以方法內(nèi)使用了大量的 “或運(yùn)算”和 “右移”操作屑那,目的是保證從最高位起的每個 bit 都是 1拱镐。

  1. 首行 int n = cap - 1; 的作用,是為了防止傳入的參數(shù)本身就是一個 2 的冪次方持际,否則會返回兩倍于參數(shù)的值沃琅;
  2. n |= n >>> 1; 的作用,是保證倒數(shù)第二個高位也是 1蜘欲,下面的代碼類似益眉。
  3. 最后一行之前,得到的數(shù)類似 0000 1111 這種從第一個高位起全是 1姥份,這樣只要加了 1郭脂,則返回的數(shù)值必然是 2 的冪次方。

詳細(xì)的計算過程詳解見下圖:

tableSizeFor 過程

putMapEntries(Map<? extends K, ? extends V> m, boolean evict)

該方法是將參數(shù) m 中的所有數(shù)據(jù)存入到當(dāng)前的 HashMap 中去澈歉,比如在上文提到的第四種構(gòu)造函數(shù)便調(diào)用了此方法展鸡。

此方法還是比較簡單的,下文代碼中都注釋埃难,其中涉及到的兩個關(guān)鍵方法 resize()putVal() 方法莹弊,作用分別為擴(kuò)容和賦值,下文再詳細(xì)介紹這兩個方法涡尘。

// 將參數(shù) m 中的所有元素存入到當(dāng)前的 HashMap 中去
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    // 獲取 m 中的參數(shù)個數(shù)(key-value 的數(shù)量)
    int s = m.size();
    if (s > 0) {
        // 判斷 table 是否被初始化過忍弛,否則初始化一遍。(PS:table 是在 put 操作的時候進(jìn)行初始化的考抄,所以如果當(dāng)前的 HashMap 沒有進(jìn)行過 put 操作细疚,則當(dāng)前的 table 并不會被初始化)
        if (table == null) { // pre-size
            // 根據(jù)傳進(jìn)來的 map 的元素數(shù)量來計算當(dāng)前 HashMap 需要的容量
            float ft = ((float)s / loadFactor) + 1.0F;
            // 計算而得的容量是不能大于最大容量的
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
            // 將計算而得的容量賦值給 threshold,前提是大于當(dāng)前容量(即不會減小當(dāng)前的 HashMap 的容量)
            if (t > threshold)
                // 將容量轉(zhuǎn)換為最近的 2 的 冪次方
                threshold = tableSizeFor(t);
        }
        // table 不為空座泳,即已經(jīng)初始化過了惠昔,
        // 如果 m 中的元素數(shù)量超過了當(dāng)前 HashMap 的容量幕与,則要進(jìn)行擴(kuò)容
        else if (s > threshold)
            resize();
        // 遍歷 m 的每個元素,將它的 key-value 插入到當(dāng)前的 HashMap 中去
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            // 插入數(shù)據(jù)(注意镇防,為什么不是 put() 呢啦鸣,因為 put() 其實也是調(diào)用的 putVal() 方法)
            putVal(hash(key), key, value, false, evict);
        }
    }
}

hash(Object key)

HashMap 中的 hash 算法,是將 key 進(jìn)行 hashCode 計算得到 h来氧,然后將 h 的高16位與低16位進(jìn)行異或運(yùn)算诫给。

這樣做是從速度、質(zhì)量等多方面綜合考慮的啦扬,而且將高位和低位進(jìn)行混合運(yùn)算中狂,這樣是可以有效降低沖突概率的。

另外扑毡,高位是可以保證不變的胃榕,變的是低位瞄摊,并且低位中摻雜了高位的信息换帜,最后生成的 hash 值的隨機(jī)性會增大蹲嚣。

下圖舉例介紹異或計算(例如 h 為 467,926,597):

異或運(yùn)算

從上圖中也看出,高位的數(shù)字是不變的。

// (h = key.hashCode()) ^ (h >>> 16);
static final int hash(Object key) {
    int h;
    // 高 16 位與低 16 位進(jìn)行異或運(yùn)算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

另外换淆,hash() 的作用是根據(jù) key 計算一個 hash 數(shù)值,然后根據(jù)這個 hash 數(shù)值計算得到數(shù)組的索引 i蛋哭,基于這個索引我們才能進(jìn)行相關(guān)的增刪改查操作叛本,所以這個索引甚是關(guān)鍵来候。

計算公式為:i = hash(key) & (n-1),即下面這個方法转质,但是這個下面這個方法僅限 1.8 以前的版本:

// jdk1.7 的源碼日矫,jdk1.8 沒有這個方法,但是原理一樣
static int indexFor(int h, int length) {  
    // 取模運(yùn)算
    return h & (length-1);
}

這里又是一個很精妙的設(shè)計缔逛,一般情況下,我們要獲取索引 i敦冬,最常用的計算方式是取模運(yùn)算:hash % length,但是此處卻使用的是:hash & (length-1)萌庆,妙哉妙哉。

為什么要這么做呢彭则?因為 '%' 操作相對于位運(yùn)算是比較消耗性能的俯抖,所以采用了奇淫技巧 '&' 運(yùn)算凛捏。但是為什么結(jié)果是和取模運(yùn)算是一致的呢坯癣?其實還是因為table的 length 的問題瓶盛。

我們上文提到過,HashMap 的長度 length 始終是 2 的冪次方示罗,這個是關(guān)鍵惩猫,所以才會有這種結(jié)果,簡單分析見下圖:

使用 & 位運(yùn)算替代常規(guī)的 % 取模運(yùn)算蚜点,性能上提高了很多轧房,這個是 table 如此設(shè)計數(shù)組長度的優(yōu)勢之一,另一個很大的優(yōu)勢是在擴(kuò)容的時候绍绘,下文會分析奶镶。

取模運(yùn)算

resize()

resize() 是一個很重要的方法,作用是擴(kuò)容陪拘,從而使得 HashMap 可以存儲更多的數(shù)據(jù)厂镇。

因為當(dāng)我們不斷向 HashMap 中添加數(shù)據(jù)時,它總會超過允許存儲的數(shù)據(jù)量上限左刽,所以必然會經(jīng)歷 擴(kuò)容 這一步操作捺信,但是 HashMap 底層是一個數(shù)組,我們都知道數(shù)組是無法增大容量的欠痴,所以 resize 的過程其實就是新建一個更大容量的數(shù)組來存儲當(dāng)前 HashMap 中的數(shù)據(jù)迄靠。

resize() 方法是很精妙的,我們就一起來看下 JDK1.8 的源碼吧斋否。

final Node<K,V>[] resize() {
    // 當(dāng)前 table
    Node<K,V>[] oldTab = table;
    // 當(dāng)前的 table 的大小
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 當(dāng)前 table 的 threshold梨水,即允許存儲的數(shù)據(jù)量閥值
    int oldThr = threshold;
    // 新的 table 的大小和閥值暫時初始化為 0
    int newCap, newThr = 0;
    // ① 開始計算新的 table 的大小和閥值
    // a、當(dāng)前 table 的大小大于 0茵臭,則意味著當(dāng)前的 table 肯定是有數(shù)據(jù)的
    if (oldCap > 0) {
        // 當(dāng)前 table 的大小已經(jīng)到了上線了疫诽,還咋擴(kuò)容,自個兒繼續(xù)哈希碰撞去吧 
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新的 table 的大小直接翻倍,閥值也直接翻倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // b奇徒、當(dāng)前的 table 中無數(shù)據(jù)雏亚,但是閥值不為零,說明初始化的時候指定過容量或者閥值摩钙,但是沒有被 put 過數(shù)據(jù)罢低,因為在上文中有提到過,此時的閥值就是數(shù)組的大小胖笛,所以直接把當(dāng)前的閥值當(dāng)做新 table 的數(shù)組大小即可
    // 回憶一下:threshold = tableSizeFor(t);
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // c网持、這種情況就代表當(dāng)前的 table 是調(diào)用的空參構(gòu)造來初始化的,所有的數(shù)據(jù)都是默認(rèn)值长踊,所以新的 table 也只要使用默認(rèn)值即可
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新的閥值是 0功舀,那么就簡單計算一遍就行了
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // ② 初始化新的 table
    // 這個 newTab 就是新的 table,數(shù)組大小就是上面這一堆邏輯所計算出來的
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 遍歷當(dāng)前 table身弊,處理每個下標(biāo)處的 bucket辟汰,將其處理到新的 table 中去
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 釋放當(dāng)前 table 數(shù)組的對象引用(for循環(huán)后,當(dāng)前 table 數(shù)組不再引用任何對象)
                oldTab[j] = null;
                // a阱佛、只有一個 Node帖汞,則直接 rehash 賦值即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // b、當(dāng)前的 bucket 是紅黑樹凑术,直接進(jìn)行紅黑樹的 rehash 即可
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // c翩蘸、當(dāng)前的 bucket 是鏈表
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍歷鏈表中的每個 Node,分別判斷是否需要進(jìn)行 rehash 操作
                    // (e.hash & oldCap) == 0 算法是精髓淮逊,充分運(yùn)用了上文提到的 table 大小為 2 的冪次方這一優(yōu)勢鹿鳖,下文會細(xì)講
                    do {
                        next = e.next;
                        // 根據(jù) e.hash & oldCap 算法來判斷節(jié)點位置是否需要變更
                        // 索引不變
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引 + oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原 bucket 位置的尾指針不為空(即還有 node )
                    if (loTail != null) {
                        // 鏈表末尾必須置為 null
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        // 鏈表末尾必須置為 null
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

好了,下面我們就介紹下上面的源碼中提到的計算索引的操作壮莹,即判斷 if ((e.hash & oldCap) == 0)

下圖展示的是擴(kuò)容前和擴(kuò)容后的計算索引的方法姻檀,主要關(guān)注紅色框中的內(nèi)容命满。這種方案是沒問題的,但是之前我們提到绣版,table 的大小為 2 的冪次方胶台,這什么要這么設(shè)計呢,期間的又一個奧秘便體現(xiàn)在此杂抽,請看圖中的備注诈唬。

擴(kuò)容索引計算1

既然擴(kuò)容后每個 key 的新索引的生成規(guī)則是固定有規(guī)律的,即只有兩種形式缩麸,要么不變 i铸磅,要么增加原先的數(shù)組大小的量(i+n),所以我們其實并不需要真的去計算每個 key 的索引,而只需要判斷索引是否不變即可阅仔。所以此處巧妙地使用了 (e.hash & oldCap) == 0 這個判斷吹散,著實精妙,計算的細(xì)節(jié)過程看下面的圖即可八酒。

擴(kuò)容索引計算2

put(K key, V value)

此處介紹的是 putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)空民,因為 put() 其實就是直接調(diào)用的 putVal();

put() 方法是 HashMap 中最常用的方法之一羞迷,我們先大體關(guān)注下 put() 方法的流程界轩,文字就暫不贅述了,看下圖便很清晰了:

put 流程

下面簡單分析下源碼:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

// 參數(shù) onlyIfAbsent衔瓮,true:不修改已存在的 value浊猾,false:已存在則進(jìn)行修改
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // ① 如果當(dāng)前 table 為空則進(jìn)行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash 計算得到索引 i,算法在上文有提到报辱,然后查看索引處是否有數(shù)據(jù)
    // ② 如果沒有數(shù)據(jù)与殃,則新建一個新的 Node
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 索引處有數(shù)據(jù)
    else {
        Node<K,V> e; K k;
        // ③ 索引處的第一個 Node 的  key 和參數(shù) key 是一致的,所以直接修改 value 值即可(修改的動作放在下面)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // ④ 索引處的 bucket 是紅黑樹碍现,按照紅黑樹的邏輯進(jìn)行插入或修改
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // ⑤ 索引處的 bucket 是鏈表
        else {
            // 遍歷鏈表上面的所有 Node
            for (int binCount = 0; ; ++binCount) {
                // 索引處的 Node 為尾鏈
                if ((e = p.next) == null) {
                    // 直接新建一個 Node 插在尾鏈處
                    p.next = newNode(hash, key, value, null);
                    // 判斷是否需要轉(zhuǎn)換為紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 鏈表轉(zhuǎn)換為紅黑樹幅疼,此方法在上文中也有介紹
                        treeifyBin(tab, hash);
                    break;
                }
                // 當(dāng)前 Node 的 key 值和參數(shù) key 是一致的,即直接修改 value 值即可(修改的動作放在下面)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 找到了相同 key 的 Node昼接,所以進(jìn)行修改 vlaue 值即可
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 修改 value 值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            // 修改操作爽篷,直接 return 結(jié)束掉代碼邏輯
            return oldValue;
        }
    }
    // 記錄結(jié)構(gòu)發(fā)生變化的次數(shù)
    ++modCount;
    // ⑥ 判斷是否需要擴(kuò)容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    // 新增的 Node,返回 null
    return null;
}

get(Object key)

此處介紹的是 getNode(int hash, Object key慢睡,因為 get() 其實就是直接調(diào)用的 getNode()逐工;

get() 方法也是比較簡單的,就是根據(jù) key 獲取 table 的索引漂辐,然后再分情況查找擁有相同 key 的 Node泪喊;

源碼大致如下:

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;
    // 當(dāng)前 table 不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 判斷索引處的第一個 Node 的 key 值是否和參數(shù) key 相同,相同則返回該 Node
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 索引處的第一個 Node 不是想要的髓涯,則接著查 next
        if ((e = first.next) != null) {
            // bucket 是紅黑樹結(jié)構(gòu)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // bucket 是鏈表結(jié)構(gòu)
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

JDK1.8 VS JDK1.7

1.7 和 1.8 之間的區(qū)別袒啼,最最主要的是如下三方面,當(dāng)然纬纪,鄙人覺得這三點的變化可以算是比較成功的優(yōu)化蚓再。

  1. 擴(kuò)容后 Node 索引的計算方式不同。上文提到包各,由于 table 大小的這種神奇的設(shè)計摘仅,所以擴(kuò)容時計算索引的時候,1.8 中只需要簡單使用 & 運(yùn)算來判斷是否為 0 即可问畅,并不需要像 1.7 一樣每次都是用 & 運(yùn)算來計算出索引值娃属。
  2. 1.8 中引入了紅黑樹結(jié)構(gòu)六荒。上文也提到了,當(dāng)鏈表長度大于 8 的時候會轉(zhuǎn)換為紅黑樹膳犹,但是 1.7 中是數(shù)組+鏈表的組合恬吕。
  3. 1.8 中采用的是尾插法,而 1.7 中采用的是頭插法须床。比如擴(kuò)容的時候铐料,頭插法會使鏈表上 Node 的順序調(diào)轉(zhuǎn),而尾插法則不會豺旬,另外钠惩,頭插法也會造成環(huán)形鏈死循環(huán)等問題,本文就不深討了族阅。

其它

HashMap 是線程不安全的篓跛,因為它是允許多線程同時操作同一個數(shù)組的,比如 put()坦刀,比如 resize()愧沟,這些都會造成數(shù)據(jù)異常甚至死循環(huán)。

所以要使用線程安全的 Map 的時候鲤遥,可以使用 HashTable沐寺,但是這個不推薦,或者使用自 JDK1.8 開始引入的 Concurrent 包中的 ConcurrentHashMap盖奈,這個是比較推薦的混坞,具體的介紹就放到下文再說吧。

參考了美團(tuán)的技術(shù)博客:傳送門

image
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末钢坦,一起剝皮案震驚了整個濱河市究孕,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌爹凹,老刑警劉巖厨诸,帶你破解...
    沈念sama閱讀 212,383評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異禾酱,居然都是意外死亡泳猬,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,522評論 3 385
  • 文/潘曉璐 我一進(jìn)店門宇植,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人埋心,你說我怎么就攤上這事指郁。” “怎么了拷呆?”我有些...
    開封第一講書人閱讀 157,852評論 0 348
  • 文/不壞的土叔 我叫張陵闲坎,是天一觀的道長疫粥。 經(jīng)常有香客問我,道長腰懂,這世上最難降的妖魔是什么梗逮? 我笑而不...
    開封第一講書人閱讀 56,621評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮绣溜,結(jié)果婚禮上慷彤,老公的妹妹穿的比我還像新娘。我一直安慰自己怖喻,他們只是感情好底哗,可當(dāng)我...
    茶點故事閱讀 65,741評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著锚沸,像睡著了一般跋选。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上哗蜈,一...
    開封第一講書人閱讀 49,929評論 1 290
  • 那天前标,我揣著相機(jī)與錄音,去河邊找鬼距潘。 笑死炼列,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的绽昼。 我是一名探鬼主播唯鸭,決...
    沈念sama閱讀 39,076評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼硅确!你這毒婦竟也來了目溉?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,803評論 0 268
  • 序言:老撾萬榮一對情侶失蹤菱农,失蹤者是張志新(化名)和其女友劉穎缭付,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體循未,經(jīng)...
    沈念sama閱讀 44,265評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡陷猫,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,582評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了的妖。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片绣檬。...
    茶點故事閱讀 38,716評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖嫂粟,靈堂內(nèi)的尸體忽然破棺而出娇未,到底是詐尸還是另有隱情,我是刑警寧澤星虹,帶...
    沈念sama閱讀 34,395評論 4 333
  • 正文 年R本政府宣布零抬,位于F島的核電站镊讼,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏平夜。R本人自食惡果不足惜蝶棋,卻給世界環(huán)境...
    茶點故事閱讀 40,039評論 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望忽妒。 院中可真熱鬧玩裙,春花似錦、人聲如沸锰扶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,798評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽坷牛。三九已至罕偎,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間京闰,已是汗流浹背颜及。 一陣腳步聲響...
    開封第一講書人閱讀 32,027評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留蹂楣,地道東北人俏站。 一個月前我還...
    沈念sama閱讀 46,488評論 2 361
  • 正文 我出身青樓,卻偏偏與公主長得像痊土,于是被迫代替她去往敵國和親肄扎。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,612評論 2 350

推薦閱讀更多精彩內(nèi)容