主要包括兩者:Vector
和 HashTable
。這些類實現執行緒安全的方式主要是將它們的狀態封裝起來,對每個公有方法都進行同步,使得每次只有一個執行緒能訪問這些容器
1.1 問題一:同步容器類在所有情況下都是執行緒安全的嗎
答案是:No!
的確,如果只是很簡單地,原子地去使用這些同步容器類的方法的話是執行緒安全的,但當進行一些複合操作的時候,例如迭代,條件語句等,在併發環境下就很容易出現問題!
例如以下程式碼:
public static Object getLast(Vector vector) {
int lastindex = vector.size() - 1; //1
return vector.get(lastindex); //2
}
public static Object deleteLast(Vector vector) {
int lastindex = vector.size() - 1;//1
return vector.remove(lastindex); //2
}
如果此時有兩個執行緒分別同時執行get和delete方法,此時假設他們透過了對應的第一行語句,即分別獲取了這個同步容器類的大小,ok,下一步,只能有一個執行緒進行操作,假設此時恰好是執行delete方法,導致容器變小,之後退出方法。然後另一個執行緒執行get方法,因為此時容器的容量減小,故導致丟擲陣列訪問越界異常!
透過對上面情況的討論,我們應該知道,對這些容易出錯的複合操作,也應該加上鎖來保證同步,但此時併發性也會大大降低
1.2 迭代器及其問題
對於同步容器類的迭代,其實開發者並沒有考慮到併發修改的問題,即在併發環境下,如果其他執行緒在該迭代器進行迭代期間而進行修改,那很有可能丟擲ConcurrentModificatinException
!——fail fast!
public synchronized Iterator<E> iterator() {
return new Itr();
}
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
public boolean hasNext() {
// Racy but within spec, since modifications are checked
// within or after synchronization in next/previous
return cursor != elementCount;
}
public E next() {
synchronized (Vector.this) {
checkForComodification();
//.......
}
}
public void remove() {
if (lastRet == -1)
throw new IllegalStateException();
synchronized (Vector.this) {
checkForComodification();
//.......
}
cursor = lastRet;
lastRet = -1;
}
@Override
public void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
synchronized (Vector.this) {
//.......
checkForComodification();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
上面是同步容器類Vector的迭代器原始碼,我們可以發現,雖然其Next方法做到了同步,但是致命的一點就是checkForComodification
檢查方法沒有進行同步!也就意味著在併發情況下很有可能導致fail-fast情況。而且我們仔細看,其實它的remove方法也是有問題,其上鎖的區塊並沒有包含整個方法,同步範圍小,也是很容易出問題的!
注意:fail——fast機制在單執行緒環境下也是會出現的,即在迭代期間沒有透過迭代器本身進行刪除,而是透過容器直接新增,那麼會很容易陷入快速失敗的情況!
那我們該如何解決呢 ?
- 迭代期間加鎖!
這並不是一個好方法,如果容器的容量很大,那麼迭代將耗費很長時間,此時其他執行緒都處於阻塞狀態,會極大地降低吞吐量和CPU的利用率!
注意:我們知道在用迭代器的地方加鎖,可是要注意某些情況下迭代器會隱藏!!!!比如容器的hashcode和equals等方法也會間接進行迭代!!所以要時刻警惕這些場景! - 克隆容器!
將容器克隆出副本,並在副本進行迭代,克隆期間還是要加鎖!這方法也有問題,就是取決於容器克隆的效能開銷
1.3 總結
同步容器類其實是一種古老的是實現執行緒安全的方法,你完全也可以自己透過Collections.SynchronizedXXX
靜態工廠方法將你的容器同步。但由於其所有方法都是同步的,複合操作的不安全性以及迭代器的問題,在併發場景下,很少再用這些古老的,效率低下的同步容器了,取而代之的是下面將要介紹的 併發容器!
由於併發場景下,同步容器類表現出令人不滿意的情況——併發性低,吞吐量低。
併發容器應運而生:ConcurrentHashMap 代替 同步的 Map,CopyOnWriteArraylList 代替同步的list 以及還有 ConcurrentSkipListMap代替同步的 SortedMap等等
2.1 ConcurrentHashMap
JDK 1.7及以前,ConcurrentHashMap採用的是分段鎖 setment繼承自ReentrantLock,即Segment 陣列 + HashEntry 陣列 + 連結串列。將鎖的粒度由之前的整個容器降低為一段一段,在併發環境下實現更高的吞吐量
JDK 1.8之後,採用Node 陣列 + 連結串列 / 紅黑樹,併發控制使用 synchronized 和 CAS 來操作。將鎖的粒度從之前的段到了現在的Node結點!並且採用synchronized 和CAS操作,使併發程度更高,效能更好。
注意:雖然ConcurrentHashMap表現出很好的併發性,但是其一些方法是被弱化了的,比如size 和 isEmpty。比如size方法返回的允許是一個近似值,事實上這樣的方法用處小,因為返回值總是在不斷變化。因此這些操作的需求被弱化了,更多的是提升put ,get等操作的效能!
2.2 CopyOnWriteArraylList
在很多應用場景中,讀操作可能會遠遠大於寫操作。由於讀操作根本不會修改原有的資料,因此對於每次讀取都進行加鎖其實是一種資源浪費。我們應該允許多個執行緒同時訪問 List 的內部資料,畢竟讀取操作是安全的。
‘寫入時複製’容器的執行緒安全性是每次修改時,都會建立並重新釋出一個新的容器副本!即讀取是完全不用加鎖的,寫入也不會阻塞讀取操作,因為寫入操作是在新的副本上操作!只有寫入和寫入之間需要進行同步等待。這樣一來,讀操作的效能就會大幅度提升。
直接上原始碼:
public E get(int index) { //讀取操作不加鎖
return get(getArray(), index);
}
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock(); //上鎖
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock(); //解鎖
}
}
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();//上鎖
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();//解鎖
}
}
透過以上原始碼我們可以發現:
- CopyOnWriteArraylList 的讀讀共享,讀寫共享,寫讀共享,唯有寫寫互斥!
- 實現寫讀共享的機制就是透過在寫時先複製原陣列,然後操作完成之後將原陣列的引用指向新陣列!
但這有一定的問題,CopyOnWriteArraylList容器是無法保證讀寫的瞬時一致性,只能保證最終一致性!試想同時有兩個執行緒,執行緒A將某個i位置的值更新,而執行緒B讀取i位置的值,由於複製陣列的開銷,很有可能導致執行緒B先讀取到原先的舊值,即出現 “髒讀”!還有就是之前討論過的,複製陣列的開銷!
雖然有一定的問題,但是在讀操作或者迭代操作多於寫操作的場景下,CopyOnWriteArraylList的效能是有目共睹的!
2.3 ConcurrentLinkedQueue
Java 提供的執行緒安全的 Queue 可以分為阻塞佇列和非阻塞佇列,其中阻塞佇列的典型例子是 BlockingQueue,非阻塞佇列的典型例子是 ConcurrentLinkedQueue,在實際應用中要根據實際需要選用阻塞佇列或者非阻塞佇列。 阻塞佇列可以透過加鎖來實現,非阻塞佇列可以透過 CAS 操作實現。
2.4 BlockingQueue
阻塞佇列(BlockingQueue)被廣泛使用在“生產者-消費者”問題中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。當佇列容器已滿,生產者執行緒會被阻塞,直到佇列未滿;當佇列容器為空時,消費者執行緒會被阻塞,直至佇列非空時為止。
2.4.1 ArrayBlockingQueue
2.4.2 LinkedBlockingQueue
2.4.3 PriorityBlockingQueue
本作品採用《CC 協議》,轉載必須註明作者和本文連結