本文將深入剖析 Java7 中的 HashMap 和 ConcurrentHashMap 的源碼,解析 HashMap 線程不安全的原理以及解決方案,最後以測試用例加以驗證。
1 Java7 HashMap
HashMap 的數據結構:
從上圖中可以看出,HashMap 底層就是一個數組結構,數組中的每一項又是一個鏈表。
通過查看 JDK 中的 HashMap 源碼,可以看到其構造函數有一行代碼:
public HashMap(int initialCapacity, float loadFactor) { ... table = new Entry[capacity]; ... }
即創建了一個大小爲 capacity 的 Entry 數組,而 Entry 的結構如下:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; final int hash; …… }
可以看到,Entry 是一個 static class,其中包含了 key 和 value ,也就是鍵值對,另外還包含了一個 next 的 Entry 指針。
capacity:當前數組容量,始終保持 2^n,可以擴容,擴容後數組大小爲當前的 2 倍。默認初始容量爲 16。
loadFactor:負載因子,默認爲 0.75。
threshold:擴容的閾值,等於 capacity * loadFactor
1.1 put過程分析
public V put(K key, V value) { // 當插入第一個元素的時候,需要先初始化數組大小 if (table == EMPTY_TABLE) { inflateTable(threshold); } // 如果 key 爲 null,則這個 entry 放到 table[0] 中 if (key == null) return putForNullKey(value); // key 的 hash 值 int hash = hash(key); // 找到對應的數組下標 int i = indexFor(hash, table.length); // 遍歷一下對應下標處的鏈表,看是否有重複的 key 已經存在, // 如果有,直接覆蓋,put 方法返回舊值就結束了 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; // 不存在重複的 key,將此 entry 添加到鏈表中 addEntry(hash, key, value, i); return null; }
這裏對一些方法做深入解析。
數組初始化
private void inflateTable(int toSize) { // 保證數組大小一定是 2^n int capacity = roundUpToPowerOf2(toSize); // 計算擴容閾值 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); // 初始化數組 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
找到對應的數組下標
static int indexFor(int hash, int length) { // 作用等價於取模運算,但這種方式效率更高 return hash & (length-1); }
因爲HashMap的底層數組長度總是 2^n,當 length 爲 2 的 n 次方時,hash & (length-1) 就相當於對length取模,而且速度比直接取模要快的多。
添加節點到鏈表中
void addEntry(int hash, K key, V value, int bucketIndex) { // 如果當前 HashMap 大小已經達到了閾值,並且新值要插入的數組位置已經有元素了,那麼要擴容 if ((size >= threshold) && (null != table[bucketIndex])) { // 擴容 resize(2 * table.length); // 重新計算 hash 值 hash = (null != key) ? hash(key) : 0; // 計算擴容後的新的下標 bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }// 永遠都是在鏈表的表頭添加新元素void createEntry(int hash, K key, V value, int bucketIndex) { // 獲取指定 bucketIndex 索引處的 Entry Entry<K,V> e = table[bucketIndex]; // 將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
當系統決定存儲 HashMap 中的 key-value 對時,完全沒有考慮 Entry 中的 value,僅僅只是 根據 key 來計算並決定每個 Entry 的存儲位置 。我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的存儲位置之後,value 隨之保存在那裏即可。
數組擴容
隨着 HashMap 中元素的數量越來越多,發生碰撞的概率將越來越大,所產生的子鏈長度就會越來越長,這樣勢必會影響 HashMap 的存取速度。爲了保證 HashMap 的效率,系統必須要在某個臨界點進行擴容處理,該臨界點 threshold。而在 HashMap 數組擴容之後,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,並放進去,這就是 resize。
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; // 若 oldCapacity 已達到最大值,直接將 threshold 設爲 Integer.MAX_VALUE if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; // 直接返回 } // 否則,創建一個更大的數組 Entry[] newTable = new Entry[newCapacity]; //將每條Entry重新哈希到新的數組中 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; // 重新設定 threshold threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
1.2 get過程分析
public V get(Object key) { // key 爲 null 的話,會被放到 table[0],所以只要遍歷下 table[0] 處的鏈表就可以了 if (key == null) return getForNullKey(); // key 非 null 的情況,詳見下文 Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }final Entry<K,V> getEntry(Object key) { // The number of key-value mappings contained in this map. if (size == 0) { return null; } // 根據該 key 的 hashCode 值計算它的 hash 碼 int hash = (key == null) ? 0 : hash(key); // 確定數組下標,然後從頭開始遍歷鏈表,直到找到爲止 for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; //若搜索的key與查找的key相同,則返回相對應的value if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
2 Java7 ConcurrentHashMap
ConcurrentHashMap 的成員變量中,包含了一個 Segment 數組 final Segment<K,V>[] segments;,而 Segment 是ConcurrentHashMap 的內部類。
然後在 Segment 這個類中,包含了一個 HashEntry 的數組transient volatile HashEntry<K,V>[] table,而 HashEntry 也是 ConcurrentHashMap 的內部類。
HashEntry 中,包含了 key 和 value 以及 next 指針(類似於 HashMap 中的 Entry),所以 HashEntry 可以構成一個鏈表。
2.1 成員變量及構造函數
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> implements ConcurrentMap<K, V>, Serializable { ... //初始的容量 static final int DEFAULT_INITIAL_CAPACITY = 16; //初始的加載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //初始的併發等級,表示當前更新線程的估計數 static final int DEFAULT_CONCURRENCY_LEVEL = 16; //最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; //最小的segment數量 static final int MIN_SEGMENT_TABLE_CAPACITY = 2; //最大的segment數量 static final int MAX_SEGMENTS = 1 << 16; // static final int RETRIES_BEFORE_LOCK = 2; // segments 的掩碼值, key 的散列碼的高位用來選擇具體的 segment final int segmentMask; // 偏移量 final int segmentShift; final Segment<K,V>[] segments; ... // 創建一個帶有指定初始容量、加載因子和併發級別的新的空映射 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // 尋找最佳匹配參數(不小於給定參數的最接近的 2^n) int sshift = 0; // 用來記錄向左按位移動的次數 int ssize = 1; // 用來記錄Segment數組的大小 // 計算並行級別 ssize,因爲要保持並行級別是 2^n while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 若爲默認值,concurrencyLevel 爲 16,sshift 爲 4 // 那麼計算出 segmentShift 爲 28,segmentMask 爲 15 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // 記錄每個 Segment 上要放置多少個元素 int c = initialCapacity / ssize; // 假如有餘數,則Segment數量加1 if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; // create segments and segments[0] Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }
當用 new ConcurrentHashMap() 無參構造函數進行初始化的,那麼初始化完成後:
Segment 數組長度爲 16,不可以擴容
Segment[i] 的默認大小爲 2,負載因子是 0.75,得出初始閾值爲 1.5,也就是以後插入第一個元素不會觸發擴容,插入第二個會進行第一次擴容
這裏初始化了 segment[0],其他位置還是 null,至於爲什麼要初始化 segment[0],後面的代碼會介紹
當前 segmentShift 的值爲 32 – 4 = 28,segmentMask 爲 16 – 1 = 15,姑且把它們簡單翻譯爲移位數和掩碼,這兩個值馬上就會用到
2.2 put過程分析
根據 hash 值很快就能找到相應的 Segment,之後就是 Segment 內部的 put 操作。
public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); int hash = hash(key); // 根據 hash 值找到 Segment 數組中的位置 j // hash 是 32 位,無符號右移 segmentShift(28) 位,剩下低 4 位, // 然後和 segmentMask(15) 做一次與操作,也就是說 j 是 hash 值的最後 4 位,也就是槽的數組下標 int j = (hash >>> segmentShift) & segmentMask; // 剛剛說了,初始化的時候初始化了 segment[0],但是其他位置還是 null, // ensureSegment(j) 對 segment[j] 進行初始化 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); // 插入新值到 槽 s 中 return s.put(key, hash, value, false); }
Segment 內部是由 數組+鏈表 組成的。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 先獲取該 segment 的獨佔鎖 // 每一個Segment進行put時,都會加鎖 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { // segment 內部的數組 HashEntry<K,V>[] tab = table; // 利用 hash 值,求應該放置的數組下標 int index = (tab.length - 1) & hash; // 數組該位置處的鏈表的表頭 HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { // 如果鏈頭不爲 null if (e != null) { K k; //如果在該鏈中找到相同的key,則用新值替換舊值,並退出循環 if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } //如果沒有和key相同的,一直遍歷到鏈尾,鏈尾的next爲null,進入到else e = e.next; } else { // node 到底是不是 null,這個要看獲取鎖的過程,不過和這裏都沒有關係。 // 如果不爲 null,那就直接將它設置爲鏈表表頭;如果是null,初始化並設置爲鏈表表頭。 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; // 如果超過了該 segment 的閾值,這個 segment 需要擴容 if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else // 沒有達到閾值,將 node 放到數組 tab 的 index 位置, // 其實就是將新的節點設置成原鏈表的表頭 setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { // 解鎖 unlock(); } return oldValue; }
2.3 初始化Segment
ConcurrentHashMap 初始化的時候會初始化第一個槽 segment[0],對於其他槽來說,在插入第一個值的時候進行初始化。
這裏需要考慮併發,因爲很可能會有多個線程同時進來初始化同一個槽 segment[k],不過只要有一個成功了就可以。
private Segment<K,V> ensureSegment(int k) { final Segment<K,V>[] ss = this.segments; long u = (k << SSHIFT) + SBASE; // raw offset Segment<K,V> seg; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // 這裏看到爲什麼之前要初始化 segment[0] 了, // 使用當前 segment[0] 處的數組長度和負載因子來初始化 segment[k] // 爲什麼要用“當前”,因爲 segment[0] 可能早就擴容過了 Segment<K,V> proto = ss[0]; // use segment 0 as prototype int cap = proto.table.length; float lf = proto.loadFactor; int threshold = (int)(cap * lf); // 初始化 segment[k] 內部的數組 HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap]; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck Segment[k] 是否被其它線程初始化了 Segment<K,V> s = new Segment<K,V>(lf, threshold, tab); // 使用 while 循環,內部用 CAS,當前線程成功設值或其他線程成功設值後,退出 while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break; } } } return seg; }
2.4 get過程分析
比較簡單,先找到 Segment 數組的位置,然後找到 HashEntry 數組的位置,最後順着鏈表查找即可。
public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
3 線程不安全
3.1 哈希碰撞
多個線程同時使用 put() 方法添加元素,若存在兩個或多個 put() 的 key 發生了碰撞,那麼有可能其中一個線程的數據被覆蓋。
3.2 擴容
當數據要插入 HashMap 時,都會檢查容量有沒有超過設定的 thredhold,如果超過,則需要擴容。而多線程會導致擴容後的鏈表形成環形數據結構,一旦形成環形數據結構,Entry 的 next 的節點永遠不爲 null,就會在獲取 Entry 時產生死循環。
例子可見文章《HashMap多線程死循環問題》。
不過要注意,其使用的 Java 版本既不是 7,也不是 8。在 Java7 中方法 addEntry() 添加節點到鏈表中是先擴容後再添加,而例子中的源碼是:
void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; // 先添加節點 table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 然後擴容 if (size++ >= threshold) resize(2 * table.length); }
所以在 Java7 中此例子無效。而在 Java8 中,通過確保建新鏈與舊鏈的順序是相同的,即可避免產生死循環。
4 HashMap遍歷方式
import java.util.HashMap;import java.util.Iterator;import java.util.Map; public class HashMapTest { private final static Map<Integer, Object> map = new HashMap<Integer, Object>(10000); private static final Object PRESENT = new Object(); public static void main(String args[]) { long startTime; long endTime; long totalTime; for (int i = 0; i < 7500; i++) { map.put(i, PRESENT); } // 方法一 startTime = System.nanoTime(); Iterator iter1 = map.entrySet().iterator(); while (iter1.hasNext()) { Map.Entry<Integer, Object> entry = (Map.Entry) iter1.next(); Integer key = entry.getKey(); Object val = entry.getValue(); } endTime = System.nanoTime(); totalTime = endTime - startTime; System.out.println("methor1 pays " + totalTime + " ms"); // 方法二 startTime = System.nanoTime(); Iterator iter2 = map.keySet().iterator(); while (iter2.hasNext()) { Object key = iter2.next(); Object val = map.get(key); } endTime = System.nanoTime(); totalTime = endTime - startTime; System.out.println("methor2 pays " + totalTime + " ms"); } }
運行結果:
5 性能對比
線程安全的使用 HashMap 有三種方式,分別爲 Hashtable、SynchronizedMap()、ConcurrentHashMap。
Hashtable
使用 synchronized 來保證線程安全,幾乎所有的 public 的方法都是 synchronized 的,而有些方法也是在內部通過 synchronized 代碼塊來實現。
synchronizedMap()
通過創建一個線程安全的 Map 對象,並把它作爲一個封裝的對象來返回。
ConcurrentHashMap
支持多線程對 Map 做讀操作,並且不需要任何的 blocking 。這得益於 CHM 將 Map 分割成了不同的部分,在執行更新操作時只鎖住一部分。根據默認的併發級別, Map 被分割成 16 個部分,並且由不同的鎖控制。這意味着,同時最多可以有 16個 寫線程操作 Map 。試想一下,由只能一個線程進入變成同時可由 16 個寫線程同時進入(讀線程幾乎不受限制),性能的提升是顯而易見的。但由於一些更新操作,如 put(), remove(), putAll(), clear()只鎖住操作的部分,所以在檢索操作不能保證返回的是最新的結果。
在迭代遍歷 CHM 時, keySet 返回的 iterator 是弱一致和 fail-safe 的,可能不會返回某些最近的改變,並且在遍歷過程中,如果已經遍歷的數組上的內容變化了,不會拋出 ConcurrentModificationExceptoin 的異常。
什麼時候使用 ConcurrentHashMap ?
CHM 適用於讀者數量超過寫者時,當寫者數量大於等於讀者時,CHM 的性能是低於 Hashtable 和 synchronizedMap 的。這是因爲當鎖住了整個 Map 時,讀操作要等待對同一部分執行寫操作的線程結束。
CHM 適用於做 cache ,在程序啓動時初始化,之後可以被多個請求線程訪問。
CHM 是Hashtable一個很好的替代,但要記住, CHM 的比 Hashrable 的同步性稍弱。
6 拓展:Java8 HashMap & ConcurrentHashMap
Java8 對 HashMap 和 ConcurrentHashMap 做了一些修改:
二者均利用了紅黑樹,所以其數據結構由 數組 + 鏈表 + 紅黑樹 組成。我們知道,鏈表上的數據需要一個一個比較下去才能找到我們需要的,時間複雜度取決於鏈表的長度,爲 O(n)。爲了降低這一部分的開銷,在 Java8 中,當鏈表中的元素超過了 8 個以後,會將鏈表轉換爲紅黑樹,這個時候時間複雜度就降爲了 O(logN)
Java8 中 ConcurrentHashMap 摒棄 Java7 中的 Segment 的概念,使用了另一種方式實現保證線程安全。
Linux公社的RSS地址: https://www.linuxidc.com/rssFeed.aspx
本文永久更新鏈接地址: https://www.linuxidc.com/Linux/2018-09/154133.htm
轉: