《深刻淺出 Java Concurrency》—併發容器 ConcurrentMap

(轉自:http://blog.csdn.net/fg2006/article/details/6404226html

在JDK 1.4如下只有Vector和Hashtable是線程安全的集合(也稱併發容器,Collections.synchronized*系列也能夠看做是線程安全的實現)。從JDK 5開始增長了線程安全的Map接口ConcurrentMap和線程安全的隊列BlockingQueue(儘管Queue也是同時期引入的新的集合,可是規範並無規定必定是線程安全的,事實上一些實現也不是線程安全的,好比PriorityQueue、ArrayDeque、LinkedList等,在Queue章節中會具體討論這些隊列的結構圖和實現)。java

 

在介紹ConcurrencyMap以前先來回顧下Map的體系結構。下圖描述了Map的體系結構,其中藍色字體的是JDK 5之後新增的併發容器。node

image

針對上圖有如下幾點說明:c++

  1. Hashtable是JDK 5以前Map惟一線程安全的內置實現(Collections.synchronizedMap不算)。特別說明的是Hashtable的t是小寫的(不知道爲啥),Hashtable繼承的是Dictionary(Hashtable是其惟一公開的子類),並不繼承AbstractMap或者HashMap。儘管Hashtable和HashMap的結構很是相似,可是他們之間並無多大聯繫。
  2. ConcurrentHashMap是HashMap的線程安全版本,ConcurrentSkipListMap是TreeMap的線程安全版本。
  3. 最終可用的線程安全版本Map實現是ConcurrentHashMap/ConcurrentSkipListMap/Hashtable/Properties四個,可是Hashtable是過期的類庫,所以若是能夠的應該儘量的使用ConcurrentHashMap和ConcurrentSkipListMap。

 

回到正題來,這個小節主要介紹ConcurrentHashMap的API以及應用,下一節纔開始將原理和分析。算法

ConcurrentMap 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;

import Java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class ConcurrentDemo1 {

    private static final ConcurrentMap<String, ConcurrentDemo1> map = new ConcurrentHashMap<String, ConcurrentDemo1>();
    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儘管添加的時候只能經過制定的類型<K,V>,可是刪除的時候卻容許經過一個Object來操做,而沒必要是K類型。

既然Map裏面有一個remove(Object)方法,爲何ConcurrentMap還須要remove(Object,Object)方法呢?這是由於儘管Map裏面的key沒有變化,可是value可能已經被其餘線程修改了,若是修改後的值是咱們指望的,那麼咱們就不能拿一個key來刪除此值,儘管咱們的指望值是刪除此key對於的舊值。

這種特性在原子操做章節的AtomicMarkableReferenceAtomicStampedReference裏面介紹過。

 

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中每次擴容就是將擴大數組的一倍,使數組大小爲原來的兩倍。

 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. 同一個索引的數組元素組成一個鏈表,查找容許時循環鏈表找到須要的元素。
  2. 儘量的將元素均勻的分佈在數組上。

Map.Entry結構對於問題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<K,V> 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<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++;
    addEntry(hash, key, value, i);
    return null;
}
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);
}

清單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<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> 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在內部也有一些比較大的調整,這個在下篇會具體介紹。

 

 

參考資料:

  1. HashMap hash方法分析
  2. 經過分析 JDK 源代碼研究 Hash 存儲機制
  3. Java 理論與實踐: 哈希
  4. Java 理論與實踐: 構建一個更好的 HashMap
  5. jdk1.6 ConcurrentHashMap
  6. ConcurrentHashMap之實現細節
  7. 深刻理解HashMap
  8. memcached-數據結構
  9. memcached存儲管理 數據結構
  10. memcached

part3

 

ConcurrentHashMap原理

 

讀寫鎖章節部分介紹過一種是用讀寫鎖實現Map的方法。此種方法看起來能夠實現Map響應的功能,並且吞吐量也應該不錯。可是經過前面對讀寫鎖原理的分析後知道,讀寫鎖的適合場景是讀操做>>寫操做,也就是讀操做應該佔據大部分操做,另外讀寫鎖存在一個很嚴重的問題是讀寫操做不能同時發生。要想解決讀寫同時進行問題(至少不一樣元素的讀寫分離),那麼就只能將鎖拆分,不一樣的元素擁有不一樣的鎖,這種技術就是「鎖分離」技術。

默認狀況下ConcurrentHashMap是用了16個相似HashMap 的結構,其中每個HashMap擁有一個獨佔鎖。也就是說最終的效果就是經過某種Hash算法,將任何一個元素均勻的映射到某個HashMap的Map.Entry上面,而對某個一個元素的操做就集中在其分佈的HashMap上,與其它HashMap無關。這樣就支持最多16個併發的寫操做。

 

 image

上圖就是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<K,V> 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<K,V> 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<K,V> getFirst(int hash) {
    HashEntry<K,V>[] tab = table;
    return tab[hash & (tab.length - 1)];
}

V readValueUnderLock(HashEntry<K,V> 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<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> 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<K,V>(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<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> 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<K,V> newFirst = e.next;
                for (HashEntry<K,V> p = first; p != e; p = p.next)
                    newFirst = new HashEntry<K,V>(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回收掉。這樣作的一個好處是,若是某個讀操做在刪除時已經定位到了舊的鏈表上,那麼此操做仍然將能讀到數據,只不過讀取到的是舊數據而已,這在多線程裏面是沒有問題的。

imageimage

除了對單個元素操做外,還有對所有的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<K,V>[] 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的介紹。

 

參考資料:

  1. Hash this
  2. Single-word Wang/Jenkins Hash in ConcurrentHashMap
  3. 指令重排序與happens-before法則
  4. 經過分析 JDK 源代碼研究 TreeMap 紅黑樹算法實現
相關文章
相關標籤/搜索