前言
最近在看并發(fā)編程藝術這本書,對看書的一些總結及個人理解。
為什么使用ConcurrentHashMap?
jdk5.0以后提供了多種并發(fā)類容器來替代同步類容器從而改善性能。同步類容器的狀態(tài)都是串行化的。他們雖然實現(xiàn)了線程安全,但是嚴重降低了并發(fā)性,在多線程環(huán)境時,嚴重降低了應用程序的吞吐量。
線程不安全的HashMap
public class ConcurrentPutHashMap {
public static void main(String[] args) throws InterruptedException {
final HashMap<String, String> map = new HashMap<>(2);
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
map.put(UUID.randomUUID().toString(), "");
}
}, "ftf" + i).start();
}
}
}, "ftf");
t.start();
t.join();
}
}
效率低下的HashTable
HashTable容器使用synchronized來保證線程安全,但在線程競爭激烈的情況下HashTable的效率非常低下。因為當一個線程訪問HashTable的同步方法,其他線程也訪問HashTable的同步方法時,會進入阻塞或輪詢狀態(tài)。如線程1使用put進行元素添加,線程2不但不能使用put方法添加元素,也不能使用get方法來獲取元素,所以競爭越激烈效率越低。
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;
}
ConcurrentHashMap的鎖分段技術可有效提升并發(fā)訪問率
HashTable容器在競爭激烈的并發(fā)環(huán)境下表現(xiàn)出效率低下的原因是所有訪問HashTable的線程都必須競爭同一把鎖,假如容器里有多把鎖,每一把鎖用于鎖容器其中一部分數(shù)據(jù),那么當多線程訪問容器里不同數(shù)據(jù)段的數(shù)據(jù)時,線程間就不會存在鎖競爭,從而可以有效提高并發(fā)訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術。首先將數(shù)據(jù)分成一段一段地存儲,然后給每一段數(shù)據(jù)配一把鎖,當一個線程占用鎖訪問其中一個段數(shù)據(jù)的時候,其他段的數(shù)據(jù)也能被其他線程訪問。
ConcurrentHashMap的結構
ConcurrentHashMap是由Segment數(shù)組結構和HashEntry數(shù)組結構組成。Segment是一種可重入鎖(ReentrantLock),在ConcurrentHashMap里扮演鎖的角色;HashEntry則用于存儲鍵值對數(shù)據(jù)。一個ConcurrentHashMap里包含一個Segment數(shù)組。Segment的結構和HashMap類似,是一種數(shù)組和鏈表結構。一個Segment里包含一個HashEntry數(shù)組,每個HashEntry是一個鏈表結構的元素,每個Segment守護著一個HashEntry數(shù)組里的元素,當對HashEntry數(shù)組的數(shù)據(jù)進行修改時,必須首先獲得與它對應的Segment鎖.
ConcurrentHashMap初始化
/**
* Creates a new, empty map with the default initial table size (16).
*/
public ConcurrentHashMap() {
}
默認的無參數(shù)構造就是生成segments數(shù)組的長度為16的一個對象,即容器中的鎖也是16個。
既然ConcurrentHashMap使用分段鎖Segment來保護不同段的數(shù)據(jù),那么在插入和獲取元素的時候,必須先通過散列算法定位到Segment。可以看到ConcurrentHashMap會首先使用Wang/Jenkins hash的變種算法對元素的hashCode進行一次再散列。
ConcurrentHashMap的操作
get操作
Segment的get操作實現(xiàn)非常簡單和高效。先經(jīng)過一次再散列,然后使用這個散列值通過散列運算定位到Segment,再通過散列算法定位到元素,代碼如下。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
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;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
get操作的高效之處在于整個get過程不需要加鎖,除非讀到的值是空才會加鎖重讀。我們知道HashTable容器的get方法是需要加鎖的,那么ConcurrentHashMap的get操作是如何做到不加鎖的呢?原因是它的get方法里將要使用的共享變量都定義成volatile類型,如用于統(tǒng)計當前Segement大小的count字段和用于存儲值的HashEntry的value。定義成volatile的變量,能夠在線程之間保持可見性,能夠被多線程同時讀,并且保證不會讀到過期的值,但是只能被單線程寫(有一種情況可以被多線程寫,就是寫入的值不依賴于原值),在get操作里只需要讀不需要寫共享變量count和value,所以可以不用加鎖。之所以不會讀到過期的值,是因為根據(jù)Java內(nèi)存模型的happen before原則,對volatile字段的寫入操作先于讀操作,即使兩個線程同時修改和獲取volatile變量,get操作也能拿到最新的值,這是用volatile替換鎖的經(jīng)典應用場景。
put操作
由于put方法里需要對共享變量進行寫入操作,所以為了線程安全,在操作共享變量時必須加鎖。put方法首先定位到Segment,然后在Segment里進行插入操作。插入操作需要經(jīng)歷兩個步驟,第一步判斷是否需要對Segment里的HashEntry數(shù)組進行擴容,第二步定位添加元素的位置,然后將其放在HashEntry數(shù)組里。
在插入元素前會先判斷Segment里的HashEntry數(shù)組是否超過容量(threshold),如果超過閾值,則對數(shù)組進行擴容。
ConcurrentHashMap不會對整個容器進行擴容,而只對某個segment進行擴容。
size操作
如果要統(tǒng)計整個ConcurrentHashMap里元素的大小,就必須統(tǒng)計所有Segment里元素的大小后求和。Segment里的全局變量count是一個volatile變量,那么在多線程場景下,是不是直接把所有Segment的count相加就可以得到整個ConcurrentHashMap大小了呢?不是的,雖然相加時可以獲取每個Segment的count的最新值,但是可能累加前使用的count發(fā)生了變化,那么統(tǒng)計結果就不準了。所以,最安全的做法是在統(tǒng)計size的時候把所有Segment的put、remove和clean方法全部鎖住,但是這種做法顯然非常低效。
因為在累加count操作過程中,之前累加過的count發(fā)生變化的幾率非常小,所以ConcurrentHashMap的做法是先嘗試2次通過不鎖住Segment的方式來統(tǒng)計各個Segment大小,如果統(tǒng)計的過程中,容器的count發(fā)生了變化,則再采用加鎖的方式來統(tǒng)計所有Segment的大小。
那么ConcurrentHashMap是如何判斷在統(tǒng)計的時候容器是否發(fā)生了變化呢?使用modCount變量,在put、remove和clean方法里操作元素前都會將變而得知容器的大小是否發(fā)生變化。
總結
ConcurrentHashMap內(nèi)部使用段(Segment)來表示這些不同的部分,每個段其實就是一個小的HashTable,它們有自己的鎖。只要多個修改操作發(fā)生在不同的段上,他們就可以并發(fā)進行。把一個著呢個體分成了16個段(Segment)。
也就是最高支持16個線程的并發(fā)修改操作。這也是在多線程場景時減小鎖的粒度從而降低鎖競爭的一種方案,并且代碼中大多共享變量使用volatile關鍵字聲明(比如size方法中的count),目的是第一時間獲取修改的內(nèi)容,性能非常好。