一、HashMap爲什麼是線程不安全的
問題主要出現是hashmap的擴容操作的rehash操作上。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//下面兩行代碼我們可以看出在rehash的時候是通過頭插法插入到table中的
e.next = newTable[i];
//以下分析都假設在併發時線程A在此刻被掛起
newTable[i] = e;
e = next;
}
}
}
1、hashmap會形成死循環,環形鏈表
假設容器初始值爲如下圖、hash的算法簡單的使用取模操作
線程A和B併發向容器中put元素,發現容器使用率已經超過了容器的個人乘以加載因子的值,則需要擴容。
線程A在執行到上述代碼時候時間片結束,此時A的結構爲
此時線程B獲取時間片,進行操作,並且擴容完成。
然後線程A再獲取時間片來進行執行,由java內存模型可知道,newTable和table中的鏈表都是最新的值,A執行完成一輪循環後的結構爲。
繼續第二次循環
此時主存中的7的next是3,此時再將3rehash到table中,此時e已經爲nulll,循環結束,就會出現如下結構
之後涉及到輪詢table3的結構時就會發生死循環操作
數據丟失的問題只要將初始化的結構該爲7-》5-》3。最終resize後的結構是如下圖,有興趣的可以自己分析下。出現了環形鏈表和丟失了3
二、concurrentHashMap
JDK1.7的實現
1、concurrentHashMap的數據結構
segment可以看作是一把可重入鎖,因爲它繼承了ReentrantLock,也就是所謂的分段鎖,這裏是在爲併發時候做提高性能使用的。
hashEntry的定義是用volatile關鍵字修飾的,則可以保障他的內存可見性,線程間可以即使看到修改的數據。
static final class HashEntry<K,V> {
final K key; // 聲明 key 爲 final 型
final int hash; // 聲明 hash 值爲 final 型
volatile V value; // 聲明 value 爲 volatile 型
final HashEntry<K,V> next; // 聲明 next 爲 final 型
HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
this.key = key;
this.hash = hash;
this.next = next;
this.value = value;
}
}
2、初始化做了哪些事情
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
//默認的segment的大小
static final int DEFAULT_INITIAL_CAPACITY= 16;
//加載因子,當table的佔用個數大於table的容量乘以當前值的時候要觸發擴容,進行rehash
static final float DEFAULT_LOAD_FACTOR= 0.75f;
// 散列表的默認併發級別爲 16。該值表示當前更新線程的估計併發量
static final int DEFAULT_CONCURRENCY_LEVEL= 16;
/**
* segments 的掩碼值
* key 的散列碼的高位用來選擇具體的 segment
* 初始化的時候取的是
*/
final int segmentMask;
/**
* 偏移量
*/
final int segmentShift;
/**
* 由 Segment 對象組成的數組
*/
final Segment<K,V>[] segments;
/**
* 創建一個帶有指定初始容量、加載因子和併發級別的新的空映射。
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
if(!(loadFactor > 0) || initialCapacity < 0 ||
concurrencyLevel <= 0)
throw new IllegalArgumentException();
//seghment的大小不能超過65535
if(concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 尋找最佳匹配參數(不小於給定參數的最接近的 2 次冪)
int sshift = 0;
int ssize = 1;
while(ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//此時ssize算下來等於16因爲將1左移了4位
//sshift等於4
segmentShift = 32 - sshift; // 偏移量值等於32-4=28這裏是在put操作時候取用的是hash值的前3位來進行的定位segment位置
segmentMask = ssize - 1; // 掩碼值,等於15,因爲當前值要
this.segments = Segment.newArray(ssize); // 創建數組
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if(c * ssize < initialCapacity)
++c;
//table的個數是2
int cap = 1;
while(cap < c)
cap <<= 1;
// 依次遍歷每個數組元素
for(int i = 0; i < this.segments.length; ++i)
// 初始化每個數組元素引用的 Segment 對象
this.segments[i] = new Segment<K,V>(cap, loadFactor);
}
/**
* 創建一個帶有默認初始容量 (16)、默認加載因子 (0.75) 和 默認併發級別 (16)
* 的空散列映射表。
*/
public ConcurrentHashMap() {
// 使用三個默認參數,調用上面重載的構造函數來創建空散列映射表
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
總結下初始化做的事情
- 計算出segment的掩碼值=15,以爲對segment定位的時候是按照%的方式進行操作的,使用的方式是利用位運算&的方式,因爲a&2^n = a&2的n次方減1,2的4次方等於16.對16取餘就相當於對2的4次方減1=15按位&的操作,這裏一會get\put的時候會用到
- 計算出cap的值=2,cap的含義就是table中的數組的個數
- 初始化segment
3、get操作、怎麼定位、如何保證線程安全、get方法的弱一致性
1 public V get(Object key) {
2 Segment<K,V> s; // manually integrate access methods to reduce overhead
3 HashEntry<K,V>[] tab;
4 int h = hash(key);
5 long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
6 if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
7 (tab = s.table) != null) {
8 for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
9 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
10 e != null; e = e.next) {
11 K k;
12 if ((k = e.key) == key || (e.hash == h && key.equals(k)))
13 return e.value;
14 }
15 }
16 return null;
17 }
- 通過獲取到hash的值,如果是自定義對象的話,會要求重寫hashCode方法,hash中是先取到對象的hash值,再進行一個wang jenkis算法的再哈希。獲取到hash值後先先右移segmentShift=28位,取前三位和segmentmask進行按位&運算得到segment的下標
- 再對segmet中的table進行也進行按位&的操作獲取到table的下標
- 然後去遍歷鏈表,獲取到對應的值
如何保證線程安全:開始我們看到使用volatile修飾了hashEntry,即保證了線程之間的內存可見性,線程A改變之後不會進行緩存,直接會回寫到內存當中,保證了線程之間的數據可見。
get方法的弱一致性:我們對hashEntry的可見性有了保證但是如果hashEntry已經進行了擴容and rehash則我們查詢的還是舊的鏈表,則會出現get到的數據還是舊數據,這就是get方法的弱一致性。
4、put操作、怎麼定位、如何保證線程安全、key相同是否會覆蓋,那種方法會覆蓋。
1 public V put(K key, V value) {
2 Segment<K,V> s;
3 if (value == null)
4 throw new NullPointerException();
5 int hash = hash(key);
//獲取segment的位置
6 int j = (hash >>> segmentShift) & segmentMask;
7 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
8 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
//如果segmengt還未被初始化,則此處進行一個初始化動作
9 s = ensureSegment(j);
//執行put元素的操作
10 return s.put(key, hash, value, false);
11 }
1 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//首先對segmnet進行一個加鎖操作
2 HashEntry<K,V> node = tryLock() ? null :
3 scanAndLockForPut(key, hash, value);
4 V oldValue;
5 try {
6 HashEntry<K,V>[] tab = table;
//定位segment中的table中的下標
7 int index = (tab.length - 1) & hash;
8 HashEntry<K,V> first = entryAt(tab, index);
9 for (HashEntry<K,V> e = first;;) {
10 if (e != null) {
11 K k;
//如果hash值相同,key也相同,根據是否需要覆蓋的標識onlyIfAbsent來進行操作,覆蓋的話直接將數據更新,不覆蓋的話直接返回舊值
12 if ((k = e.key) == key ||
13 (e.hash == hash && key.equals(k))) {
14 oldValue = e.value;
15 if (!onlyIfAbsent) {
16 e.value = value;
17 ++modCount;
18 }
19 break;
20 }
21 e = e.next;
22 }
23 else {
24 if (node != null)
25 node.setNext(first);
26 else
27 node = new HashEntry<K,V>(hash, key, value, first);
28 int c = count + 1;
29 if (c > threshold && tab.length < MAXIMUM_CAPACITY)
//如果當前的table使用已經超過數組大小乘以加載因子則進行擴容和rehash
30 rehash(node);
31 else
32 setEntryAt(tab, index, node);
33 ++modCount;
34 count = c;
35 oldValue = null;
36 break;
37 }
38 }
39 } finally {
40 unlock();
41 }
42 return oldValue;
43 }
- 定位segment位置並加鎖
- 定位table中的index
- 去鏈表中查找,根據hashCode和key和onlyIfAbsent來判斷是否需要覆蓋舊值,代碼中有詳細註釋
- 如果當前的table使用已經超過數組大小乘以加載因子則進行擴容和rehash,並插入要插入的數據
- 解鎖
JDK1.8實現
1、1.8和1.7之間的變化
- 取消了segment數據,鎖的粒度直接作用在table上,減少了併發衝突的概率
- 存儲數據用鏈表+紅黑樹的的形式,紅黑樹的查找速度是log(n),性能很快,。但是插入操作需要進行紅黑樹的平衡調整,所以在8個元素以內使用鏈表的形式,8個元素以上使用紅黑樹的存儲方式
2.主要數據結構和關鍵變量
- Node和1.7中的hashEntry基本一致,是存儲鏈表時的節點數據
- sizeCtl
負數:表示正在初始化或者擴容,-1表示正在初始化、-N表示正在有N個線程進行擴容
正數:0表示還沒有被初始化,N表示初始化或者下一次擴容的閾值 - TreeNode 紅黑樹節點
- TreeBin放在table中數據,也就是紅黑樹的頭節點
3、操作的剖析,都做了哪些事情。
3、初始化
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
//算法的功能爲將你輸入的數字轉換爲距離最近的2的冪次方的正數
private static final int tableSizeFor(int c) {
int n = c - 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;
}
只是給成員變量賦值,put時進行實際數組的填充
4、get()方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//對hash值進行再散列,使得散列值更均衡
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果當前第一節點的值是我取到的數據就直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//table中存儲的是紅黑樹,需要去紅黑樹中進行查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//table中存儲的是鏈表,遍歷鏈表進行查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
5、put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
//如果第一次put需要對容器進行初始化
tab = initTable();
//如果當前table中沒有元素,則直接將數據插入到當前table的當前位置中
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//當前線程檢測到容易正在擴容,去幫助進行擴容,所做的事情就是將數據進行重新rehash並且搬數據
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//對當前table進行加鎖
synchronized (f) {
//如果當前table存儲的是鏈表。則將數據插入到鏈表中,和1.7操作類似
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果存儲的是紅黑樹,則去插入到紅黑樹中
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//如果當前table中存儲的是鏈表,並且數據已經超過了8則進行鏈表到紅黑樹的轉化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
初始化方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//如果有其他線程正在初始化,則將當前線程讓出cpu
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//循環操作,使用CAS進行設置sizeCtl爲-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//數組的初始化
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//sc的值設置爲0.75n
sc = n - (n >>> 2);
}
} finally {
//設置下一次需要擴容的閾值
sizeCtl = sc;
}
break;
}
}
return tab;
}
這裏需要注意下,在擴容的過程中,如果rehash後的table下的數據小於鏈表於紅黑樹的轉化值(6),則需要將紅黑樹轉化爲鏈表,1.7版本hash的算法是將再散列後值的高位來進行和segmnet的個數減一進行&操作,1.8是用再散列後的值的高16位和tables的大小進行異或操作。