【鎖機制】synchronized和ReentrantLock、鎖優化
java中的鎖的種類很多。目前對這部分知識點只是停留在瞭解的基礎上,就目前知識進行梳理,並長期補充。
java中鎖常用的為synchronized 和java.util.concurrent.Lock下的鎖。
下面對java中實現的鎖進行個人分析
Synchronized
這是java中的一種隱式鎖,顧名思義,用於保證多執行緒之間的同步和互斥問題。又稱為同步鎖或者監視器鎖。
synchronized為方法提供一種同步機制,即當執行緒進入帶有鎖的方法中時,會獲得該鎖,若其他執行緒想要訪問該方法,必須拿到這個鎖物件,如果沒有,那麼就會執行緒阻塞等待鎖的釋放。
互斥性
synchronize是互斥鎖,一個鎖同時只能被一個執行緒持有。
可重入性
若同步程式碼塊中執行緒,想要再次進入擁有同一個鎖的方法中,那麼就需要有可重入特性。synchronize提供這種特性,是一種個可衝入鎖,當執行緒想要再次進入同步程式碼塊時,會維護一個計數器,每進入一次,計數器加1,退出一次程式碼塊就減一,直到計數器為0時,釋放掉該鎖。
作用範圍:程式碼塊,方法,靜態方法,Class。
鎖的粒度問題:synchronized的粒度是可變的,修改方法的時候該方法保持該鎖,當我們需要細化粒度就可以使用同步程式碼塊。當鎖是一個物件時,該鎖就是物件鎖,監視是物件,若是靜態方法或者Class類,那麼就是類鎖,監視該類的所有物件。
java.util.concurrent.locks.Lock
當java併發的情況變得越來複雜的時候,synchronized無法很好的解決,引入一種新的鎖介面:Lock介面,提供更好的解決方案,以及高階特性。
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
Lock介面是鎖的抽象介面,定義了鎖的一些基本功能:加鎖,釋放,可中斷,可迴圈的,定時的等等。
使用Lock介面下的鎖都必須顯示的加鎖 lock()和釋放鎖 unlock()。
ReentrantLock
可重入鎖可以說是synchronized功能相同的一個替代品,並且有更強大的功能。比如:可中斷響應、鎖申請等待限時、公平鎖,可以結合Condition使用。
ReentrantLock加鎖必須顯示上鎖並且釋放鎖,擁有可重入機制,每次重入計數器都會加一。並且提供了公平鎖機制
ReentrantLock reentrantLock = new ReentrantLock(true);
在程式碼中為True就是公平鎖,會根據執行緒的先後順序獲得鎖。
公平鎖和非公平鎖
公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的先後順序來一次獲得鎖。
公平鎖的好處是等待鎖的執行緒不會餓死,但是整體效率相對低一些;非公平鎖的好處是整體效率相對高一些,但是有些執行緒可能會餓死或者說很早就在等待鎖,但要等很久才會獲得鎖。其中的原因是公平鎖是嚴格按照請求所的順序來排隊獲得鎖的,而非公平鎖時可以搶佔的,即如果在某個時刻有執行緒需要獲取鎖,而這個時候剛好鎖可用,那麼這個執行緒會直接搶佔,而這時阻塞在等待佇列的執行緒則不會被喚醒。
公平鎖可以使用new ReentrantLock(true)實現。
公平鎖和非公平鎖實現的核心:
當選擇公平鎖時,執行緒在嘗試獲取鎖之前進行一次CAS運算,當且僅當當前鎖處於空閒狀態並且排隊等候鎖的佇列裡沒有其他執行緒的時候,該執行緒可以獲得鎖;否則進入佇列進行等待。
當選擇非公平鎖時,執行緒在嘗試獲取鎖之前進行兩次CAS運算,如果發現所空閒,則直接獲得鎖,如果兩次cas運算都未能獲得鎖的情況下,該執行緒才進入等候佇列。
公平鎖和非公平鎖在說的獲取上都使用到了 volatile
關鍵字修飾的state
欄位, 這是保證多執行緒環境下鎖的獲取與否的核心。
但是當併發情況下多個執行緒都讀取到 state == 0
時,則必須用到CAS技術,一門CPU的原子鎖技術,可通過CPU對共享變數加鎖的形式,實現資料變更的原子操作。
volatile 和 CAS的結合是併發搶佔的關鍵。
可中斷響應機制:
對於synchronized鎖機制,要麼獲取到鎖執行,要麼持續等待。那麼就有可能出現死鎖的情況,而ReentrantLock可以有效避免這種情況,利用lockinterruptibly()方法,該方法允許優先響應中斷,而不是優先獲取普通鎖和可重入鎖。
public class KillDeadlock implements Runnable{
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
public KillDeadlock(int lock) {
this.lock = lock;
}
@Override
public void run() {
try {
if (lock == 1) {
lock1.lockInterruptibly(); // 以可以響應中斷的方式加鎖
try {
Thread.sleep(500);
} catch (InterruptedException e) {}
lock2.lockInterruptibly();
} else {
lock2.lockInterruptibly(); // 以可以響應中斷的方式加鎖
try {
Thread.sleep(500);
} catch (InterruptedException e) {}
lock1.lockInterruptibly();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1.isHeldByCurrentThread()) lock1.unlock(); // 注意判斷方式
if (lock2.isHeldByCurrentThread()) lock2.unlock();
System.err.println(Thread.currentThread().getId() + "退出!");
}
}
public static void main(String[] args) throws InterruptedException {
KillDeadlock deadLock1 = new KillDeadlock(1);
KillDeadlock deadLock2 = new KillDeadlock(2);
Thread t1 = new Thread(deadLock1);
Thread t2 = new Thread(deadLock2);
t1.start();t2.start();
Thread.sleep(1000);
t2.interrupt(); // ③
}
}
- 看上面這個例子,執行緒2持有鎖2請求鎖1,執行緒1持有鎖1請求鎖2,如果用lock() unlock()會出現執行緒死鎖。
- t1、t2執行緒開始執行時,會分別持有lock1和lock2而請求lock2和lock1,這樣就發生了死鎖。但是,在③處給t2執行緒狀態標記為中斷後,持有重入鎖lock2的執行緒t2會響應中斷,並不再繼續等待lock1,同時釋放了其原本持有的lock2,這樣t1獲取到了lock2,正常執行完成。t2也會退出,但只是釋放了資源並沒有完成工作。
- 即lockInterruptibly()方法允許在等待鎖的同時,利用Thread.interrupt()方法中斷執行緒的等待,直接返回。lock()方法是不可以的。
- ReentrantLock.lockInterruptibly允許在等待時由其它執行緒呼叫等待執行緒的Thread.interrupt方法來中斷等待執行緒的等待而直接返回,這時不用獲取鎖,而會丟擲一個InterruptedException。 ReentrantLock.lock方法不允許Thread.interrupt中斷,即使檢測到Thread.isInterrupted,一樣會繼續嘗試獲取鎖,失敗則繼續休眠。只是在最後獲取鎖成功後再把當前執行緒置為interrupted狀態,然後再中斷執行緒。
鎖優化
這裡的鎖優化主要是指 JVM 對 synchronized 的優化。
自旋鎖
互斥同步進入阻塞狀態的開銷都很大,應該儘量避免。在許多應用中,共享資料的鎖定狀態只會持續很短的一段時間。自旋鎖的思想是讓一個執行緒在請求一個共享資料的鎖時執行忙迴圈(自旋)一段時間,如果在這段時間內能獲得鎖,就可以避免進入阻塞狀態。
自旋鎖雖然能避免進入阻塞狀態從而減少開銷,但是它需要進行忙迴圈操作佔用 CPU 時間,它只適用於共享資料的鎖定狀態很短的場景。
在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味著自旋的次數不再固定了,而是由前一次在同一個鎖上的自旋次數及鎖的擁有者的狀態來決定。
鎖消除
鎖消除是指對於被檢測出不可能存在競爭的共享資料的鎖進行消除。
鎖消除主要是通過逃逸分析來支援,如果堆上的共享資料不可能逃逸出去被其它執行緒訪問到,那麼就可以把它們當成私有資料對待,也就可以將它們的鎖進行消除。
對於一些看起來沒有加鎖的程式碼,其實隱式的加了很多鎖。例如下面的字串拼接程式碼就隱式加了鎖:
public static String concatString(String s1, String s2, String s3) { return s1 + s2 + s3; }
String 是一個不可變的類,編譯器會對 String 的拼接自動優化。在 JDK 1.5 之前,會轉化為 StringBuffer 物件的連續 append() 操作:
public static String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
每個 append() 方法中都有一個同步塊。虛擬機器觀察變數 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會逃逸到 concatString() 方法之外,其他執行緒無法訪問到它,因此可以進行消除。
鎖粗化
如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,頻繁的加鎖操作就會導致效能損耗。
上一節的示例程式碼中連續的 append() 方法就屬於這類情況。如果虛擬機器探測到由這樣的一串零碎的操作都對同一個物件加鎖,將會把加鎖的範圍擴充套件(粗化)到整個操作序列的外部。對於上一節的示例程式碼就是擴充套件到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。
輕量級鎖
JDK 1.6 引入了偏向鎖和輕量級鎖,從而讓鎖擁有了四個狀態:無鎖狀態(unlocked)、偏向鎖狀態(biasble)、輕量級鎖狀態(lightweight locked)和重量級鎖狀態(inflated)。
以下是 HotSpot 虛擬機器物件頭的記憶體佈局,這些資料被稱為 Mark Word。其中 tag bits 對應了五個狀態,這些狀態在右側的 state 表格中給出。除了 marked for gc 狀態,其它四個狀態已經在前面介紹過了。
下圖左側是一個執行緒的虛擬機器棧,其中有一部分稱為 Lock Record 的區域,這是在輕量級鎖執行過程建立的,用於存放鎖物件的 Mark Word。而右側就是一個鎖物件,包含了 Mark Word 和其它資訊。
輕量級鎖是相對於傳統的重量級鎖而言,它使用 CAS 操作來避免重量級鎖使用互斥量的開銷。對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,因此也就不需要都使用互斥量進行同步,可以先採用 CAS 操作進行同步,如果 CAS 失敗了再改用互斥量進行同步。
當嘗試獲取一個鎖物件時,如果鎖物件標記為 0 01,說明鎖物件的鎖未鎖定(unlocked)狀態。此時虛擬機器在當前執行緒的虛擬機器棧中建立 Lock Record,然後使用 CAS 操作將物件的 Mark Word 更新為 Lock Record 指標。如果 CAS 操作成功了,那麼執行緒就獲取了該物件上的鎖,並且物件的 Mark Word 的鎖標記變為 00,表示該物件處於輕量級鎖狀態。
如果 CAS 操作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的虛擬機器棧,如果是的話說明當前執行緒已經擁有了這個鎖物件,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其他執行緒執行緒搶佔了。如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖。
偏向鎖
偏向鎖的思想是偏向於讓第一個獲取鎖物件的執行緒,這個執行緒在之後獲取該鎖就不再需要進行同步操作,甚至連 CAS 操作也不再需要。
當鎖物件第一次被執行緒獲得的時候,進入偏向狀態,標記為 1 01。同時使用 CAS 操作將執行緒 ID 記錄到 Mark Word 中,如果 CAS 操作成功,這個執行緒以後每次進入這個鎖相關的同步塊就不需要再進行任何同步操作。
當有另外一個執行緒去嘗試獲取這個鎖物件時,偏向狀態就宣告結束,此時撤銷偏向(Revoke Bias)後恢復到未鎖定狀態或者輕量級鎖狀態。