從synchronized優化看Java鎖概念
一、悲觀鎖和樂觀鎖概念
悲觀鎖和樂觀鎖是一種廣義的鎖概念,Java中沒有哪個Lock實現類就叫PessimisticLock或OptimisticLock,而是在資料併發情況下的兩種不同處理策略。
針對同一個資料併發操作,悲觀鎖認為自己在使用資料時,一定有其它的執行緒操作資料,因此獲取資料前先加鎖確保資料使用過程中不會被其它執行緒修改;樂觀鎖則認為自己在使用資料的時候不會被其它執行緒修改。基於兩者的不同點我們不難發現:
(1)悲觀鎖適用於寫操作較多的場景。
(2)樂觀鎖適用於讀操作較多的場景。
二、樂觀鎖的一種實現方案
樂觀鎖通常採用了無鎖程式設計方法,基於CAS(Compare And Swap)演算法實現,下面重點介紹一下該演算法:
先看一個例子,假設有100個執行緒同時併發,且每個執行緒累加1000次,那麼結果很容易算出時100,000,實現程式碼如下:
public class Test {
private static int sum = 0;
public static void main(String[] args) throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
sum += 1;
}
latch.countDown();
}).start();
}
latch.await();
System.out.println(String.format("Sum=%s", sum));
}
}
很顯然,由於資源(sum變數)同步的問題,上述程式碼執行結果跟我們預期不一樣,而且每次結果也不一樣。
那麼sum變數增加volatile修飾符呢?結果還是有問題,這是因此為sum +=1不是原子語句,很顯然我們需要把sum+=1這個語句加鎖,那麼每次執行結果都一樣且跟預期(100,000)相符。
定義一個可重入鎖
private static Lock lock = new ReentrantLock();
資源加鎖
lock.lock();
sum += 1;
lock.unlock();
ReentrantLock是基於悲觀鎖實現方案,每次加鎖、釋放鎖都涉及到使用者態和核心態切換(儲存、恢復執行緒上下文以及執行緒排程等),因此效能損失較大。那麼樂觀鎖又是如何實現的呢?實現方法如下:
public class Test {
private static AtomicInteger sum = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
sum.addAndGet(1);
}
latch.countDown();
}).start();
}
latch.await();
System.out.println(String.format("Sum=%s", sum.get()));
}
}
上述這個例子會出現頻繁寫入,在實際工程中並不一定適合樂觀鎖,這裡主要講解一下樂觀鎖實現原理。
AtomicInteger是針對Integer型別的封裝,除此之外還包括AtomicLong、AtomicReference等,下面重分析addAndGet這個方法。
addAndGet會呼叫unsafe.getAndAddInt,第一個引數是AtomaticInteger例項(sum物件);第三個引數是我們傳入要累加的值;第二個引數valueOffset是AtomaticInteger中value屬性(我們每次累加的結果就是儲存在value中)的偏移地址,初始化程式碼如下:
getAndAddInt實現程式碼如下:
其中,var5 = this.getIntVolatile(var1, var2),var1是sum物件、var2是value的偏移量地址,getIntVolatile就是根據偏移量地址讀取sum物件中儲存的value值,即var5=value
compareAndSwapInt(var1, var2, var5, var5 + var4),var1是sum物件,var2是sum物件中value的偏移量地址,var5是之前讀取的value值,var5+var4是本次操作期望寫入的value新值。寫入新值之前會判斷最新的value值是否和之前獲取的值(var5)相等,相等的話更新新值並返回true;否則直接返回false,不做任何操作。
當寫入成功時就會跳出do-while迴圈,否則會一直重試,注意整個迴圈體是沒有阻塞的,因此也避免了執行緒上下文切換。
compareAndSwapInt是Java的native方法,並不由Java語言實現,其底層依賴於CPU提供的指令集(比如x86的cmpxchg)保證其操作的原子性。
三、輕量級自旋鎖
自旋鎖是指當一個執行緒嘗試獲取某個鎖時,如果該鎖已被其他執行緒佔用,就一直迴圈檢測鎖是否被釋放,而不是進入執行緒阻塞狀態,自旋鎖的好處是避免執行緒上下文切換,但是壞處也很明顯,如果沒有獲取到鎖時會不停的迴圈監測,這個迴圈監測過程就是自旋操作。
本節還是基於CAS操作實現一個簡單的自旋鎖,程式碼如下:
public class SimpleSpinLock {
private AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void lock() {
Thread currentThread = Thread.currentThread();
//沒有獲取到鎖時候,處於自旋過程而不是阻塞狀態。
while (!atomicReference.compareAndSet(null, currentThread)) {
}
System.out.println(String.format("Lock success. atomic=%s", atomicReference.get().getName()));
}
public void unLock() {
Thread currentThread = Thread.currentThread();
if (atomicReference.compareAndSet(currentThread, null)) {
System.out.println(String.format("Unlock success. atomic=%s", currentThread.getName()));
} else {
System.out.println(String.format("Unlock failure. atomic=%s", currentThread.getName()));
}
}
}
public class Test {
private static int sum = 0;
private static SimpleSpinLock lock = new SimpleSpinLock();
public static void main(String[] args) throws InterruptedException {
final CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
lock.lock();
sum++;
lock.unLock();
}
latch.countDown();
});
thread.setName(String.format("CountThread-%s", i));
thread.start();
}
latch.await();
System.out.println(String.format("Sum=%d", sum));
}
}
上述SimpleSpinLock是一個最簡的實現方案,假如某個執行緒一直申請不到鎖,那麼就會一直處於空轉自旋狀態,這個使用我們通常會設定一個自旋次數,超過這個次數(比如10次)時膨脹成重量級的互斥鎖,減少CPU空轉消耗。
那麼本節的最後一個問題,在實際工程使用中如何定義自旋次數?
JDK1.6引入了自適應自旋鎖,所謂自適應自旋鎖,就意味著自旋的次數不再是固定的,具體規則如下:
自旋次數通常由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態決定。比如執行緒T1自旋10次成功,那麼等到下一個執行緒T2自旋時,也會預設認為T2自旋10次。
如果T2自旋了5次就成功了,那麼此時這個自旋次數就會縮減到5次。
四、偏向鎖
偏向鎖是JDK 1.6提出的一種鎖優化方式。其核心思想是如果資源沒有競爭,就取消之前已經取得鎖得執行緒同步操作。具體實現方案如下:
- 某一執行緒第一次獲取鎖時便進入偏向模式,當該執行緒再次請求這個鎖時,無需再進行相關得同步操作(不需要CAS計算)。
- 如果在此期間有其它執行緒進行了鎖請求,則鎖退出偏向模式。
- 當鎖處於偏向模式時,虛擬機器中的Mark Word會記錄獲得鎖得執行緒ID。
最後我們看一下Mark Word在哪裡:
五、再談synchronized
看完偏向鎖實現方案,你是否和我一樣有這樣的疑問?沒有資源競爭情況偏向鎖才有用,一旦有有競爭偏向鎖就失效了,那麼在沒有資源競爭的情況下,我為什麼要加鎖呢?好吧,本節的最後我將回答這個問題。
synchronized在JDK 1.5的早期版本中使用重量級鎖(通過Monitor關聯到作業系統的互斥鎖),效率很低,因此JDK 1.6做了大幅度優化,整個資源同步過程支援鎖升級(無鎖、偏向鎖、輕量級鎖、重量級鎖),且升級後不能降級。這一升級過程都伴隨著Mark Word儲存內容的改變,Mark Word會根據物件的不同狀態存放不同的資料,資料格式如下:
好吧,到這裡我們來回答一下開頭的那個疑惑,早期的Java版本提供了Vector、HashTable、StringBuffer等這些執行緒安全的集合,其內部實現依賴於synchronized實現重量級鎖,因此效率低下,但是開發人員使用這些集合時大部分都是在單執行緒環境下,並不會出現資源競爭的場景,因此在後續優化synchornized時,順便增加了這個偏向鎖在保證可能出現併發的情況下提高的Vector、HashTable執行效率。然而今天我們在寫Java程式碼時,任何一本編碼規範都有要求我們優先考慮ArrayList、HashMap、StringBuilder這些非執行緒安全的集合,那麼我們還需要偏向鎖嗎?O(∩_∩)O