Collection集合的最大特點是每次進行單個對象的存儲,而Map是進行一對對象的保存,並且這兩個對象之間的關係是key=value的關係。這種結構最大的特點是可以通過key值找到value值。
Map接口常用的方法如下:
當然,Map也是一個接口,要想實例化,也需要子類,Map有四個子類:HashMap
、Hashtable
、TreeMap
、ConcurrentHashMap
。
HashMap
範例:
Map<Integer,String> map=new HashMap<Integer, String>();
map.put(1,"hello");
map.put(1,"hello");
map.put(2,"Java");
map.put(3,"is");
map.put(4,"best");
map.put(null,"!!!");
map.put(null,"...");
map.put(5,null);
map.put(6,null);
System.out.println(map);
System.out.println(map.get(1));
System.out.println(map.get(10));
HashMap的內部實現原理
對於HashMap最重要的是理解內部實現原理!!!!!
先看兩個圖
HashMap內部可以看作是數組(Node[] table)和鏈表結合組成的複合結構,數組被分爲一個個桶(bucket),通過哈希值決定了鍵值對在這個數組的尋址;哈希值相同的鍵值對,則以鏈表形式存儲。如果鏈表大小超過閾值(TREEIFY_THRESHOLD, 8),圖中的鏈表就會被改造爲樹形結構。
這裏我們需要看着源碼來解析:
這是構造函數,從這個構造函數的源碼中我們可以知道HashMap並不是一開始就初始化好的。只是設置了一些初始值。
再看看放數據的時候是如何存儲的,如下圖:
調用了putVal()方法,下面是次方法的源碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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;
}
從 putVal 方法最初的幾行,我們就可以發現幾個有意思的地方:
如果表格是 null,resize 方法會負責初始化它,這從 tab = resize() 可以看出。
resize 方法兼顧兩個職責,創建初始存儲表格,或者在容量不滿足需求的時候,進行擴容(resize)
。
具體鍵值對在哈希表中的位置(數組 index)取決於下面的位運算:
i=(n-1)&hash
我們會發現,它並不是 key 本身的 hashCode,而是來自於 HashMap內部的另外一個 hash 方法。注意,爲
什麼這裏需要將高位數據移位到低位進行異或運算呢?這除是因爲有些數據計算出的哈希值差異主要在高
位,而 HashMap 裏的哈希尋址是忽略容量以上的高位的,那麼這種處理就可以有效避免類似情況下的哈希
碰撞。
再看看realize()方法:
依據 resize 源碼,不考慮極端情況(容量理論最大極限由 MAXIMUM_CAPACITY 指定,數值爲 1<<30,也就是 2的 30 次方),我們可以歸納爲:
- 門限值等於(負載因子)*(容量),如果構建 HashMap 的時候沒有指定它們,那麼就是依據相應的
默認常量值。- 門限通常是以倍數進行調整 (newThr = oldThr << 1),我前面提到,根據 putVal 中的邏輯,當元素
個數超過門限大小時,則調整 Map 大小。- 擴容後,需要將老的數組中的元素重新放置到新的數組,這是擴容的一個主要開銷來源
在上面的討論中,我們離不開的兩個詞是負載因子和容量,爲什麼在這麼在乎負載因子和容量呢?
這是因爲負載因子和容量關乎可用桶的數量,如果空桶太多會浪費空間,使用的太滿則會影響操作的性能。可以假想極端情況下,只有一個桶,那就變成了鏈表,性能大大降低。可以考慮預先設置合適的容量大小。具體數值我們可以根據擴容發生的條件來做簡單預估,根據前面的代碼分析,我們知道它需要符合計算條件:
負載因子 * 容量 > 元素數量
所以,預先設置的容量需要滿足,大於“預估元素數量 / 負載因子”,同時它是 2 的冪數
當桶的容量小於等於64,鏈表長度大於8,進行擴容
當桶的容量大於64並且鏈表長度大於8的時候,進行樹化
那麼,爲什麼 HashMap 要樹化呢?
本質上這是個安全問題。因爲在元素放置過程中,如果一個對象哈希衝突,都被放置到同一個桶裏,則會形成一個鏈表,我們知道鏈表查詢是線性的,會嚴重影響存取的性
Hashtable
Map<Integer,String > map=new Hashtable<Integer, String>();
map.put(1,"hello");
map.put(1,"hello");
map.put(2,"Java");
map.put(3,"is");
map.put(4,"best");
// map.put(null,"!!!");//異常
// map.put(null,"...");//異常
// map.put(5,null);//異常
// map.put(6,null);//異常
System.out.println(map.get(1));
System.out.println(map.get(10));
事實是,Hashtable裏面的key值和value值都不允許爲空,否則會拋出異常。
HashMap與Hashtable的區別:
ConcurrentHashMap
Hashtable本身比較低效,因爲它的實現基本上是將put(),get(),size()方法加上了synchronized,這就導致了所有併發操作都要競爭同一把鎖,也就是說一個線程獲得這個鎖的時候,其他線程都在等待,這就是能效率大大降低。
早期 ConcurrentHashMap,其實現是基於:
分離鎖,也就是將內部進行分段(Segment),裏面則是HashEntry 的數組,和 HashMap類似,哈希
相同的條目也是以鏈表形式存放。
HashEntry 內部使用 volatile 的 value 字段來保證可見性,也利用了不可變對象的機制以改進利用Unsafe 提供的底層能力,比如 volatile access,去直接完成部分操作,以最優化性能,畢竟 Unsafe 中的很多操作都是 JVM intrinsic 優化過的。
可以參考下面這個早期 ConcurrentHashMap 內部結構的示意圖,其核心是利用分段設計,在進行併發操作的時
候,只需要鎖定相應段,這樣就有效避免了類似 Hashtable 整體同步的問題,大大提高了性能:
在構造的時候,Segment 的數量由所謂的 concurrentcyLevel 決定,默認是 16,也可以在相應構造函數直接指
定。注意,Java 需要它是 2 的冪數值,如果輸入是類似 15 這種非冪值,會被自動調整到 16 之類 2 的冪數值。
- ConcurrentHashMap 會獲取再入鎖,以保證數據一致性,Segment 本身就是基於ReentrantLock 的
擴展實現,所以,在併發修改期間,相應 Segment 是被鎖定的。- 在最初階段,進行重複性的掃描,以確定相應 key 值是否已經在數組裏面,進而決定是更新還是放置操
作。重複掃描、檢測衝突是ConcurrentHashMap 的常見技巧。- 在 ConcurrentHashMap中擴容同樣存在。不過有一個明顯區別,就是它進行的不是整體的擴容,而是
單獨對 Segment 進行擴容
另外一個 Map 的 size 方法同樣需要關注,它的實現涉及分離鎖的一個副作用。
試想,如果不進行同步,簡單的計算所有 Segment 的總值,可能會因爲併發 put,導致結果不準確,但是直接鎖定所有 Segment 進行計算,就會變得非常昂貴。其實,分離鎖也限制了 Map的初始化等操作。所以,ConcurrentHashMap 的實現是通過重試機制(RETRIES_BEFORE_LOCK,指定重試次數 2),來試圖獲得可靠值。如果沒有監控到發生變化(通過對比 Segment.modCount),就直接返回,否則獲取鎖進行操作。
下面來對比一下,在 Java 8 和之後的版本中,ConcurrentHashMap 發生了哪些變化呢?
- 總體結構上,它的內部存儲變得和HashMap 結構非常相似,同樣是大的桶(bucket)數組,然後內部
也是一個個所謂的鏈表結構(bin),同步的粒度要更細緻一些。- 其內部仍然有 Segment 定義,但僅僅是爲了保證序列化時的兼容性而已,不再有任何結構上的用處。
- 因爲不再使用 Segment,初始化操作大大簡化,修改爲 lazy-load 形式,這樣可以有效避免初始開銷,解決了老版本很多人抱怨的這一點。
- 數據存儲利用 volatile 來保證可見性。
- 使用 CAS 等操作,在特定場景進行無鎖併發操作。
- 使用 Unsafe、LongAdder 之類底層手段,進行極端情況的優化。
TreeMap
TreeMap是一個可以排序的子類,它是按照key的內容進行排序的。
Map<Integer,String> map=new TreeMap<Integer, String>();
map.put(2,"is");
map.put(1,"Java");
map.put(3,"best");
map.put(0,"hello");
System.out.println(map);