背景
在實際工程實踐中,多線程併發執行場景十分常見。所謂線程安全性即是多線程併發執行場景中需要保證的基本要求,如果不能保證線程安全性,那麼勢必會在實際工程實踐中產生錯誤數據、甚至嚴重且不易察覺的異常處理,導致最終結果的不確定性。對於臨界資源,或者是必須串行操作的流程,勢必需要保證多個線程中每次僅有一個線程持有或僅有一個線程進入。如何保證多個線程由並行轉串行,去持有臨界資源或進入必須串行操作的流程呢?計算機領域中提供了“鎖”的概念來進行保證,各種語言中都提供了對鎖的實現。我們在這裏僅針對JAVA語言中的鎖進行分析。
synchronized關鍵字的原理
在早期的JAVA程序中,通常是使用synchronized關鍵字來保證線程安全性的。synchronized關鍵字,顧名思義表達的是同步的意思。也就是說,被synchronized關鍵字修飾的方法、語句塊同一時刻只能有一個線程進入。
很多同學都會很好奇,究竟synchronized關鍵字的原理是怎樣的,才能做到同一時刻只能有一個線程進入呢?其實是JVM來對此進行實現和保證的。每一個JAVA對象,其都對應一個Monitor(監視器)。標註有synchronized的語句塊(如果我們將函數也看做是語句塊,只不過是一段包裝在函數內部的語句塊)的頭部和尾部,在JDK編譯的時候,加入了monitorenter和monitorexit指令。通過對monitor的排他性獲取,實現了同一時間僅有一個線程可以進入synchronized語句塊,當語句執行完畢之後,線程釋放monitor,從而使得其他等待獲取monitor的線程依次獲取到monitor,串行進入語句塊,實現並行轉串行。
使用方式
synchronized關鍵字的使用方式主要有以下三種:
- 修飾非靜態方法
- 修飾靜態方法
- 修飾語句塊
修飾非靜態方法的方式保證同一個對象(注意與靜態方法進行區分)的這個方法內部同一時刻僅有一個線程可以進入;
相對應的,修飾靜態方法的方式保證的是同一個類(注意不再是類的對象了,因爲是靜態方法)的這個方法內部同一時刻僅有一個線程可以進入。
修飾語句塊的方式與修飾非靜態方法的方式類似。
synchronized關鍵字的侷限性
從上述的原理描述中,我們不難看出,由於synchronized是通過monitorenter和monitorexit來對語句塊進行加鎖,保證單一線程進入的,因此具備以下問題:
- 易死鎖;
- 無法設置嘗試獲取鎖的超時時間,一旦synchronized語句塊中需要進行耗時的操作時,同時等待的線程的任務完成時間不可控且不可預期。
我們現在針對這二者進行簡單的描述。
對於1,參見下述例子:
public class DeadLockDemo {
public Integer aInt = new Integer(1);
public Integer bInt = new Integer(1);
public static void main(String[] args) {
DeadLockDemo deadLockDemo = new DeadLockDemo();
Thread thread1 = new Thread(() -> {
synchronized (deadLockDemo.aInt) {
System.out.println("in thread1");
try {
Thread.currentThread().sleep(5000);
System.out.println("waiting for getting bInt object");
synchronized (deadLockDemo.bInt) {
System.out.println("got bInt object");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
synchronized (deadLockDemo.bInt) {
System.out.println("in thread2");
try {
Thread.currentThread().sleep(5000);
System.out.println("waiting for getting aInt object");
synchronized (deadLockDemo.aInt) {
System.out.println("got aInt object");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread2.start();
}
}
從中不難看出,共有兩個線程。線程1首先獲取aInt對象的鎖,然後嘗試獲取bInt對象的鎖。線程2與線程1恰恰相反。最終導致,線程1持有aInt對象的鎖並嘗試獲取bInt對象的鎖的同時,線程2也在嘗試獲取aInt對象的鎖,導致二者均無法繼續向下運行。
由於synchronized無法提供超時機制,因此一旦出現死鎖很難通過超時機制解鎖。
對於2,我們就不構造代碼實例了。
改進方案
上邊提出了兩個比較影響實際工程使用的問題,JAVA後續提出了ReentrantLock(可重入鎖)對此進行了改進。
ReentrantLock記錄了嘗試獲取鎖的線程數,並維護了等待線程的隊列,提供公平方式(正常排隊,根據到達等待時間點的先後順序安排解鎖後獲取鎖的順序)和非公平方式(插隊搶佔模式),當解鎖後將鎖分配給後續等待的線程。此外,ReentrantLock提供了超時時間,使得等待時間更可控。
在後面的文章中,會重點介紹ReentrantLock的原理。