Map再整理,从底层源码探究HashMap
前言
本文為對Map集合的再一次整理。內容包括:Map HashMap LinkedHashMap TreeHashMap HashTable ConcurrentHashMap
Map
Map<k,v>使用鍵值對存儲,map會維護與鍵k相關聯的值v。兩個key可以關聯相同的對象,但key不能重復,常見的key是String類型,但也可以是任何對象。通過鍵就可以找到對應的值,這種數據結構就是Map(映射)
Map接口中定義的方法:
void clear()
//刪除所有的映射
default V compute(K key, BiFunction<? super K,? super V,? extends V> remappingFunction)
//嘗試計算指定鍵的映射及其當前映射的值(如果沒有當前映射, null )。
default V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)
/*如果指定的鍵尚未與值相關聯(或映射到 null ),則嘗試使用給定的映射函數計算其值,并將其輸入到此/映射中,除非 null 。 */
default V computeIfPresent(K key, BiFunction<? super K,? super V,? extends V> remappingFunction)
//如果指定的密鑰的值存在且非空,則嘗試計算給定密鑰及其當前映射值的新映射。
boolean containsKey(Object key)
//如果此映射包含指定鍵的映射,則返回 true 。
boolean containsValue(Object value)
//如果此map將一個或多個鍵映射到指定的值,則返回 true 。
Set<Map.Entry<K,V>> entrySet()
//返回此map中包含的映射的Set視圖。
boolean equals(Object o)
//將指定的對象與此映射進行比較以獲得相等性。
default void forEach(BiConsumer<? super K,? super V> action)
//對此映射中的每個條目執行給定的操作,直到所有條目都被處理或操作引發異常。
V get(Object key)
//返回到指定鍵所映射的值,或 null
default V getOrDefault(Object key, V defaultValue)
//返回到指定鍵所映射的值,或 defaultValue
int hashCode()
//返回此map的哈希碼值。
boolean isEmpty()
//如果此map不包含鍵值映射,則返回 true 。
Set<K> keySet()
//返回此map中包含的鍵的Set視圖。
default V merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction)
//如果指定的鍵尚未與值相關聯或與null相關聯,則將其與給定的非空值相關聯。
V put(K key, V value)
//將指定的值與該映射中的指定鍵相關聯(可選操作)。
void putAll(Map<? extends K,? extends V> m)
//將指定map的所有映射復制到此映射(可選操作)。
default V putIfAbsent(K key, V value)
/*如果指定的鍵尚未與某個值相關聯(或映射到 null )將其與給定值相關聯并返回null,否則返回當前值。 */
V remove(Object key)
//如果存在(從可選的操作),從該map中刪除一個鍵的映射。
default boolean remove(Object key, Object value)
//僅當指定的密鑰當前映射到指定的值時刪除該條目。
default V replace(K key, V value)
//只有當目標映射到某個值時,才能替換指定鍵的條目。
default boolean replace(K key, V oldValue, V newValue)
//僅當當前映射到指定的值時,才能替換指定鍵的條目。
default void replaceAll(BiFunction<? super K,? super V,? extends V> function)
//將每個條目的值替換為對該條目調用給定函數的結果,直到所有條目都被處理或該函數拋出異常。
int size()
//返回此map中鍵值映射的數量。
Collection<V> values()
//返回此map中包含的值的Collection視圖。
Map的實現類
線程不安全實現類
1.HashMap
JDK1.8 之前 HashMap 底層是 數組和鏈表 結合在一起使用也就是 鏈表散列。HashMap 通過 key 的 hashCode 經過擾動函數(所謂擾動函數指的就是 HashMap 的 hash 方法。使用 hash 方法也就是擾動函數是為了防止一些實現比較差的 hashCode() 方法 換句話說使用擾動函數之后可以減少碰撞。)處理過后得到 hash 值,然后通過 (n - 1) & hash 判斷當前元素存放的位置(這里的 n 指的是數組的長度),如果當前位置存在元素的話,就判斷該元素與要存入的元素的 hash 值以及 key 是否相同,如果相同的話,直接覆蓋,不相同就通過拉鏈法解決沖突。數據結構如下圖:
JDK1.8 以后的 HashMap 在解決哈希沖突時有了較大的變化,當鏈表長度大于閾值(默認為8)時,將鏈表轉化為紅黑樹,以減少搜索時間。數據結構圖如下:
HashMap最多只允許一條記錄的鍵為null,允許多條記錄的值為null。HashMap非線程安全,即任一時刻可以有多個線程同時寫HashMap,可能會導致數據的不一致。如果需要滿足線程安全,可以用 Collections的synchronizedMap方法使HashMap具有線程安全的能力,或者使用ConcurrentHashMap。
HashMap的初始化源碼:
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16static final int MAXIMUM_CAPACITY = 1 << 30;static final float DEFAULT_LOAD_FACTOR = 0.75f;transient Node<K,V>[] table; // 節點數組final float loadFactor; // 加載因子也稱填充因子int threshold; // 閥值,計算:threshold = loadFactor * capcity(節點數組容量)...public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor = loadFactor;this.threshold = tableSizeFor(initialCapacity);}static final int tableSizeFor(int cap) {int n = cap - 1;n |= n >>> 1;n |= n >>> 2;n |= n >>> 4;n |= n >>> 8;n |= n >>> 16;return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;}
}
從源碼中可以看到:
- DEFAULT_INITIAL_CAPACITY = 1 << 4;節點數組默認大小為16,這里使用位運算,相比乘法運算性能更快。
- Node<K,V>[] table,節點數組(也叫桶數組),就相當于是鏈表中的節點。需要注意的是Node類中的K是使用final關鍵字修飾的,也就說明了Map中的鍵(key)是不能修改的。
- threshold :閥值,計算方式為threshold = loadFactor * capcity(節點數組容量),當節點數組的使用容量超過閥值時就會以2的冪次方擴充節點數組。
- HashMap空參初始化的時候并沒有初始化節點數組,而只是設置了默認的加載因子,初始化大小會在第一次調用put方法后調用resize()方法進行初始化,其實數組的擴容也是調用這個方法進行的。
HashMap的put方法
HashMap的put方法的源碼有些多和難以理解(這里就不展示和解讀了,感興趣的可以自己去看源碼),我們看一張java8中put方法的處理邏輯圖:
這里我們重點說說resize()方法,在兩個地方會調用到resize方法:
- 當節點數組table為null時,會調用resize方發完成初始化
- 當節點數組table使用的容量大于閥值threshold時會調用resize方法擴容。
看看resize方法的部分源碼:
final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;int newCap, newThr = 0;if (oldCap > 0) {if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}else if (oldThr > 0) // initial capacity was placed in thresholdnewCap = oldThr;else { // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;...
}
從源碼中我們可以看到,初始化時table為空,緊接著就執行newCap = DEFAULT_INITIAL_CAPACITY;初始化新的容量newCap大小為默認容量大小16,最后再new一個容量為16的節點數組再將成員變量中的節點數組table的引用指向新的節點數組。所以說HashMap是在執行put()方法才初始化數組大小的。
我們看當oldCap>0,容量大于等于默認值且未超過最大值時,會通過newCap = oldCap << 1 和newThr = oldThr << 1; 將容量和加載因子都擴大一倍。也就是說,HashMap的擴容機制就是重新申請一個容量是當前的2倍的節點數組,然后將原先的記錄逐個重新映射到新的節點數組里面,然后將原先的節點數組里的節點逐個置為null使得引用失效。
剩余部分的代碼就是通過新插入的節點(每一個put(key,value)都會被封裝成一個節點)通過e.hash & (newCap - 1)找到節點數組的下標判斷是否有值…自己看圖。
為什么HashMap是不安全的?
其實HashMap之所以線程不安全就是出在了這個resize方法上,在resize操作的時候會造成線程不安全,如:put的時候導致的多線程數據不一致。
這個問題比較好想象,比如有兩個線程A和B,首先A希望插入一個key-value對到HashMap中,首先計算記錄所要落到的桶的索引坐標,然后獲取到該桶里面的鏈表頭結點,此時線程A的時間片用完了,而此時線程B被調度得以執行,和線程A一樣執行,只不過線程B成功將記錄插到了桶里面,假設線程A插入的記錄計算出來的桶索引和線程B要插入的記錄計算出來的桶索引是一樣的,那么當線程B成功插入之后,線程A再次被調度運行時,它依然持有過期的鏈表頭但是它對此一無所知,以至于它認為它應該這樣做,如此一來就覆蓋了線程B插入的記錄,這樣線程B插入的記錄就憑空消失了,造成了數據不一致的行為。
2.LinkedHashMap
LinkedHashMap是HashMap的一個子類,保存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照訪問次序排序。看名字也可以猜到,LinkedHashMap就是在HashMap的基礎上加了一個鏈表來保存記錄的插入順序。結構圖如下:
3.TreeMap
TreeMap實現SortedMap接口,能夠把它保存的記錄根據鍵排序,默認是按鍵值的升序排序,也可以指定排序的比較器,當用Iterator遍歷TreeMap時,得到的記錄是排過序的。如果使用排序的映射,建議使用TreeMap。在使用TreeMap時,key必須實現Comparable接口或者在構造TreeMap傳入自定義的Comparator,否則會在運行時拋出java.lang.ClassCastException類型的異常
這里順便提一下Comparable與Comparator的簡單區別:
- comparable是java.lang包下的一個接口,使用compareTo(Object obj)方法來進行排序的。
- comparator是java.util包下的一個接口,使用compare(Object obj1,Object obj2)方法來進行排序的。
線程安全類
1.HashTable
HashTable和HashMap的實現原理幾乎一樣,差別無非是1.HashTable不允許key和value為null;2.HashTable是線程安全的。但是HashTable線程安全的策略實現代價卻太大了,簡單粗暴,get/put所有相關操作都是synchronized的,這相當于給整個哈希表加了一把大鎖,多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當于將所有的操作串行化,在競爭激烈的并發場景中性能就會非常差。Hashtable不建議在新代碼中使用,不需要線程安全的場合可以用HashMap替換,需要線程安全的場合可以用ConcurrentHashMap替換。數據結構如下圖:
2.ConcurrentHashMap
ConcurrentHashMap是Java并發包中提供的一個線程安全且高效的HashMap實現
1.分段鎖機制
概念由來:上面我們已經說了HashTable線程安全的策略實現代價太大了,它將get/put所有相關操作都是使用synchronized修飾的,這相當于給整個哈希表加了一把大鎖,多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當于將所有的操作串行化,在競爭激烈的并發場景中性能就會非常差。所以在jdk1.5~1.7版本提出了分段機制概念。
原理:分段鎖對整個桶數組進行了分割分段(Segment),每一把鎖只鎖容器其中一部分數據,多線程訪問容器里不同數據段的數據,就不會存在鎖競爭,提高并發訪問率。結構圖如下:
2.jdk1.8版本(現在所用的實現方式)
在jdk1.8版本中,由于采用分段鎖時,最大并發數受限于segment個數,有很大的局限性,所以在jdk1.8版本中ConcurrentHashMap取消了Segment分段鎖,采用CAS和synchronized來保證并發安全。數據結構跟HashMap1.8的結構類似,數組+鏈表/紅黑二叉樹。Java 8在鏈表長度超過一定閾值(8)時將鏈表(尋址時間復雜度為O(N))轉換為紅黑樹(尋址時間復雜度為O(log(N)))synchronized只鎖定當前鏈表或紅黑二叉樹的首節點,這樣只要hash不沖突,就不會產生并發,性能得到了很大的提升。
JDK1.8的ConcurrentHashMap(TreeBin: 紅黑二叉樹節點 Node: 鏈表節點):
參考
https://www.jianshu.com/p/e2f75c8cce01
https://snailclimb.top/JavaGuide/#/java/Multithread
總結
以上是生活随笔為你收集整理的Map再整理,从底层源码探究HashMap的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: List再整理,从代码底层全面解析Lis
- 下一篇: java锁(公平锁和非公平锁、可重入锁(