深入理解HashMap上篇


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

一.HashMap的概述

1.1 HashMap的數據結構

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

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

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

從上圖我們可以發現HashMap是由Entry數組+鏈表組成的,一個長度為16的數組中,每個元素存儲的是一個鏈表的頭結點。那么這些元素是按照什么樣的規則存儲到數組中呢。一般情況是通過hash(key)%len獲得,也就是元素的key的哈希值對數組長度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、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[]會以一定的規則加長長度。      

二.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/

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM