上篇文章我們介紹了 synchronized 這個關鍵字,通過它可以基本實現執行緒間在臨界區對臨界資源正確的訪問與修改。但是,它依賴一個 Java 物件內建鎖,某個時刻只能由一個執行緒佔有該鎖,其他試圖佔有的執行緒都得阻塞在物件的阻塞佇列上。
但實際上還有一種情況也是存在的,如果某個執行緒獲得了鎖但在執行過程中由於某些條件的缺失,比如資料庫查詢的資源還未到來,磁碟讀取指令的資料未返回等,這種情況下,讓執行緒依然佔有 CPU 等待是一種資源上的浪費。
所以,每個物件上也存在一個等待佇列,這個佇列上阻塞了所有獲得鎖並處於執行期間缺失某些條件的執行緒,所以整個物件的鎖與佇列狀況是這樣的。
Entry Set 中阻塞了所有試圖獲得當前物件鎖而失敗的執行緒,Wait Set 中阻塞了所有在獲得鎖執行期間由於缺失某些條件而交出 CPU 的執行緒集合。
而當某個現場稱等待的條件滿足了,就會被移除等待佇列進入阻塞佇列重新競爭鎖資源。
wait/notify 方法
Object 類中有幾個方法我們雖然不常使用,但是確實執行緒協作的核心方法,我們通過這幾個方法控制執行緒間協作。
public final native void wait(long timeout)
public final void wait()
public final native void notify();
public final native void notify();
複製程式碼
wait 類方法用於阻塞當前執行緒,將當前執行緒掛載進 Wait Set 佇列,notify 類方法用於釋放一個或多個處於等待佇列中的執行緒。
所以,這兩個方法主要是操作物件的等待佇列,也即是將那些獲得鎖但是執行期間缺乏繼續執行的條件的執行緒阻塞和釋放的操作。
但是有一個前提大家需要注意,wait 和 notify 操作的是物件內建鎖的等待佇列,也就是說,必須在獲得物件內建鎖的前提下才能阻塞和釋放等待佇列上的執行緒。簡單來說,這兩個方法的只能在 synchronized 修飾的程式碼塊內部進行呼叫。
下面我們看一段程式碼:
public class Test {
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(){
@Override
public void run(){
synchronized (lock){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread2 = new Thread(){
@Override
public void run(){
synchronized (lock){
System.out.println("hello");
}
}
};
thread1.start();
thread2.start();
Thread.sleep(2000);
System.out.println(thread1.getState());
System.out.println(thread2.getState());
}
}
複製程式碼
執行結果:
可以看到,程式是沒有正常結束的,也就是說,有執行緒還未正常退出。執行緒一優先啟動於執行緒二,所以它將先獲得 lock 鎖,接著呼叫 wait 方法將自己阻塞在 lock 物件的等待佇列上,並釋放鎖交出 CPU。
執行緒二啟動時可能由於執行緒一依然佔有鎖而阻塞,但當執行緒一釋放鎖以後,執行緒二將獲得鎖並執行列印語句,隨後同步方法結束並釋放鎖。
此時,執行緒一依然阻塞在 lock 物件的等待佇列上,所以整個程式沒有正常退出。
演示這麼一段程式的意義是什麼呢?就是想告訴大家,雖然阻塞佇列和等待佇列上的執行緒都不能得到 CPU 正常執行指令,但是它們卻屬於兩種不同的狀態,阻塞佇列上的執行緒在得知鎖已經釋放後將公平競爭鎖資源,而等待佇列上的執行緒則必須有其他執行緒通過呼叫 notify 方法通知並移出等待佇列進入阻塞佇列,重新競爭鎖資源。
相關方法的實現
1、sleep 方法
sleep 方法用於阻塞當前執行緒指定時長,執行緒狀態隨即變成 TIMED_WAITING,但區別於 wait 方法。兩者都是讓出 CPU,但是 sleep 方法不會釋放當前持有的鎖。
也就是說,sleep 方法不是用於執行緒間同步協作的方法,它只是讓執行緒暫時交出 CPU,暫停執行一段時間,時間到了將由系統排程分配 CPU 繼續執行。
2、join 方法
join 方法用於實現兩個執行緒之間相互等待的一個操作,看段程式碼:
public void testJoin() throws InterruptedException {
Thread thread = new Thread(){
@Override
public void run(){
for (int i=0; i<1000; i++)
System.out.println(i);
}
};
thread.start();
thread.join();
System.out.println("main thread finished.....");
}
複製程式碼
拋開 join 方法不談,main 執行緒中的列印方法一定是先執行的,而實際上這段程式會線上程 thread 執行完成之後才執行主執行緒的列印方法。
實現機理區別於 sleep 方法,我們一起看看:
方法的核心就是呼叫 wait(delay) 阻塞當前執行緒,當執行緒被喚醒計算從進入方法到當前時間共經過了多久。
接著比較 millis 和 這個 now,如果 millis 小於 now 說明,說明等待時間已經到了,可以退出方法返回了。否則則說明執行緒提前被喚醒,需要繼續等待。
需要注意的是,既然是呼叫的 wait 方法,那麼等待的執行緒必然是需要釋放持有的當前物件內建鎖的,這區別於 sleep 方法。
一個典型的執行緒同步問題
下面我們寫一個很有意思的程式碼,實現作業系統中的生產者消費者模型,藉助我們的 wait 和 notify 方法。
生產者不停生產產品到倉庫中直到倉庫滿,消費者不停的從倉庫中取出產品直到倉庫為空。如果生產者發現倉庫已經滿了,就不能繼續生產產品,而消費者如果發現倉庫為空,就不能從倉庫中取出產品。
public class Repository {
private List<Integer> list = new ArrayList<>();
private int limit = 10; //設定倉庫容量上限
public synchronized void addGoods(int count) throws InterruptedException {
while(list.size() == limit){
//達到倉庫上限,不能繼續生產
wait();
}
list.add(count);
System.out.println("生產者生產產品:" + count);
//通知所有的消費者
notifyAll();
}
public synchronized void removeGoods() throws InterruptedException {
while(list.size() <= 0){
//倉庫中沒有產品
wait();
}
int res = list.get(0);
list.remove(0);
System.out.println("消費者消費產品:" + res);
//通知所有的生產者
notifyAll();
}
}
複製程式碼
寫一個倉庫類,該類提供兩個方法供外部呼叫,一個是往倉庫放產品,如果倉庫滿了則阻塞到倉庫物件的等待佇列上,一個是從倉庫中取出產品,如果倉庫為空則阻塞在倉庫的等待佇列上。
public class Producer extends Thread{
Repository repository = null;
public Producer(Repository p){
this.repository = p;
}
@Override
public void run(){
int count = 1;
while(true){
try {
Thread.sleep((long) (Math.random() * 500));
repository.addGoods(count++);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
複製程式碼
定義一個生產者類,生產者隨機的向倉庫新增產品。如果沒有能成功的新增,會被阻塞在迴圈裡。
public class Customer extends Thread{
Repository repository = null;
public Customer(Repository p){
this.repository = p;
}
@Override
public void run(){
while(true){
try {
Thread.sleep((long) (Math.random() * 500));
repository.removeGoods();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
複製程式碼
定義一個消費者類,消費者類隨機的從倉庫中取一個產品。如果沒有成功的取出一個產品,同樣會被阻塞在迴圈裡。
public void testProducerAndCustomer() {
Repository repository = new Repository();
Thread producer = new Producer(repository);
Thread consumer = new Customer(repository);
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println("main thread finished..");
}
複製程式碼
主執行緒啟動這兩個執行緒,程式執行的情況大致是這樣的:
生產者生產產品:1
消費者消費產品:1
生產者生產產品:2
消費者消費產品:2
生產者生產產品:3
消費者消費產品:3
。。。。。
。。。。。
消費者消費產品:17
生產者生產產品:21
消費者消費產品:18
生產者生產產品:22
消費者消費產品:19
生產者生產產品:23
消費者消費產品:20
生產者生產產品:24
生產者生產產品:25
生產者生產產品:26
消費者消費產品:21
生產者生產產品:27
生產者生產產品:28
消費者消費產品:22
消費者消費產品:23
生產者生產產品:29
生產者生產產品:30
。。。。。。
。。。。。。
複製程式碼
仔細觀察,你會發現,消費者者永遠不會消費一個不存在的產品,消費的一定是生產者生產的產品。剛開始可能是生產者生產一個產品,消費者消費一個產品,而一旦消費者執行緒執行的速度超過了生產者,必然會由於倉庫容量為空而被阻塞。
生產者執行緒的執行速度可以超過消費者執行緒,而消費者執行緒的執行速度如果一直超過生產者就會導致倉庫容量為空而致使自己被阻塞。
總結一下,synchronized 修飾的程式碼塊是直接使用的物件內建鎖的阻塞佇列,執行緒獲取不到鎖自然被阻塞在該佇列上,而 wait/notify 則是我們手動的控制等待佇列的入隊和出隊操作。但本質上都是利用的物件內建鎖的兩個佇列。
這兩篇文章介紹的是利用 Java 提供給我們的物件中的內建鎖來完成基本的執行緒間同步操作,這部分知識是後續介紹的各種同步工具,集合類框架等實現的底層原理。
文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:
歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。