Java併發學習筆記 —— 淺析Java中的鎖
引言
在多執行緒環境下,為了保證共享變數的原子性操作,我們需要鎖來保證資源的獨佔;在資料庫連線等資源不足的情況下,我們需要控制獲取連線的資源數以防出現異常;還有一些情況下,我們需要多個執行緒任務完成的條件滿足後再繼續程式……在以上的種種情況,我們都需要使用鎖,讓我們的程式按照我們的預期執行。
本篇文章主要分為三個部分,第一個部分簡單介紹目前Java中鎖的區別,第二部分會介紹最簡單的鎖,也是Java的保留字——Synchronized
。第三部分則會介紹基於同步器的鎖,也就是java.util.concurrent
包含的鎖。
Java中鎖的分類
獨佔鎖與共享鎖
獨佔的意思是隻是否只能有一個執行緒擁有鎖,當鎖被執行緒獨佔了之後,其它再想要獲取鎖的執行緒就只能等待。等待的機制有自旋等待以及阻塞等待。自旋等待的時候,執行緒會不斷迴圈查詢是否滿足獲取鎖的條件,這樣消耗CPU但是卻節省了執行緒上下文切換所需的時間,一般在同步塊執行速度較短時選擇使用。而阻塞等待則會導致執行緒從執行態轉變成堵塞態。
共享鎖則允許多個執行緒同時獲取鎖,一般在併發讀的時候採用共享鎖。當然也可以使用共享鎖來控制當前併發執行緒的數量,如引言中提到的在資料庫連線有限的情況下,就可以控制併發執行緒數與資料庫連線數一致。
可重入鎖和不可重入鎖
根據是否可以多次獲得同一個鎖,又有可重入鎖與不可重入鎖的區分。
Synchronized —— 不可重入的獨佔鎖
用法及簡介
Synchronized做為Java的保留字,有以下兩種用法。
在方法上使用Synchronized
public synchronized void sync() { int a = 1; }
在特定的程式碼區域中使用Synchronized
public void syncInBlock() { synchronized(this) { int a = 1; } }
但無論哪個地方使用Synchronized,其實都是在一個特定的物件上加鎖。如果是在靜態方法上加鎖,則是對Class物件進行了加鎖,而在方法上或者是方法腫的同步塊上加鎖,則是對相應的例項加鎖。
加鎖原理
我們將上述程式碼的Java檔案編譯後再使用javap -verbose *>*.txt
命令進行反編譯,可以得到以下的位元組碼命令。通過位元組碼命令,我們能夠更加清晰地看到synchronized的加鎖原理。
public class com.java.SampleSynchronized minor version: 0 major version: 49 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Class #2 // com/java/SampleSynchronized #2 = Utf8 com/java/SampleSynchronized #3 = Class #4 // java/lang/Object #4 = Utf8 java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Methodref #3.#9 // java/lang/Object."<init>":()V #9 = NameAndType #5:#6 // "<init>":()V #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/java/SampleSynchronized; #14 = Utf8 sync #15 = Utf8 a #16 = Utf8 I #17 = Utf8 syncInBlock #18 = Utf8 SourceFile #19 = Utf8 SampleSynchronized.java { public com.java.SampleSynchronized(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/java/SampleSynchronized; public synchronized void sync(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED //因為Synchronized在方法上使用,因此方法的ACC_SYNCHRONIZED標誌位被設定成1 Code: stack=1, locals=2, args_size=1 0: iconst_1 1: istore_1 2: return LineNumberTable: line 5: 0 line 6: 2 LocalVariableTable: Start Length Slot Name Signature 0 3 0 this Lcom/java/SampleSynchronized; 2 1 1 a I public void syncInBlock(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter //加鎖,然後進入同步區 4: iconst_1 5: istore_2 6: aload_1 7: monitorexit //正常執行完畢,解鎖 8: goto 14 //程式返回 11: aload_1 12: monitorexit //異常程式碼處理,執行緒丟擲異常的時候,釋放鎖 13: athrow 14: return Exception table: from to target type 4 8 11 any 11 13 11 any LineNumberTable: line 9: 0 line 10: 4 line 9: 6 line 12: 14 LocalVariableTable: Start Length Slot Name Signature 0 15 0 this Lcom/java/SampleSynchronized; }
從上面我們可以看出,對於使用在方法上的sychronized,在方法的flags上會多出一個ACC_SYNCHRONIZED
的flag,以標誌這個方法是必須要同步訪問的。而在方法內宣告的同步塊,則會有monitorenter
和monitorexit
來完成加鎖和解鎖。
實際上,monitor也就是加在物件上的鎖,如果執行monitorenter的時候發現無法獲取物件上的鎖,執行緒就會對鎖進行等待。而具體的加鎖/等待方式又會因為鎖的型別不同而有所不同,包括有偏向鎖,輕量級鎖和重量級鎖。對於這些鎖的實現在這裡就不展開描述了。
ReentrantLock——使用同步器的鎖
除了Synchronized之外,JDK還提供了可重入的獨佔鎖——ReentrantLock以及可重入的共享鎖——ReentrantReadWriteLock。因為這兩個鎖的實現都是基於同步器的,因此這裡以ReentrantLock為例,介紹基於同步器的鎖。
基本用法
ReentrantLock實現了java.util.concurrent.locks.Lock
介面。其主要方法如下所示:
public void lock(){} //獲取鎖,若不成功執行緒會被阻塞
public boolean tryLock() {} //嘗試獲取鎖,獲取成功返回true,獲取失敗返回false,不阻塞執行緒
public void unlock() {} //釋放鎖
public final boolean isFair() {} //公平鎖還是非公平鎖,此處暫時略過
對於鎖的使用者而言,我們只要瞭解了這些介面,就能夠使用鎖了。最簡單的用法如下所示:
ReentrantLock lock = new ReentrantLock();
//執行緒阻塞式獲取鎖
try{
lock.lock();
...sync code...
} finally {
lock.unlock();
}
//執行緒非阻塞式獲取鎖
try{
while(lock.tryLock()){}
} finally {
lock.unlock();
}
加鎖原理
通過檢視ReentrantLock的原始碼,我們能夠發現,在ReentrantLock中,還存在著一個內部抽象類Sync
,而其具體實現則有FairSync
和NonfairSync
。Sync
繼承了AbstractQueuedSynchronizer
。而AbstractQueuedSynchronizer
就是我們常說的同步器。
當我們檢視加鎖以及釋放鎖的原始碼的時候,不難發現,所有的加鎖/釋放鎖的操作,最後都會委託給同步器。也就是說,同步器才是真正加鎖的實現者。=
具體呼叫過程(加鎖)如下圖所示:
而觀察AbstractQueuedSynchronizer
我們可以發現,在加鎖過程中,除了tryAcquire
方法之外,其它方法都被final
修飾,或者為私有方法。
事實上,抽象同步器已經實現了同步器大部分的功能,包括執行緒的等待與喚醒操作,同步狀態原子更新等。而我們需要做的,就是在獲取鎖以及釋放鎖的時候,完成對同步狀態的管理。
同步狀態是如何管理的呢?是通過同步器中的一個volatile變數,state來管理的。要修改/獲取state的值,有三個非常重要的方法。
protected final int getState()//獲取state的值
protected final void setState() //非原子性地修改state的值
protected final boolean compareAndSetState() //原子性修改state的值,適用於多執行緒競爭時
state可以表明當前有多少個執行緒擁有鎖,也可以表明當前還有多少個執行緒可以申請鎖,這一切,都取決於我們對同步器中兩個關鍵方法,tryAcqure
以及tryRelease
的實現方式。
然後,我們以ReentrantLock為例,研究其中重寫的tryAcquire
方法。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {//c為0,說明當前沒有執行緒獲得鎖
if (!hasQueuedPredecessors() //確保沒有執行緒在排隊
&& compareAndSetState(0, acquires)) { //原子性修改同步器狀態
setExclusiveOwnerThread(current); //設定當前執行緒為獨佔執行緒
return true; //加鎖成功
}
}
else if (current == getExclusiveOwnerThread()) { //若擁有鎖的執行緒為當前執行緒,則修改同步器狀態,並返回加鎖成功
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc); //因為是當前執行緒擁有鎖,所以可以直接修改同步器狀態(其它執行緒此時已經不可能修改同步器狀態,不會出現衝突)
return true;
}
return false; //加鎖失敗
}
在上面的程式碼中我們能夠知道,state為0則表示當前沒有執行緒獲取鎖,而state大於0的時候,則代表有執行緒持有鎖,因為ReentrantLock是可重入的獨佔鎖,因此當持鎖執行緒再申請鎖時,state也會進行加1。
以上,就是同步器加鎖的具體原理。使用同步器實現具體功能的還有CountDownLatch
,CyclicBarrier
,Semaphore
。
小結
本篇文章簡單介紹了Java中的鎖,包括鎖的不同類別,以及基於關鍵字Synchronized的鎖和基於同步器的鎖,並且分析了他們的實現原理。