基礎篇:內置鎖和顯式鎖摸底

引言

內置鎖和顯式鎖是 Java 的兩種不同加鎖機制,且是互斥的,只能二選一,不能混用。但是,筆者近幾年在 CSDN 問答模塊看到不少關於 synchronizedwait、notify 的提問,不是個案,而是不少類似的問題。竊以爲這可能是初學者不太容易理解的知識點,筆者也是踩過很多坑後才領悟它們的用法的。

其實,只要銘記兩句話,就不怕用錯了,筆者的使用經驗是這樣的:

  1. 魚和熊掌不可兼得
  2. waitnotify 需要裹在 synchronized 裏面

筆者將內置鎖和顯式鎖作爲專欄的基礎篇部分,先從大家最熟悉的 Object 類說起吧!

Object 類,你真的瞭解嗎

Object 類是 Java 所有類的父類,查看源碼,總數不到六百行,它包含了內置鎖的核心 API,相信各位讀者也是耳熟能詳的啦:
在這裏插入圖片描述
紅框裏的這幾個方法,就是我們使用內置鎖的基礎,JDK 的源碼其實是自解釋的,每個方法定義時已經包含了詳細的使用說明,來跟一下它們的源碼。

wait 阻塞方法

wait 的三個方法屬於重載函數,最後統一調用的是 wait(long) 方法,來自 JDK 源碼中的註釋如下:

Causes the current thread to wait until another thread invokes the method notify or notifyAll method for this object, or some other thread interrupts the current thread, or a certain
amount of real time has elapsed.
The current thread must own this object’s monitor.

從這段文字中,我們可以知道使用該方法的場景和限制條件:

  1. 一個具有擁有 Object 鎖對象的線程,才能調用該方法
  2. 該方法將導致當前線程阻塞
  3. 線程結束阻塞的條件是,另一個線程調用了 notify/notifyAll 喚醒方法,或者等待指定的時間後,自動喚醒自己

notify 喚醒方法

notifynotifyAll ,二者都是喚醒因調用鎖對象的 wait 方法後被阻塞在鎖對象的條件隊列上的線程,notifyAll 的解釋是這樣的:

Wakes up all threads that are waiting on this object’s monitor

notify 稍有不同:

Wakes up a single thread that is waiting on this object’s
If any threads are waiting on this object, one of them
is chosen to be awakened. The choice is arbitrary and occurs at
the discretion of the implementation.

它每次只能喚起單個阻塞線程,如果同時有多個線程阻塞時,它會自由選擇一個阻塞線程來喚醒, 而 notifyAll 則是無條件喚醒所有阻塞線程。

常見異常

Object 類的阻塞和喚醒方法,都會拋出 IllegalMonitorStateException 異常,因爲當前線程必須先擁有該 Object 對象的監控器,才能調用該對象的 waitnotify 方法阻塞自己或者喚醒其他阻塞線程。

否則,貿然調用這些方法,就會遭遇監控狀態非法異常了。使用內置鎖的阻塞或喚醒方法,必須先擁有鎖對象,這是基本前提。

內置鎖和顯式鎖

使用內置鎖和顯式鎖完成簡單的同步代碼邏輯,【這裏的簡單是指不使用條件隊列的情況下】,沒什麼特別注意的地方,這是基本的使用語法:

//內置鎖
synchronized(內置鎖對象){
      // TODO
}

//顯式鎖
lock.lock();
try{
      // TODO
}finally{
     lock.unlock();
}

條件隊列

條件隊列,是 JVM 底層維護的一種隊列,它存儲的是因等待某種條件出現而被主動阻塞的線程,包含入隊和出隊兩種操作。

條件隊列不能單獨存在,它必須依附於鎖,它是鎖的一個結構。JVM 會對條件隊列上的阻塞和喚醒調用進行上下文進行檢查,一旦沒有鎖或者鎖對象和條件隊列的所屬對象不一致,就會拋出 IllegalMonitorStateException 異常。它是一種在獲取同步鎖之後因需要等待某種條件,而自動阻塞並讓出鎖的行爲。

Java 提供了兩種互斥的條件隊列,分別對應兩類鎖:

  1. Object 類的 wait、notify 等方法是內置條件隊列 API,一個 Object 對象的內置鎖上只能維護一個條件隊列。
  2. AbstractQueuedSynchronizer (簡稱 AQS)的內部類 ConditionObject 實現的顯式條件隊列,一個顯式鎖可以通過 newCondition() 維護多個條件隊列,API 是 await、 signal 等方法。

內置條件隊列與內置鎖

內置條件隊列必須與內置鎖一起使用,是 has-a 的關係,鎖對象有一個對應的條件隊列。Java 的 Object 類有 wait、notify 兩個方法,它們是內置條件隊列的 API,用以喚醒或者阻塞某個線程,只有在某個內置鎖的同步代碼塊內才能調用該對象的 waitnotify 方法【這是前面反覆強調的重要知識】。

API 方法上的註釋說明了它們的使用約束,例如 wait 方法上的註釋說明有這段話:

This method should only be called by a thread that is the owner of this object's monitor.

內置鎖和內置條件隊列的基本語法是:

synchronized (obj) {
      while (<condition does not hold>)
         obj.wait(timeout, nanos);
      ... // Perform action appropriate to condition
 }

顯式條件隊列與顯式鎖

顯式條件隊列的實現類 ConditionObject 是顯式鎖 Lock 實現類的一個內部類,它的創建需要一個顯式鎖對象,即宿主對象。使用方法比內置條件隊列稍微複雜一點,需要維護一個條件隊列的實例作爲類的成員變量,然後在不同的方法中根據業務邏輯使用條件隊列對象的阻塞和喚醒方法。

顯式鎖的好處是,它可以在一把鎖上創建多個條件隊列,而內置鎖只有一個條件隊列,在多條件的場景下,顯式鎖更方便一些。它的使用模式爲:

private Lock lock=new ReentrantLock();	
private Condition condition =lock.newCondition();

public void methodA(){
    lock.lock();//先獲取鎖
    try{
        if(需要等待某種條件,阻塞){
            condition.await();
        }
    }finally{
        lock.unlock();
    }
}

public void methodB(){
    lock.lock();//先獲取鎖
    try{
        if(已經滿足某種條件,喚醒){
            condition.singnal();
        }
    }finally{
        lock.unlock();
    }
}

錯誤案例分析

條件隊列的阻塞和喚醒操作應該由不同的線程調用,如果某一個線程被阻塞在某個條件隊列上了,又沒有別的線程喚醒它,它將一直處於“假死”狀態。這裏總結幾種典型的錯誤用法,供讀者參考。

案例一,顯式鎖和內置鎖混用

CSDN 問答頻道的一個 錯誤案例 ,顯式鎖和內置鎖混用,導致運行結果混亂:

 public void stock() {
        lock.lock();
        try {
            if (money > 500) {
                try {
                    wait();
                    notify();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                // 每次調用存入100元
                money += 100;
                System.out.println("媽媽存進了100元,現在還有" + money + "元");

                // 喚醒兒子
                if (money > 200) {
                    this.notify();
                }
            }
        } finally {
            lock.unlock();
        }
    }

問題分析waitnotify 方法的調用必須被包裹在同步代碼塊內才能調用,否則就會報錯。這裏既然用了顯式鎖 Lock 就不能再用 wait 和 notify 了,它們是兩套加鎖機制。

修正思路:用 lock 創建一個 Condition ,然後在該 Conditionawait() 和 singnal()

案例二,阻塞和喚醒在同一個方法中

CSDN 問答頻道的另一個 錯誤案例,它在同一個方法中既阻塞又喚醒,導致線程死鎖,程序無法終結:

 public synchronized void run()
    {
        for (int i = 0; i < 52; i++)
        {
            if (i % 2 == 0 && i != 0)
            {
                try
                {
                    notify();;
                    this.wait();
                } catch (InterruptedException e)
                {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
            System.out.print(n + i);
        }
    }
}

問題分析:這裏需要理解 Object 類的 wait() 和 notify() 、notifyAll() 的用法,它們是針對內置條件隊列的阻塞和喚醒,一旦調用 wait 後,當前線程已經掛起了,即使後面的代碼用了 notify,也是無效的,必須由另一個線程調用 notify 才能喚醒它。

這裏的用法是錯誤的,定義了兩個線程,各自操作自己的 waitnotify ,實際在調用 wait 後就掛起了自己,由於沒有其他線程調用 notify ,導致線程一直處於掛起狀態。

正確的編碼邏輯是:在一個類的不同方法中,一個方法用 wait() 掛起,另一個方法中調 notify() 喚醒,並且這兩個方法由不同的線程來調用。

案例三,違背鎖使用常識

每個共享可變變量只能被同一把鎖保護,否則它依舊是不安全的。這是一個非常重要的鎖使用常識,也很好理解。如果在不同的地方,對同一個共享變量的訪問用了不同的鎖,就相當於有多個鑰匙可以進入同一個房間,那麼同一時刻該房間的訪問就不是獨佔狀態,錯誤的加鎖等於不加鎖

曾經見到一個錯誤的用法,貌似卻用的挺對。大概是這樣的,一個工作線程,訪問了一個全局計數器,對這個計數器的訪問使用了內置鎖,示例代碼爲:

public class LockTest implements Runnable{
	//全局共享變量
	public static int count = 0;

	@Override
	public void run() {
		while(!Thread.currentThread().isInterrupted()) {
			//以當前實例爲鎖,保護 count 的操作
			synchronized (this) {
				count++;
				System.out.println(Thread.currentThread().getName()+",update count:"+count);
			}
		}
	}

	public static void main(String[] args) {
		Thread t1 = new Thread(new LockTest());
		Thread t2 = new Thread(new LockTest());
		Thread t3 = new Thread(new LockTest());
		Thread t4 = new Thread(new LockTest());
		
		t1.start();
		t2.start();
		t3.start();
		t4.start();
	}
}

預期 count 的值順序增加,而實際結果卻有重複:
在這裏插入圖片描述
問題分析:癥結在於 synchronized (this) 用了當前實例對象作爲鎖,而每個線程執行時都有自己的 this 鎖,導致 count 的操作過程實際是由多把鎖控制,沒個線程一把鎖,當然 “個自爲王了”。

解決辦法:對 count 這個共享變量的任何操作,都使用同一把鎖,比如: synchronized (LockTest.class),這樣就能保證操作的互斥了:
在這裏插入圖片描述
“一個變量多個鎖” 會出現不正確的結果,該案例給我們的啓示是:遵循 “一個變量一把鎖 ” 這一常識,纔可能編寫健壯可靠的併發程序。

以上就是本章節的內容了,下一節筆者將使用鎖實現生產者消費者的通信模型。學習前車之鑑,對我們理解正確的用法也是大有裨益的!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章