Java併發鎖概念整理
Java併發鎖整理
各種鎖的概念
顯示鎖 vs 內建鎖(隱式鎖)
顯示鎖(JDK1.5之後才有) | 內建鎖(隱式鎖) | |
---|---|---|
鎖的控制物件 | 鎖的申請和釋放都可以由程式所控制 | 鎖的申請和釋放都是由 JVM 所控制 |
實現 | ReentrantLock、ReentrantReadWriteLock | synchronized |
優化趨勢 | 無 | JDK1.6之後對synchronized做了大量優化,效能已經與顯示鎖基本持平 |
優缺點 | 使用不當可能造成死鎖 | synchronized是 JVM 內建屬性,能執行一些優化 |
能否響應中斷 | √ | × |
超時機制 | √ | × |
支援公平鎖 | √ | × |
支援共享 | √(ReentrantReadWriteLock讀鎖) | × |
支援讀寫分離 | √ | × |
悲觀鎖 vs 樂觀鎖
悲觀鎖 | 樂觀鎖 | |
---|---|---|
思想 | 總是假設併發操作一定會發生衝突,所以每次進行併發操作都加上鎖 | 樂觀地認為不會發生衝突,只在需要操作值的時候檢查值有沒有發生變化,沒變化才去更新 |
實現 | synchronized、Lock | CAS + (使用版本號解決ABA問題) |
適用場景 | 適合寫操作頻繁而讀操作少的場景 | 適合讀操作頻繁而寫操作少的場景 |
公平鎖 vs 非公平鎖
ReentrantLock中的內部NoFairSync對應非公平鎖
ReentrantLock中的內部類FairSync對應公平鎖
公平鎖 | 非公平鎖 | |
---|---|---|
思想 | 多執行緒按照申請鎖的順序來獲取鎖 | 多執行緒不按照申請鎖的順序來獲取鎖 |
實現 | ReentrantLock、ReentrantReadWriteLock(也支援公平鎖) | synchronized、ReentrantLock、ReentrantReadWriteLock(預設非公平鎖) |
缺點 | 為了保證執行緒申請順序,勢必要付出一定的效能代價,因此其吞吐量一般低於非公平鎖 | 飢餓現象(某執行緒總是搶不過別的執行緒,導致始終無法執行) |
獨佔鎖 vs 共享鎖
獨享鎖 | 共享鎖 | |
---|---|---|
思想 | 鎖一次只能被一個執行緒持有 | 鎖可被多個執行緒所持有 |
實現 | synchronized、ReentrantLock、ReentrantReadWriteLock寫鎖 | ReentrantReadWriteLock讀鎖 |
實際用途 | 互斥 | 讀讀共享 |
可重入鎖
概念
可重入鎖又名遞迴鎖,是指同一個執行緒在外層方法獲取了鎖,在進入內層方法會自動獲取鎖。
典型的可重入鎖
- ReentrantLock
- ReentrantReadWriteLock
- synchronized
意義
在一定程度上避免死鎖的發生。
例子
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
如果使用的鎖不是可重入鎖的話,setB
可能不會被當前執行緒執行,從而造成死鎖。
分段鎖
分段鎖其實是一種鎖的設計,並不是具體的一種鎖。所謂分段鎖,就是把鎖的物件分成多段,每段獨立控制,使得鎖粒度更細,減少阻塞開銷,從而提高併發性。
JDK1.7之前的ConcurrentHashMap就是分段鎖的典型案例。
ConcurrentHashMap
維護了一個 Segment
陣列,一般稱為分段桶。
final Segment<K,V>[] segments;
當有執行緒訪問 ConcurrentHashMap
的資料時,ConcurrentHashMap
會先根據 hashCode 計算出資料在哪個桶(即哪個 Segment),然後鎖住這個 Segment
。
JDK1.8之後
,取消了 Segment 分段鎖,採⽤ CAS 和 synchronized 來保證併發安全,資料結構跟 HashMap1.8 的結構類似。
輕量級鎖/重量級鎖/偏向鎖
輕量級鎖 vs 重量級鎖
這裡鎖的量級指的是鎖控制粒度的粗細,控制粒度越細,鎖越輕量,併發時阻塞造成的開銷就越小,併發度也就越高。
JDK 1.6之前 | 輕量級鎖 | 重量級鎖 |
---|---|---|
實現 | volatile | synchronized |
JDK1.6之後,針對synchronized做了大量的優化,引入了4種鎖的狀態:
- 無鎖狀態
- 偏向鎖 —— 指一段同步程式碼
一直被一個執行緒所訪問
,那麼該執行緒會自動獲取鎖。降低獲取鎖的代價。 - 輕量級鎖 —— 當鎖是偏向鎖的時候,被另一個執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會通過
自旋
的形式嘗試獲取鎖,不會阻塞
,提高效能。 - 重量級鎖 —— 當鎖為輕量級鎖的時候,另一個執行緒雖然是自旋,但自旋不會一直持續下去,
當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞
,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的執行緒進入阻塞,效能降低。
鎖可以單向地從偏向鎖升級到輕量級鎖,再從輕量級鎖升級到重量級鎖。
synchronized
鎖的範圍
- 在修飾靜態方法時,鎖的是類物件,如 Object.class。
- 修飾非靜態方法時,鎖的是物件,即 this。
使用
- synchronized
鎖住的是物件而非程式碼
,只要訪問的是同一個物件的synchronized 方法,即使是不同的程式碼,也會被同步順序訪問。 - 多個執行緒是可以同時執行同一個synchronized例項方法的,只要它們訪問的物件是不同的。
- 在保護變數時,需要在所有訪問該變數的方法上加上synchronized。
位元組碼實現
同步程式碼塊
public class SynchronizedTest {
public void test2() {
synchronized(this) {
}
}
}
synchronized
關鍵字基於monitorenter指令和monitorexit指令實現了鎖的獲取和釋放過程:
當執行monitorenter指令時,當前執行緒將試圖獲取objectref(即物件鎖) 所對應的monitor 的持有權,當objectref 的 monitor 的進入計數器為0,那執行緒可以成功取得monitor,並將計數器值設定為1,取鎖成功。如果當前執行緒已經擁有 objectref 的monitor 的持有權,那它可以重入這個monitor,重入時計數器的值也會加 1。倘若其他執行緒已經擁有 objectref 的monitor 的所有權,那當前執行緒將被阻塞,直到正在執行執行緒執行完畢,即monitorexit指令被執行,執行執行緒將釋放monitor(鎖)並設定計數器值為0,其他執行緒將有機會持有monitor 。
同步方法
在 JVM 位元組碼層面並沒有任何特別的指令來實現被synchronized 修飾的方法,而是在 Class 檔案的方法表中將該方法的access_flags 欄位中的synchronized 標誌位置設定為1, 表示該方法是同步方法,並使用呼叫該方法的物件或該方法所屬的Class。
鎖優化
- 鎖消除 —— Java虛擬機器在JIT即時編譯時, 通過對執行上下文的掃描,去除不可能存在共享資源競爭的鎖, 通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間。
- 鎖粗化 —— 將多個連續的加鎖、解鎖操作連線在一起,擴充套件成一個範圍更大的鎖。
- 自旋鎖 —— 執行緒的阻塞和喚醒,需要 CPU 從使用者態轉為核心態。頻繁的阻塞和喚醒對 CPU 來說是一件負擔很重的工作,勢必會給系統的併發效能帶來很大的壓力。 同時,我們發現在許多應用上面,物件鎖的鎖狀態只會持續很短一段時間。為了這一段很短的時間,頻繁地阻塞和喚醒執行緒是非常不值得的。自旋鎖的核心思想就是:當後面請求鎖的執行緒沒拿到鎖的時候,不掛起執行緒,而是繼續佔用處理器的執行時間,讓當前執行緒執行一個
忙迴圈
(自旋操作(預設10次)),也就是不斷在盯著持有鎖的執行緒是否已經釋放鎖。 - 自適應自旋鎖 —— 所謂的“自適應”意味著對於同一個鎖物件,執行緒的自旋時間是根據上一個持有該鎖的執行緒的自旋時間以及狀態來確定的。
- 鎖升級 —— 鎖可以單向地從偏向鎖升級到輕量級鎖,再從輕量級鎖升級到重量級鎖。
CAS
概念
CAS(compare and swap),即比較並交換。CAS是一種操作機制,不是某個具體的類或方法。在 Java 平臺上對這種操作進行了包裝。操作方法在Unsafe 類中:
@ForceInline
public final boolean compareAndSwapInt(Object o, long offset,
int expected,
int x) {
return theInternalUnsafe.compareAndSetInt(o, offset, expected, x);
}
它需要三個引數,分別是記憶體位置offset,舊的預期值x和新的值expected。操作時,先從記憶體位置讀取到值,然後和預期值x比較。如果相等,則將此記憶體位置的值改為新值expected,返回 true。如果不相等,說明和其他執行緒衝突了,則不做任何改變,返回 false。
這種機制在不阻塞其他執行緒的情況下避免了併發衝突,比獨佔鎖的效能高很多。 CAS 在 Java 的原子類和併發包中有大量使用。
重試機制
CAS 本身並未實現失敗後的處理機制,它只負責返回成功或失敗的布林值,後續由呼叫者自行處理。
在CAS操作失敗後,我們最常用的方法就是使用一個死迴圈進行 CAS 操作
,成功了就結束迴圈返回,失敗了就重新從記憶體讀取值和計算新值
,再呼叫 CAS。
底層實現
CAS主要分為三步:
讀取
—— 從記憶體位置讀到值。比較
—— 檢測值是否與期待值一致,不一致,則CAS失敗;一致,則可以去修改,但仍然無法保證修改後結果正確,因為在多執行緒環境下,可能其他的執行緒在此時也在修改。修改
—— 更新變數的值。
要保證CAS的正確性,則需要保證比較-修改
這兩步操作的原子性
。
CAS 底層是靠呼叫 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架構中的 compare and exchange 指令。在多核的情況下,這個指令也不能保證原子性,需要在前面加上lock 指令。lock 指令可以保證一個 CPU 核心在操作期間獨佔一片記憶體區域
。在處理器中,一般使用快取鎖
實現CAS比較和交換的原子性的。
值得注意的是, CAS 只是保證了操作的原子性,並不保證變數的可見性,因此變數需要加上 volatile 關鍵字。
volatile關鍵字
多執行緒情況下,讀和寫發生在不同的執行緒中,而讀執行緒未能及時的讀到寫執行緒寫入的最新的值。
作用
被volatile修飾的共享變數,就具有了以下兩點特性:
- 保證了多執行緒環境下不同執行緒對該共享變數操作的記憶體可見性
- 禁止指令重排序
執行緒之間為何不可見?
計算機記憶體模型
從硬體角度來看
計算機記憶體模型如下圖所示
其中,CPU 擁有最頂層的 4 層快取結構(內建 3 層),快取記憶體(Cache)來作為記憶體與處理器之間的緩衝,而主記憶體和硬碟是外部的儲存介質。
由於CPU、記憶體和IO裝置三者在處理速度上差異很大,最終整體的計算效率還是取決於最慢的那個裝置。為了平衡三者之間的速度差異,最大化的利用CPU提升效能。無論是硬體方面,作業系統還是編譯器方面都做了很多的優化:
- CPU增加了快取記憶體(快取記憶體作為記憶體和處理器之間的緩衝,通過將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取中同步到記憶體之中,這樣就能平衡記憶體和處理器之間的處理速度差異)
- 作業系統靈活高效的排程策略最大化提升CPU效能
- 編譯器進行指令優化,更加合理地去利用好CPU的快取記憶體
快取一致性的問題
CPU快取記憶體的引入雖然很好的解決了處理器與記憶體之間的速度矛盾,但同時也引入了一個新的問題,快取一致性。
有了快取記憶體後,每個CPU處理過程變成這樣:
- 將計算機需要用到的資料快取在CPU快取記憶體中
- 在CPU進行計算時,直接從快取記憶體中讀取資料並且計算完成之後寫到快取中
- 在整個運算完成後,再把快取中的資料同步到記憶體
在多CPU中
,每個執行緒可能會執行在不同的CPU中,並且每個執行緒都有自己的快取記憶體,同一份資料可能會被快取到多個CPU中
,如果在不同CPU中執行的不同執行緒看到同一份記憶體的快取值不一樣,就會存在快取不一致的問題。
怎麼解決快取一致性問題?
使用匯流排鎖和快取鎖。
匯流排鎖
快取鎖
匯流排鎖開銷較大,所以需要優化,最好的方法就是控制鎖的粒度,我們只需要保證,對於被多個CPU快取的同一份資料是一致的就行,所以引入了快取鎖,它的核心機制就是快取一致性協議
。
快取一致性協議
為了達成資料訪問的一致性,需要各個處理器在訪問記憶體時,遵循一些協議,在讀寫時根據協議來操作,常見的協議有,MSI,MESI,MOSI等等,最常見的就是MESI協議;
具體的快取一致性怎麼實現的,請參考一文吃透Volatile,征服面試官
使用匯流排鎖和快取鎖後,CPU對於記憶體的操作大概可以抽象成下面這樣的結構,從而達成快取一致性效果。
如上圖模型所示,計算機將需要的資料從主記憶體拷貝至快取記憶體,之後將運算後的結果回寫到主記憶體。這個執行計算的過程中,各個非公有變數的變化,各執行緒間都是不可見的
,這就會導致執行緒不安全。
JMM
JMM(Java記憶體模型)是一個抽象的概念,並不是真實的存在,它涵蓋了緩衝區,暫存器以及其他硬體和編譯器優化。在JMM中,
-
所有的
例項變數和類變數都儲存於主記憶體
。(其不包含區域性變數,因為區域性變數是執行緒私有的,因此不存在競爭問題。) -
執行緒對變數的所有的
讀寫操作都在工作記憶體中完成
,而不能直接讀寫主記憶體中的變數。
實際上JMM存在和計算機記憶體模型一樣的問題,即實際上執行緒可見性還是沒有得到解決。
如何解決可見性的問題
兩種方式:
- 加悲觀鎖synchronized —— 保證了執行緒間變數的原子性和可見性,但是併發性差
- 用volatile修飾變數使得共享變數線上程之間可見 —— 不能保證原子性,即是執行緒不安全的
volatile的記憶體語義
即對用volatile關鍵字修飾的變數進行計算操作時,記憶體中要實現的功能或遵循的規則:
- 寫一個volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體
- 讀一個volatile 變數時,JMM 會把該執行緒對應的本地記憶體置為無效,並從主記憶體中讀取共享變數
指令重排
為了優化效能,編譯器會根據一些特定的規則,在as-if-serial(不管怎麼重排序,單執行緒下的執行結果不能被改變)規則的前提下,進行重新排序。
要實現volatile的記憶體語義,要第一時間寫 volatile 變數時,將更新的變數更新到主存;線上程讀 volatile 變數時,要第一時間讀到主存的變數。因此必須要禁止指令重排。
為什麼volatile不能保證原子性
以++操作為例,當有了變數++操作時,會有以下三個步驟在一個執行緒中產生:
- 從主存中取值到工作記憶體
- 計算工作記憶體的值
- 將計算得到的新值更新到主記憶體
對應的CPU指令如下:
mov 0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier
如果現在有兩個執行緒執行同一個 volatile 變數的 ++ 操作,可能出現的情況:
- 執行緒1讀取變數到工作記憶體後,由於沒有鎖,所以執行緒2線上程1拷貝後,搶到了CPU,立刻也將主存的變數拷貝至工作記憶體。
- 之後,執行緒1與執行緒2先後在工作記憶體進行了自增。
- 最後,執行緒1、2分別將自增後的值刷回主存。執行緒2回寫時,會覆蓋掉執行緒1回寫的值,導致執行緒不安全,所以
volatile 計算不能保證原子性。
ABA問題
CAS 保證了比較和交換的原子性。但是從讀取到開始比較這段期間,其他核心仍然是可以修改這個值的。如果核心將 A 修改為 B,CAS 可以判斷出來。但是如果核心將 A 修改為 B 再修改回 A。那麼 CAS 會認為這個值並沒有被改變,從而繼續操作。這是和實際情況不符的。解決方案是加一個版本號version
,相當於增加一個時間戳,來區分不同時間線上的同值變數。
volatile應用
單例模式的雙重檢查
public class Singleton {
private static volatile Singleton singleton =null;
private void Singleton(){}
public static Singleton getSingleton(){
if(singleton==null){
synchronized (Singleton.class){
if(singleton==null){
singleton =new Singleton();
}
}
}
return singleton;
}
}
ReentrantLock
概述
ReentrantLock 使用程式碼實現了和synchronized 一樣的語義,包括可重入,保證記憶體可見性和解決競態條件問題等。相比 synchronized,它還有如下好處:
- 支援以非阻塞方式獲取鎖
- 可以響應中斷
- 可以限時
- 支援了公平鎖和非公平鎖
特性
-
ReentrantLock
提供了與synchronized
相同的互斥性、記憶體可見性和可重入性。 -
ReentrantLock
支援公平鎖和非公平鎖(預設)兩種模式。 -
ReentrantLock
實現了Lock
介面,支援了synchronized
所不具備的靈活性。public interface Lock { void lock(); //無條件獲取鎖 void lockInterruptibly() throws InterruptedException; boolean tryLock(); //嘗試獲取鎖 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); //釋放鎖 Condition newCondition(); //返回一個繫結到Lock物件上的Condition例項 }
synchronized
無法中斷一個正在等待獲取鎖的執行緒synchronized
無法在請求獲取一個鎖時無休止地等待
lock() and unLock()
lock()
—— 無條件獲取鎖。如果當前執行緒無法獲取鎖,則當前執行緒進入休眠狀態不可用,直至當前執行緒獲取到鎖。如果該鎖沒有被另一個執行緒持有,則獲取該鎖並立即返回,將鎖的持有計數設定為 1。unlock()
—— 用於釋放鎖。
使用注意
:獲取鎖操作 lock()
必須在 try catch
塊中進行,並且將釋放鎖操作 unlock()
放在 finally
塊中進行,以保證鎖一定被被釋放,防止死鎖的發生。
tryLock()
與無條件獲取鎖相比,tryLock 有更完善的容錯機制。
tryLock()
—— 可輪詢獲取鎖。如果成功,則返回 true;如果失敗,則返回 false。也就是說,這個方法無論成敗都會立即返回,獲取不到鎖(鎖已被其他執行緒獲取)時不會一直等待。tryLock(long, TimeUnit)
—— 可定時獲取鎖。和tryLock()
類似,區別僅在於這個方法在獲取不到鎖時會等待一定的時間,在時間期限之內如果還獲取不到鎖,就返回 false。如果如果一開始拿到鎖或者在等待期間內拿到了鎖,則返回 true。
案例 —— 單Lock物件採用多Condition實現精確喚醒
package com.youzikeji.juc;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
//單Lock物件採用多Condition精確通知喚醒
public class PCDemo03 {
public static void main(String[] args) {
Data3 data3 = new Data3();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printA();}, "A").start();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printB();}, "B").start();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printC();}, "C").start();
}
}
class Data3{
//資源,先給到printA
private int number = 1;
//Lock
Lock lock = new ReentrantLock();
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
Condition condition3 = lock.newCondition();
public void printA(){
//加鎖
lock.lock();
try {
//業務
while (number != 1){
condition1.await();
}
//執行
System.out.println("AAAAAA");
//通知喚醒B
number = 2;
condition2.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
//解鎖
lock.unlock();
}
}
public void printB(){
lock.lock();
try {
while (number != 2){
condition2.await();
}
System.out.println("BBBBBB");
//喚醒C
number = 3;
condition3.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void printC(){
lock.lock();
try {
while (number != 3){
condition3.await();
}
System.out.println("CCCCCC");
//喚醒A
number = 1;
condition1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}