面試倒在了HashMap上?別急HashMap面試題驯用,看這一篇就夠了

序言

在后端的日常開發(fā)工作中脸秽,集合是使用頻率相當高的一個工具,而其中的HashMap蝴乔,則更是我們用以處理業(yè)務邏輯的好幫手记餐,同時HashMap的底層實現(xiàn)和原理,也成了面試題中的侈闭客片酝。

以前曾有詳細了解過HashMap的實現(xiàn)原理,看過源碼(JDK7版本)挖腰。但隨著jdk版本的飛速迭代(現(xiàn)在都到JDK13了雕沿,但新特性還從沒用過。猴仑。)审轮,主流的jdk使用版本也終于從JDK7挪到了JDK8。

由于JDK的向前兼容辽俗,在JDK8的使用過程中也沒發(fā)現(xiàn)HashMap有什么特別之處疾渣,特性并無變化(依然線程不安全)。但最近的一次好奇心驅(qū)使崖飘,從IDE中點進去看了下HashMap的put()方法榴捡,有點兒懵逼,怎么跟我記憶中的不太一樣朱浴?從JDK7到JDK8薄疚,HashMap也做了升級么?升級了什么哪些內(nèi)容赊琳?

借著這股好奇心,把JDK7和JDK8的源碼都翻了翻砰碴,對兩者的實現(xiàn)原理做一下對比躏筏,JDK版本都在半年左右一次的速度推陳出新,我們的認知當然也要跟上呈枉,不斷學習趁尼,站在浪潮之巔,不然就要被這滾滾的信息泥石流給裹挾淹沒了猖辫。

先展示下Map家族的關(guān)系層級酥泞,有助于我們更好的理解后面的內(nèi)容。

HashMap的基本知識點介紹就不多啰嗦了啃憎,直奔主題芝囤,看JDK7和JDK8的功能實現(xiàn)吧。

一、JDK7中的HashMap底層實現(xiàn)

1.1 基礎知識

不管是1.7悯姊,還是1.8羡藐,HashMap的實現(xiàn)框架都是哈希表 + 鏈表的組合方式。結(jié)構(gòu)圖如下:

平常使用最多的就是put()悯许、get()操作仆嗦,想要了解底層實現(xiàn),最直接的就是從put()/get()方法看起先壕。不過在具體看源碼前瘩扼,我們先關(guān)注幾個域變量,打打基礎垃僚,如下:

上圖中集绰,已對各個變量做了簡單的解釋。再多說一下冈在,最后一個變量modCount倒慧,記錄了map新增/刪除k-v對,或者內(nèi)部結(jié)構(gòu)做了調(diào)整的次數(shù)包券,其主要作用纫谅,是對Map的iterator()操作做一致性校驗,如果在iterator操作的過程中溅固,map的數(shù)值有修改付秕,直接拋出ConcurrentModificationException異常。

還需要說明的是侍郭,上面的域變量中存在一個等式:

threshold = table.length * loadFactor;

當執(zhí)行put()操作放入一個新的值時询吴,如果map中已經(jīng)存在對應的key,則作替換即可亮元,若不存在猛计,則會首先判斷size>=threshold是否成立,這是決定哈希table是否擴容的重要因素爆捞。

就使用層面來說奉瘤,用的最多的莫過于put()方法、get()方法煮甥。想要詳細了解運作原理盗温,那就先從這兩個方法看起吧,這兩個方法弄明白了成肘,也就基本能理清HashMap的實現(xiàn)原理了卖局。

1.2 put()方法

當了解了以上的變量和用途后,接下來看下put()方法的具體實現(xiàn):

如上面的截圖代碼所示双霍,整個put方法的處理過程砚偶,可拆分為四部分:

  • part1:特殊key值處理批销,key為null;
  • part2:計算table中目標bucket的下標蟹演;
  • part3:指定目標bucket风钻,遍歷Entry結(jié)點鏈表,若找到key相同的Entry結(jié)點酒请,則做替換骡技;
  • part4:若未找到目標Entry結(jié)點,則新增一個Entry結(jié)點羞反。

不知大家有沒有發(fā)現(xiàn)布朦,上面截圖中的put()方法是有返回值的,場景區(qū)分如下:

  • 場景1:若執(zhí)行put操作前昼窗,key已經(jīng)存在是趴,那么在執(zhí)行put操作時,會使用本次的新value值來覆蓋前一次的舊value值澄惊,返回的就是舊value值唆途;
  • 場景2:若key不存在,則返回null值掸驱。

下面對put方法的各部分做詳細的拆解分析肛搬。

1.2.1 特殊key值處理

特殊key值,指的就是key為null毕贼。
先說結(jié)論:

  • HashMap中温赔,是允許key、value都為null的鬼癣,且key為null只存一份陶贼,多次存儲會將舊value值覆蓋;
  • key為null的存儲位置待秃,都統(tǒng)一放在下標為0的bucket拜秧,即:table[0]位置的鏈表;
  • 如果是第一次對key=null做put操作章郁,將會在table[0]的位置新增一個Entry結(jié)點腹纳,使用頭插法做鏈表插入。

上代碼:

private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}


/**
 * Adds a new entry with the specified key, value and hash code to
 * the specified bucket.  It is the responsibility of this
 * method to resize the table if appropriate.
 *
 * Subclass overrides this to alter the behavior of put method.
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }


    createEntry(hash, key, value, bucketIndex);
}


/**
 * Like addEntry except that this version is used when creating entries
 * as part of Map construction or "pseudo-construction" (cloning,
 * deserialization).  This version needn't worry about resizing the table.
 *
 * Subclass overrides this to alter the behavior of HashMap(Map),
 * clone, and readObject.
 */
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

putForNullKey()方法中的代碼較為簡單:首先選擇table[0]位置的鏈表驱犹,然后對鏈表做遍歷操作,如果有結(jié)點的key為null足画,則將新value值替換掉舊value值雄驹,返回舊value值,如果未找到淹辞,則新增一個key為null的Entry結(jié)點医舆。

重點我們看下第二個方法addEntry()。這是一個通用方法:

給定hash、key蔬将、value爷速、bucket下?標,新增一個Entry結(jié)點霞怀,另外還擔負了擴容職責惫东。如果哈希表中存放的k-v對數(shù)量超過了當前閾值(threshold = table.length * loadFactor),且當前的bucket下標有鏈表存在毙石,那么就做擴容處理(resize)廉沮。擴容后,重新計算hash徐矩,最終得到新的bucket下標滞时,然后使用頭插法新增結(jié)點。

1.2.2 擴容

上一節(jié)有提及滤灯,當k-v對的容量超出一定限度后坪稽,需要對哈希table做擴容操作。那么問題來了鳞骤,怎么擴容的窒百?
下面看下源代碼:

有兩個核心點:

  • 擴容后大小是擴容前的2倍;
oldCapacity=table.length;
newCapacity = 2 * oldCapacity;
  • 數(shù)據(jù)搬遷弟孟,從舊table遷到擴容后的新table贝咙。為避免碰撞過多,先決策是否需要對每個Entry鏈表結(jié)點重新hash拂募,然后根據(jù)hash值計算得到bucket下標庭猩,然后使用頭插法做結(jié)點遷移。

1.2.3 如何計算bucket下標陈症?

  • ① hash值的計算

首先得有key的hash值蔼水,就是一個整數(shù),int類型录肯,其計算方式使用了一種可盡量減少碰撞的算式(高位運算)趴腋,具體原理不再展開,只要知道一點就行:使用key的hashCode作為算式的輸入论咏,得到了hash值优炬。

從以上知識點,我們可以得到一個推論:對于兩個對象厅贪,若其hashCode相同蠢护,那么兩個對象的hash值就一定相同。

這里還牽涉到另外一個知識點养涮。對于HashMap中key的類型葵硕,必須滿足以下的條件:若兩個對象邏輯相等茁肠,那么他們的hashCode一定相等宋雏,反之卻不一定成立卑惜。

邏輯相等的含義就比較寬泛了沟堡,我們可以將邏輯的相等定義為兩個對象的內(nèi)存地址相同,也可以定義為對象的某個域值相等介评,自定義兩個對象的邏輯相等库北,可通過重寫Object類的equals()方法來實現(xiàn)。比如String類威沫,請看以下代碼:

String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2);  // false贤惯,兩個對象的內(nèi)存地址并不同
System.out.println(str1.equals(str2)); // true 兩個對象的域值相同,都存儲了 abc 這三個字符

對于上面代碼中的str1棒掠、str2兩個對象孵构,雖然它們的內(nèi)存地址不同,但根據(jù)String類中對Object類的equals()方法的重寫(@override)烟很,兩個對象的域變量(即char數(shù)組)都存儲了'a'颈墅、'b'、'c'三個字符雾袱,因此邏輯上是相等的恤筛。既然str1、str2兩個對象邏輯上相等芹橡,那么一定有如下結(jié)果:

System.out.println(str1.hashCode() == str2.hashCode());


---輸出---
true

從而我們就可以知道毒坛,在同一個HashMap對象中,會有如下結(jié)果:

String str1 = "abc";
String str2 = new String("abc");
Map<String, Integer> testMap = new HashMap<>();
testMap.put(str1, 12);
testMap.put(str2, 13);


String str3 = new StringBuilder("ab").append("c").toString();
System.out.println(testMap.get(str3));


---輸出---
13

另外林说,我們也可以反過來想一下煎殷。

假設HashMap的key不滿足上面提到的條件,即:兩個對象相等的情況下腿箩,他們的hashCode可能不一致豪直。那么,這會帶來什么后果呢珠移?以上面示例代碼中的str1弓乙、str2為例,若它們的hashCode不相等钧惧,那么對應的hash也就可能不相等(注意:這里是可能不相等暇韧,也有可能相等),testMap做put操作時浓瞪,str1锨咙、str2為就會被分配到不同的bucket上,導致的最直接后果就是會存儲兩份追逮。間接的后果那就更多了酪刀,比如:使用str3對象執(zhí)行testMap.get(str3)操作時,可能獲取不到值钮孵,更進一步的后果就是這部分無法觸達的對象無法回收骂倘,導致內(nèi)存泄漏。

因此巴席,再重新一遍历涝,HashMap的key所對應的類型,一定要滿足如下條件:

若兩個對象邏輯相等漾唉,那么他們的hashCode一定相等荧库,反之卻不一定成立。

  • ② 取模的邏輯

前面我們分析了hash值的計算赵刑,接下來就可以引出bucket下標的計算:

/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    return h & (length-1);
}

計算相當簡潔:將table的容量與hash值做“與”運算分衫,得到哈希table的bucket下標。

  • ③ 拓展

這種通俗的不能再通俗的計算大家都能看懂般此,但為何要這樣做呢蚪战?背后的思考是什么?在看到下面的解釋前铐懊,大家可以先思考下~

在文檔開頭邀桑,給出了HashMap類中的各個域變量。其中科乎,哈希table的初始大小默認設置為16壁畸,為2的次冪數(shù)。后面在擴容時茅茂,都是以2的倍數(shù)來擴容捏萍。為什么非要將哈希table的大小控制為2的次冪數(shù)?

原因1:降低發(fā)生碰撞的概率玉吁,使散列更均勻照弥。根據(jù)key的hash值計算bucket的下標位置時,使用“與”運算公式:h & (length-1)进副,當哈希表長度為2的次冪時这揣,等同于使用表長度對hash值取模(不信大家可以自己演算一下),散列更均勻影斑;

原因2:表的長度為2的次冪给赞,那么(length-1)的二進制最后一位一定是1,在對hash值做“與”運算時矫户,最后一位就可能為1片迅,也可能為0,換句話說皆辽,取模的結(jié)果既有偶數(shù)柑蛇,又有奇數(shù)芥挣。設想若(length-1)為偶數(shù),那么“與”運算后的值只能是0耻台,奇數(shù)下標的bucket就永遠散列不到空免,會浪費一半的空間。

1.2.4 在目標bucket中遍歷Entry結(jié)點

先把這部分代碼拎出來:

int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
    Object k;
    if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
        V oldValue = e.value;
        e.value = value;
        e.recordAccess(this);
        return oldValue;
    }
}

通過hash值計算出下標盆耽,找到對應的目標bucket蹋砚,然后對鏈表做遍歷操作,逐個比較摄杂,如下:

注意這里的查找條件:e.hash == hash && ((k = e.key) == key || key.equals(k))結(jié)點的key與目標key的相等坝咐,要么內(nèi)存地址相等,要么邏輯上相等析恢,兩者有一個滿足即可墨坚。

1.3 get()方法

相比于put()方法,get()方法的實現(xiàn)就相對簡單多了氮昧。主要分為兩步框杜,先是通過key的hash值計算目標bucket的下標,然后遍歷對應bucket上的鏈表袖肥,逐個對比咪辱,得到結(jié)果。

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);


    return null == entry ? null : entry.getValue();
}


/**
 * Returns the entry associated with the specified key in the
 * HashMap.  Returns null if the HashMap contains no mapping
 * for the key.
 */
final Entry<K,V> getEntry(Object key) {
    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

1.4 Map中的迭代器Iterator

1.4.1 Map遍歷的幾種方式

先問個問題椎组,你能想到幾種遍歷Map的方式油狂?

方式1:Iterator迭代器

Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
    Entry<String, Integer> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
}

逐個獲取哈希table中的每個bucket中的每個Entry結(jié)點,后面會詳細介紹寸癌。

方式2:最常見的使用方式专筷,可同時得到key、value值

// 方式一
for (Map.Entry<String, Integer> entry : testMap.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

這種方式是一個語法糖蒸苇,我們可通過反編譯命令javap磷蛹,或通過IDE來查下編譯之后的語句:

Iterator var2 = testMap.entrySet().iterator();
while(var2.hasNext()) {
    Entry<String, Integer> entry = (Entry)var2.next();
    System.out.println((String)entry.getKey() + ":" + entry.getValue());
}

其底層還是使用的是Iterator功能。

方式3:使用foreach方式(JDK1.8才有)

testMap.forEach((key, value) -> {
    System.out.println(key + ":" + value);
});

這是一種Lambda表達式溪烤。foreach也是一個語法糖味咳,其內(nèi)部是使用了方式二的處理方式,Map的foreach方法實現(xiàn)如下:

方式4:通過key的set集合遍歷

Iterator<String> keyIterator = testMap.keySet().iterator();
while (keyIterator.hasNext()) {
    String key = keyIterator.next();
    System.out.println(key + ":" + testMap.get(key));
}

這種也是Iterator的方式檬嘀,不過是通過Set類的iterator方式槽驶。

相比方式1,這種方式在獲取value時鸳兽,還需要再次通過testMap.get()的方式掂铐,性能相比方式1要降低很多。但兩者有各自的使用場景,若在Map的遍歷中僅使用key全陨,則方式4較為適合爆班,若需用到value,推薦使用方式1辱姨。

從前面的方式1和方式2可知蛋济,方式4還有如下的變體(語法糖的方式):

for (String key : testMap.keySet()) {
    System.out.println(key + ":" + testMap.get(key));
}

綜合以上,在遍歷Map時炮叶,從性能方面考慮,若需同時使用key和value渡处,推薦使用方式1或方式2镜悉,若單純只是使用key,推薦使用方式4医瘫。任何情況下都不推薦使用方式3侣肄,因為會新增二次查詢(通過key再一次在Map中查找value)。

另外醇份,使用方式1時稼锅,還可以做remove操作,這個下面會講到僚纷。

1.4.2 Iterator的實現(xiàn)原理

先看一張類/接口的繼承關(guān)系圖:

Iterator為一個頂層接口矩距,只提供了三個基礎方法聲明:

public interface Iterator<E> {
    
  boolean hasNext();
    
    E next();
    
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
}

這也是我們使用Iterator時繞不開的三個方法。 在HashMap中怖竭,首先是新增了一個內(nèi)部抽象類HashIterator锥债,如下:

我們以Entry結(jié)點的遍歷為例(map的key、value的Iterator遍歷方式都類似):

Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
    Entry<String, Integer> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
}

首先痊臭,第一行代碼哮肚,找到Iterator接口的具體實現(xiàn)類EntryIterator:

private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
    public Map.Entry<K,V> next() {
        return nextEntry();
    }
}

非常簡潔有木有?广匙?允趟?就只有一個next()方法,其正是對Iterator接口的next()方法的實現(xiàn)鸦致。方法內(nèi)部也只有一行潮剪,指向了父類的nextEntry()方法,即上面截圖中的HashIterator類中的nextEntry()方法蹋凝。

HashMap中的Iterator實現(xiàn)原理也不過如此鲁纠,就是這么樸實無華,是不是都想動手自己擼一個HashMap的實現(xiàn)了鳍寂?嗯改含,你可以的!F础捍壤!

1.5 fail-fast策略

和fail-fast經(jīng)常一起出現(xiàn)的還有一個異常類ConcurrentModificationException骤视,接下來我們聊下這兩者是什么關(guān)系,以及為什么搞這么個策略出來鹃觉。

什么是fail-fast专酗?我們可以稱它為"快速失效策略",下面是Wikipedia中的解釋:

In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process.
Such designs often check the system's state at several points in an operation, so any failures can be detected early. The responsibility of a fail-fast module is detecting errors, then letting the next-highest level of the system handle them.

大白話翻譯過來盗扇,就是在系統(tǒng)設計中祷肯,當遇到可能會誘導失敗的條件時立即上報錯誤,快速失效系統(tǒng)往往被設計在立即終止正常操作過程疗隶,而不是嘗試去繼續(xù)一個可能會存在錯誤的過程佑笋。再簡潔點說,就是盡可能早的發(fā)現(xiàn)問題斑鼻,立即終止當前執(zhí)行過程蒋纬,由更高層級的系統(tǒng)來做處理。

在HashMap中坚弱,我們前面提到的modCount域變量蜀备,就是用于實現(xiàn)hashMap中的fail-fast。出現(xiàn)這種情況荒叶,往往是在非同步的多線程并發(fā)操作碾阁。

在對Map的做迭代(Iterator)操作時,會將modCount域變量賦值給expectedModCount局部變量停撞。在迭代過程中瓷蛙,用于做內(nèi)容修改次數(shù)的一致性校驗。若此時有其他線程或本線程的其他操作對此Map做了內(nèi)容修改時戈毒,那么就會導致modCount和expectedModCount不一致艰猬,立即拋出異常ConcurrentModificationException。

舉個栗子:

public static void main(String[] args) {
  Map<String, Integer> testMap = new HashMap<>();
  testMap.put("s1", 11);
  testMap.put("s2", 22);
  testMap.put("s3", 33);


  for (Map.Entry<String, Integer> entry : testMap.entrySet()) {
      String key = entry.getKey();
      if ("s1".equals(key)) {
          testMap.remove(key);
      }
  }
}


---- output ---
Exception in thread "main" java.util.ConcurrentModificationException
  at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
  at java.util.HashMap$EntryIterator.next(HashMap.java:1471)
  at java.util.HashMap$EntryIterator.next(HashMap.java:1469)
    ...

正確的刪除Map元素的姿勢:只有一個埋市,Iteator的remove()方法冠桃。

// 方式三
Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
    Entry<String, Integer> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
    if (next.getKey().equals("s2")) {
        iterator.remove();
    }
}

但也要注意一點,能安全刪除道宅,并不代表就是多線程安全的食听,在多線程并發(fā)執(zhí)行時,若都執(zhí)行上面的操作污茵,因未設置為同步方法樱报,也可能導致modCount與expectedModCount不一致,從而拋異常ConcurrentModificationException泞当。線程不安全的體現(xiàn)和規(guī)避方式迹蛤,后續(xù)章節(jié)會詳細提及。

二、JDK8中的HashMap底層實現(xiàn)

前面我們已經(jīng)詳細剖析了HashMap在JDK7中的實現(xiàn)盗飒,不知大家有沒有發(fā)現(xiàn)其中可以優(yōu)化的地方嚷量?比如哈希表中因為hash碰撞而產(chǎn)生的鏈表結(jié)構(gòu),如果數(shù)據(jù)量很大逆趣,那么產(chǎn)生碰撞的幾率很增加蝶溶,這帶來的后果就是鏈表長度也一直在增加,對于查詢來說宣渗,性能會越來越低抖所。如何提升查詢性能,成了JDK8中的HashMap要解決的問題痕囱。

因此部蛇,相比于JDK7,HashMap在JDK8中做鏈表結(jié)構(gòu)做了優(yōu)化(但仍然線程不安全)咐蝇,在一定條件下將鏈表轉(zhuǎn)為紅黑樹,提升查詢效率巷查。

JDK8中的HashMap其底層存儲結(jié)構(gòu)如下:

相比于JDK7有序,JDK8中的HashMap會將較長的鏈表轉(zhuǎn)為紅黑樹,這也是與JDK7的核心差異岛请。下面先看下put()方法的實現(xiàn)旭寿。

2.1 put()操作

在進一步分析put()操作前,先說明一下:除了底層存儲結(jié)構(gòu)有調(diào)整崇败,鏈表結(jié)點的定義也由Entry類轉(zhuǎn)為了Node類盅称,但內(nèi)核沒有變化,不影響理解后室。

先上源代碼:

是不是很長很復雜缩膝?其實不難,只要記住上面的底層存儲結(jié)構(gòu)圖岸霹,代碼就很容易看懂疾层。還是一樣的存儲套路,先根據(jù)key確定在哈希table中的下標贡避,找到對應的bucket痛黎,遍歷鏈表(或紅黑樹),做插入操作刮吧。在JDK7中湖饱,新增結(jié)點是使用頭插法,但在JDK8中杀捻,在鏈表使用尾插法井厌,將待新增結(jié)點追加到鏈表末尾。

為方便理解,將上面的代碼轉(zhuǎn)為了下面的流程圖:

  • 步驟①:若哈希table為null旗笔,或長度為0彪置,則做一次擴容操作;
  • 步驟②:根據(jù)index找到目標bucket后蝇恶,若當前bucket上沒有結(jié)點拳魁,那么直接新增一個結(jié)點,賦值給該bucket撮弧;
  • 步驟③:若當前bucket上有鏈表潘懊,且頭結(jié)點就匹配,那么直接做替換即可贿衍;
  • 步驟④:若當前bucket上的是樹結(jié)構(gòu)授舟,則轉(zhuǎn)為紅黑樹的插入操作;
  • 步驟⑤:若步驟①贸辈、②释树、③、④都不成立擎淤,則對鏈表做遍歷操作奢啥。
  1. 若鏈表中有結(jié)點匹配,則做value替換嘴拢;
  2. 若沒有結(jié)點匹配桩盲,則在鏈表末尾追加。同時席吴,執(zhí)行以下操作:
    a. 若鏈表長度大于TREEIFY_THRESHOLD赌结,則執(zhí)行紅黑樹轉(zhuǎn)換操作;
    b. 若條件a不成立孝冒,則執(zhí)行擴容resize()操作柬姚。
    c. 以上5步都執(zhí)行完后,再看當前Map中存儲的k-v對的數(shù)量是否超出了threshold庄涡,若超出伤靠,還需再次擴容。

紅黑樹的轉(zhuǎn)換操作如下:

/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 若表為空啼染,或表長度小于MIN_TREEIFY_CAPACITY宴合,也不做轉(zhuǎn)換,直接做擴容處理迹鹅。
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

2.2 擴容操作

什么場景下會觸發(fā)擴容卦洽?

  • 場景1:哈希table為null或長度為0;
  • 場景2:Map中存儲的k-v對數(shù)量超過了閾值threshold斜棚;
  • 場景3:鏈表中的長度超過了TREEIFY_THRESHOLD阀蒂,但表長度卻小于MIN_TREEIFY_CAPACITY该窗。

一般的擴容分為2步,第1步是對哈希表長度的擴展(2倍)蚤霞,第2步是將舊table中的數(shù)據(jù)搬到新table上酗失。那么,在JDK8中昧绣,HashMap是如何擴容的呢规肴?

上源代碼片段:

...
// 前面已經(jīng)做了第1步的長度拓展,我們主要分析第2步的操作:如何遷移數(shù)據(jù)
table = newTab;
if (oldTab != null) {
    // 循環(huán)遍歷哈希table的每個不為null的bucket
    // 注意夜畴,這里是"++j"拖刃,略過了oldTab[0]的處理
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            // 若只有一個結(jié)點,則原地存儲
            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
                // "lo"前綴的代表要在原bucket上存儲贪绘,"hi"前綴的代表要在新的bucket上存儲
                // loHead代表是鏈表的頭結(jié)點兑牡,loTail代表鏈表的尾結(jié)點
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    // 以oldCap=8為例,
                    //   0001 1000  e.hash=24
                    // & 0000 1000  oldCap=8
                    // = 0000 1000  --> 不為0税灌,需要遷移
                    // 這種規(guī)律可發(fā)現(xiàn)均函,[oldCap, (2*oldCap-1)]之間的數(shù)據(jù),
                    // 以及在此基礎上加n*2*oldCap的數(shù)據(jù)菱涤,都需要做遷移边酒,剩余的則不用遷移
                    if ((e.hash & oldCap) == 0) {
                        // 這種是有序插入,即依次將原鏈表的結(jié)點追加到當前鏈表的末尾
                        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;
                    // 需要搬遷的結(jié)點狸窘,新下標為從當前下標往前挪oldCap個距離。
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}

2.3 get()操作

了解了上面的put()操作坯认,get()操作就比較簡單了翻擒。

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 && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

先根據(jù)key計算hash值,進一步計算得到哈希table的目標index牛哺,若此bucket上為紅黑樹陋气,則再紅黑樹上查找,若不是紅黑樹引润,遍歷鏈表巩趁。

三、HashMap淳附、HashTable是什么關(guān)系议慰?

再把文章開頭的這張圖放出來,溫習一下:

3.1 共同點與異同點

共同點:

  • 底層都是使用哈希表 + 鏈表的實現(xiàn)方式奴曙。

區(qū)別:

  • 從層級結(jié)構(gòu)上看别凹,HashMap、HashTable有一個共用的Map接口洽糟。另外炉菲,HashTable還單獨繼承了一個抽象類Dictionary堕战;
  • HashTable誕生自JDK1.0,HashMap從JDK1.2之后才有拍霜;
  • HashTable線程安全嘱丢,HashMap線程不安全;
  • 初始值和擴容方式不同祠饺。HashTable的初始值為11越驻,擴容為原大小的2*d+1。容量大小都采用奇數(shù)且為素數(shù)吠裆,且采用取模法伐谈,這種方式散列更均勻。但有個缺點就是對素數(shù)取模的性能較低(涉及到除法運算)试疙,而HashTable的長度都是2的次冪诵棵,設計就較為巧妙,前面章節(jié)也提到過祝旷,這種方式的取模都是直接做位運算履澳,性能較好。
  • HashMap的key怀跛、value都可為null距贷,且value可多次為null,key多次為null時會覆蓋吻谋。當HashTable的key忠蝗、value都不可為null,否則直接NPE(NullPointException)漓拾。

示例:

public static void main(String[] args) {
  Map<String, Integer> testTable = new Hashtable<>();
    testTable.put(null, 23);  // 拋NPE
    testTable.put("s1", null); // 拋NPE
}

看下put()方法的源碼:

public synchronized V put(K key, V value) {
    // Make sure the value is not null
    if (value == null) {
        throw new NullPointerException();
    }


    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }


    addEntry(hash, key, value, index);
    return null;
}

源碼中不允許value為null阁最,若為null則直接拋NPE。對于key為null時骇两,源碼第9行:int hash = key.hashCode(); 未做判空操作速种,也會外拋NPE。

另外低千,我們現(xiàn)在看到的抽象類Dictionary配阵,是一個已經(jīng)廢棄的類,源碼注釋中有如下說明:

<strong>NOTE: This class is obsolete.  New implementations should
implement the Map interface, rather than extending this class.</strong>

3.2 HashMap的線程安全

能保證線程線程安全的方式有多個示血,比如添加synchronized關(guān)鍵字棋傍,或者使用lock機制。兩者的差異不在此展開难审,后續(xù)會寫有關(guān)線程安全的文章舍沙,到時再詳細說明。而HashTable使用了前者剔宪,即synchronized關(guān)鍵字拂铡。

put操作壹无、get操作、remove操作感帅、equals操作斗锭,都使用了synchronized關(guān)鍵字修飾。

這么做是保證了HashTable對象的線程安全特性失球,但同樣也帶來了問題岖是,突出問題就是效率低下染苛。為何會說它效率低下呢想邦?

因為按synchronized的特性隔崎,對于多線程共享的臨界資源揉阎,同一時刻只能有一個線程在占用,其他線程必須原地等待米者,為方便理解捌斧,大家不妨想下計時用的沙漏嫉嘀,中間最細的瓶頸處阻擋了上方細沙的下落猾浦,同樣的道理陆错,當有大量線程要執(zhí)行g(shù)et()操作時,也存在此類問題金赦,大量線程必須排隊一個個處理音瓷。

這時可能會有人說,既然get()方法只是獲取數(shù)據(jù)夹抗,并沒有修改Map的結(jié)構(gòu)和數(shù)據(jù)绳慎,不加不就行了嗎?不好意思漠烧,不加也不行杏愤,別的方法都加,就你不加沽甥,會有一種場景,那就是A線程在做put或remove操作時乏奥,B線程摆舟、C線程此時都可以同時執(zhí)行g(shù)et操作,可能哈希table已經(jīng)被A線程改變了邓了,也會帶來問題恨诱,因此不加也不行。

現(xiàn)在好了骗炉,HashMap線程不安全照宝,HashTable雖然線程安全,但性能差句葵,那怎么破厕鹃?使用ConcurrentHashMap類吧兢仰,既線程安全,還操作高效剂碴,誰用誰說好把将。莫急,下面章節(jié)會詳細解釋ConcurrentHashMap類忆矛。

四察蹲、HashMap線程不安全在哪?

本章節(jié)主要探討下HashMap的線程不安全會帶來哪些方面的問題催训。

4.1 數(shù)據(jù)覆蓋問題

兩個線程執(zhí)行put()操作時洽议,可能導致數(shù)據(jù)覆蓋。JDK7版本和JDK8版本的都存在此問題漫拭,這里以JDK7為例亚兄。

假設A、B兩個線程同時執(zhí)行put()操作嫂侍,且兩個key都指向同一個buekct儿捧,那么此時兩個結(jié)點,都會做頭插法挑宠。

先看這里的代碼實現(xiàn):

public V put(K key, V value) {
    ...
    addEntry(hash, key, value, i);
}


void addEntry(int hash, K key, V value, int bucketIndex) {
    ...
    createEntry(hash, key, value, bucketIndex);
}


void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

看下最后的createEntry()方法菲盾,首先獲取到了bucket上的頭結(jié)點,然后再將新結(jié)點作為bucket的頭部各淀,并指向舊的頭結(jié)點懒鉴,完成一次頭插法的操作。

當線程A和線程B都獲取到了bucket的頭結(jié)點后碎浇,若此時線程A的時間片用完临谱,線程B將其新數(shù)據(jù)完成了頭插法操作,此時輪到線程A操作奴璃,但這時線程A所據(jù)有的舊頭結(jié)點已經(jīng)過時了(并未包含線程B剛插入的新結(jié)點)悉默,線程A再做頭插法操作,就會抹掉B剛剛新增的結(jié)點苟穆,導致數(shù)據(jù)丟失抄课。

其實不光是put()操作,刪除操作雳旅、修改操作跟磨,同樣都會有覆蓋問題。

4.2 擴容時導致死循環(huán)

這是最常遇到的情況攒盈,也是面試經(jīng)常被問及的考題抵拘。但說實話,這個多線程環(huán)境下導致的死循環(huán)問題型豁,并不是那么容易解釋清楚僵蛛,因為這里已經(jīng)深入到了擴容的細節(jié)尚蝌。這里盡可能簡單的描述死循環(huán)的產(chǎn)生過程。

另外墩瞳,只有JDK7及以前的版本會存在死循環(huán)現(xiàn)象驼壶,在JDK8中,resize()方式已經(jīng)做了調(diào)整喉酌,使用兩隊鏈表热凹,且都是使用的尾插法,及時多線程下泪电,也頂多是從頭結(jié)點再做一次尾插法般妙,不會造成死循環(huán)。而JDK7能造成死循環(huán)相速,就是因為resize()時使用了頭插法碟渺,將原本的順序做了反轉(zhuǎn),才留下了死循環(huán)的機會突诬。

在進一步說明死循環(huán)的過程前苫拍,我們先看下JDK7中的擴容代碼片段:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

其實就是簡單的鏈表反轉(zhuǎn),再進一步簡化的話旺隙,分為當前結(jié)點e绒极,以及下一個結(jié)點e.next。我們以鏈表a->b->c->null為例蔬捷,兩個線程A和B垄提,分別做擴容操作。

原表:

線程A和B各自新增了一個新的哈希table周拐,在線程A已做完擴容操作后铡俐,線程B才開始擴容。此時對于線程B來說妥粟,當前結(jié)點e指向a結(jié)點审丘,下一個結(jié)點e.next仍然指向b結(jié)點(此時在線程A的鏈表中,已經(jīng)是c->b->a的順序)勾给。按照頭插法滩报,哈希表的bucket指向a結(jié)點,此時a結(jié)點成為線程B中鏈表的頭結(jié)點锦秒,如下圖所示:

a結(jié)點成為線程B中鏈表的頭結(jié)點后露泊,下一個結(jié)點e.next為b結(jié)點喉镰。既然下一個結(jié)點e.next不為null旅择,那么當前結(jié)點e就變成了b結(jié)點,下一個結(jié)點e.next變?yōu)閍結(jié)點侣姆。繼續(xù)執(zhí)行頭插法生真,將b變?yōu)殒湵淼念^結(jié)點沉噩,同時next指針指向舊的頭節(jié)點a,如下圖:

此時柱蟀,下一個結(jié)點e.next為a節(jié)點川蒙,不為null,繼續(xù)頭插法长已。指針后移畜眨,那么當前結(jié)點e就成為了a結(jié)點,下一個結(jié)點為null术瓮。將a結(jié)點作為線程B鏈表中的頭結(jié)點康聂,并將next指針指向原來的舊頭結(jié)點b,如下圖所示:

此時胞四,已形成環(huán)鏈表恬汁。同時下一個結(jié)點e.next為null,流程結(jié)束辜伟。

4.3 小結(jié)

多線程環(huán)境下使用HashMap氓侧,會引起各類問題,上面僅為不安全問題的兩個典型示例导狡,具體問題無法一一列舉约巷,但大體會分為以下三類:

  • 死循環(huán)
  • 數(shù)據(jù)重復
  • 數(shù)據(jù)丟失(覆蓋)

在JDK1.5之前,多線程環(huán)境往往使用HashTable烘豌,但在JDK1.5及以后的版本中载庭,在并發(fā)包中引入了專門用于多線程環(huán)境的ConcurrentHashMap類,采用分段鎖實現(xiàn)了線程安全廊佩,相比HashTable有更高的性能囚聚,推薦使用。

五标锄、如何規(guī)避HashMap的線程不安全顽铸?

前面提到了HashMap在多線程環(huán)境下的各類不安全問題,那么有哪些方式可以轉(zhuǎn)成線程安全的呢料皇?

5.1 將Map轉(zhuǎn)為包裝類

如何轉(zhuǎn)谓松?使用Collections.SynchronizedMap()方法,示例代碼:

Map<String, Integer> testMap = new HashMap<>();
...
// 轉(zhuǎn)為線程安全的map
Map<String, Integer> map = Collections.synchronizedMap(testMap);

其內(nèi)部實現(xiàn)也很簡單践剂,等同于HashTable鬼譬,只是對當前傳入的map對象,新增對象鎖(synchronized):

// 源碼來自Collections類


private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
    private static final long serialVersionUID = 1978198479659022715L;


    private final Map<K,V> m;     // Backing Map
    final Object      mutex;        // Object on which to synchronize


    SynchronizedMap(Map<K,V> m) {
        this.m = Objects.requireNonNull(m);
        mutex = this;
    }


    SynchronizedMap(Map<K,V> m, Object mutex) {
        this.m = m;
        this.mutex = mutex;
    }


    public int size() {
        synchronized (mutex) {return m.size();}
    }
    public boolean isEmpty() {
        synchronized (mutex) {return m.isEmpty();}
    }
    public boolean containsKey(Object key) {
        synchronized (mutex) {return m.containsKey(key);}
    }
    public boolean containsValue(Object value) {
        synchronized (mutex) {return m.containsValue(value);}
    }
    public V get(Object key) {
        synchronized (mutex) {return m.get(key);}
    }


    public V put(K key, V value) {
        synchronized (mutex) {return m.put(key, value);}
    }
    public V remove(Object key) {
        synchronized (mutex) {return m.remove(key);}
    }
    public void putAll(Map<? extends K, ? extends V> map) {
        synchronized (mutex) {m.putAll(map);}
    }
    public void clear() {
        synchronized (mutex) {m.clear();}
    }
}

5.2 使用ConcurrentHashMap

既然HashMap類是線程不安全的逊脯,那就不妨找個線程安全的替代品——ConcurrentHashMap類优质。

使用示例:

Map<String, Integer> susuMap = new ConcurrentHashMap<>();
susuMap.put("susu1", 111);
susuMap.put("susu2", 222);
System.out.println(susuMap.get("susu1"));

在使用習慣上完全兼容了HashMap的使用。

JDK1.5版本引入,位于并發(fā)包java.util.concurrent下巩螃。

在JDK7版本及以前演怎,ConcurrentHashMap類使用了分段鎖的技術(shù)(segment + Lock),但在jdk8中避乏,也做了較大改動爷耀,使用回了synchronized修飾符。

具體差別拍皮,在以后的文章中再詳細介紹歹叮。

推薦閱讀

Java架構(gòu)師進階之路<常用資料分享>

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市铆帽,隨后出現(xiàn)的幾起案子盗胀,更是在濱河造成了極大的恐慌,老刑警劉巖锄贼,帶你破解...
    沈念sama閱讀 217,542評論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件票灰,死亡現(xiàn)場離奇詭異,居然都是意外死亡宅荤,警方通過查閱死者的電腦和手機屑迂,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,822評論 3 394
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來冯键,“玉大人惹盼,你說我怎么就攤上這事”谷罚” “怎么了手报?”我有些...
    開封第一講書人閱讀 163,912評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長改化。 經(jīng)常有香客問我掩蛤,道長,這世上最難降的妖魔是什么陈肛? 我笑而不...
    開封第一講書人閱讀 58,449評論 1 293
  • 正文 為了忘掉前任揍鸟,我火速辦了婚禮,結(jié)果婚禮上句旱,老公的妹妹穿的比我還像新娘阳藻。我一直安慰自己,他們只是感情好谈撒,可當我...
    茶點故事閱讀 67,500評論 6 392
  • 文/花漫 我一把揭開白布腥泥。 她就那樣靜靜地躺著,像睡著了一般啃匿。 火紅的嫁衣襯著肌膚如雪蛔外。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,370評論 1 302
  • 那天,我揣著相機與錄音冒萄,去河邊找鬼。 笑死橙数,一個胖子當著我的面吹牛尊流,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播灯帮,決...
    沈念sama閱讀 40,193評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼崖技,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了钟哥?” 一聲冷哼從身側(cè)響起迎献,我...
    開封第一講書人閱讀 39,074評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎腻贰,沒想到半個月后吁恍,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,505評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡播演,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,722評論 3 335
  • 正文 我和宋清朗相戀三年冀瓦,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片写烤。...
    茶點故事閱讀 39,841評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡翼闽,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出洲炊,到底是詐尸還是另有隱情感局,我是刑警寧澤,帶...
    沈念sama閱讀 35,569評論 5 345
  • 正文 年R本政府宣布暂衡,位于F島的核電站询微,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏狂巢。R本人自食惡果不足惜拓提,卻給世界環(huán)境...
    茶點故事閱讀 41,168評論 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望隧膘。 院中可真熱鬧代态,春花似錦、人聲如沸疹吃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,783評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽萨驶。三九已至歉摧,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背叁温。 一陣腳步聲響...
    開封第一講書人閱讀 32,918評論 1 269
  • 我被黑心中介騙來泰國打工再悼, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人膝但。 一個月前我還...
    沈念sama閱讀 47,962評論 2 370
  • 正文 我出身青樓冲九,卻偏偏與公主長得像,于是被迫代替她去往敵國和親跟束。 傳聞我的和親對象是個殘疾皇子莺奸,可洞房花燭夜當晚...
    茶點故事閱讀 44,781評論 2 354

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