1. 程式人生 > >Java 集合之 Map(轉載)

Java 集合之 Map(轉載)

iter 估算 做的 sortedmap 的區別 復雜度 detail 四種 測試環境

原文鏈接 :http://www.importnew.com/20386.html

Map 就是另一個頂級接口了,總感覺 Map 是 Collection 的子接口呢。Map 主要用於表示那些含有映射關系的數據,存儲的是一組一組的鍵值對。Map 是允許你將某些對象與其它一些對象關聯起來的關聯數組。

舉個例子感受一下:我想通過學生的學號來找到對應的姓名就可以使用 Map 來存儲 Map< Integer ,String > 。我想知道每個學生一共選了幾門課可以這樣存儲 Map < Student ,List < Course > > 。這樣我們就將 Student 這個類和課程的集合 List < Course > 關聯起來了 。

下面來說說 Map 這個頂級的接口都有哪些具體的實現。

技術分享圖片

HashMap :它根據鍵的 hashCode 值存儲數據,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但遍歷順序卻是不確定的。 HashMap 最多只允許一條記錄的鍵為 null ,允許多條記錄的值為 null 。HashMap 非線程安全,即任一時刻可以有多個線程同時寫 HashMap ,可能會導致數據的不一致。如果需要滿足線程安全,可以用 Collections 的 synchronizedMap 方法使HashMap 具有線程安全的能力,或者使用 ConcurrentHashMap 。

Hashtable :Hashtable 是遺留類,很多映射的常用功能與 HashMap 類似,不同的是它承自 Dictionary 類,並且是線程安全的,任一時間只有一個線程能寫 Hashtable,並發性不如 ConcurrentHashMap,因為 ConcurrentHashMap 引入了分段鎖。Hashtable 不建議在新代碼中使用,不需要線程安全的場合可以用 HashMap 替換,需要線程安全的場合可以用 ConcurrentHashMap 替換。

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

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

對於上述四種 Map 類型的類,要求映射中的 key 是不可變對象。不可變對象是該對象在創建後它的哈希值不會被改變 。可以參考這篇文章理解,String 與不可變對象。如果對象的哈希值發生變化,Map 對象很可能就定位不到映射的位置了。

以上這些實現類中 HashMap 的使用頻率是最高的,也是在面試中經常被問到的,所以下面就重點說一下關於 HashMap 的相關知識。

搞清楚 HashMap,首先需要知道 HashMap 是什麽,就要搞清楚 HashMap 中的字段和方法都有哪些,這些字段在底層是以何種結構存儲的。

從結構實現來講,HashMap 是數組 + 鏈表 + 紅黑樹(JDK1.8 增加了紅黑樹部分)實現的,如下圖所示。

技術分享圖片

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

從源碼可知,HashMap 類中有一個非常重要的字段,就是 Node[] table,即哈希桶數組,明顯它是一個 Node 的數組。我們來看 Node[JDK1.8] 是何物。

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 對象。

HashMap 就是使用哈希表來存儲的。哈希表為解決沖突,可以采用開放地址法和鏈地址法等來解決問題,Java 中 HashMap 采用了鏈地址法。鏈地址法,簡單來說,就是數組加鏈表的結合。在每個數組元素上都一個鏈表結構,當數據被 Hash 後,得到數組下標,把數據放在對應下標元素的鏈表上。例如程序執行下面代碼:

map.put("name","YJK923");

系統將調用 ”name” 這個 key 的 hashCode() 方法得到其 hashCode 值(該方法繼承自 Object ),然後再通過 Hash 算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的存儲位置,有時兩個 key 會定位到相同的位置,表示發生了 Hash 碰撞。當然 Hash 算法計算結果越分散均勻,Hash 碰撞的概率就越小,map 的存取效率就會越高。

如果數組很長,即使較差的 Hash 算法也會比較分散,如果數組很短,即使好的 Hash 算法也會出現較多碰撞,所以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況確定數組的大小,並在此基礎上設計好的 hash 算法減少 Hash 碰撞。那麽通過什麽方式來控制 map 使得 Hash 碰撞的概率又小,哈希桶數組(Node[] table)占用空間又少呢 ?答案就是好的 Hash 算法和擴容機制。

在理解 Hash 和擴容流程之前,我們得先了解下 HashMap 的幾個字段。從 HashMap 的默認構造函數源碼可知,構造函數就是對下面幾個字段進行初始化,源碼如下 :

int threshold;             // 所能容納的key-value對極限

final float loadFactor;    // 負載因子

int modCount; 

int size;

首先,Node[] table 的初始化長度 length (默認值是 16),Load factor 為負載因子(默認值是 0.75),threshold 是 HashMap 所能容納的最大數據量的 Node (鍵值對)個數。threshold = length * Load factor。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。

結合負載因子的定義公式可知,threshold 就是在此 Load factor 和 length (數組長度)對應下允許的最大元素數目,超過這個數目就重新 resize (擴容),擴容後的 HashMap 容量是之前容量的兩倍。默認的負載因子 0.75 是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果內存空間很多而又對時間效率要求很高,可以降低負載因子 Load factor 的值;相反,如果內存空間緊張而對時間效率要求不高,可以增加負載因子 loadFactor 的值,這個值可以大於 1。

size 這個字段其實很好理解,就是 HashMap 中實際存在的鍵值對數量。註意和 table 的長度 length、容納最大鍵值對數量 threshold 的區別。而 modCount 字段主要用來記錄 HashMap 內部結構發生變化的次數,主要用於叠代的快速失敗。強調一點,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。

在 HashMap 中,哈希桶數組 table 的長度 length 大小必須為 2 的 n 次方(一定是合數),這是一種非常規的設計,常規的設計是把桶的大小設計為素數。相對來說素數導致沖突的概率要小於合數,具體證明可以參考

http://blog.csdn.net/liuqiyao_01/article/details/14475159

Hashtable 初始化桶大小為 11,就是桶大小設計為素數的應用( Hashtable 擴容後不能保證還是素數)。HashMap 采用這種非常規設計,主要是為了在取模和擴容時做優化,同時為了減少沖突, HashMap 定位哈希桶索引位置時,也加入了高位參與運算的過程。

這裏存在一個問題,即使負載因子和 Hash 算法設計的再合理,也免不了會出現拉鏈過長的情況,一旦出現拉鏈過長,則會嚴重影響 HashMap 的性能。於是,在 JDK1.8 版本中,對數據結構做了進一步的優化,引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高 HashMap 的性能,其中會用到紅黑樹的插入、刪除、查找等算法。本文不再對紅黑樹展開討論,想了解更多紅黑樹數據結構的工作原理可以參考

http://blog.csdn.net/v_july_v/article/details/6105630

HashMap 的內部功能實現很多,本文主要從根據 key 獲取哈希桶數組索引位置、put 方法的詳細執行、擴容過程三個具有代表性的點深入展開講解。

1. 確定哈希桶數組索引位置

不管增加、刪除還是查找鍵值對,定位到哈希桶數組的位置都是很關鍵的第一步。前面說過 HashMap 的數據結構是數組和鏈表的結合,所以我們當然希望這個 HashMap 裏面的元素位置盡量分布均勻些,盡量使得每個位置上的元素數量只有一個,那麽當我們用 hash 算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,不用遍歷鏈表,大大優化了查詢的效率。HashMap 定位數組索引位置,直接決定了 hash 方法的離散性能。先看看源碼的實現(方法一 + 方法二)

方法一:
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 數組的哪個索引處。

這個方法非常巧妙,它通過 h & (table.length - 1) 來得到該對象的保存位,而 HashMap 底層數組的長度總是 2 的 n 次方,這是 HashMap 在速度上的優化。當 length 總是 2 的 n 次方時,h & (length - 1) 運算等價於對 length 取模,也就是 h % length,但是 & 比 % 具有更高的效率。

在 JDK1.8 的實現中,優化了高位運算的算法,通過 hashCode() 的高 16 位異或低 16 位實現的 :( h = k.hashCode()) ^ (h >>> 16 ),主要是從速度、功效、質量來考慮的,這麽做可以在數組 table 的 length 比較小的時候,也能保證考慮到高低 Bit 都參與到 Hash 的計算中,同時不會有太大的開銷。

下面舉例說明得出數組下標的過程,n 為 table 的長度。

技術分享圖片

2. 分析 HashMap 的 put 方法

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.8 HashMap 的 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;
     // 步驟 1 :tab為空則創建
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // 步驟 2 :計算index,並對null做處理 
     if ((p = tab[i = (n - 1) & hash]) == null) 
         tab[i] = newNode(hash, key, value, null);
     else {
         Node<K,V> e; K k;
         // 步驟 3 :節點key存在,直接覆蓋value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // 步驟 4 :判斷該鏈為紅黑樹
         else if (p instanceof TreeNode)
             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
         // 步驟 5 :該鏈為鏈表
         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;
     // 步驟 6 :超過最大容量 就擴容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
}

3. 擴容機制

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

我們分析下 resize 的源碼,鑒於 JDK 1.8 融入了紅黑樹,較復雜,為了便於理解我們仍然使用 JDK 1.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]; 
                 newTable[i] = e;      //將元素放在數組上
                 e = next;             //訪問下一個Entry鏈上的元素
             } while (e != null);
         }
     }
 }

newTable[i] 的引用賦給了 e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到 Entry 鏈的尾部 ( 如果發生了hash沖突的話),這一點和 JDK 1.8有區別,下文詳解。在舊數組中同一條 Entry 鏈上的元素,通過重新計算索引位置後,有可能被放到了新數組的不同位置上。

下面舉個例子說明下擴容過程。假設我們的 hash 算法就是簡單的用 key mod 一下表的大小(也就是數組的長度)。其中的哈希桶數組 table 的 size = 2, 所以 key = 3、7、5,put 順序依次為 5、7、3 。在 mod 2以後都沖突在 table[1] 這裏了。這裏假設負載因子 loadFactor = 1,即當鍵值對的實際大小 size 大於 table 的實際大小時進行擴容。接下來的三個步驟是哈希桶數組 resize 成 4,然後所有的 Node 重新 rehash 的過程。

技術分享圖片

下面我們講解下 JDK1.8 做了哪些優化。經過觀測可以發現,我們使用的是 2 次冪的擴展(指長度擴為原來2倍),所以,元素的位置要麽是在原位置,要麽是在原位置再移動 2 次冪的位置。看下圖可以明白這句話的意思,n 為 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容後 key1 和 key2 兩種 key 確定索引位置的示例,其中 hash1 是 key1 對應的哈希與高位運算結果。

技術分享圖片

元素在重新計算 hash 之後,因為 n 變為 2 倍,那麽 n-1 的 mask 範圍在高位多 1bit (紅色),因此新的 index 就會發生這樣的變化 :

技術分享圖片

因此,我們在擴充 HashMap 的時候,不需要像 JDK1.7 的實現那樣重新計算 hash ,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成 “ 原索引 + oldCap ”,可以看看下圖為 16 擴充為 32 的 resize 示意圖 :

技術分享圖片

這個設計確實非常的巧妙,既省去了重新計算 hash 值的時間,而且同時,由於新增的 1bit 是 0 還是 1 可以認為是隨機的,因此 resize 的過程,均勻的把之前的沖突的節點分散到新的 bucket 了。這一塊就是 JDK1.8 新增的優化點。有一點註意區別,JDK1.7 中 rehash 的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8 不會倒置。有興趣的同學可以研究下 JDK1.8 的 resize 源碼,寫的很贊,如下 :

1 final Node<K,V>[] resize() {
 2     Node<K,V>[] oldTab = table;
 3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4     int oldThr = threshold;
 5     int newCap, newThr = 0;
 6     if (oldCap > 0) {
 7         // 超過最大值就不再擴充了,就只好隨你碰撞去吧
 8         if (oldCap >= MAXIMUM_CAPACITY) {
 9             threshold = Integer.MAX_VALUE;
10             return oldTab;
11         }
12         // 沒超過最大值,就擴充為原來的2倍
13         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14                  oldCap >= DEFAULT_INITIAL_CAPACITY)
15             newThr = oldThr << 1; // double threshold
16     }
17     else if (oldThr > 0) // initial capacity was placed in threshold
18         newCap = oldThr;
19     else {               // zero initial threshold signifies using defaults
20         newCap = DEFAULT_INITIAL_CAPACITY;
21         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22     }
23     // 計算新的resize上限
24     if (newThr == 0) {
25
26         float ft = (float)newCap * loadFactor;
27         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28                   (int)ft : Integer.MAX_VALUE);
29     }
30     threshold = newThr;
31     @SuppressWarnings({"rawtypes","unchecked"})
32         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33     table = newTab;
34     if (oldTab != null) {
35         // 把每個bucket都移動到新的buckets中
36         for (int j = 0; j < oldCap; ++j) {
37             Node<K,V> e;
38             if ((e = oldTab[j]) != null) {
39                 oldTab[j] = null;
40                 if (e.next == null)
41                     newTab[e.hash & (newCap - 1)] = e;
42                 else if (e instanceof TreeNode)
43                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44                 else { // 鏈表優化重hash的代碼塊
45                     Node<K,V> loHead = null, loTail = null;
46                     Node<K,V> hiHead = null, hiTail = null;
47                     Node<K,V> next;
48                     do {
49                         next = e.next;
50                         // 原索引
51                         if ((e.hash & oldCap) == 0) {
52                             if (loTail == null)
53                                 loHead = e;
54                             else
55                                 loTail.next = e;
56                             loTail = e;
57                         }
58                         // 原索引+oldCap
59                         else {
60                             if (hiTail == null)
61                                 hiHead = e;
62                             else
63                                 hiTail.next = e;
64                             hiTail = e;
65                         }
66                     } while ((e = next) != null);
67                     // 原索引放到bucket裏
68                     if (loTail != null) {
69                         loTail.next = null;
70                         newTab[j] = loHead;
71                     }
72                     // 原索引+oldCap放到bucket裏
73                     if (hiTail != null) {
74                         hiTail.next = null;
75                         newTab[j + oldCap] = hiHead;
76                     }
77                 }
78             }
79         }
80     }
81     return newTab;
82 }

線程安全性

在多線程使用場景中,應該盡量避免使用線程不安全的 HashMap,而使用線程安全的 ConcurrentHashMap 。在並發的多線程使用場景中使用 HashMap 可能會造成死循環。

JDK1.8 與 JDK1.7 的性能對比

HashMap 中,如果 key 經過 hash 算法得出的數組索引位置全部不相同,即 Hash 算法非常好,那樣的話,get(key) 方法的時間復雜度就是O(1),如果 Hash 算法的結果碰撞非常多,假如 Hash 算極其差,所有的 Hash 算法結果得出的索引位置一樣,那樣所有的鍵值對都集中到一個桶中,或者在一個鏈表中,或者在一個紅黑樹中,時間復雜度分別為O(n)和O(log n)。 鑒於 JDK1.8 做了多方面的優化,總體性能優於JDK1.7,下面我們從兩個方面用例子證明這一點。

Hash 較均勻的情況

為了便於測試,我們先寫一個類 Key,如下 :

public class Key implements Comparable<Key> {

    private final int value;

    Key(int value) {
        this.value = value;
    }

    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }

    @Override
    public int hashCode() {
        return value;
    }
}

這個類復寫了 equals 方法,並且提供了相當好的 hashCode 函數,任何一個值的 hashCode 都不會相同,因為直接使用 value 當做 hashcode 。為了避免頻繁的 GC,我將不變的 Key 實例緩存了起來,而不是一遍一遍的創建它們。代碼如下 :

public class Keys {

    public static final int MAX_KEY = 10_000_000; // 等價於 10000000
    private static final Key[] KEYS_CACHE = new Key[MAX_KEY];

    static {
        for (int i = 0; i < MAX_KEY; ++i) {
            KEYS_CACHE[i] = new Key(i);
        }
    }

    public static Key of(int value) {
        return KEYS_CACHE[value];
    }
}

現在開始我們的試驗,測試需要做的僅僅是,創建不同 size 的 HashMap(1、10、100、……10000000),屏蔽了擴容的情況,代碼如下:

static void test(int mapSize) {

       HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
       for (int i = 0; i < mapSize; ++i) {
           map.put(Keys.of(i), i);
       }

       long beginTime = System.nanoTime(); //獲取納秒
       for (int i = 0; i < mapSize; i++) {
           map.get(Keys.of(i));
       }
       long endTime = System.nanoTime();
       System.out.println(endTime - beginTime);
   }

   public static void main(String[] args) {
       for(int i=10;i<= 1000_0000;i*= 10){
           test(i);
       }
   }

在測試中會查找不同的值,然後度量花費的時間,為了計算 get(key) 的平均時間,我們遍歷所有的 get 方法,計算總的時間,除以 key 的數量,計算一個平均值,主要用來比較,絕對值可能會受很多環境因素的影響。結果如下:

技術分享圖片

通過觀測測試結果可知,JDK1.8 的性能要高於 JDK1.7 15% 以上,在某些 size 的區域上,甚至高於 100%。由於 Hash 算法較均勻,JDK1.8 引入的紅黑樹效果不明顯,下面我們看看 Hash 不均勻的的情況。

Hash 極不均勻的情況

假設我們又一個非常差的 Key,它們所有的實例都返回相同的 hashCode 值。這是使用 HashMap 最壞的情況。代碼修改如下:

class Key implements Comparable<Key> {

    //...

    @Override
    public int hashCode() {
        return 1;
    }
}

仍然執行 main 方法,得出的結果如下表所示:

技術分享圖片

從表中結果中可知,隨著 size 的變大,JDK1.7 的花費時間是增長的趨勢,而 JDK1.8 是明顯的降低趨勢,並且呈現對數增長穩定。當一個鏈表太長的時候, HashMap 會動態的將它替換成一個紅黑樹,這樣的話會將時間復雜度從 O(n) 降為 O(log n) 。hash 算法均勻和不均勻所花費的時間明顯也不相同,這兩種情況的相對比較,可以說明一個好的 hash 算法是多麽的重要。

測試環境:處理器為 2.2 GHz Intel Core i7,內存為 16 GB 1600 MHz DDR3,SSD 硬盤,使用默認的 JVM 參數,運行在 64 位的 OS X 10.10.1 上。

小結

  1. 擴容是一個特別耗性能的操作,所以當程序員在使用 HashMap 的時候,估算 map 的大小,初始化的時候給一個大致的數值,避免 map 進行頻繁的擴容。

  2. 負載因子是可以修改的,也可以大於 1,但是建議不要輕易修改,除非情況非常特殊。

  3. HashMap 是線程不安全的,不要在並發的環境中同時操作 HashMap,建議使用 ConcurrentHashMap 。

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

  5. 還沒升級 JDK1.8 的,現在開始升級吧。HashMap 的性能提升僅僅是 JDK1.8 的冰山一角。

最後我想問,還有誰 ?還在使用 JDK1.7 。我 ~

推薦閱讀:Java 集合之 Collection

Java 集合之 Map(轉載)