HashMap詳解


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

簡介

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

image

  1. HashMap:它根據鍵的hashCode值存儲數據,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度。
    HashMap最多只允許一條記錄的鍵為null,允許多條記錄的值為null。非線程安全。
    如果需要滿足線程安全,可以用 Collections的synchronizedMap方法使HashMap具有線程安全的能力,或者使用ConcurrentHashMap

  2. Hashtable:Hashtable是遺留類,很多映射的常用功能與HashMap類似,不同的是它承自Dictionary類。線程安全。並發性不如ConcurrentHashMap,因為ConcurrentHashMap引入了分段鎖。

  3. LinkedHashMap:LinkedHashMap是HashMap的一個子類,保存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照訪問次序排序。

  4. TreeMap:TreeMap實現SortedMap接口,能夠把它保存的記錄根據鍵排序,默認是按鍵值的升序排序,也可以指定排序的比較器,當用Iterator遍歷TreeMap時,得到的記錄是排過序的。
    在使用TreeMap時,key必須實現Comparable接口或者在構造TreeMap傳入自定義的Comparator,否則會在運行時拋出java.lang.ClassCastException類型的異常。

內部實現

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

存儲結構-字段

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

這里需要講明白兩個問題:數據底層具體存儲的是什么?這樣的存儲方式有什么優點呢?

  1. HashMap類中有一個非常重要的字段,就是 Node[] table,即哈希桶數組
static class Node<K,V> implements 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對象。

  1. 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的內部功能實現很多,本文主要講述:

  1. 根據key獲取哈希桶數組索引位置
  2. put方法的詳細執行
  3. 擴容過程

確定哈希桶數組索引位置

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

方法一:
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,但是&比%具有更高的效率。

image

分析HashMap的put方法

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

 1 public V put(K key, V value) {
 2     // 對key的hashCode()做hash
 3     return putVal(hash(key), key, value, false, true);
 4 }
 5 
 6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 7                boolean evict) {
 8     Node<K,V>[] tab; Node<K,V> p; int n, i;
 9     // 步驟①:tab為空則創建
10     if ((tab = table) == null || (n = tab.length) == 0)
11         n = (tab = resize()).length;
12     // 步驟②:計算index,並對null做處理 
13     if ((p = tab[i = (n - 1) & hash]) == null) 
14         tab[i] = newNode(hash, key, value, null);
15     else {
16         Node<K,V> e; K k;
17         // 步驟③:節點key存在,直接覆蓋value
18         if (p.hash == hash &&
19             ((k = p.key) == key || (key != null && key.equals(k))))
20             e = p;
21         // 步驟④:判斷該鏈為紅黑樹
22         else if (p instanceof TreeNode)
23             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24         // 步驟⑤:該鏈為鏈表
25         else {
26             for (int binCount = 0; ; ++binCount) {
27                 if ((e = p.next) == null) {
28                     p.next = newNode(hash, key,value,null);
                        //鏈表長度大於8轉換為紅黑樹進行處理
29                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
30                         treeifyBin(tab, hash);
31                     break;
32                 }
                    // key已經存在直接覆蓋value
33                 if (e.hash == hash &&
34                     ((k = e.key) == key || (key != null && key.equals(k))))                                          break;
36                 p = e;
37             }
38         }
39         
40         if (e != null) { // existing mapping for key
41             V oldValue = e.value;
42             if (!onlyIfAbsent || oldValue == null)
43                 e.value = value;
44             afterNodeAccess(e);
45             return oldValue;
46         }
47     }

48     ++modCount;
49     // 步驟⑥:超過最大容量 就擴容
50     if (++size > threshold)
51         resize();
52     afterNodeInsertion(evict);
53     return null;
54 }

擴容機制

當然Java里的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。

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

 1 void resize(int newCapacity) {   //傳入新的容量
 2     Entry[] oldTable = table;    //引用擴容前的Entry數組
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //擴容前的數組大小如果已經達到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一個新的Entry數組
10     transfer(newTable);                         //!!將數據轉移到新的Entry數組里
11     table = newTable;                           //HashMap的table屬性引用新的Entry數組
12     threshold = (int)(newCapacity * loadFactor);//修改閾值
13 }

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

 1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了舊的Entry數組
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
 5         Entry<K,V> e = src[j];             //取得舊Entry數組的每個元素
 6         if (e != null) {
 7             src[j] = null;//釋放舊Entry數組的對象引用(for循環后,舊的Entry數組不再引用任何對象)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
11                 e.next = newTable[i]; //標記[1]
12                 newTable[i] = e;      //將元素放在數組上
13                 e = next;             //訪問下一個Entry鏈上的元素
14             } while (e != null);
15         }
16     }
17 }

同一位置上新元素總會被放在鏈表的頭部位置
我們在擴充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


免責聲明!

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



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