轉載:https://www.cnblogs.com/xdouby/p/6026618.html
在JDK 1.4以下只有Vector和Hashtable是線程安全的集合(也稱并發容器,Collections.synchronized*系列也可以看作是線程安全的實現)。從JDK 5開始增加了線程安全的Map接口ConcurrentMap和線程安全的隊列BlockingQueue(盡管Queue也是同時期引入的新的集合,但是規范并沒有規定一定是線程安全的,事實上一些實現也不是線程安全的,比如PriorityQueue、ArrayDeque、LinkedList等,在Queue章節中會具體討論這些隊列的結構圖和實現)。
在介紹ConcurrencyMap之前先來回顧下Map的體系結構。下圖描述了Map的體系結構,其中藍色字體的是JDK 5以后新增的并發容器。
針對上圖有以下幾點說明:
Hashtable是JDK 5之前Map唯一線程安全的內置實現(Collections.synchronizedMap不算)。特別說明的是Hashtable的t是小寫的(不知道為啥),Hashtable繼承的是Dictionary(Hashtable是其唯一公開的子類),并不繼承AbstractMap或者HashMap。盡管Hashtable和HashMap的結構非常類似,但是他們之間并沒有多大聯系。
ConcurrentHashMap是HashMap的線程安全版本,ConcurrentSkipListMap是TreeMap的線程安全版本。
最終可用的線程安全版本Map實現是ConcurrentHashMap/ConcurrentSkipListMap/Hashtable/Properties四個,但是Hashtable是過時的類庫,因此如果可以的應該盡可能的使用ConcurrentHashMap和ConcurrentSkipListMap。
回到正題來,這個小節主要介紹ConcurrentHashMap的API以及應用,下一節才開始將原理和分析。
除了實現Map接口里面對象的方法外,ConcurrentHashMap還實現了ConcurrentMap里面的四個方法。
V putIfAbsent(K key,V value)
如果不存在key對應的值,則將value以key加入Map,否則返回key對應的舊值。這個等價于清單1 的操作:
清單1 putIfAbsent的等價操作
if (!map.containsKey(key))
return map.put(key, value);
else
return map.get(key);
在前面的章節中提到過,連續兩個或多個原子操作的序列并不一定是原子操作。比如上面的操作即使在Hashtable中也不是原子操作。而putIfAbsent就是一個線程安全版本的操作的。
有些人喜歡用這種功能來實現單例模式,例如清單2。
清單2 一種單例模式的實現
package xylz.study.concurrency;
importJava.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class ConcurrentDemo1 {
private static final ConcurrentMap map = new ConcurrentHashMap();
private static ConcurrentDemo1 instance;
public static ConcurrentDemo1 getInstance() {
if (instance == null) {
??????????? map.putIfAbsent("INSTANCE", new ConcurrentDemo1());
instance = map.get("INSTANCE");
}
return instance;
}
private ConcurrentDemo1() {
}
}
當然這里只是一個操作的例子,實際上在單例模式文章中有很多的實現和比較。清單2 在存在大量單例的情況下可能有用,實際情況下很少用于單例模式。但是這個方法避免了向Map中的同一個Key提交多個結果的可能,有時候在去掉重復記錄上很有用(如果記錄的格式比較固定的話)。
boolean remove(Object key,Object value)
只有目前將鍵的條目映射到給定值時,才移除該鍵的條目。這等價于清單3 的操作。
清單3 remove(Object,Object)的等價操作
if (map.containsKey(key) && map.get(key).equals(value)) {
map.remove(key);
return true;
}
return false;
由于集合類通常比較的hashCode和equals方法,而這兩個方法是在Object對象里面,因此兩個對象如果hashCode一致,并且覆蓋了equals方法后也一致,那么這兩個對象在集合類里面就是“相同”的,不管是否是同一個對象或者同一類型的對象。也就是說只要key1.hashCode()==key2.hashCode() && key1.equals(key2),那么key1和key2在集合類里面就認為是一致,哪怕他們的Class類型不一致也沒關系,所以在很多集合類里面允許通過Object來類型來比較(或者定位)。比如說Map盡管添加的時候只能通過制定的類型,但是刪除的時候卻允許通過一個Object來操作,而不必是K類型。
既然Map里面有一個remove(Object)方法,為什么ConcurrentMap還需要remove(Object,Object)方法呢?這是因為盡管Map里面的key沒有變化,但是value可能已經被其他線程修改了,如果修改后的值是我們期望的,那么我們就不能拿一個key來刪除此值,盡管我們的期望值是刪除此key對于的舊值。
這種特性在原子操作章節的AtomicMarkableReference和AtomicStampedReference里面介紹過。
boolean replace(K key,V oldValue,V newValue)
只有目前將鍵的條目映射到給定值時,才替換該鍵的條目。這等價于清單4 的操作。
清單4 replace(K,V,V)的等價操作
if (map.containsKey(key) && map.get(key).equals(oldValue)) {
map.put(key, newValue);
return true;
}
return false;
V replace(K key,V value)
只有當前鍵存在的時候更新此鍵對于的值。這等價于清單5 的操作。
清單5 replace(K,V)的等價操作
if (map.containsKey(key)) {
return map.put(key, value);
}
return null;
replace(K,V,V)相比replace(K,V)而言,就是增加了匹配oldValue的操作。
其實這4個擴展方法,是ConcurrentMap附送的四個操作,其實我們更關心的是Map本身的操作。當然如果沒有這4個方法,要完成類似的功能我們可能需要額外的鎖,所以有總比沒有要好。比如清單6,如果沒有putIfAbsent內置的方法,我們如果要完成此操作就需要完全鎖住整個Map,這樣就大大降低了ConcurrentMap的并發性。這在下一節中有詳細的分析和討論。
清單6 putIfAbsent的外部實現
public V putIfAbsent(K key, V value) {
synchronized (map) {
if (!map.containsKey(key)) return map.put(key, value);
return map.get(key);
}
}
part2
本來想比較全面和深入的談談ConcurrentHashMap的,發現網上有很多對HashMap和ConcurrentHashMap分析的文章,因此本小節盡可能的分析其中的細節,少一點理論的東西,多談談內部設計的原理和思想。
要談ConcurrentHashMap的構造,就不得不談HashMap的構造,因此先從HashMap開始簡單介紹。
HashMap原理
我們從頭開始設想。要將對象存放在一起,如何設計這個容器。目前只有兩條路可以走,一種是采用分格技術,每一個對象存放于一個格子中,這樣通過對格子的編號就能取到或者遍歷對象;另一種技術就是采用串聯的方式,將各個對象串聯起來,這需要各個對象至少帶有下一個對象的索引(或者指針)。顯然第一種就是數組的概念,第二種就是鏈表的概念。所有的容器的實現其實都是基于這兩種方式的,不管是數組還是鏈表,或者二者俱有。HashMap采用的就是數組的方式。
有了存取對象的容器后還需要以下兩個條件才能完成Map所需要的條件。
能夠快速定位元素:Map的需求就是能夠根據一個查詢條件快速得到需要的結果,所以這個過程需要的就是盡可能的快。
能夠自動擴充容量:顯然對于容器而然,不需要人工的去控制容器的容量是最好的,這樣對于外部使用者來說越少知道底部細節越好,不僅使用方便,也越安全。
首先條件1,快速定位元素。快速定位元素屬于算法和數據結構的范疇,通常情況下哈希(Hash)算法是一種簡單可行的算法。所謂哈希算法,是將任意長度的二進制值映射為固定長度的較小二進制值。常見的MD2,MD4,MD5,SHA-1等都屬于Hash算法的范疇。具體的算法原理和介紹可以參考相應的算法和數據結構的書籍,但是這里特別提醒一句,由于將一個較大的集合映射到一個較小的集合上,所以必然就存在多個元素映射到同一個元素上的結果,這個叫“碰撞”,后面會用到此知識,暫且不表。
條件2,如果滿足了條件1,一個元素映射到了某個位置,現在一旦擴充了容量,也就意味著元素映射的位置需要變化。因為對于Hash算法來說,調整了映射的小集合,那么原來映射的路徑肯定就不復存在,那么就需要對現有重新計算映射路徑,也就是所謂的rehash過程。
好了有了上面的理論知識后來看HashMap是如何實現的。
在HashMap中首先由一個對象數組table是不可避免的,修飾符transient只是表示序列號的時候不被存儲而已。size描述的是Map中元素的大小,threshold描述的是達到指定元素個數后需要擴容,loadFactor是擴容因子(loadFactor>0),也就是計算threshold的。那么元素的容量就是table.length,也就是數組的大小。換句話說,如果存取的元素大小達到了整個容量(table.length)的loadFactor倍(也就是table.length*loadFactor個),那么就需要擴充容量了。在HashMap中每次擴容就是將擴大數組的一倍,使數組大小為原來的兩倍。
然后接下來看如何將一個元素映射到數組table中。顯然要映射的key是一個無盡的超大集合,而table是一個較小的有限集合,那么一種方式就是將key編碼后的hashCode值取模映射到table上,這樣看起來不錯。但是在Java中采用了一種更高效的辦法。由于與(&)是比取模(%)更高效的操作,因此Java中采用hash值與數組大小-1后取與來確定數組索引的。為什么這樣做是更有效的?參考資料7對這一塊進行非常詳細的分析,這篇文章的作者非常認真,也非常仔細的分析了里面包含的思想。
清單1 indexFor片段
static int indexFor(int h, int length) {
return h & (length-1);
}
前面說明,既然是大集合映射到小集合上,那么就必然存在“碰撞”,也就是不同的key映射到了相同的元素上。那么HashMap是怎么解決這個問題的?
在HashMap中采用了下面方式,解決了此問題。
同一個索引的數組元素組成一個鏈表,查找允許時循環鏈表找到需要的元素。
盡可能的將元素均勻的分布在數組上。
對于問題1,HashMap采用了上圖的一種數據結構。table中每一個元素是一個Map.Entry,其中Entry包含了四個數據,key,value,hash,next。key和value是存儲的數據;hash是元素key的Hash后的表現形式(最終要映射到數組上),這里鏈表上所有元素的hash經過清單1 的indexFor后將得到相同的數組索引;next是指向下一個元素的索引,同一個鏈表上的元素就是通過next串聯起來的。
再來看問題2 盡可能的將元素均勻的分布在數組上這個問題是怎么解決的。首先清單2 是將key的hashCode經過一系列的變換,使之更符合小數據集合的散列模型。
清單2 hashCode的二次散列
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
至于清單2 為什么這樣散列我沒有找到依據,也沒有什么好的參考資料。參考資料1分析了此過程,認為是一種比較有效的方式,有興趣的可以研究下。
第二點就是在清單1 的描述中,盡可能的與數組的長度減1的數與操作,使之分布均勻。這在參考資料7中有介紹。
第三點就是構造數組時數組的長度是2的倍數。清單3 反映了這個過程。為什么要是2的倍數?在參考資料7中分析說是使元素盡可能的分布均勻。
清單3 HashMap 構造數組
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
另外loadFactor的默認值0.75和capacity的默認值16是經過大量的統計分析得出的,很久以前我見過相關的數據分析,現在找不到了,有興趣的可以查詢相關資料。這里不再敘述了。
有了上述原理后再來分析HashMap的各種方法就不是什么問題的。
清單4 HashMap的get操作
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
清單4 描述的是HashMap的get操作,在這個操作中首先判斷key是否為空,因為為空的話總是映射到table的第0個元素上(可以看上面的清單2和清單1)。然后就需要查找table的索引。一旦找到對應的Map.Entry元素后就開始遍歷此鏈表。由于不同的hash可能映射到同一個table[index]上,而相同的key卻同時映射到相同的hash上,所以一個key和Entry對應的條件就是hash(key)==e.hash 并且key.equals(e.key)。從這里我們看到,Object.hashCode()只是為了將相同的元素映射到相同的鏈表上(Map.Entry),而Object.equals()才是比較兩個元素是否相同的關鍵!這就是為什么總是成對覆蓋hashCode()和equals()的原因。
清單5 HashMap的put操作
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry 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++;
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
清單5 描述的是HashMap的put操作。對比get操作,可以發現,put實際上是先查找,一旦找到key對應的Entry就直接修改Entry的value值,否則就增加一個元素。增加的元素是在鏈表的頭部,也就是占據table中的元素,如果table中對應索引原來有元素的話就將整個鏈表添加到新增加的元素的后面。也就是說新增加的元素再次查找的話是優于在它之前添加的同一個鏈表上的元素。這里涉及到就是擴容,也就是一旦元素的個數達到了擴容因子規定的數量(threhold=table.length*loadFactor),就將數組擴大一倍。
清單6 HashMap擴容過程
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry e = src[j];
if (e != null) {
src[j] = null;
do {
Entry next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
清單6 描述的是HashMap擴容的過程。可以看到擴充過程會導致元素數據的所有元素進行重新hash計算,這個過程也叫rehash。顯然這是一個非常耗時的過程,否則擴容都會導致所有元素重新計算hash。因此盡可能的選擇合適的初始化大小是有效提高HashMap效率的關鍵。太大了會導致過多的浪費空間,太小了就可能會導致繁重的rehash過程。在這個過程中loadFactor也可以考慮。
舉個例子來說,如果要存儲1000個元素,采用默認擴容因子0.75,那么1024顯然是不夠的,因為1000>0.75*1024了,所以選擇2048是必須的,顯然浪費了1048個空間。如果確定最多只有1000個元素,那么擴容因子為1,那么1024是不錯的選擇。另外需要強調的一點是擴容因此越大,從統計學角度講意味著鏈表的長度就也大,也就是在查找元素的時候就需要更多次的循環。所以凡事必然是一個平衡的過程。
這里可能有人要問題,一旦我將Map的容量擴大后(也就是數組的大小),這個容量還能減小么?比如說剛開始Map中可能有10000個元素,運行一旦時間以后Map的大小永遠不會超過10個,那么Map的容量能減小到10個或者16個么?答案就是不能,這個capacity一旦擴大后就不能減小了,只能通過構造一個新的Map來控制capacity了。
HashMap的幾個內部迭代器也是非常重要的,這里限于篇幅就不再展開了,有興趣的可以自己研究下。
Hashtable的原理和HashMap的原理幾乎一樣,所以就不討論了。另外LinkedHashMap是在Map.Entry的基礎上增加了before/after兩個雙向索引,用來將所有Map.Entry串聯起來,這樣就可以遍歷或者做LRU Cache等。這里也不再展開討論了。
memcached內部數據結構就是采用了HashMap類似的思想來實現的,有興趣的可以參考資料8,9,10。
為了不使這篇文章過長,因此將ConcurrentHashMap的原理放到下篇講。需要說明的是,盡管ConcurrentHashMap與HashMap的名稱有些淵源,而且實現原理有些相似,但是為了更好的支持并發,ConcurrentHashMap在內部也有一些比較大的調整,這個在下篇會具體介紹。
參考資料:
part3
ConcurrentHashMap原理
在讀寫鎖章節部分介紹過一種是用讀寫鎖實現Map的方法。此種方法看起來可以實現Map響應的功能,而且吞吐量也應該不錯。但是通過前面對讀寫鎖原理的分析后知道,讀寫鎖的適合場景是讀操作>>寫操作,也就是讀操作應該占據大部分操作,另外讀寫鎖存在一個很嚴重的問題是讀寫操作不能同時發生。要想解決讀寫同時進行問題(至少不同元素的讀寫分離),那么就只能將鎖拆分,不同的元素擁有不同的鎖,這種技術就是“鎖分離”技術。
默認情況下ConcurrentHashMap是用了16個類似HashMap 的結構,其中每一個HashMap擁有一個獨占鎖。也就是說最終的效果就是通過某種Hash算法,將任何一個元素均勻的映射到某個HashMap的Map.Entry上面,而對某個一個元素的操作就集中在其分布的HashMap上,與其它HashMap無關。這樣就支持最多16個并發的寫操作。
上圖就是ConcurrentHashMap的類圖。參考上面的說明和HashMap的原理分析,可以看到ConcurrentHashMap將整個對象列表分為segmentMask+1個片段(Segment)。其中每一個片段是一個類似于HashMap的結構,它有一個HashEntry的數組,數組的每一項又是一個鏈表,通過HashEntry的next引用串聯起來。
這個類圖上面的數據結構的定義非常有學問,接下來會一個個有針對性的分析。
首先如何從ConcurrentHashMap定位到HashEntry。在HashMap的原理分析部分說過,對于一個Hash的數據結構來說,為了減少浪費的空間和快速定位數據,那么就需要數據在Hash上的分布比較均勻。對于一次Map的查找來說,首先就需要定位到Segment,然后從過Segment定位到HashEntry鏈表,最后才是通過遍歷鏈表得到需要的元素。
在不討論并發的前提下先來討論如何定位到HashEntry的。在ConcurrentHashMap中是通過hash(key.hashCode())和segmentFor(hash)來得到Segment的。清單1 描述了如何定位Segment的過程。其中hash(int)是將key的hashCode進行二次編碼,使之能夠在segmentMask+1個Segment上均勻分布(默認是16個)。可以看到的是這里和HashMap還是有點不同的,這里采用的算法叫Wang/Jenkins hash,有興趣的可以參考資料1和參考資料2。總之它的目的就是使元素能夠均勻的分布在不同的Segment上,這樣才能夠支持最多segmentMask+1個并發,這里segmentMask+1是segments的大小。
清單1 定位Segment
private static int hash(int h) {
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h <<? 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h <<?? 3);
h ^= (h >>>? 6);
h += (h <<?? 2) + (h << 14);
return h ^ (h >>> 16);
}
final Segment segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
顯然在不能夠對Segment擴容的情況下,segments的大小就應該是固定的。所以在ConcurrentHashMap中segments/segmentMask/segmentShift都是常量,一旦初始化后就不能被再次修改,其中segmentShift是查找Segment的一個常量偏移量。
有了Segment以后再定位HashEntry就和HashMap中定位HashEntry一樣了,先將hash值與Segment中HashEntry的大小減1進行與操作定位到HashEntry鏈表,然后遍歷鏈表就可以完成相應的操作了。
能夠定位元素以后ConcurrentHashMap就已經具有了HashMap的功能了,現在要解決的就是如何并發的問題。要解決并發問題,加鎖是必不可免的。再回頭看Segment的類圖,可以看到Segment除了有一個volatile類型的元素大小count外,Segment還是集成自ReentrantLock的。另外在前面的原子操作和鎖機制中介紹過,要想最大限度的支持并發,那么能夠利用的思路就是盡量讀操作不加鎖,寫操作不加鎖。如果是讀操作不加鎖,寫操作加鎖,對于競爭資源來說就需要定義為volatile類型的。volatile類型能夠保證happens-before法則,所以volatile能夠近似保證正確性的情況下最大程度的降低加鎖帶來的影響,同時還與寫操作的鎖不產生沖突。
同時為了防止在遍歷HashEntry的時候被破壞,那么對于HashEntry的數據結構來說,除了value之外其他屬性就應該是常量,否則不可避免的會得到ConcurrentModificationException。這就是為什么HashEntry數據結構中key,hash,next是常量的原因(final類型)。
有了上面的分析和條件后再來看Segment的get/put/remove就容易多了。
get操作
清單2 Segment定位元素
V get(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null)
return v;
return readValueUnderLock(e); // recheck
}
e = e.next;
}
}
return null;
}
HashEntry getFirst(int hash) {
HashEntry[] tab = table;
return tab[hash & (tab.length - 1)];
}
V readValueUnderLock(HashEntry e) {
lock();
try {
return e.value;
} finally {
unlock();
}
}
清單2 描述的是Segment如何定位元素。首先判斷Segment的大小count>0,Segment的大小描述的是HashEntry不為空(key不為空)的個數。如果Segment中存在元素那么就通過getFirst定位到指定的HashEntry鏈表的頭節點上,然后遍歷此節點,一旦找到key對應的元素后就返回其對應的值。但是在清單2 中可以看到拿到HashEntry的value后還進行了一次判斷操作,如果為空還需要加鎖再讀取一次(readValueUnderLock)。為什么會有這樣的操作?盡管ConcurrentHashMap不允許將value為null的值加入,但現在仍然能夠讀到一個為空的value就意味著此值對當前線程還不可見(這是因為HashEntry還沒有完全構造完成就賦值導致的,后面還會談到此機制)。
put操作
清單3 描述的是Segment的put操作。首先就需要加鎖了,修改一個競爭資源肯定是要加鎖的,這個毫無疑問。需要說明的是Segment集成的是ReentrantLock,所以這里加的鎖也就是獨占鎖,也就是說同一個Segment在同一時刻只有能一個put操作。
接下來來就是檢查是否需要擴容,這和HashMap一樣,如果需要的話就擴大一倍,同時進行rehash操作。
查找元素就和get操作是一樣的,得到元素就直接修改其值就好了。這里onlyIfAbsent只是為了實現ConcurrentMap的putIfAbsent操作而已。需要說明以下幾點:
如果找到key對于的HashEntry后直接修改就好了,如果找不到那么就需要構造一個新的HashEntry出來加到hash對于的HashEntry的頭部,同時就的頭部就加到新的頭部后面。這是因為HashEntry的next是final類型的,所以只能修改頭節點才能加元素加入鏈表中。
如果增加了新的操作后,就需要將count+1寫回去。前面說過count是volatile類型,而讀取操作沒有加鎖,所以只能把元素真正寫回Segment中的時候才能修改count值,這個要放到整個操作的最后。
在將新的HashEntry寫入table中時是通過構造函數來設置value值的,這意味對table的賦值可能在設置value之前,也就是說得到了一個半構造完的HashEntry。這就是重排序可能引起的問題。所以在讀取操作中,一旦讀到了一個value為空的value是就需要加鎖重新讀取一次。為什么要加鎖?加鎖意味著前一個寫操作的鎖釋放,也就是前一個鎖的數據已經完成寫完了了,根據happens-before法則,前一個寫操作的結果對當前讀線程就可見了。當然在JDK 6.0以后不一定存在此問題。
在Segment中table變量是volatile類型,多次讀取volatile類型的開銷要不非volatile開銷要大,而且編譯器也無法優化,所以在put操作中首先建立一個臨時變量tab指向table,多次讀寫tab的效率要比volatile類型的table要高,JVM也能夠對此進行優化。
清單3 Segment的put操作
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
try {
int c = count;
if (c++ > threshold) // ensure capacity
rehash();
HashEntry[] tab = table;
int index = hash & (tab.length - 1);
HashEntry first = tab[index];
HashEntry e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next;
V oldValue;
if (e != null) {
oldValue = e.value;
if (!onlyIfAbsent)
e.value = value;
}
else {
oldValue = null;
++modCount;
tab[index] = new HashEntry(key, hash, first, value);
count = c; // write-volatile
}
return oldValue;
} finally {
unlock();
}
}
remove 操作
清單4 描述了Segment刪除一個元素的過程。同put一樣,remove也需要加鎖,這是因為對table可能會有變更。由于HashEntry的next節點是final類型的,所以一旦刪除鏈表中間一個元素,就需要將刪除之前或者之后的元素重新加入新的鏈表。而Segment采用的是將刪除元素之前的元素一個個重新加入刪除之后的元素之前(也就是鏈表頭結點)來完成新鏈表的構造。
清單4 Segment的remove操作
V remove(Object key, int hash, Object value) {
lock();
try {
int c = count - 1;
HashEntry[] tab = table;
int index = hash & (tab.length - 1);
HashEntry first = tab[index];
HashEntry e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next;
V oldValue = null;
if (e != null) {
V v = e.value;
if (value == null || value.equals(v)) {
oldValue = v;
// All entries following removed node can stay
// in list, but all preceding ones need to be
// cloned.
++modCount;
HashEntry newFirst = e.next;
for (HashEntry p = first; p != e; p = p.next)
newFirst = new HashEntry(p.key, p.hash,
newFirst, p.value);
tab[index] = newFirst;
count = c; // write-volatile
}
}
return oldValue;
} finally {
unlock();
}
}
下面的示意圖描述了如何刪除一個已經存在的元素的。假設我們要刪除B3元素。首先定位到B3所在的Segment,然后再定位到Segment的table中的B1元素,也就是Bx所在的鏈表。然后遍歷鏈表找到B3,找到之后就從頭結點B1開始構建新的節點B1(藍色)加到B4的前面,繼續B1后面的節點B2構造B2(藍色),加到由藍色的B1和B4構成的新的鏈表。繼續下去,直到遇到B3后終止,這樣就構造出來一個新的鏈表B2(藍色)->B1(藍色)->B4->B5,然后將此鏈表的頭結點B2(藍色)設置到Segment的table中。這樣就完成了元素B3的刪除操作。需要說明的是,盡管就的鏈表仍然存在(B1->B2->B3->B4->B5),但是由于沒有引用指向此鏈表,所以此鏈表中無引用的(B1->B2->B3)最終會被GC回收掉。這樣做的一個好處是,如果某個讀操作在刪除時已經定位到了舊的鏈表上,那么此操作仍然將能讀到數據,只不過讀取到的是舊數據而已,這在多線程里面是沒有問題的。
除了對單個元素操作外,還有對全部的Segment的操作,比如size()操作等。
size操作
size操作涉及到統計所有Segment的大小,這樣就會遍歷所有的Segment,如果每次加鎖就會導致整個Map都被鎖住了,任何需要鎖的操作都將無法進行。這里用到了一個比較巧妙的方案解決此問題。
在Segment中有一個變量modCount,用來記錄Segment結構變更的次數,結構變更包括增加元素和刪除元素,每增加一個元素操作就+1,每進行一次刪除操作+1,每進行一次清空操作(clear)就+1。也就是說每次涉及到元素個數變更的操作modCount都會+1,而且一直是增大的,不會減小。
遍歷兩次ConcurrentHashMap中的segments,每次遍歷是記錄每一個Segment的modCount,比較兩次遍歷的modCount值的和是否相同,如果相同就返回在遍歷過程中獲取的Segment的count的和,也就是所有元素的個數。如果不相同就重復再做一次。重復一次還不相同就將所有Segment鎖住,一個一個的獲取其大小(count),最后將這些count加起來得到總的大小。當然了最后需要將鎖一一釋放。清單5 描述了這個過程。
這里有一個比較高級的話題是為什么在讀取modCount的時候總是先要讀取count一下。為什么不是先讀取modCount然后再讀取count的呢?也就是說下面的兩條語句能否交換下順序?
sum += segments[i].count;
mcsum += mc[i] = segments[i].modCount;
答案是不能!為什么?這是因為modCount總是在加鎖的情況下才發生變化,所以不會發生多線程同時修改的情況,也就是沒必要時volatile類型。另外總是在count修改的情況下修改modCount,而count是一個volatile變量。于是這里就充分利用了volatile的特性。
根據happens-before法則,第(3)條:對volatile字段的寫入操作happens-before于每一個后續的同一個字段的讀操作。也就是說一個操作C在volatile字段的寫操作之后,那么volatile寫操作之前的所有操作都對此操作C可見。所以修改modCount總是在修改count之前,也就是說如果讀取到了一個count的值,那么在count變化之前的modCount也就能夠讀取到,換句話說就是如果看到了count值的變化,那么就一定看到了modCount值的變化。而如果上面兩條語句交換下順序就無法保證這個結果一定存在了。
在ConcurrentHashMap.containsValue中,可以看到每次遍歷segments時都會執行int c = segments[i].count;,但是接下來的語句中又不用此變量c,盡管如此JVM仍然不能將此語句優化掉,因為這是一個volatile字段的讀取操作,它保證了一些列操作的happens-before順序,所以是至關重要的。在這里可以看到:
ConcurrentHashMap將volatile發揮到了極致!
另外isEmpty操作于size操作類似,不再累述。
清單5 ConcurrentHashMap的size操作
public int size() {
final Segment[] segments = this.segments;
long sum = 0;
long check = 0;
int[] mc = new int[segments.length];
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
check = 0;
sum = 0;
int mcsum = 0;
for (int i = 0; i < segments.length; ++i) {
sum += segments[i].count;
mcsum += mc[i] = segments[i].modCount;
}
if (mcsum != 0) {
for (int i = 0; i < segments.length; ++i) {
check += segments[i].count;
if (mc[i] != segments[i].modCount) {
check = -1; // force retry
break;
}
}
}
if (check == sum)
break;
}
if (check != sum) { // Resort to locking all segments
sum = 0;
for (int i = 0; i < segments.length; ++i)
segments[i].lock();
for (int i = 0; i < segments.length; ++i)
sum += segments[i].count;
for (int i = 0; i < segments.length; ++i)
segments[i].unlock();
}
if (sum > Integer.MAX_VALUE)
return Integer.MAX_VALUE;
else
return (int)sum;
}
ConcurrentSkipListMap/Set
本來打算介紹下ConcurrentSkipListMap的,結果打開源碼一看,徹底放棄了。那里面的數據結構和算法我估計研究一周也未必能夠完全弄懂。很久以前我看TreeMap的時候就頭大,想想那些復雜的“紅黑二叉樹”我頭都大了。這些都歸咎于從前沒有好好學習《數據結構和算法》,現在再回頭看這些復雜的算法感覺非常頭疼,為了減少腦細胞的死亡,暫且還是不要惹這些“玩意兒”。有興趣的可以看看參考資料4中對TreeMap的介紹。
參考資料: