前言
官人們好啊,我是湯圓,今天給大家帶來的是《Java并發-同步容器篇》,希望有所幫助,謝謝
文章如果有問題,歡迎大家批評指正,在此謝過啦
簡介
同步容器主要分兩類,一種是Vector這樣的普通類,一種是通過Collections的工廠方法建立的内部類
雖然很多人都對同步容器的性能低有偏見,但它也不是一無是處,在這裡我們插播一條阿裡巴巴的開發手冊規範:
高并發時,同步調用應該去考量鎖的性能損耗。能用無鎖資料結構,就不要用鎖;能鎖區塊,就不要鎖整個方法體;能用對象鎖,就不要用類鎖。
可以看到,隻有在高并發才會考慮到鎖的性能問題,是以在一些小而全的系統中,同步容器還是有用武之地的(當然也可以考慮并發容器,後面章節再讨論)
附言:這不是洗白貼
目錄
我們這裡分三步來分析:
- 什麼是同步容器
- 為什麼要有同步容器
- 同步容器的優缺點
- 同步容器的使用場景
正文
1. 什麼是同步容器
定義:就是把容器類同步化,這樣我們在并發中使用容器時,就不用手動同步,因為内部已經自動同步了
例子:比如Vector就是一個同步容器類,它的同步化就是把内部的所有方法都上鎖(有的重載方法沒上鎖,但是最終調用的方法還是有鎖的)
源碼:Vector.add
// 通過synchronized為add方法上鎖public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e; return true;
}複制代碼
同步容器主要分兩類:
- 普通類:Vector、Stack、HashTable
- 内部類:Collections建立的内部類,比如Collections.SynchronizedList、 Collections.SynchronizedSet等
那這兩種有沒有差別呢?
當然是有的,剛開始的時候(Java1.0)隻有第一種同步容器(Vector等)
但是因為Vector這種類太局氣了,它就想着把所有的東西都弄過來自己搞(Vector通過toArray轉為己有,HashTable通過putAll轉為己有);
源碼:Vector構造函數
public Vector(Collection c) { // 這裡通過toArray将傳來的集合 轉為己有
elementData = c.toArray();
elementCount = elementData.length; // c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
}複制代碼
是以就有了第二種同步容器類(通過工廠方法建立的内部容器類),它就比較聰明了,它隻是把原有的容器進行包裝(通過this.list = list直接指向需要同步的容器),然後局部加鎖,這樣一來,即生成了線程安全的類,又不用太費力;
源碼:Collections.SynchronizedList構造函數
SynchronizedList(Listlist) { super(list); // 這裡隻是指向傳來的list,不轉為己有,後面的相關操作還是基于原有的list集合
this.list = list;
}複制代碼
他們之間的差別如下:
兩種同步容器的差別 | 普通類 | 内部類 |
---|---|---|
鎖的對象 | 不可指定,隻能this | 可指定,預設this |
鎖的範圍 | 方法體(包括疊代) | 代碼塊(不包括疊代) |
适用範圍 | 窄-個别容器 | 廣-所有容器 |
這裡我們重點說下鎖的對象:
- 普通類鎖的是目前對象this(鎖在方法上,預設this對象);
- 内部類鎖的是mutex屬性,這個屬性預設是this,但是可以通過構造函數(或工廠方法)來指定鎖的對象
源碼:Collections.SynchronizedCollection構造函數
final Collectionc; // Backing Collection// 這個就是鎖的對象final Object mutex; // Object on which to synchronizeSynchronizedCollection(Collectionc) { this.c = Objects.requireNonNull(c);// 初始化為 this
mutex = this;
}
SynchronizedCollection(Collectionc, Object mutex) { this.c = Objects.requireNonNull(c); this.mutex = Objects.requireNonNull(mutex);
}複制代碼
這裡要注意一點就是,内部類的疊代器沒有同步(Vector的疊代器有同步),需要手動加鎖來同步
源碼:Vector.Itr.next 疊代方法(有上鎖)
public E next() { synchronized (Vector.this) {
checkForComodification(); int i = cursor; if (i >= elementCount) throw new NoSuchElementException();
cursor = i + 1; return elementData(lastRet = i);
}
}複制代碼
源碼:Collections.SynchronizedCollection.iterator 疊代器(沒上鎖)
public Iteratoriterator() { // 這裡會直接實作類的疊代器(比如ArrayList,它裡面的疊代器肯定是沒上鎖的)
return c.iterator(); // Must be manually synched by user!}複制代碼
2. 為什麼要有同步容器
因為普通的容器類(比如ArrayList)是線程不安全的,如果是在并發中使用,我們就需要手動對其加鎖才會安全,這樣的話就很麻煩;
是以就有了同步容器,它來幫我們自動加鎖
下面我們用代碼來對比下
線程不安全的類:ArrayList
public class SyncCollectionDemo {
private ListlistNoSync; public SyncCollectionDemo() { this.listNoSync = new ArrayList<>();
} public void addNoSync(int temp){
listNoSync.add(temp);
} public static void main(String[] args) throws InterruptedException {
SyncCollectionDemo demo = new SyncCollectionDemo(); // 建立10個線程
for (int i = 0; i < 10; i++) { // 每個線程執行100次添加操作
new Thread(()->{ for (int j = 0; j < 1000; j++) {
demo.addNoSync(j);
}
}).start();
}
}
}複制代碼
上面的代碼看似沒問題,感覺就算有問題也應該是插入的順序比較亂(多線程交替插入)
但實際上運作會發現,可能會報錯數組越界,如下所示:

原因有二:
- 因為ArrayList.add操作沒有加鎖,導緻多個線程可以同時執行add操作
- add操作時,如果發現list的容量不足,會進行擴容,但是由于多個線程同時擴容,就會出現擴容不足的問題
源碼:ArrayList.grow擴容
// 擴容方法private void grow(int minCapacity) { // overflow-conscious code
int oldCapacity = elementData.length; // 這裡可以看到,每次擴容增加一半的容量
int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0)
newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}複制代碼
可以看到,擴容是基于之前的容量進行的,是以如果多個線程同時擴容,那擴容基數就不準确了,結果就會有問題
線程安全的類:Collections.SynchronizedList
/**
** 同步容器類:為什麼要有它
**
* @author: JavaLover
* @time: 2021/5/3
*/public class SyncCollectionDemo { private ListlistSync; public SyncCollectionDemo() { // 這裡包裝一個空的ArrayList
this.listSync = Collections.synchronizedList(new ArrayList<>());
} public void addSync(int j){ // 内部是同步操作: synchronized (mutex) {return c.add(e);}
listSync.add(j);
} public static void main(String[] args) throws InterruptedException {
SyncCollectionDemo demo = new SyncCollectionDemo(); for (int i = 0; i < 10; i++) { new Thread(()->{ for (int j = 0; j < 100; j++) {
demo.addSync(j);
}
}).start();
}
TimeUnit.SECONDS.sleep(1); // 輸出1000
System.out.println(demo.listSync.size());
}
}複制代碼
輸出正确,因為現在ArrayList被Collections包裝成了一個線程安全的類
這就是為啥會有同步容器的原因:因為同步容器使得并發程式設計時,線程更加安全
3. 同步容器的優缺點
一般來說,都是先說優點,再說缺點
但是我們這次先說優點
優點:
- 并發程式設計中,獨立操作是線程安全的,比如單獨的add操作
缺點(是的,優點已經說完了):
- 性能差,基本上所有方法都上鎖,完美的诠釋了“甯可錯殺一千,不可放過一個”
- 複合操作,還是不安全,比如putIfAbsent操作(如果沒有則添加)
- 快速失敗機制,這種機制會報錯提示ConcurrentModificationException,一般出現在當某個線程在周遊容器時,其他線程恰好修改了這個容器的長度
為啥第三點是缺點呢?
因為它隻能作為一個建議,告訴我們有并發修改異常,但是不能保證每個并發修改都會爆出這個異常
爆出這個異常的前提如下:
源碼:Vector.Itr.checkForComodification 檢查容器修改次數
final void checkForComodification() { // modCount:容器的長度變化次數, expectedModCount:期望的容器的長度變化次數
if (modCount != expectedModCount) throw new ConcurrentModificationException();
}複制代碼
那什麼情況下并發修改不會爆出異常呢?有兩種:
- 周遊沒加鎖的情況:對于第二種同步容器(Collections内部類)來說,假設線程A修改了modCount的值,但是沒有同步到線程B,那麼線程B周遊就不會發生異常(但實際上問題已經存在了,隻是暫時沒有出現)
- 依賴線程執行順序的情況:對于所有的同步容器來說,假設線程B已經周遊完了容器,此時線程A才開始周遊修改,那麼也不會發生異常
代碼就不貼了,大家感興趣的可以直接寫幾個線程周遊試試,多運作幾次,應該就可以看到效果(不過第一種情況也是基于理論分析,實際代碼我這邊也沒跑出來)
根據阿裡巴巴的開發規範:不要在 foreach 循環裡進行元素的 remove/add 操作。remove 元素請使用 Iterator方式,如果并發操作,需要對 Iterator 對象加鎖。
這裡解釋下,關于List.remove和Iterator.remove的差別
- Iterator.remove:會同步修改expectedModCount=modCount
- list.remove:隻會修改modCount,因為expectedModCount屬于iterator對象的屬性,不屬于list的屬性(但是也可以間接通路)
源碼:ArrayList.remove移除元素操作
public E remove(int index) {
rangeCheck(index); // 1. 這裡修改了 modCount
modCount++;
E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}複制代碼
源碼:ArrayList.Itr.remove疊代器移除元素操作
public void remove() { if (lastRet < 0) throw new IllegalStateException();
checkForComodification(); try { // 1. 這裡調用上面介紹的list.romove,修改modCount
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1; // 2. 這裡再同步更新 expectedModCount
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException();
}
}複制代碼
由于同步容器的這些缺點,于是就有了并發容器(下期來介紹)
4. 同步容器的使用場景
多用在并發程式設計,但是并發量又不是很大的場景,比如一些簡單的個人部落格系統(具體多少并發量算大,這個也是分很多情況而論的,并不是說每秒處理超過多少個請求,就說是高并發,還要結合吞吐量、系統響應時間等多個因素一起考慮)
具體點來說的話,有以下幾個場景:
- 寫多讀少,這個時候同步容器和并發容器的性能差别不大(并發容器可以并發讀)
- 自定義的複合操作,比如getLast等操作(putIfAbsent就算了,因為并發容器有預設提供這個複合操作)
- 等等
總結
- 什麼是同步容器:就是把容器類同步化,這樣我們在并發中使用容器時,就不用手動同步,因為内部已經自動同步了
- 為什麼要有同步容器:因為普通的容器類(比如ArrayList)是線程不安全的,如果是在并發中使用,我們就需要手動對其加鎖才會安全,這樣的話就很太麻煩;是以就有了同步容器,它來幫我們自動加鎖
- 同步容器的優缺點:
優點 | 缺點 | |
---|---|---|
同步容器 | 獨立操作,線程安全 | 複合操作,還是不安全 |
性能差 | ||
快速失敗機制,隻适合bug調試 |
多用在并發量不是很大的場景,比如個人部落格、背景系統等
具體點來說,有以下幾個場景:
- 寫多讀少:這個時候同步容器和并發容器差别不是很大