高併發之Phaser、ReadWriteLock、StampedLock

等不到的口琴發表於2021-02-16

本系列研究總結高併發下的幾種同步鎖的使用以及之間的區別,分別是:ReentrantLock、CountDownLatch、CyclicBarrier、Phaser、ReadWriteLock、StampedLock、Semaphore、Exchanger、LockSupport。由於部落格園對部落格字數的要求限制,會分為三個篇幅:

高併發之ReentrantLock、CountDownLatch、CyclicBarrier

高併發之Phaser、ReadWriteLock、StampedLock

高併發之Semaphore、Exchanger、LockSupport

Phaser

Phaser是JDK7開始引入的一個同步工具類,適用於一些需要分階段的任務的處理。它的功能與 CyclicBarrierCountDownLatch有些類似,功能上與 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

StampedLockReadWriteLock相比,改進之處在於:讀的過程中也允許獲取寫鎖後寫入!這樣一來,我們讀的資料就可能不一致,所以,需要一點額外的程式碼來判斷讀的過程中是否有寫入,這種讀鎖是一種樂觀鎖。

樂觀鎖的意思就是樂觀地估計讀的過程中大概率不會有寫入,因此被稱為樂觀鎖。反過來,悲觀鎖則是讀的過程中拒絕有寫入,也就是寫入必須等待。顯然樂觀鎖的併發效率更高,但一旦有小概率的寫入導致讀取的資料不一致,需要能檢測出來,再讀一遍就行。

我們來看例子:

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是不可重入鎖。

相關文章