HashMap詳解

 

JDK1.8對HashMap底層的實現進行了優化,例如引入紅黑樹的數據結構和擴容的優化等。java

 

簡介node

Java爲數據結構中的映射定義了一個接口java.util.Map程序員

 

 

 

一、HashMap:它根據鍵的hashCode值存儲數據,大多數狀況下能夠直接定位到它的值,於是具備很快的訪問速度。算法

HashMap最多隻容許一條記錄的鍵爲null,容許多條記錄的值爲null。非線程安全。數組

若是須要知足線程安全,能夠用 Collections的synchronizedMap方法使HashMap具備線程安全的能力,或者使用ConcurrentHashMap安全

 

二、Hashtable:Hashtable是遺留類,不少映射的經常使用功能與HashMap相似,不一樣的是它承自Dictionary類。線程安全。併發性不如ConcurrentHashMap,由於ConcurrentHashMap引入了分段鎖。微信

 

三、LinkedHashMap:LinkedHashMap是HashMap的一個子類,保存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先獲得的記錄確定是先插入的,也能夠在構造時帶參數,按照訪問次序排序。數據結構

 

四、TreeMap:TreeMap實現SortedMap接口,可以把它保存的記錄根據鍵排序,默認是按鍵值的升序排序,也能夠指定排序的比較器,當用Iterator遍歷TreeMap時,獲得的記錄是排過序的。多線程


在使用TreeMap時,key必須實現Comparable接口或者在構造TreeMap傳入自定義的Comparator,不然會在運行時拋出java.lang.ClassCastException類型的異常。併發

 

內部實現

(1) 存儲結構-字段
(2) 功能實現-方法

 

存儲結構-字段

HashMap是數組+鏈表+紅黑樹(JDK1.8增長了紅黑樹部分)實現的。

 

 

 

 

這裏須要講明白兩個問題:數據底層具體存儲的是什麼?這樣的存儲方式有什麼優勢呢?

 

HashMap類中有一個很是重要的字段,就是 Node[] table,即哈希桶數組

static class Node<K,Vimplements Map.Entry<K,V{
   final int hash;    //用來定位數組索引位置
   final K key;
   V value;
   Node<K,V> next;   //鏈表的下一個node

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

 

Node是HashMap的一個內部類,實現了Map.Entry接口,本質是就是一個映射(鍵值對)。上圖中的每一個黑色圓點就是一個Node對象。

 

HashMap就是使用哈希表來存儲的。Java中HashMap採用了拉鍊法解決衝突。
例如程序執行下面代碼:

map.put("美團","小美");

 

系統將調用"美團"這個key的hashCode()方法獲得其hashCode 值(該方法適用於每一個Java對象),而後再經過Hash算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的存儲位置。

 

哈希桶數組須要在空間成本和時間成本之間權衡。那麼經過什麼方式來控制map使得Hash碰撞的機率又小,哈希桶數組(Node[] table)佔用空間又少呢?答案就是好的Hash算法和擴容機制

 

HashMap的默認構造函數就是對下面幾個字段進行初始化

int threshold;             // 所能容納的key-value對極限 
final float loadFactor;    // 負載因子
int modCount;              // 用來記錄HashMap內部結構發生變化的次數
int size;

 

 

Node[] table的初始化長度length(默認值是16),Load factor爲負載因子(默認值是0.75),threshold是HashMap所能容納的最大數據量的Node(鍵值對)個數。

 

threshold就是在此Load factor和length(數組長度)對應下容許的最大元素數目,超過這個數目就從新resize(擴容),擴容後的HashMap容量是以前容量的兩倍

 

在HashMap中,哈希桶數組table的長度length大小必須爲2的n次方(必定是合數),這是一種很是規的設計,常規的設計是把桶的大小設計爲素數。相對來講素數致使衝突的機率要小於合數[2].

 

HashMap採用這種很是規設計,主要是爲了在取模和擴容時作優化,同時爲了減小衝突,HashMap定位哈希桶索引位置時,也加入了高位參與運算的過程。

 

當鏈表長度太長(默認超過8)時,鏈表就轉換爲紅黑樹,利用紅黑樹快速增刪改查的特色提升HashMap的性能。

 

功能實現-方法

HashMap的內部功能實現不少,本文主要講述:

一、根據key獲取哈希桶數組索引位置

二、put方法的詳細執行

三、擴容過程

 

肯定哈希桶數組索引位置

 

先看看源碼的實現(方法一+方法二):

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
    int h;
    // h = key.hashCode() 爲第一步 取hashCode值
    // h ^ (h >>> 16)  爲第二步 高位參與運算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

方法二:
static int indexFor(int h, int length) {  
//jdk1.7的源碼,jdk1.8沒有這個方法,可是實現原理同樣的
    return h & (length-1);  //第三步 取模運算
}

 

這裏的Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算


只要它的hashCode()返回值相同,那麼程序調用方法一所計算獲得的Hash碼值老是相同的。咱們首先想到的就是把hash值對數組長度取模運算,這樣一來,元素的分佈相對來講是比較均勻的。可是,模運算的消耗仍是比較大的,在HashMap中是這樣作的:調用方法二來計算該對象應該保存在table數組的哪一個索引處。

 

而HashMap底層數組的長度老是2的n次方,這是HashMap在速度上的優化。當length老是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,可是&比%具備更高的效率。

 

 

 

 

分析HashMap的put方法

 

 

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

public V put(K key, V value{
   // 對key的hashCode()作hash
   return putVal(hash(key), key, valuefalsetrue);
 }

 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, valuenull);
   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, valuenull);
           // 鏈表長度大於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;
 }

 

擴容機制

固然Java裏的數組是沒法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像咱們用一個小桶裝水,若是想裝更多的水,就得換大水桶。

 

鑑於JDK1.8融入了紅黑樹,較複雜,爲了便於理解咱們仍然使用JDK1.7的代碼,好理解一些,本質上區別不大,具體區別後文再說。

 

void resize(int newCapacity) {   //傳入新的容量
   Entry[] oldTable = table;    //引用擴容前的Entry數組
   int oldCapacity = oldTable.length;         
   if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小若是已經達到最大(2^30)了
       threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣之後就不會擴容了
       return;
   }
  
  Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
  transfer(newTable);                         //!!將數據轉移到新的Entry數組裏
  table = newTable;                           //HashMap的table屬性引用新的Entry數組
  threshold = (int)(newCapacity * loadFactor);//修改閾值
  }

 

transfer()方法將原有Entry數組的元素拷貝到新的Entry數組裏。

void transfer(Entry[] newTable{
   Entry[] src = table;                   //src引用了舊的Entry數組
   int newCapacity = newTable.length;
   for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
       Entry<K,V> e = src[j];             //取得舊Entry數組的每一個元素
       if (e != null) {
           src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組再也不引用任何對象)
           do {
               Entry<K,V> next = e.next;
               int i = indexFor(e.hash, newCapacity); //!!從新計算每一個元素在數組中的位置
               e.next = newTable[i]; //標記[1]
               newTable[i] = e;      //將元素放在數組上
               e = next;             //訪問下一個Entry鏈上的元素
           } while (e != null);
       }
   }
}

 

同一位置上新元素總會被放在鏈表的頭部位置,咱們在擴充HashMap的時候,不須要像JDK1.7的實現那樣從新計算hash,只須要看看原來的hash值新增的那個bit是1仍是0就行了(這部分並無徹底懂)

 

線程安全

HashMap在多線程的狀況下可能鏈結構會受到破壞,致使無限循壞(JDK8 可能已經解決)

 

小結

    (1) 擴容是一個特別耗性能的操做,因此當程序員在使用HashMap的時候,估算map的大小,初始化的時候給一個大體的數值,避免map進行頻繁的擴容。

    (2) 負載因子是能夠修改的,也能夠大於1,可是建議不要輕易修改,除非狀況很是特殊。

    (3) HashMap是線程不安全的,不要在併發的環境中同時操做HashMap,建議使用ConcurrentHashMap。

    (4) JDK1.8引入紅黑樹大程度優化了HashMap的性能。

 

參考資料:

  1. 美團點評技術團隊 Java 8系列之從新認識HashMap https://zhuanlan.zhihu.com/p/21673805

  2. 爲何通常hashtable的桶數會取一個素數 http://blog.csdn.net/liuqiyao_01/article/details/14475159 

 
 
 

 

讚揚

長按二維碼向我轉帳

 

受蘋果公司新規定影響,微信 iOS 版的讚揚功能被關閉,可經過二維碼轉帳支持公衆號。

相關文章
相關標籤/搜索