深刻理解HashMap上篇

前言: HashMap是Java程序員使用頻率最高的用於映射(鍵值對)處理的數據類型。隨着JDK(Java Developmet Kit)版本的更新,JDK1.8對HashMap底層的實現進行了優化,例如引入紅黑樹的數據結構和擴容的優化等。最近恰好有時間,恰好把HashMap相關的內容和以前作惟品會網關的一些經驗整理一下。html

一.HashMap的概述

1.1 HashMap的數據結構

HashMap的內存結構和原理,以及線程安全都是面試的熱點問題。Java中的數據結構基本能夠用數組+鏈表的解決。java

  • 數組的優缺點:經過下標索引方便查找,可是在數組中插入或刪除一個元素比較困難。
  • 鏈表的優缺點:因爲在鏈表中查找一個元素須要以遍歷鏈表的方式去查找,而插入,刪除快速。所以鏈表適合快速插入和刪除的場景,不利於查找

而HashMap就是綜合了上述的兩種數據結構的優勢,HashMap由Entry數組+鏈表組成,以下圖所示:程序員

從上圖咱們能夠發現HashMap是由Entry數組+鏈表組成的,一個長度爲16的數組中,每一個元素存儲的是一個鏈表的頭結點。那麼這些元素是按照什麼樣的規則存儲到數組中呢。通常狀況是經過hash(key)%len得到,也就是元素的key的哈希值對數組長度取模獲得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108以及140都存儲在數組下標爲12的位置。面試

1.2 HashMap的存取實現簡單說明

1.2.1 HashMap put方法實現

1.首先HashMap裏面實現一個靜態內部類Entry,其重要的屬性有 key , value, next,從屬性key,value咱們就能很明顯的看出來Entry就是HashMap鍵值對實現的一個基礎bean,咱們上面說到HashMap的基礎就是一個線性數組,這個數組就是Entry[],Map裏面的內容都保存在Entry[]裏面。算法

static class Entry<K,V> implements Map.Entry<K,V> {
      final K key;//Key-value結構的key
      V value;//存儲值
      Entry<K,V> next;//指向下一個鏈表節點
      final int hash;//哈希值
}

2.既然是線性數組,爲何能隨機存取?這裏HashMap用了一個小算法,大體是這樣實現:數組

 

//存儲時:
// 這個hashCode方法這裏不詳述,只要理解每一個key的hash是一個固定的int值
int hash = key.hashCode();
int index = hash % Entry[].length;
Entry[index] = value;
//取值時:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];

到這裏咱們輕鬆的理解了HashMap經過鍵值對實現存取的基本原理安全

3.疑問:若是兩個key經過hash%Entry[].length獲得的index相同,會不會有覆蓋的危險?數據結構

  這裏HashMap裏面用到鏈式數據結構的一個概念。上面咱們提到過Entry類裏面有一個next屬性,做用是指向下一個Entry。打個比方, 第一個鍵值對A進來,經過計算其key的hash獲得的index=0,記作:Entry[0] = A。一會後又進來一個鍵值對B,經過計算其index也等於0,如今怎麼辦?HashMap會這樣作:B.next = A,Entry[0] = B,若是又進來C,index也等於0,那麼C.next = B,Entry[0] = C;這樣咱們發現index=0的地方其實存取了A,B,C三個鍵值對,他們經過next這個屬性連接在一塊兒。因此疑問不用擔憂。也就是說數組中存儲的是最後插入的元素。到這裏爲止,HashMap的大體實現,咱們應該已經清楚了。併發

  固然HashMap裏面也包含一些優化方面的實現,這裏也說一下。好比:Entry[]的長度必定後,隨着map裏面數據的愈來愈長,這樣同一個index的鏈就會很長,會不會影響性能?HashMap裏面設置一個因素(也稱爲因子),隨着map的size愈來愈大,Entry[]會以必定的規則加長長度。      app

二.HashMap非線程安全

2.1 HashMap進行Put操做

2.1.1 Jdk8如下HashMap的Put操做

put操做主要是判空,對key的hashcode執行一次HashMap本身的哈希函數,獲得bucketindex位置,還有對重複key的覆蓋操做。

在HashMap作put操做的時候會調用到如下的方法,addEntry和createEntry

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        //獲得key的hashcode,同時再作一次hash操做
        int hash = hash(key.hashCode());
        //對數組長度取餘,決定下標位置
        int i = indexFor(hash, table.length);
        /**
          * 首先找到數組下標處的鏈表結點,
          * 判斷key對一個的hash值是否已經存在,若是存在將其替換爲新的value
          */
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //Hash碰撞的解決
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
 
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

涉及到的幾個方法:

 

static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
     
static int indexFor(int h, int length) {
        return h & (length-1);
    }

如今假如A線程和B線程同時進入addEntry,而後計算出了相同的哈希值對應了相同的數組位置,由於此時該位置還沒數據,而後對同一個數組位置調用createEntry,兩個線程會同時獲得如今的頭結點,而後A寫入新的頭結點以後,B也寫入新的頭結點,那B的寫入操做就會覆蓋A的寫入操做形成A的寫入操做丟失。

2.1.2 jdk8中HashMap的Put操做

①.判斷鍵值對數組table[i]是否爲空或爲null,不然執行resize()進行擴容;

②.根據鍵值key計算hash值獲得插入的數組索引i,若是table[i]==null,直接新建節點添加,轉向⑥,若是table[i]不爲空,轉向③;

③.判斷table[i]的首個元素是否和key同樣,若是相同直接覆蓋value,不然轉向④,這裏的相同指的是hashCode以及equals;

④.判斷table[i] 是否爲treeNode,即table[i] 是不是紅黑樹,若是是紅黑樹,則直接在樹中插入鍵值對,不然轉向⑤;

⑤.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操做,不然進行鏈表的插入操做;遍歷過程當中若發現key已經存在直接覆蓋value便可;

⑥.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,若是超過,進行擴容。

JDK1.8HashMap的put方法源碼以下:

   public V put(K key, V value) {
      // 對key的hashCode()作hash
      return putVal(hash(key), key, value, false, true);
  }
  
  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                 boolean evict) {
     Node<K,V>[] tab; Node<K,V> p; int n, i;
      // 步驟①:tab爲空則建立
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // 步驟②:計算index,並對null作處理 
     if ((p = tab[i = (n - 1) & hash]) == null) 
         tab[i] = newNode(hash, key, value, null);
     else {
         Node<K,V> e; K k;
         // 步驟③:節點key存在,直接覆蓋value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // 步驟④:判斷該鏈爲紅黑樹
         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;
                 }
                    // key已經存在直接覆蓋value
                 if (e.hash == hash &&
                     ((k = e.key) == key || (key != null && key.equals(k)))) 
                            break;
                 p = e;
             }
         }
         
         if (e != null) { // existing mapping for key
             V oldValue = e.value;
             if (!onlyIfAbsent || oldValue == null)
                 e.value = value;
             afterNodeAccess(e);
             return oldValue;
         }
     }
     ++modCount;
     // 步驟⑥:超過最大容量 就擴容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
}

2.2 HashMap進行Get操做

 

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        /**
          * 先定位到數組元素,再遍歷該元素處的鏈表
          * 判斷的條件是key的hash值相同,而且鏈表的存儲的key值和傳入的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.equals(k)))
                return e.value;
        }
        return null;

看一下鏈表的結點數據結構,保存了四個字段,包括key,value,key對應的hash值以及鏈表的下一個節點:

 

static class Entry<K,V> implements Map.Entry<K,V> {
      final K key;//Key-value結構的key
      V value;//存儲值
      Entry<K,V> next;//指向下一個鏈表節點
      final int hash;//哈希值
}

2.3 HashMap擴容的時候

擴容(resize)就是從新計算容量,向HashMap對象裏不停的添加元素,而HashMap對象內部的數組沒法裝載更多的元素時,對象就須要擴大數組的長度,以便能裝入更多的元素。固然Java裏的數組是沒法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像咱們用一個小桶裝水,若是想裝更多的水,就得換大水桶。

仍是上面那個addEntry方法中,有個擴容的操做,這個操做會新生成一個新的容量的數組,而後對原數組的全部鍵值對從新進行計算和寫入新的數組,以後指向新生成的數組。來看一下擴容的源碼:

//用新的容量來給table擴容  
void resize(int newCapacity) {  
    Entry[] oldTable = table; //引用擴容前的Entry數組 
    int oldCapacity = oldTable.length; //保存old capacity  
    // 若是舊的容量已是系統默認最大容量了(擴容前的數組大小若是已經達到最大(2^30)了 ),那麼將閾值設置成整形的最大值,退出 ,  
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
    //初始化一個新的Entry數組  
    Entry[] newTable = new Entry[newCapacity];  
    //將數據轉移到新的Entry數組裏 
    transfer(newTable, initHashSeedAsNeeded(newCapacity));  
    //HashMap的table屬性引用新的Entry數組
    table = newTable;  
    //設置閾值  
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);  
}

這裏就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()方法將原有Entry數組的元素拷貝到新的Entry數組裏。

那麼問題來了,當多個線程同時進來,檢測到總數量超過門限值的時候就會同時調用resize操做,各自生成新的數組並rehash後賦給該map底層的數組table,結果最終只有最後一個線程生成的新數組被賦給table變量,其餘線程的均會丟失。並且當某些線程已經完成賦值而其餘線程剛開始的時候,就會用已經被賦值的table做爲原始數組,這樣也會有問題。因此在擴容操做的時候也有可能會引發一些併發的問題。

2.4 刪除數據的時候

  //根據指定的key刪除Entry,返回對應的value  
public V remove(Object key) {  
    Entry<K,V> e = removeEntryForKey(key);  
    return (e == null ? null : e.value);  
}  
//根據指定的key,刪除Entry,並返回對應的value  
final Entry<K,V> removeEntryForKey(Object key) {  
    if (size == 0) {  
        return null;  
    }  
    int hash = (key == null) ? 0 : hash(key);  
    int i = indexFor(hash, table.length);  
    Entry<K,V> prev = table[i];  
    Entry<K,V> e = prev;  
    while (e != null) {  
        Entry<K,V> next = e.next;  
        Object k;  
        if (e.hash == hash &&  
            ((k = e.key) == key || (key != null && key.equals(k)))) {  
            modCount++;  
            size--;  
            if (prev == e) //若是刪除的是table中的第一項的引用  
                table[i] = next;//直接將第一項中的next的引用存入table[i]中  
            else  
                prev.next = next; //不然將table[i]中當前Entry的前一個Entry中的next置爲當前Entry的next  
            e.recordRemoval(this);  
            return e;  
        }  
        prev = e;  
        e = next;  
    }  
    return e;  
}

刪除這一塊可能會出現兩種線程安全問題,第一種是一個線程判斷獲得了指定的數組位置i並進入了循環,此時,另外一個線程也在一樣的位置已經刪掉了i位置的那個數據了,而後第一個線程那邊就沒了。可是刪除的話,沒了倒問題不大。
  再看另外一種狀況,當多個線程同時操做同一個數組位置的時候,也都會先取得如今狀態下該位置存儲的頭結點,而後各自去進行計算操做,以後再把結果寫會到該數組位置去,其實寫回的時候可能其餘的線程已經就把這個位置給修改過了,就會覆蓋其餘線程的修改。   

總之HashMap是非線程安全的,在高併發的場合使用的話,要用Collections.synchronizedMap進行包裝一下。

三.參考文章

https://zhuanlan.zhihu.com/p/21673805
http://www.importnew.com/7099.html
http://www.admin10000.com/document/3322.html
http://www.cnblogs.com/chenssy/p/3521565.html

 

http://xujin.org/java/hm01/

相關文章
相關標籤/搜索