本系列研究總結高併發下的幾種同步鎖的使用以及之間的區別,分別是:ReentrantLock、CountDownLatch、CyclicBarrier、Phaser、ReadWriteLock、StampedLock、Semaphore、Exchanger、LockSupport。由於部落格園對部落格字數的要求限制,會分為三個篇幅:
高併發之ReentrantLock、CountDownLatch、CyclicBarrier
高併發之Phaser、ReadWriteLock、StampedLock
高併發之Semaphore、Exchanger、LockSupport
Phaser
Phaser
是JDK7開始引入的一個同步工具類,適用於一些需要分階段的任務的處理。它的功能與 CyclicBarrier和CountDownLatch有些類似,功能上與 CountDownLatch 和 CyclicBarrier類似但支援的場景更加靈活類似於一個多階段的柵欄,並且功能更強大,我們來比較下這三者的功能:
同步器 | 作用 |
---|---|
CountDownLatch | 倒數計數器,初始時設定計數器值,執行緒可以在計數器上等待,當計數器值歸0後,所有等待的執行緒繼續執行 |
CyclicBarrier | 迴圈柵欄,初始時設定參與執行緒數,當執行緒到達柵欄後,會等待其它執行緒的到達,當到達柵欄的總數滿足指定數後,所有等待的執行緒繼續執行 |
Phaser | 多階段柵欄,可以在初始時設定參與執行緒數,也可以中途註冊/登出參與者,當到達的參與者數量滿足柵欄設定的數量後,會進行階段升級(advance) |
使用場景
相對於前面的CyclicBarrier和CountDownLatch而言,這個稍微有一些難以理解,這兒引入一個場景:結婚
一場婚禮中勢必分成很多個階段,例如賓客到齊、舉行婚禮、新郎新娘拜天地、入洞房、吃宴席、賓客離開等,如果把不同的人看成是不同的執行緒的話,那麼不同的執行緒所要到的階段是不一樣的,例如新郎新娘可能要走完全流程,而賓客可能只是其中的幾步而已。
程式碼示例:
Person
static class Person {
String name;
public Person(String name) {
this.name = name;
}
public void arrive() {
milliSleep(r.nextInt(1000));
System.out.printf("%s 到達現場!\n", name);
}
public void eat() {
milliSleep(r.nextInt(1000));
System.out.printf("%s 吃完!\n", name);
}
public void leave() {
milliSleep(r.nextInt(1000));
System.out.printf("%s 離開!\n", name);
}
}
}
MarriagePhaser
static class MarriagePhaser extends Phaser {
@Override
protected boolean onAdvance(int phase, int registeredParties) {
switch (phase) {
case 0:
System.out.println("所有人到齊了!");
return false;
case 1:
System.out.println("所有人吃完了!");
return false;
case 2:
System.out.println("所有人離開了!");
System.out.println("婚禮結束!");
return true;
default:
return true;
}
}
}
TestPhaser
public class TestPhaser {
static Random r = new Random();
static MarriagePhaser phaser = new MarriagePhaser();
static void milliSleep(int milli) {
try {
TimeUnit.MILLISECONDS.sleep(milli);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
phaser.bulkRegister(5);
for(int i=0; i<5; i++) {
final int nameIndex = i;
new Thread(()->{
Person p = new Person("person " + nameIndex);
p.arrive();
phaser.arriveAndAwaitAdvance();
p.eat();
phaser.arriveAndAwaitAdvance();
p.leave();
phaser.arriveAndAwaitAdvance();
}).start();
}
}
列印結果
person 0 到達現場!
person 2 到達現場!
person 4 到達現場!
person 1 到達現場!
person 3 到達現場!
所有人到齊了!
person 2 吃完!
person 0 吃完!
person 4 吃完!
person 3 吃完!
person 1 吃完!
所有人吃完了!
person 3 離開!
person 1 離開!
person 0 離開!
person 4 離開!
person 2 離開!
所有人離開了!
婚禮結束!
Phaser常見的方法
Phaser() //預設的構造方法,初始化註冊的執行緒數量為0
Phaser(int parties)//一個指定執行緒數量的構造方法
此外Phaser還支援Tiering型別具有父子關係的構造方法,主要是為了減少在註冊者數量龐大的時候,通過分組的形式複用Phaser從而減少競爭,提高吞吐,這種形式一般不常見,所以這裡不再提及,有興趣的可以參考官網文件。
其他幾個常見方法:
register()//新增一個新的註冊者
bulkRegister(int parties)//新增指定數量的多個註冊者
arrive()// 到達柵欄點直接執行,無須等待其他的執行緒
arriveAndAwaitAdvance()//到達柵欄點,必須等待其他所有註冊者到達
arriveAndDeregister()//到達柵欄點,登出自己無須等待其他的註冊者到達
onAdvance(int phase, int registeredParties)//多個執行緒達到註冊點之後,會呼叫該方法。
- arriveAndAwaitAdvance() 當前執行緒當前階段執行完畢,等待其它執行緒完成當前階段。如果當前執行緒是該階段最後一個未到達的,則該方法直接返回下一個階段的序號(階段序號從0開始),同時其它執行緒的該方法也返回下一個階段的序號。
- arriveAndDeregister() 該方法立即返回下一階段的序號,並且其它執行緒需要等待的個數減一,並且把當前執行緒從之後需要等待的成員中移除。如果該Phaser是另外一個Phaser的子Phaser(層次化Phaser會在後文中講到),並且該操作導致當前Phaser的成員數為0,則該操作也會將當前Phaser從其父Phaser中移除。
- arrive()該方法不作任何等待,直接返回下一階段的序號。
- awaitAdvance(int phase) 該方法等待某一階段執行完畢。如果當前階段不等於指定的階段或者該Phaser已經被終止,則立即返回。該階段數一般由arrive()方法或者arriveAndDeregister()方法返回。返回下一階段的序號,或者返回引數指定的值(如果該引數為負數),或者直接返回當前階段序號(如果當前Phaser已經被終止)。
- awaitAdvanceInterruptibly(int phase) 效果與awaitAdvance(int phase)相當,唯一的不同在於若該執行緒在該方法等待時被中斷,則該方法丟擲InterruptedException。
- awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit) 效果與awaitAdvanceInterruptibly(int phase)相當,區別在於如果超時則丟擲TimeoutException。
- bulkRegister(int parties) 註冊多個party。如果當前phaser已經被終止,則該方法無效,並返回負數。如果呼叫該方法時,onAdvance方法正在執行,則該方法等待其執行完畢。如果該Phaser有父Phaser則指定的party數大於0,且之前該Phaser的party數為0,那麼該Phaser會被註冊到其父Phaser中。
- forceTermination() 強制讓該Phaser進入終止狀態。已經註冊的party數不受影響。如果該Phaser有子Phaser,則其所有的子Phaser均進入終止狀態。如果該Phaser已經處於終止狀態,該方法呼叫不造成任何影響。
ReadWriteLock
根據翻譯,讀寫鎖,顧名思義,在讀的時候上讀鎖,在寫的時候上寫鎖,這樣就很巧妙的解決synchronized的一個效能問題:讀與讀之間互斥。
ReadWriteLock也是一個介面,原型如下:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
該介面只有兩個方法,讀鎖和寫鎖。也就是說,我們在寫檔案的時候,可以將讀和寫分開,分成2個鎖來分配給執行緒,從而可以做到讀和讀互不影響,讀和寫互斥,寫和寫互斥,提高讀寫檔案的效率。該介面也有一個實現類ReentrantReadWriteLock,下面我們就來學習下這個類。
我們先看一下,多執行緒同時讀取檔案時,用synchronized實現的效果,程式碼如下:
public class ReadAndWriteLock {
public synchronized void get(Thread thread) {
long start = System.currentTimeMillis();
for(int i=0; i<5; i++){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ":正在進行讀操作……");
}
System.out.println(thread.getName() + ":讀操作完畢!");
long end = System.currentTimeMillis();
System.out.println("用時:"+(end-start)+"ms");
}
public static void main(String[] args) {
final ReadAndWriteLock lock = new ReadAndWriteLock();
new Thread(new Runnable() {
@Override
public void run() {
lock.get(Thread.currentThread());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
lock.get(Thread.currentThread());
}
}).start();
}
}
測試結果如下:
Thread-1:正在進行讀操作……
Thread-1:正在進行讀操作……
Thread-1:正在進行讀操作……
Thread-1:正在進行讀操作……
Thread-1:正在進行讀操作……
Thread-1:讀操作完畢!
用時:112ms
Thread-0:正在進行讀操作……
Thread-0:正在進行讀操作……
Thread-0:正在進行讀操作……
Thread-0:正在進行讀操作……
Thread-0:正在進行讀操作……
Thread-0:讀操作完畢!
用時:107ms
我們可以看到,即使是在讀取檔案,在加了synchronized關鍵字之後,讀與讀之間,也是互斥的,也就是說,必須等待Thread-0讀完之後,才會輪到Thread-1執行緒讀,而無法做到同時讀檔案,這種情況在大量執行緒同時都需要讀檔案的時候,讀寫鎖的效率,明顯要高於synchronized關鍵字的實現。下面我們來測試一下,程式碼如下:
public class ReadAndWriteLock {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void get(Thread thread) {
lock.readLock().lock();
try{
System.out.println("start time:"+System.currentTimeMillis());
for(int i=0; i<5; i++){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ":正在進行讀操作……");
}
System.out.println(thread.getName() + ":讀操作完畢!");
System.out.println("end time:"+System.currentTimeMillis());
}finally{
lock.readLock().unlock();
}
}
public static void main(String[] args) {
final ReadAndWriteLock lock = new ReadAndWriteLock();
new Thread(new Runnable() {
@Override
public void run() {
lock.get(Thread.currentThread());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
lock.get(Thread.currentThread());
}
}).start();
}
}
注意的是,如果有一個執行緒已經佔用了讀鎖,則此時其他執行緒如果要申請寫鎖,則申請寫鎖的執行緒會一直等待釋放讀鎖。如果有一個執行緒已經佔用了寫鎖,則此時其他執行緒如果申請寫鎖或者讀鎖,則申請的執行緒會一直等待釋放寫鎖。讀鎖和寫鎖是互斥的。
下面我們來驗證下讀寫鎖的互斥關係,程式碼如下:
public class ReadAndWriteLock {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public static void main(String[] args) {
final ReadAndWriteLock lock = new ReadAndWriteLock();
// 建N個執行緒,同時讀
ExecutorService service = Executors.newCachedThreadPool();
service.execute(new Runnable() {
@Override
public void run() {
lock.readFile(Thread.currentThread());
}
});
// 建N個執行緒,同時寫
ExecutorService service1 = Executors.newCachedThreadPool();
service1.execute(new Runnable() {
@Override
public void run() {
lock.writeFile(Thread.currentThread());
}
});
}
// 讀操作
public void readFile(Thread thread){
lock.readLock().lock();
boolean readLock = lock.isWriteLocked();
if(!readLock){
System.out.println("當前為讀鎖!");
}
try{
for(int i=0; i<5; i++){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ":正在進行讀操作……");
}
System.out.println(thread.getName() + ":讀操作完畢!");
}finally{
System.out.println("釋放讀鎖!");
lock.readLock().unlock();
}
}
// 寫操作
public void writeFile(Thread thread){
lock.writeLock().lock();
boolean writeLock = lock.isWriteLocked();
if(writeLock){
System.out.println("當前為寫鎖!");
}
try{
for(int i=0; i<5; i++){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(thread.getName() + ":正在進行寫操作……");
}
System.out.println(thread.getName() + ":寫操作完畢!");
}finally{
System.out.println("釋放寫鎖!");
lock.writeLock().unlock();
}
}
}
測試結果如下:
// 讀鎖和讀鎖測試結果:
當前為讀鎖!
當前為讀鎖!
pool-2-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-2-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-2-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-2-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-2-thread-1:正在進行讀操作……
pool-1-thread-1:讀操作完畢!
pool-2-thread-1:讀操作完畢!
釋放讀鎖!
釋放讀鎖!
// 測試結果不互斥
// 讀鎖和寫鎖,測試結果如下:
當前為讀鎖!
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:正在進行讀操作……
pool-1-thread-1:讀操作完畢!
釋放讀鎖!
當前為寫鎖!
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:寫操作完畢!
釋放寫鎖!
// 測試結果互斥
// 寫鎖和寫鎖,測試結果如下:
當前為寫鎖!
pool-1-thread-1:正在進行寫操作……
pool-1-thread-1:正在進行寫操作……
pool-1-thread-1:正在進行寫操作……
pool-1-thread-1:正在進行寫操作……
pool-1-thread-1:正在進行寫操作……
pool-1-thread-1:寫操作完畢!
釋放寫鎖!
當前為寫鎖!
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:正在進行寫操作……
pool-2-thread-1:寫操作完畢!
釋放寫鎖!
// 測試結果互斥
ReadWriteLock小結
使用ReadWriteLock
可以提高讀取效率:
ReadWriteLock
只允許一個執行緒寫入;ReadWriteLock
允許多個執行緒在沒有寫入時同時讀取;ReadWriteLock
適合讀多寫少的場景。
StampedLock
前面介紹的ReadWriteLock
可以解決多執行緒同時讀,但只有一個執行緒能寫的問題。
如果我們深入分析ReadWriteLock
,會發現它有個潛在的問題:如果有執行緒正在讀,寫執行緒需要等待讀執行緒釋放鎖後才能獲取寫鎖,即讀的過程中不允許寫,這是一種悲觀的讀鎖。
要進一步提升併發執行效率,Java 8引入了新的讀寫鎖:StampedLock
。
StampedLock
和ReadWriteLock
相比,改進之處在於:讀的過程中也允許獲取寫鎖後寫入!這樣一來,我們讀的資料就可能不一致,所以,需要一點額外的程式碼來判斷讀的過程中是否有寫入,這種讀鎖是一種樂觀鎖。
樂觀鎖的意思就是樂觀地估計讀的過程中大概率不會有寫入,因此被稱為樂觀鎖。反過來,悲觀鎖則是讀的過程中拒絕有寫入,也就是寫入必須等待。顯然樂觀鎖的併發效率更高,但一旦有小概率的寫入導致讀取的資料不一致,需要能檢測出來,再讀一遍就行。
我們來看例子:
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 獲取寫鎖
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 釋放寫鎖
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 獲得一個樂觀讀鎖
// 注意下面兩行程式碼不是原子操作
// 假設x,y = (100,200)
double currentX = x;
// 此處已讀取到x=100,但x,y可能被寫執行緒修改為(300,400)
double currentY = y;
// 此處已讀取到y,如果沒有寫入,讀取是正確的(100,200)
// 如果有寫入,讀取是錯誤的(100,400)
if (!stampedLock.validate(stamp)) { // 檢查樂觀讀鎖後是否有其他寫鎖發生
stamp = stampedLock.readLock(); // 獲取一個悲觀讀鎖
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 釋放悲觀讀鎖
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
和ReadWriteLock
相比,寫入的加鎖是完全一樣的,不同的是讀取。注意到首先我們通過tryOptimisticRead()
獲取一個樂觀讀鎖,並返回版本號。接著進行讀取,讀取完成後,我們通過validate()
去驗證版本號,如果在讀取過程中沒有寫入,版本號不變,驗證成功,我們就可以放心地繼續後續操作。如果在讀取過程中有寫入,版本號會發生變化,驗證將失敗。在失敗的時候,我們再通過獲取悲觀讀鎖再次讀取。由於寫入的概率不高,程式在絕大部分情況下可以通過樂觀讀鎖獲取資料,極少數情況下使用悲觀讀鎖獲取資料。
可見,StampedLock
把讀鎖細分為樂觀讀和悲觀讀,能進一步提升併發效率。但這也是有代價的:
一是程式碼更加複雜
二是StampedLock
是不可重入鎖,不能在一個執行緒中反覆獲取同一個鎖。
StampedLock
還提供了更復雜的將悲觀讀鎖升級為寫鎖的功能,它主要使用在if-then-update的場景:即先讀,如果讀的資料滿足條件,就返回,如果讀的資料不滿足條件,再嘗試寫。
StampedLock小結
StampedLock
提供了樂觀讀鎖,可取代ReadWriteLock
以進一步提升併發效能;
StampedLock
是不可重入鎖。