不懂什麼是鎖?看看這篇你就明白了
Java 鎖分類
Java 中的鎖有很多,可以按照不同的功能、種類進行分類,下面是我對 Java 中一些常用鎖的分類,包括一些基本的概述
- 從執行緒是否需要對資源加鎖可以分為
悲觀鎖
和樂觀鎖
- 從資源已被鎖定,執行緒是否阻塞可以分為
自旋鎖
- 從多個執行緒併發訪問資源,也就是 Synchronized 可以分為
無鎖
、偏向鎖
、輕量級鎖
和重量級鎖
- 從鎖的公平性進行區分,可以分為
公平鎖
和非公平鎖
- 從根據鎖是否重複獲取可以分為
可重入鎖
和不可重入鎖
- 從那個多個執行緒能否獲取同一把鎖分為
共享鎖
和排他鎖
下面我們依次對各個鎖的分類進行詳細闡述。
執行緒是否需要對資源加鎖
Java 按照是否對資源加鎖分為樂觀鎖
悲觀鎖
,樂觀鎖和悲觀鎖並不是一種真實存在的鎖,而是一種設計思想,樂觀鎖和悲觀鎖對於理解 Java 多執行緒和資料庫來說至關重要,下面就來探討一下這兩種實現方式的區別和優缺點
悲觀鎖
悲觀鎖
是一種悲觀思想,它總認為最壞的情況可能會出現,它認為資料很可能會被其他人所修改,所以悲觀鎖在持有資料的時候總會把資源
或者 資料
鎖住,這樣其他執行緒想要請求這個資源的時候就會阻塞,直到等到悲觀鎖把資源釋放為止。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。悲觀鎖的實現往往依靠資料庫本身的鎖功能實現。
Java 中的 Synchronized
和 ReentrantLock
樂觀鎖
樂觀鎖的思想與悲觀鎖的思想相反,它總認為資源和資料不會被別人所修改,所以讀取不會上鎖,但是樂觀鎖在進行寫入操作的時候會判斷當前資料是否被修改過(具體如何判斷我們下面再說)。樂觀鎖的實現方案一般來說有兩種: 版本號機制
和 CAS實現
。樂觀鎖多適用於多度的應用型別,這樣可以提高吞吐量。
在Java中java.util.concurrent.atomic
包下面的原子變數類就是使用了樂觀鎖的一種實現方式 CAS 實現的。
兩種鎖的使用場景
上面介紹了兩種鎖的基本概念,並提到了兩種鎖的適用場景,一般來說,悲觀鎖不僅會對寫操作加鎖還會對讀操作加鎖,一個典型的悲觀鎖呼叫:
select * from student where name="cxuan" for update
這條 sql 語句從 Student 表中選取 name = "cxuan" 的記錄並對其加鎖,那麼其他寫操作再這個事務提交之前都不會對這條資料進行操作,起到了獨佔和排他的作用。
悲觀鎖因為對讀寫都加鎖,所以它的效能比較低,對於現在網際網路提倡的三高
(高效能、高可用、高併發)來說,悲觀鎖的實現用的越來越少了,但是一般多讀的情況下還是需要使用悲觀鎖的,因為雖然加鎖的效能比較低,但是也阻止了像樂觀鎖一樣,遇到寫不一致的情況下一直重試的時間。
相對而言,樂觀鎖用於讀多寫少的情況,即很少發生衝突的場景,這樣可以省去鎖的開銷,增加系統的吞吐量。
樂觀鎖的適用場景有很多,典型的比如說成本系統,櫃員要對一筆金額做修改,為了保證資料的準確性和實效性,使用悲觀鎖鎖住某個資料後,再遇到其他需要修改資料的操作,那麼此操作就無法完成金額的修改,對產品來說是災難性的一刻,使用樂觀鎖的版本號機制能夠解決這個問題,我們下面說。
樂觀鎖的實現方式
樂觀鎖一般有兩種實現方式:採用版本號機制
和 CAS(Compare-and-Swap,即比較並替換)
演算法實現。
版本號機制
版本號機制是在資料表中加上一個 version
欄位來實現的,表示資料被修改的次數,當執行寫操作並且寫入成功後,version = version + 1,當執行緒A要更新資料時,在讀取資料的同時也會讀取 version 值,在提交更新時,若剛才讀取到的 version 值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。
我們以上面的金融系統為例,來簡述一下這個過程。
- 成本系統中有一個數據表,表中有兩個欄位分別是
金額
和version
,金額的屬性是能夠實時變化,而 version 表示的是金額每次發生變化的版本,一般的策略是,當金額發生改變時,version 採用遞增的策略每次都在上一個版本號的基礎上 + 1。 - 在瞭解了基本情況和基本資訊之後,我們來看一下這個過程:公司收到回款後,需要把這筆錢放在金庫中,假如金庫中存有100 元錢
- 下面開啟事務一:當男櫃員執行回款寫入操作前,他會先檢視(讀)一下金庫中還有多少錢,此時讀到金庫中有 100 元,可以執行寫操作,並把資料庫中的錢更新為 120 元,提交事務,金庫中的錢由 100 -> 120,version的版本號由 0 -> 1。
- 開啟事務二:女櫃員收到給員工發工資的請求後,需要先執行讀請求,檢視金庫中的錢還有多少,此時的版本號是多少,然後從金庫中取出員工的工資進行發放,提交事務,成功後版本 + 1,此時版本由 1 -> 2。
上面兩種情況是最樂觀的情況,上面的兩個事務都是順序執行的,也就是事務一和事務二互不干擾,那麼事務要並行執行會如何呢?
事務一開啟,男櫃員先執行讀操作,取出金額和版本號,執行寫操作
begin update 表 set 金額 = 120,version = version + 1 where 金額 = 100 and version = 0
此時金額改為 120,版本號為1,事務還沒有提交
事務二開啟,女櫃員先執行讀操作,取出金額和版本號,執行寫操作
begin update 表 set 金額 = 50,version = version + 1 where 金額 = 100 and version = 0
此時金額改為 50,版本號變為 1,事務未提交
現在提交事務一,金額改為 120,版本變為1,提交事務。理想情況下應該變為 金額 = 50,版本號 = 2,但是實際上事務二 的更新是建立在金額為 100 和 版本號為 0 的基礎上的,所以事務二不會提交成功,應該重新讀取金額和版本號,再次進行寫操作。
這樣,就避免了女櫃員 用基於 version = 0 的舊資料修改的結果覆蓋男操作員操作結果的可能。
CAS 演算法
省略程式碼,完整程式碼請參照 看完你就應該能明白的悲觀鎖和樂觀鎖
CAS 即 compare and swap(比較與交換)
,是一種有名的無鎖演算法。即不使用鎖的情況下實現多執行緒之間的變數同步,也就是在沒有執行緒被阻塞的情況下實現變數的同步,所以也叫非阻塞同步(Non-blocking Synchronization
Java 從 JDK1.5 開始支援,java.util.concurrent
包裡提供了很多面向併發程式設計的類,也提供了 CAS 演算法的支援,一些以 Atomic
為開頭的一些原子類都使用 CAS 作為其實現方式。使用這些類在多核 CPU 的機器上會有比較好的效能。
如果要把證它們的原子性,必須進行加鎖,使用 Synchronzied
或者 ReentrantLock
,我們前面介紹它們是悲觀鎖的實現,我們現在討論的是樂觀鎖,那麼用哪種方式保證它們的原子性呢?請繼續往下看
CAS 中涉及三個要素:
- 需要讀寫的記憶體值 V
- 進行比較的值 A
- 擬寫入的新值 B
當且僅當預期值A和記憶體值V相同時,將記憶體值V修改為B,否則什麼都不做。
我們以 java.util.concurrent 中的AtomicInteger
為例,看一下在不用鎖的情況下是如何保證執行緒安全的
public class AtomicCounter {
private AtomicInteger integer = new AtomicInteger();
public AtomicInteger getInteger() {
return integer;
}
public void setInteger(AtomicInteger integer) {
this.integer = integer;
}
public void increment(){
integer.incrementAndGet();
}
public void decrement(){
integer.decrementAndGet();
}
}
public class AtomicProducer extends Thread{
private AtomicCounter atomicCounter;
public AtomicProducer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("producer : " + atomicCounter.getInteger());
atomicCounter.increment();
}
}
}
public class AtomicConsumer extends Thread{
private AtomicCounter atomicCounter;
public AtomicConsumer(AtomicCounter atomicCounter){
this.atomicCounter = atomicCounter;
}
@Override
public void run() {
for(int j = 0; j < AtomicTest.LOOP; j++) {
System.out.println("consumer : " + atomicCounter.getInteger());
atomicCounter.decrement();
}
}
}
public class AtomicTest {
final static int LOOP = 10000;
public static void main(String[] args) throws InterruptedException {
AtomicCounter counter = new AtomicCounter();
AtomicProducer producer = new AtomicProducer(counter);
AtomicConsumer consumer = new AtomicConsumer(counter);
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println(counter.getInteger());
}
}
經測試可得,不管迴圈多少次最後的結果都是0,也就是多執行緒並行的情況下,使用 AtomicInteger 可以保證執行緒安全性。 incrementAndGet 和 decrementAndGet 都是原子性操作。
樂觀鎖的缺點
任何事情都是有利也有弊,軟體行業沒有完美的解決方案只有最優的解決方案,所以樂觀鎖也有它的弱點和缺陷:
ABA 問題
ABA 問題說的是,如果一個變數第一次讀取的值是 A,準備好需要對 A 進行寫操作的時候,發現值還是 A,那麼這種情況下,能認為 A 的值沒有被改變過嗎?可以是由 A -> B -> A 的這種情況,但是 AtomicInteger 卻不會這麼認為,它只相信它看到的,它看到的是什麼就是什麼。
JDK 1.5 以後的 AtomicStampedReference
類就提供了此種能力,其中的 compareAndSet 方法
就是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。
也可以採用CAS的一個變種DCAS來解決這個問題。
DCAS,是對於每一個V增加一個引用的表示修改次數的標記符。對於每個V,如果引用修改了一次,這個計數器就加1。然後再這個變數需要update的時候,就同時檢查變數的值和計數器的值。
迴圈開銷大
我們知道樂觀鎖在進行寫操作的時候會判斷是否能夠寫入成功,如果寫入不成功將觸發等待 -> 重試機制,這種情況是一個自旋鎖,簡單來說就是適用於短期內獲取不到,進行等待重試的鎖,它不適用於長期獲取不到鎖的情況,另外,自旋迴圈對於效能開銷比較大。
CAS與synchronized的使用情景
簡單的來說 CAS 適用於寫比較少的情況下(多讀場景,衝突一般較少),synchronized 適用於寫比較多的情況下(多寫場景,衝突一般較多)
- 對於資源競爭較少(執行緒衝突較輕)的情況,使用 Synchronized 同步鎖進行執行緒阻塞和喚醒切換以及使用者態核心態間的切換操作額外浪費消耗 cpu 資源;而 CAS 基於硬體實現,不需要進入核心,不需要切換執行緒,操作自旋機率較少,因此可以獲得更高的效能。
- 對於資源競爭嚴重(執行緒衝突嚴重)的情況,CAS 自旋的概率會比較大,從而浪費更多的 CPU 資源,效率低於 synchronized。
資源已被鎖定,執行緒是否阻塞
自旋鎖的提出背景
由於在多處理器環境中某些資源的有限性,有時需要互斥訪問(mutual exclusion)
,這時候就需要引入鎖的概念,只有獲取了鎖的執行緒才能夠對資源進行訪問,由於多執行緒的核心是CPU的時間分片,所以同一時刻只能有一個執行緒獲取到鎖。那麼就面臨一個問題,那麼沒有獲取到鎖的執行緒應該怎麼辦?
通常有兩種處理方式:一種是沒有獲取到鎖的執行緒就一直迴圈等待判斷該資源是否已經釋放鎖,這種鎖叫做自旋鎖,它不用將執行緒阻塞起來(NON-BLOCKING)
;還有一種處理方式就是把自己阻塞起來,等待重新排程請求,這種叫做互斥鎖
。
什麼是自旋鎖
自旋鎖的定義:當一個執行緒嘗試去獲取某一把鎖的時候,如果這個鎖此時已經被別人獲取(佔用),那麼此執行緒就無法獲取到這把鎖,該執行緒將會等待,間隔一段時間後會再次嘗試獲取。這種採用迴圈加鎖 -> 等待的機制被稱為自旋鎖(spinlock)
。
自旋鎖的原理
自旋鎖的原理比較簡單,如果持有鎖的執行緒能在短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做核心態和使用者態之間的切換進入阻塞狀態,它們只需要等一等(自旋),等到持有鎖的執行緒釋放鎖之後即可獲取,這樣就避免了使用者程序和核心切換的消耗。
因為自旋鎖避免了作業系統程序排程和執行緒切換,所以自旋鎖通常適用在時間比較短的情況下。由於這個原因,作業系統的核心經常使用自旋鎖。但是,如果長時間上鎖的話,自旋鎖會非常耗費效能,它阻止了其他執行緒的執行和排程。執行緒持有鎖的時間越長,則持有該鎖的執行緒將被 OS(Operating System)
排程程式中斷的風險越大。如果發生中斷情況,那麼其他執行緒將保持旋轉狀態(反覆嘗試獲取鎖),而持有該鎖的執行緒並不打算釋放鎖,這樣導致的是結果是無限期推遲,直到持有鎖的執行緒可以完成並釋放它為止。
解決上面這種情況一個很好的方式是給自旋鎖設定一個自旋時間,等時間一到立即釋放自旋鎖。自旋鎖的目的是佔著CPU資源不進行釋放,等到獲取鎖立即進行處理。但是如何去選擇自旋時間呢?如果自旋執行時間太長,會有大量的執行緒處於自旋狀態佔用 CPU 資源,進而會影響整體系統的效能。因此自旋的週期選的額外重要!JDK在1.6 引入了適應性自旋鎖,適應性自旋鎖意味著自旋時間不是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖擁有的狀態來決定,基本認為一個執行緒上下文切換的時間是最佳的一個時間。
自旋鎖的優缺點
自旋鎖儘可能的減少執行緒的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的程式碼塊來說效能能大幅度的提升,因為自旋的消耗會小於執行緒阻塞掛起再喚醒的操作的消耗,這些操作會導致執行緒發生兩次上下文切換!
但是如果鎖的競爭激烈,或者持有鎖的執行緒需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是佔用 cpu 做無用功,佔著 XX 不 XX,同時有大量執行緒在競爭一個鎖,會導致獲取鎖的時間很長,執行緒自旋的消耗大於執行緒阻塞掛起操作的消耗,其它需要 cpu 的執行緒又不能獲取到 cpu,造成 cpu 的浪費。所以這種情況下我們要關閉自旋鎖。
自旋鎖的實現
下面我們用Java 程式碼來實現一個簡單的自旋鎖
public class SpinLockTest {
private AtomicBoolean available = new AtomicBoolean(false);
public void lock(){
// 迴圈檢測嘗試獲取鎖
while (!tryLock()){
// doSomething...
}
}
public boolean tryLock(){
// 嘗試獲取鎖,成功返回true,失敗返回false
return available.compareAndSet(false,true);
}
public void unLock(){
if(!available.compareAndSet(true,false)){
throw new RuntimeException("釋放鎖失敗");
}
}
}
這種簡單的自旋鎖有一個問題:無法保證多執行緒競爭的公平性。對於上面的 SpinlockTest,當多個執行緒想要獲取鎖時,誰最先將available
設為false
誰就能最先獲得鎖,這可能會造成某些執行緒一直都未獲取到鎖造成執行緒飢餓
。就像我們下課後蜂擁的跑向食堂,下班後蜂擁地擠向地鐵,通常我們會採取排隊的方式解決這樣的問題,類似地,我們把這種鎖叫排隊自旋鎖(QueuedSpinlock)。電腦科學家們使用了各種方式來實現排隊自旋鎖,如TicketLock,MCSLock,CLHLock。接下來我們分別對這幾種鎖做個大致的介紹。
TicketLock
在電腦科學領域中,TicketLock 是一種同步機制或鎖定演算法,它是一種自旋鎖,它使用ticket
來控制執行緒執行順序。
就像票據佇列管理系統一樣。麵包店或者服務機構(例如銀行)都會使用這種方式來為每個先到達的顧客記錄其到達的順序,而不用每次都進行排隊。通常,這種地點都會有一個分配器(叫號器,掛號器等等都行),先到的人需要在這個機器上取出自己現在排隊的號碼,這個號碼是按照自增的順序進行的,旁邊還會有一個標牌顯示的是正在服務的標誌,這通常是代表目前正在服務的佇列號,當前的號碼完成服務後,標誌牌會顯示下一個號碼可以去服務了。
像上面系統一樣,TicketLock 是基於先進先出(FIFO) 佇列的機制。它增加了鎖的公平性,其設計原則如下:TicketLock 中有兩個 int 型別的數值,開始都是0,第一個值是佇列ticket(佇列票據)
, 第二個值是 出隊(票據)
。佇列票據是執行緒在佇列中的位置,而出隊票據是現在持有鎖的票證的佇列位置。可能有點模糊不清,簡單來說,就是佇列票據是你取票號的位置,出隊票據是你距離叫號的位置。現在應該明白一些了吧。
當叫號叫到你的時候,不能有相同的號碼同時辦業務,必須只有一個人可以去辦,辦完後,叫號機叫到下一個人,這就叫做原子性
。你在辦業務的時候不能被其他人所幹擾,而且不可能會有兩個持有相同號碼的人去同時辦業務。然後,下一個人看自己的號是否和叫到的號碼保持一致,如果一致的話,那麼就輪到你去辦業務,否則只能繼續等待。上面這個流程的關鍵點在於,每個辦業務的人在辦完業務之後,他必須丟棄自己的號碼,叫號機才能繼續叫到下面的人,如果這個人沒有丟棄這個號碼,那麼其他人只能繼續等待。下面來實現一下這個票據排隊方案
public class TicketLock {
// 佇列票據(當前排隊號碼)
private AtomicInteger queueNum = new AtomicInteger();
// 出隊票據(當前需等待號碼)
private AtomicInteger dueueNum = new AtomicInteger();
// 獲取鎖:如果獲取成功,返回當前執行緒的排隊號
public int lock(){
int currentTicketNum = dueueNum.incrementAndGet();
while (currentTicketNum != queueNum.get()){
// doSomething...
}
return currentTicketNum;
}
// 釋放鎖:傳入當前排隊的號碼
public void unLock(int ticketNum){
queueNum.compareAndSet(ticketNum,ticketNum + 1);
}
}
每次叫號機在叫號的時候,都會判斷自己是不是被叫的號,並且每個人在辦完業務的時候,叫號機根據在當前號碼的基礎上 + 1,讓佇列繼續往前走。
但是上面這個設計是有問題的,因為獲得自己的號碼之後,是可以對號碼進行更改的,這就造成系統紊亂,鎖不能及時釋放。這時候就需要有一個能確保每個人按會著自己號碼排隊辦業務的角色,在得知這一點之後,我們重新設計一下這個邏輯
public class TicketLock2 {
// 佇列票據(當前排隊號碼)
private AtomicInteger queueNum = new AtomicInteger();
// 出隊票據(當前需等待號碼)
private AtomicInteger dueueNum = new AtomicInteger();
private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();
public void lock(){
int currentTicketNum = dueueNum.incrementAndGet();
// 獲取鎖的時候,將當前執行緒的排隊號儲存起來
ticketLocal.set(currentTicketNum);
while (currentTicketNum != queueNum.get()){
// doSomething...
}
}
// 釋放鎖:從排隊緩衝池中取
public void unLock(){
Integer currentTicket = ticketLocal.get();
queueNum.compareAndSet(currentTicket,currentTicket + 1);
}
}
這次就不再需要返回值,辦業務的時候,要將當前的這一個號碼快取起來,在辦完業務後,需要釋放快取的這條票據。
缺點
TicketLock 雖然解決了公平性的問題,但是多處理器系統上,每個程序/執行緒佔用的處理器都在讀寫同一個變數queueNum ,每次讀寫操作都必須在多個處理器快取之間進行快取同步,這會導致繁重的系統匯流排和記憶體的流量,大大降低系統整體的效能。
為了解決這個問題,MCSLock 和 CLHLock 應運而生。
CLHLock
上面說到TicketLock 是基於佇列的,那麼 CLHLock 就是基於連結串列設計的,CLH的發明人是:Craig,Landin and Hagersten,用它們各自的字母開頭命名。CLH 是一種基於連結串列的可擴充套件,高效能,公平的自旋鎖,申請執行緒只能在本地變數上自旋,它會不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。
public class CLHLock {
public static class CLHNode{
private volatile boolean isLocked = true;
}
// 尾部節點
private volatile CLHNode tail;
private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");
public void lock(){
// 新建節點並將節點與當前執行緒儲存起來
CLHNode node = new CLHNode();
LOCAL.set(node);
// 將新建的節點設定為尾部節點,並返回舊的節點(原子操作),這裡舊的節點實際上就是當前節點的前驅節點
CLHNode preNode = UPDATER.getAndSet(this,node);
if(preNode != null){
// 前驅節點不為null表示當鎖被其他執行緒佔用,通過不斷輪詢判斷前驅節點的鎖標誌位等待前驅節點釋放鎖
while (preNode.isLocked){
}
preNode = null;
LOCAL.set(node);
}
// 如果不存在前驅節點,表示該鎖沒有被其他執行緒佔用,則當前執行緒獲得鎖
}
public void unlock() {
// 獲取當前執行緒對應的節點
CLHNode node = LOCAL.get();
// 如果tail節點等於node,則將tail節點更新為null,同時將node的lock狀態職位false,表示當前執行緒釋放了鎖
if (!UPDATER.compareAndSet(this, node, null)) {
node.isLocked = false;
}
node = null;
}
}
MCSLock
MCS Spinlock 是一種基於連結串列的可擴充套件、高效能、公平的自旋鎖,申請執行緒只在本地變數上自旋,直接前驅負責通知其結束自旋,從而極大地減少了不必要的處理器快取同步的次數,降低了匯流排和記憶體的開銷。MCS 來自於其發明人名字的首字母: John Mellor-Crummey 和 Michael Scott。
public class MCSLock {
public static class MCSNode {
volatile MCSNode next;
volatile boolean isLocked = true;
}
private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>();
// 佇列
@SuppressWarnings("unused")
private volatile MCSNode queue;
private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE =
AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue");
public void lock(){
// 建立節點並儲存到ThreadLocal中
MCSNode currentNode = new MCSNode();
NODE.set(currentNode);
// 將queue設定為當前節點,並且返回之前的節點
MCSNode preNode = UPDATE.getAndSet(this, currentNode);
if (preNode != null) {
// 如果之前節點不為null,表示鎖已經被其他執行緒持有
preNode.next = currentNode;
// 迴圈判斷,直到當前節點的鎖標誌位為false
while (currentNode.isLocked) {
}
}
}
public void unlock() {
MCSNode currentNode = NODE.get();
// next為null表示沒有正在等待獲取鎖的執行緒
if (currentNode.next == null) {
// 更新狀態並設定queue為null
if (UPDATE.compareAndSet(this, currentNode, null)) {
// 如果成功了,表示queue==currentNode,即當前節點後面沒有節點了
return;
} else {
// 如果不成功,表示queue!=currentNode,即當前節點後面多了一個節點,表示有執行緒在等待
// 如果當前節點的後續節點為null,則需要等待其不為null(參考加鎖方法)
while (currentNode.next == null) {
}
}
} else {
// 如果不為null,表示有執行緒在等待獲取鎖,此時將等待執行緒對應的節點鎖狀態更新為false,同時將當前執行緒的後繼節點設為null
currentNode.next.isLocked = false;
currentNode.next = null;
}
}
}
CLHLock 和 MCSLock
- 都是基於連結串列,不同的是CLHLock是基於隱式連結串列,沒有真正的後續節點屬性,MCSLock是顯示連結串列,有一個指向後續節點的屬性。
- 將獲取鎖的執行緒狀態藉助節點(node)儲存,每個執行緒都有一份獨立的節點,這樣就解決了TicketLock多處理器快取同步的問題。
多個執行緒併發訪問資源
鎖狀態的分類
Java 語言專門針對 synchronized
關鍵字設定了四種狀態,它們分別是:無鎖、偏向鎖、輕量級鎖和重量級鎖,但是在瞭解這些鎖之前還需要先了解一下 Java 物件頭和 Monitor。
Java 物件頭
我們知道 synchronized 是悲觀鎖,在操作同步之前需要給資源加鎖,這把鎖就是物件頭裡面的,而Java 物件頭又是什麼呢?我們以 Hotspot 虛擬機器為例,Hopspot 物件頭主要包括兩部分資料:Mark Word(標記欄位)
和 class Pointer(型別指標)
。
Mark Word:預設儲存物件的HashCode,分代年齡和鎖標誌位資訊。這些資訊都是與物件自身定義無關的資料,所以Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲存儘量多的資料。它會根據物件的狀態複用自己的儲存空間,也就是說在執行期間Mark Word裡儲存的資料會隨著鎖標誌位的變化而變化。
class Point:物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
在32位虛擬機器和64位虛擬機器的 Mark Word 所佔用的位元組大小不一樣,32位虛擬機器的 Mark Word 和 class Pointer 分別佔用 32bits 的位元組,而 64位虛擬機器的 Mark Word 和 class Pointer 佔用了64bits 的位元組,下面我們以 32位虛擬機器為例,來看一下其 Mark Word 的位元組具體是如何分配的
用中文翻譯過來就是
- 無狀態也就是
無鎖
的時候,物件頭開闢 25bit 的空間用來儲存物件的 hashcode ,4bit 用於存放分代年齡,1bit 用來存放是否偏向鎖的標識位,2bit 用來存放鎖標識位為01 偏向鎖
中劃分更細,還是開闢25bit 的空間,其中23bit 用來存放執行緒ID,2bit 用來存放 epoch,4bit 存放分代年齡,1bit 存放是否偏向鎖標識, 0表示無鎖,1表示偏向鎖,鎖的標識位還是01輕量級鎖
中直接開闢 30bit 的空間存放指向棧中鎖記錄的指標,2bit 存放鎖的標誌位,其標誌位為00重量級鎖
中和輕量級鎖一樣,30bit 的空間用來存放指向重量級鎖的指標,2bit 存放鎖的標識位,為11GC標記
開闢30bit 的記憶體空間卻沒有佔用,2bit 空間存放鎖標誌位為11。
其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。
關於為什麼這麼分配的記憶體,我們可以從 OpenJDK
中的markOop.hpp類中的列舉窺出端倪
來解釋一下
- age_bits 就是我們說的分代回收的標識,佔用4位元組
- lock_bits 是鎖的標誌位,佔用2個位元組
- biased_lock_bits 是是否偏向鎖的標識,佔用1個位元組
- max_hash_bits 是針對無鎖計算的hashcode 佔用位元組數量,如果是32位虛擬機器,就是 32 - 4 - 2 -1 = 25 byte,如果是64 位虛擬機器,64 - 4 - 2 - 1 = 57 byte,但是會有 25 位元組未使用,所以64位的 hashcode 佔用 31 byte
- hash_bits 是針對 64 位虛擬機器來說,如果最大位元組數大於 31,則取31,否則取真實的位元組數
- cms_bits 我覺得應該是不是64位虛擬機器就佔用 0 byte,是64位就佔用 1byte
- epoch_bits 就是 epoch 所佔用的位元組大小,2位元組。
Synchronized鎖
synchronized
用的鎖記錄是存在Java物件頭裡的。
JVM基於進入和退出 Monitor 物件來實現方法同步和程式碼塊同步。程式碼塊同步是使用 monitorenter 和 monitorexit 指令實現的,monitorenter 指令是在編譯後插入到同步程式碼塊的開始位置,而 monitorexit 是插入到方法結束處和異常處。任何物件都有一個 monitor 與之關聯,當且一個 monitor 被持有後,它將處於鎖定狀態。
根據虛擬機器規範的要求,在執行 monitorenter 指令時,首先要去嘗試獲取物件的鎖,如果這個物件沒被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1,相應地,在執行 monitorexit 指令時會將鎖計數器減1,當計數器被減到0時,鎖就釋放了。如果獲取物件鎖失敗了,那當前執行緒就要阻塞等待,直到物件鎖被另一個執行緒釋放為止。
Monitor
Synchronized是通過物件內部的一個叫做監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的作業系統的 Mutex Lock(互斥鎖)來實現的。而作業系統實現執行緒之間的切換需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼 Synchronized 效率低的原因。因此,這種依賴於作業系統 Mutex Lock 所實現的鎖我們稱之為重量級鎖
。
Java SE 1.6為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了偏向鎖
和輕量級鎖
:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖可以升級但不能降級。
所以鎖的狀態總共有四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖(但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級)。JDK 1.6中預設是開啟偏向鎖和輕量級鎖的,我們也可以通過-XX:-UseBiasedLocking=false來禁用偏向鎖。
鎖的分類及其解釋
先來個大體的流程圖來感受一下這個過程,然後下面我們再分開來說
無鎖
無鎖狀態
,無鎖即沒有對資源進行鎖定,所有的執行緒都可以對同一個資源進行訪問,但是隻有一個執行緒能夠成功修改資源。
無鎖的特點就是在迴圈內進行修改操作,執行緒會不斷的嘗試修改共享資源,直到能夠成功修改資源並退出,在此過程中沒有出現衝突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的效能是非常高的。
偏向鎖
HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,還存在鎖由同一執行緒多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是為了解決只有在一個執行緒執行同步時提高效能。
可以從物件頭的分配中看到,偏向鎖要比無鎖多了執行緒ID
和 epoch
,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
- 首先執行緒訪問同步程式碼塊,會通過檢查物件頭 Mark Word 的
鎖標誌位
判斷目前鎖的狀態,如果是 01,說明就是無鎖或者偏向鎖,然後再根據是否偏向鎖
的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執行下一步 - 執行緒使用 CAS 操作來嘗試對物件加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那麼當前執行緒就會獲得物件的偏向鎖,此時會在物件頭的 Mark Word 中記錄當前執行緒 ID 和獲取鎖的時間 epoch 等資訊,然後執行同步程式碼塊。
全域性安全點(Safe Point):全域性安全點的理解會涉及到 C 語言底層的一些知識,這裡簡單理解 SafePoint 是 Java 程式碼中的一個執行緒可能暫停執行的位置。
等到下一次執行緒在進入和退出同步程式碼塊時就不需要進行 CAS
操作進行加鎖和解鎖,只需要簡單判斷一下物件頭的 Mark Word 中是否儲存著指向當前執行緒的執行緒ID,判斷的標誌當然是根據鎖的標誌位來判斷的。如果用流程圖來表示的話就是下面這樣
關閉偏向鎖
偏向鎖在Java 6 和Java 7 裡是預設啟用
的。由於偏向鎖是為了在只有一個執行緒執行同步塊時提高效能,如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false
,那麼程式預設會進入輕量級鎖狀態。
關於 epoch
偏向鎖的物件頭中有一個被稱為 epoch
的值,它作為偏差有效性的時間戳。
輕量級鎖
輕量級鎖
是指當前鎖是偏向鎖的時候,資源被另外的執行緒所訪問,那麼偏向鎖就會升級為輕量級鎖
,其他執行緒會通過自旋
的形式嘗試獲取鎖,不會阻塞,從而提高效能,下面是詳細的獲取過程。
輕量級鎖加鎖過程
- 緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執行下一步
- 如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個執行緒的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操作,撤銷偏向鎖,然後等原持有偏向鎖的執行緒到達
全域性安全點(SafePoint)
時,會暫停原持有偏向鎖的執行緒,然後會檢查原持有偏向鎖的狀態,如果已經退出同步,就會喚醒持有偏向鎖的執行緒,執行下一步 - 檢查物件頭中的 Mark Word 記錄的是否是當前執行緒 ID,如果是,執行同步程式碼,如果不是,執行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)
重量級鎖
重量級鎖的獲取流程比較複雜,小夥伴們做好準備,其實多看幾遍也沒那麼麻煩,呵呵。
重量級鎖的獲取流程
接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執行下一步
會在原持有偏向鎖的執行緒的棧中分配鎖記錄,將物件頭中的 Mark Word 拷貝到原持有偏向鎖執行緒的記錄中,然後原持有偏向鎖的執行緒獲得輕量級鎖,然後喚醒原持有偏向鎖的執行緒,從安全點處繼續執行,執行完畢後,執行下一步,當前執行緒執行第4步
執行完畢後,開始輕量級解鎖操作,解鎖需要判斷兩個條件
- 判斷物件頭中的 Mark Word 中鎖記錄指標是否指向當前棧中記錄的指標
- 拷貝在當前執行緒鎖記錄的 Mark Word 資訊是否與物件頭中的 Mark Word 一致。
如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,並喚起等待的執行緒,進行新一輪的鎖競爭。
在當前執行緒的棧中分配鎖記錄,拷貝物件頭中的 MarkWord 到當前執行緒的鎖記錄中,執行 CAS 加鎖操作,會把物件頭 Mark Word 中鎖記錄指標指向當前執行緒鎖記錄,如果成功,獲取輕量級鎖,執行同步程式碼,然後執行第3步,如果不成功,執行下一步
當前執行緒沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限後還沒有獲取到鎖,那麼輕量級鎖就會升級為
重量級鎖
如果用流程圖表示是這樣的
鎖的公平性與非公平性
我們知道,在併發環境中,多個執行緒需要對同一資源進行訪問,同一時刻只能有一個執行緒能夠獲取到鎖並進行資源訪問,那麼剩下的這些執行緒怎麼辦呢?這就好比食堂排隊打飯的模型,最先到達食堂的人擁有最先買飯的權利,那麼剩下的人就需要在第一個人後面排隊,這是理想的情況,即每個人都能夠買上飯。那麼現實情況是,在你排隊的過程中,就有個別不老實的人想走捷徑,插隊打飯,如果插隊的這個人後面沒有人制止他這種行為,他就能夠順利買上飯,如果有人制止,他就也得去隊伍後面排隊。
對於正常排隊的人來說,沒有人插隊,每個人都在等待排隊打飯的機會,那麼這種方式對每個人來說都是公平的,先來後到嘛。這種鎖也叫做公平鎖。
那麼假如插隊的這個人成功買上飯並且在買飯的過程不管有沒有人制止他,他的這種行為對正常排隊的人來說都是不公平的,這在鎖的世界中也叫做非公平鎖。
那麼我們根據上面的描述可以得出下面的結論
公平鎖表示執行緒獲取鎖的順序是按照執行緒加鎖的順序來分配的,即先來先得的FIFO先進先出順序。而非公平鎖就是一種獲取鎖的搶佔機制,是隨機獲得鎖的,和公平鎖不一樣的就是先來的不一定先得到鎖,這個方式可能造成某些執行緒一直拿不到鎖,結果也就是不公平的了。
鎖公平性的實現
在 Java 中,我們一般通過 ReetrantLock
來實現鎖的公平性
我們分別通過兩個例子來講解一下鎖的公平性和非公平性
鎖的公平性
public class MyFairLock extends Thread{
private ReentrantLock lock = new ReentrantLock(true);
public void fairLock(){
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "正在持有鎖");
}finally {
System.out.println(Thread.currentThread().getName() + "釋放了鎖");
lock.unlock();
}
}
public static void main(String[] args) {
MyFairLock myFairLock = new MyFairLock();
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName() + "啟動");
myFairLock.fairLock();
};
Thread[] thread = new Thread[10];
for(int i = 0;i < 10;i++){
thread[i] = new Thread(runnable);
}
for(int i = 0;i < 10;i++){
thread[i].start();
}
}
}
我們建立了一個 ReetrantLock,並給建構函式傳了一個 true,我們可以檢視 ReetrantLock 的建構函式
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
根據 JavaDoc 的註釋可知,如果是 true 的話,那麼就會建立一個 ReentrantLock 的公平鎖,然後並建立一個 FairSync
,FairSync 其實是一個 Sync
的內部類,它的主要作用是同步物件以獲取公平鎖。
而 Sync 是 ReentrantLock 中的內部類,Sync 繼承 AbstractQueuedSynchronizer
類,AbstractQueuedSynchronizer 就是我們常說的 AQS ,它是 JUC(java.util.concurrent) 中最重要的一個類,通過它來實現獨佔鎖和共享鎖。
abstract static class Sync extends AbstractQueuedSynchronizer {...}
也就是說,我們把 fair 引數設定為 true 之後,就可以實現一個公平鎖了,是這樣嗎?我們回到示例程式碼,我們可以執行一下這段程式碼,它的輸出是順序獲取的(礙於篇幅的原因,這裡就暫不貼出了),也就是說我們建立了一個公平鎖
鎖的非公平性
與公平性相對的就是非公平性,我們通過設定 fair
引數為 true,便實現了一個公平鎖,與之相對的,我們把 fair 引數設定為 false,是不是就是非公平鎖了?用事實證明一下
private ReentrantLock lock = new ReentrantLock(false);
其他程式碼不變,我們執行一下看看輸出(部分輸出)
Thread-1啟動
Thread-4啟動
Thread-1正在持有鎖
Thread-1釋放了鎖
Thread-5啟動
Thread-6啟動
Thread-3啟動
Thread-7啟動
Thread-2啟動
可以看到,執行緒的啟動並沒有按順序獲取,可以看出非公平鎖對鎖的獲取是亂序的,即有一個搶佔鎖的過程。也就是說,我們把 fair 引數設定為 false 便實現了一個非公平鎖。
ReentrantLock 基本概述
ReentrantLock 是一把可重入鎖
,也是一把互斥鎖
,它具有與 synchronized
相同的方法和監視器鎖的語義,但是它比 synchronized 有更多可擴充套件的功能。
ReentrantLock 的可重入性是指它可以由上次成功鎖定但還未解鎖的執行緒擁有。當只有一個執行緒嘗試加鎖時,該執行緒呼叫 lock()
方法會立刻返回成功並直接獲取鎖。如果當前執行緒已經擁有這把鎖,這個方法會立刻返回。可以使用 isHeldByCurrentThread
和 getHoldCount
進行檢查。
這個類的建構函式接受可選擇的 fairness 引數,當 fairness 設定為 true 時,在多執行緒爭奪嘗試加鎖時,鎖傾向於對等待時間最長的執行緒訪問,這也是公平性的一種體現。否則,鎖不能保證每個執行緒的訪問順序,也就是非公平鎖。與使用預設設定的程式相比,使用許多執行緒訪問的公平鎖的程式可能會顯示較低
的總體吞吐量(即較慢;通常要慢得多)。但是獲取鎖並保證執行緒不會飢餓的次數比較小。無論如何請注意:鎖的公平性不能保證執行緒排程的公平性。因此,使用公平鎖的多執行緒之一可能會連續多次獲得它,而其他活動執行緒沒有進行且當前未持有該鎖。這也是互斥性
的一種體現。
也要注意的 tryLock()
方法不支援公平性。如果鎖是可以獲取的,那麼即使其他執行緒等待,它仍然能夠返回成功。
推薦使用下面的程式碼來進行加鎖和解鎖
class MyFairLock {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// ...
} finally {
lock.unlock()
}
}
}
ReentrantLock 鎖通過同一執行緒最多支援2147483647個遞迴鎖。 嘗試超過此限制會導致鎖定方法引發錯誤。
ReentrantLock 如何實現鎖公平性
我們在上面的簡述中提到,ReentrantLock 是可以實現鎖的公平性的,那麼原理是什麼呢?下面我們通過其原始碼來了解一下 ReentrantLock 是如何實現鎖的公平性的
跟蹤其原始碼發現,呼叫 Lock.lock()
方法其實是呼叫了 sync
的內部的方法
abstract void lock();
而 sync 是最基礎的同步控制 Lock 的類,它有公平鎖和非公平鎖的實現。它繼承 AbstractQueuedSynchronizer
即 使用 AQS 狀態代表鎖持有的數量。
lock 是抽象方法是需要被子類實現的,而繼承了 AQS 的類主要有
我們可以看到,所有實現了 AQS 的類都位於 JUC 包下,主要有五類:ReentrantLock
、ReentrantReadWriteLock
、Semaphore
、CountDownLatch
和 ThreadPoolExecutor
,其中 ReentrantLock、ReentrantReadWriteLock、Semaphore 都可以實現公平鎖和非公平鎖。
下面是公平鎖 FairSync
的繼承關係
非公平鎖的NonFairSync
的繼承關係
由繼承圖可以看到,兩個類的繼承關係都是相同的,我們從原始碼發現,公平鎖和非公平鎖的實現就是下面這段程式碼的區別(下一篇文章我們會從原理角度分析一下公平鎖和非公平鎖的實現)
通過上圖中的原始碼對比,我們可以明顯的看出公平鎖與非公平鎖的lock()方法唯一的區別就在於公平鎖在獲取同步狀態時多了一個限制條件:hasQueuedPredecessors()
。
hasQueuedPredecessors() 也是 AQS 中的方法,它主要是用來 查詢是否有任何執行緒在等待獲取鎖的時間比當前執行緒長,也就是說每個等待執行緒都是在一個佇列中,此方法就是判斷佇列中在當前執行緒獲取鎖時,是否有等待鎖時間比自己還長的佇列,如果當前執行緒之前有排隊的執行緒,返回 true,如果當前執行緒位於佇列的開頭或佇列為空,返回 false。
綜上,公平鎖就是通過同步佇列來實現多個執行緒按照申請鎖的順序來獲取鎖,從而實現公平的特性。非公平鎖加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,所以存在後申請卻先獲得鎖的情況。
根據鎖是否可重入進行區分
可重入鎖
可重入鎖又稱為遞迴鎖,是指在同一個執行緒在外層方法獲取鎖的時候,再進入該執行緒的內層方法會自動獲取鎖(前提鎖物件得是同一個物件或者class),不會因為之前已經獲取過還沒釋放而阻塞。Java 中 ReentrantLock
和synchronized
都是可重入鎖,可重入鎖的一個優點是在一定程度上可以避免死鎖。
我們先來看一段程式碼來說明一下 synchronized 的可重入性
private synchronized void doSomething(){
System.out.println("doSomething...");
doSomethingElse();
}
private synchronized void doSomethingElse(){
System.out.println("doSomethingElse...");
}
在上面這段程式碼中,我們對 doSomething()
和 doSomethingElse()
分別使用了 synchronized
進行鎖定,doSomething() 方法中呼叫了 doSomethingElse() 方法,因為 synchronized 是可重入鎖,所以同一個執行緒在呼叫 doSomething() 方法時,也能夠進入 doSomethingElse() 方法中。
不可重入鎖
如果 synchronized 是不可重入鎖的話,那麼在呼叫 doSomethingElse() 方法的時候,必須把 doSomething() 的鎖丟掉,實際上該物件鎖已被當前執行緒所持有,且無法釋放。所以此時會出現死鎖。
也就是說,不可重入鎖會造成死鎖
多個執行緒能夠共享同一把鎖
獨佔鎖和共享鎖
獨佔多和共享鎖一般對應 JDK 原始碼的 ReentrantLock 和 ReentrantReadWriteLock 原始碼來介紹獨佔鎖和共享鎖。
獨佔鎖又叫做排他鎖,是指鎖在同一時刻只能被一個執行緒擁有,其他執行緒想要訪問資源,就會被阻塞。JDK 中 synchronized和 JUC 中 Lock 的實現類就是互斥鎖。
共享鎖指的是鎖能夠被多個執行緒所擁有,如果某個執行緒對資源加上共享鎖後,則其他執行緒只能對資源再加共享鎖,不能加排它鎖。獲得共享鎖的執行緒只能讀資料,不能修改資料。
我們看到 ReentrantReadWriteLock 有兩把鎖:ReadLock
和 WriteLock
,也就是一個讀鎖一個寫鎖,合在一起叫做讀寫鎖。再進一步觀察可以發現 ReadLock 和 WriteLock 是靠內部類 Sync 實現的鎖。Sync 是繼承於 AQS 子類的,AQS 是併發的根本,這種結構在CountDownLatch、ReentrantLock、Semaphore裡面也都存在。
在 ReentrantReadWriteLock 裡面,讀鎖和寫鎖的鎖主體都是 Sync,但讀鎖和寫鎖的加鎖方式不一樣。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證併發讀非常高效,而讀寫、寫讀、寫寫的過程互斥,因為讀鎖和寫鎖是分離的。所以ReentrantReadWriteLock的併發性相比一般的互斥鎖有了很大提升。
歡迎關注
文章參考:
Java 多執行緒之悲觀鎖與樂觀鎖
https://baike.baidu.com/item/悲觀鎖
https://blog.csdn.net/qq_34337272/article/details/81252853
http://www.blogjava.net/jinfeng_wang/archive/2016/12/14/432088.html
https://blog.hufeifei.cn/ 關於自旋鎖的文章
https://en.wikipedia.org/wiki/Ticket_lock
https://blog.stephencleary.com/2013/04/recursive-re-entrant-locks.html
https://researcher.watson.ibm.com/researcher/files/us-bacon/Bacon03Retrospective.pdf
https://tech.meituan.com/2018/11/15/java-lock.html
https://www.jianshu.com/p/eaea337c5e5b
https://blog.csdn.net/oChangWen/article/details/77622