併發中的Map容器
本文作者:王一飛,叩丁狼高階講師。原創文章,轉載請註明出處。
上幾篇討論了併發環境下list容器的操作, 本篇我們來聊下另外一個集合容器:Map
Map:以key-value對的形式存在,一種資料結構,一個key, 對映一個value值, map中不能包含重複的key值, 一個key最多隻能對映到一個值。
常用方法有:
新增: V put(K key, V value);
刪除: V remove(Object key);
修改: V put(K key, V value);
查詢: V get(Object key);
常見的實現類:
HashMap
LinkedHashMap
TreeMap
Hashtable
ConcurrentHashMap
要研究HashMap在併發環境下使用, 先得了解hashmap實現原理.HashMap是基於雜湊表的 Map 介面的實現。(傳送門:雜湊表百度百科),其底層維護一個數組與連結串列.原始碼說話:
注意:
jdk8以前hashMap結構: 陣列 + 連結串列
jdk8以後hashMap結構: 陣列 + 連結串列 + 紅黑樹
此處原始碼使用的jdk8
原始碼:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { //初始容量, 預設16,俗稱桶, 可以認為陣列長度 //一個桶對應一條連結串列 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //預設負載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //真實的負載因子, 不特意指定是等於0.75f; final float loadFactor; //閾值:所能容納kv對最大數量,超過這個值,則需擴容 //規則: threshold = capacity * loadFactor int threshold; //雜湊表, 如果必要, 長度以2的n次方方式拓展 transient Node<K,V>[] table; }
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } }
HashMap運作規則:
1>HashMap 初始化時(不特意指定), 預設建立長度為16的一維陣列, 用於儲存Node(即kv對)/掛載連結串列投節點
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
2>map新增元素時map.put(key, value), 先通過雜湊演算法h = hash(key)計算出當前Node(key, value)在table陣列中位置, 如果table[h]位置為null.table[h] = Node(key, value), 如果有值, 稱之為碰撞, 則建立連結串列, 將當前Node(key, value) 拼接在連結串列後面.
3>JDK8的特性, 新增後,table某個位置的掛載連結串列個數大於等於TREEIFY_THRESHOLD=8時, 為提高查詢效率,則將該位置下的連結串列轉換成紅黑樹.
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) {
//建立新node
p.next = newNode(hash, key, value, null);
//如果node連結串列超過8
if (binCount >= TREEIFY_THRESHOLD - 1)
//轉換成紅黑樹
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果map的容量大於threshold, map擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4> 新增後, 如果Map 的的size > threshold 閾值, 則需要對map擴容, 演算法:
情況一:使用空參HashMap()構造器構建map/首次初始化
newSize = DEFAULT_INITIAL_CAPACITY = 16
newThreshold = newSize = oldSize loadFactor
情況二:非空參構造器建立Map物件/後續初始化
newSize = oldSize 2 //擴容2倍
newThreshold = oldThreshold * 2
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; // 2倍
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = 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;
//....省略資料拷貝
}
到這大體瞭解HashMap底層實現, 細心的朋友應該可以看出來, hashMap所有的操作並沒有使用synchronized修飾, 也就說, hashMap在高併發的環境下存在明顯的執行緒安全問題.
場景1:多執行緒複合操作時
執行緒t1給map新增資料(key),而執行緒t2操作相同的key值, 先新增後獲取. 某一時刻, t2先新增,如果此時切換到t1執行, t1會覆蓋t2新增的資料, t2再次讀取時, 資料被修改了, 出現髒讀問題.
public class App {
public static void main(String[] args) throws InterruptedException {
final HashMap<String, String> map = new HashMap<>();
Thread t1 = new Thread(new Runnable() {
public void run() {
map.put("key", "t1");
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
public void run() {
map.put("key", "t2");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(map.get("key")); //t1
}
}, "t2");
t2.start();
Thread.sleep(2000);
t1.start();
}
}
場景2:多執行緒同時新增相同hash 碼值時
多個執行緒同時執行put操作時, 如果key的hash碼一樣時, 根據HashMap的實現,會有多個key新增到陣列的同一個位置,如果此位置已經被佔用,掛載新節點時,容易發生執行緒put的資料被覆蓋。
public class User {
private String name;
public User(String name) {
this.name = name;
}
//保證hashMap呼叫hash算出的hashcode一致
public int hashCode() {
return 1;
}
}
public class App {
public static void main(String[] args) throws InterruptedException {
//final Hashtable<User, String> map = new Hashtable<>();
final HashMap<User, String> map = new HashMap<>();
//3個相同的執行緒, 同時往map中新增
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t1").start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t2").start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t3").start();
Thread.sleep(1000);
System.out.println(map.size());
}
}
JDK1.8計算的hash 碼演算法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
上面程式執行存在幾種結果:
1:結果為3000, 這是正確的
2:結果少於3000, 這就出現key值被覆蓋了.原因:連結串列node移位時,出現併發問題.
3:報異常, 類轉換異常.原因:前面說過,jdk8中hashmap同一個位置, 掛載大於等於8個節點會轉換成紅黑樹, 多個執行緒爭奪時,這個臨界點會出問題.
java.lang.ClassCastException: HashMap$Node cannot be cast to HashMap$TreeNode
4:報異常, 記憶體溢位異常, 這是場景3問題, map擴容造成的.
Exception in thread "t3" Exception in thread "t2" Exception in thread "t1" java.lang.StackOverflowError
場景3:多執行緒同時擴容時
多執行緒剛好同時對hashMap進行擴容,最終只有一個執行緒擴容的table替換舊table陣列, 那麼其他執行緒put的資料會丟失.另外更有甚者, 可能會造成put操作的死迴圈(詳細參考一個大牛寫的:HashMap死迴圈)
HashMap 在設計之初就沒考慮過執行緒安全的問題, 所以在併發環境下HashMap並不是首選, 更多偏向下面幾個Map.
HashTable&Collections.synchronizedMap
HashTable 跟hashMap底層實現類似, 但在設計上考慮到執行緒安全操作, hashTable中所有的核心操作都加上synchronized修飾,確保操作的安全性. 所以併發環境下, hashTable不會出現場景2, 場景3 情況.而場景1中的複合操作還需要額外加鎖, 確保操作安全. 具體操作, 參考上上遍併發中的list的案例.
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
Collections.synchronizedMap 的操作跟HashTable大同小異,都是通過synchronized給操作方加鎖, 這裡不累贅了.
ConcurrentHashMap 是jdk1.5引入的併發map實現類, 該類的設計者推薦併發環境首選Map實現了, 那麼它能解決上面場景1,場景2,場景3的併發為問麼?篇幅所限, 我們下回分解.