[Java多線程編程之十] 深入理解Java的鎖機制

??在并發(fā)編程中,鎖是一種非常重要的機制,Java提供了種類豐富的鎖,每種鎖因其特性不同,在適當(dāng)?shù)膱鼍跋履軌蛘宫F(xiàn)出非常高的效率,下面針對不同的特性,對鎖和相關(guān)概念進行分類介紹。

??在多線程中,多個線程搶資源時根據(jù)是否同步資源,分為悲觀鎖、樂觀鎖;假如是悲觀鎖,鎖住資源后,沒搶到鎖的資源有的會直接阻塞,有的不會阻塞而是不斷嘗試去獲取鎖,這叫自旋鎖,假如長時間不斷自旋會占用CPU時間片,為了優(yōu)化這缺點,退出了適應(yīng)性自旋鎖;多個線程搶鎖時根據(jù)是否排隊先來后到獲取鎖,分為公平鎖、非公平鎖;假如允許一個線程在獲得鎖后又多次重獲得鎖,則該鎖成為可重入鎖,否則為非可重入鎖;假如允許多個線程共享同一把鎖,則該鎖為共享鎖,否則為排他鎖(或者叫互斥鎖)。

??sychronized 是Java中一種非常重要的加鎖機制,它在使用過程中根據(jù)不同情況,有無鎖、偏向鎖、輕量級鎖、重量級鎖四種狀態(tài)。

??綜上所述,總結(jié)如下:



一、鎖消除與鎖粗化

??在程序運行中,如果只有一個線程反復(fù)搶鎖釋放鎖,執(zhí)行次數(shù)到了一定級別,JVM就會認為,不需要鎖,觸發(fā)鎖消除的優(yōu)化,如下代碼所示:

public class LockElimination {

  public void test(StringBuffer stringBuffer) {

      //StringBuilder線程不安全,StringBuffer用了synchronized,是線程安全的
      // jit 優(yōu)化, 消除了鎖
      // 沒有線程搶鎖,單線程重復(fù)執(zhí)行到一定次數(shù)觸發(fā)JIT優(yōu)化,將StringBuffer中的鎖消除
      StringBuffer stringBuffer = new StringBuffer();
      stringBuffer.append("a");
      stringBuffer.append("b");
      stringBuffer.append("c");

      stringBuffer.append("a");
      stringBuffer.append("b");
      stringBuffer.append("c");

      stringBuffer.append("a");
      stringBuffer.append("b");
      stringBuffer.append("c");
      // System.out.println(stringBuffer.toString());
  }

  public static void main(String[] args) throws InterruptedException {
      StringBuffer stringBuffer = new StringBuffer();
      for (int i = 0; i < 1000000; i++) {
          new LockElimination().test(stringBuffer);
      }
  }
}

??StringBuffer 是線程安全類,其 append 方法使用sychronized做同步,執(zhí)行上面代碼,會不斷加鎖解鎖,加解鎖是有性能開銷的,如果只是單線程在執(zhí)行這個程序,不加鎖也能保證線程安全,所以JVM消除了鎖,提供了執(zhí)行效率。



??鎖粗化:是合并使用相同鎖對象的相鄰?fù)綁K的過程。如果編譯器不能使用鎖省略(Lock Elision)消除鎖,那么可以使用鎖粗化來減少開銷,如下代碼所示:

//鎖粗化(運行時 jit 編譯優(yōu)化)
//jit 編譯后的匯編內(nèi)容, jitwatch可視化工具進行查看
public class LockCoarsening {
    public void test() {

        int i = 0;

        synchronized (this) {
            i++;
        }
        synchronized (this) {
            i--;
        }

        synchronized (this) {
            System.out.println("dfasdfad");
        }
        synchronized (this) {
            i++;
        }

        synchronized (this) {
            i++;
            i--;
            System.out.println("fsfdsdf....");
            System.out.println("dfasdfad");
            i++;
        }
    }
}

??上面的代碼在同個方法體中不斷加鎖解鎖,如果每個同步代碼快計算邏輯都比較簡單不耗時,就會觸發(fā)JVM的鎖粗化,合并多個相鄰的同步代碼塊,減少加解鎖的次數(shù),從而提高性能,代碼有可能被優(yōu)化成下面的形式:

        synchronized (this) {
            i++;
            
            i--;

            System.out.println("dfasdfad");

            i++;

            i++;
            i--;
            System.out.println("fsfdsdf....");
            System.out.println("dfasdfad");
            i++;
        }



二、樂觀鎖 vs 悲觀鎖

??在很多技術(shù)中,都有樂觀鎖和悲觀鎖的概念,體現(xiàn)了看待線程同步的不同角度。

??對于同一個數(shù)據(jù)的并發(fā)操作,悲觀鎖認為自己在嘗試獲取資源的時候一定有別的線程來修改數(shù)據(jù),因此在獲取數(shù)據(jù)時先加鎖,確保數(shù)據(jù)不會被別的線程修改,在Java中,sychronized 關(guān)鍵字和JDK API Lock 的實現(xiàn)類都是悲觀鎖。

??而樂觀鎖認為自己在使用數(shù)據(jù)時不會有別的線程修改數(shù)據(jù),所以不會添加鎖,只是在更新數(shù)據(jù)的時候去判斷之前有沒有別的線程更新了這個數(shù)據(jù)。如果這個數(shù)據(jù)沒有被更新,當(dāng)前線程將自己修改的數(shù)據(jù)成功寫入;如果數(shù)據(jù)已經(jīng)被其他線程更新,則根據(jù)不同的實現(xiàn)方式執(zhí)行不同的操作(例如報錯或自動重試)。

??樂觀鎖在Java中是通過使用無鎖編程來實現(xiàn),最常使用的是CAS算法,Java的J.U.C包的原子類都是基于 Unsafe 類提供的通過CAS自旋實現(xiàn)的方法來實現(xiàn)的。

樂觀鎖和悲觀鎖

根據(jù)上面的概念描述可以發(fā)現(xiàn):

  • 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數(shù)據(jù)正確
  • 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升
// ------------------------------------------ 悲觀鎖的調(diào)用方式 ----------------------------------------------
// sychronized
public sychronized void testMethod() {
  // 操作同步資源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock();  // 需要保證多個線程使用同一個鎖
public void modifyPublicResources() {
    lock.lock();
    // 操作同步資源
    lock.unlock();
}

// ------------------------------------------ 樂觀鎖的調(diào)用方式 ----------------------------------------------
private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保證多個線程使用同一個AtomicInteger 
atomicInteger.incrementAndGet();  // 執(zhí)行自增1

??Java 中悲觀鎖基本上都是在顯式地鎖定之后再操作同步資源,加鎖的方式有 sychronized、Lock,而樂觀鎖則直接去操作同步資源,樂觀鎖的實現(xiàn)方式主要是基于 CAS 原子操作。

CAS原理

??CAS全稱 Compare And Swap(比較和交換),是一種無鎖算法,從字面上包含了兩個操作,但通過調(diào)用底層硬件指令,保證了比較和交換作為一個整體的原子操作被執(zhí)行。使用CAS能夠?qū)崿F(xiàn)在不加鎖的情況下實現(xiàn)多線程之間的變量同步,JUC包的原子類就是基于CAS來實現(xiàn)的。

??CAS算法涉及到三個操作數(shù):

  • 需要讀寫的內(nèi)存值 V
  • 進行比較的值 A
  • 要寫入的新值 B

??當(dāng)且僅當(dāng) V 的值等于 A 時,CAS 通過原子方式用新值 B 來更新 V 的值,否則不會執(zhí)行任何操作。通常 “更新” 操作了失敗了會不斷重新嘗試,是一個自旋的過程。

??java.util.concurrent包中的原子類,就是通過CAS來實現(xiàn)了樂觀鎖,進入原子類AtomicInteger的源碼,看一下AtomicInteger的定義:


各成員屬性的作用如下:

  • unsafe: 獲取并操作內(nèi)存的數(shù)據(jù)。
  • valueOffset: 存儲value在AtomicInteger中的偏移量。
  • value: 存儲AtomicInteger的int值,該屬性需要借助volatile關(guān)鍵字保證其在線程間是可見的。

??接下來,我們查看AtomicInteger的自增函數(shù)incrementAndGet()的源碼時,發(fā)現(xiàn)自增函數(shù)底層調(diào)用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通過class文件中的參數(shù)名,并不能很好的了解方法的作用,所以我們通過OpenJDK 8 來查看Unsafe的源碼:


??根據(jù)OpenJDK 8的源碼我們可以看出,getAndAddInt()循環(huán)獲取給定對象o中的偏移量處的值v,然后判斷內(nèi)存值是否等于v。如果相等則將內(nèi)存值設(shè)置為 v + delta,否則返回false,繼續(xù)循環(huán)進行重試,直到設(shè)置成功才能退出循環(huán),并且將舊值返回。整個“比較+更新”操作封裝在compareAndSwapInt()中,在JNI里是借助于一個CPU指令完成的,屬于原子操作,可以保證多個線程都能夠看到同一個變量的修改值。

??后續(xù)JDK通過CPU的cmpxchg指令,去比較寄存器中的 A 和 內(nèi)存中的值 V。如果相等,就把要寫入的新值 B 存入內(nèi)存中。如果不相等,就將內(nèi)存值 V 賦值給寄存器中的值 A。然后通過Java代碼中的while循環(huán)再次調(diào)用cmpxchg指令進行重試,直到設(shè)置成功為止。

??CAS雖然很高效,但是它也存在三大問題,這里也簡單說一下:

1、ABA問題

??CAS需要在操作值的時候檢查內(nèi)存值是否發(fā)生變化,沒有發(fā)生變化才會更新內(nèi)存值。但是如果內(nèi)存值原來是A,后來變成了B,然后又變成了A,那么CAS進行檢查時會發(fā)現(xiàn)值沒有發(fā)生變化,但實際上
是有變化的。通常解決思路是為變量增加版本號,每次寫操作時版本號加1,這樣變化過程就從 “A - B - A” 變成了 “1A - 2B - 3A”, CAS操作時檢查值和版本號,兩者一致才成功。

??JDK從1.5開始提供了 AtomicStampedReference 類來解決ABA問題,具體操作封裝在compareAndSet()中。compareAndSet()首先檢查當(dāng)前引用和當(dāng)前標(biāo)志與預(yù)期引用和預(yù)期標(biāo)志是否相等,如果都相等,則以原子方式將引用值和標(biāo)志的值設(shè)置為給定的更新值。

2、循環(huán)時間長開銷大

??CAS操作如果長時間不成功,會導(dǎo)致其一直自旋,占用CPU時間片,給CPU帶來非常大的開銷。

3、只能保證一個共享變量的原子操作

??對一個共享變量執(zhí)行操作時,CAS能夠保證原子操作,但是對多個共享變量操作時,CAS是無法保證操作的原子性的。

??Java從1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象里來進行CAS操作。


三、自旋鎖 vs 適應(yīng)性自旋鎖

??阻塞或喚醒一個Java線程需要操作系統(tǒng)切換CPU狀態(tài)來完成,這種狀態(tài)切換需要耗費處理器時間。如果同步代碼塊中的內(nèi)容過于簡單,導(dǎo)致狀態(tài)轉(zhuǎn)換消耗的時間比用戶代碼執(zhí)行的時間還長,這種情況下切換線程狀態(tài)就非常不劃算。

??在許多場景中,同步資源的鎖定時間很短,為了這一小段時間去切換線程,線程掛起和恢復(fù)現(xiàn)場的花費可能會讓系統(tǒng)得不償失。如果物理機器有多個處理器,能夠讓兩個或以上的下場呢很難過同時并行執(zhí)行,我們就可以讓后面那個請求鎖的線程不放棄CPU的執(zhí)行時間,看看持有鎖的線程是否很快就會釋放鎖。

??而為了讓當(dāng)前線程 “稍等以下”,我們需要讓當(dāng)前線程進行自旋,如果在自旋完成后前面鎖定同步資源的線程已經(jīng)釋放了鎖,那么當(dāng)前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷,這就是適用于鎖定時間短的自旋鎖。


自旋鎖與非自旋鎖

??自旋鎖本身是有缺點的,它不能代替阻塞。自旋等待雖然避免了線程切換的開銷,但它要占用處理器時間。如果鎖被占用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被占用的時間很長,那么自旋的線程只會白白浪費處理器資源。所以自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(shù)(默認是10次,可以使用 -XX:PreBlockSpin 來更改)沒有成功獲得鎖,就應(yīng)當(dāng)掛起線程。

??自旋的實現(xiàn)原理同樣也是CAS,AtomicInteger中調(diào)用unsafe進行自增操作的源碼中的do-while循環(huán)就是一個自旋操作,如果修改數(shù)值失敗則通過循環(huán)來執(zhí)行自旋,直至修改成功。

??自旋鎖在JDK1.4.2中引入,使用 -XX:+UseSpinning 來開啟。JDK 6中變?yōu)槟J開啟,并且引入了自適應(yīng)的自旋鎖(適應(yīng)性自旋鎖)。

??自適應(yīng)意味這自旋的時間(次數(shù))不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態(tài)來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得鎖,并且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持有相對更長的時間。如果對于某個鎖,自旋很少成功獲得過,那在以后嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

??在自旋鎖中 另有三種常見的鎖形式:TicketLock、CLHlockMCSlock。


四、sychronized鎖機制:無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

??在Java 5.0之前,協(xié)調(diào)對共享對象的訪問時可以采用的機制只有 sychronizedvolatile,sychronized 使用的是Java對象內(nèi)置的監(jiān)視器鎖,由于內(nèi)置鎖會導(dǎo)致?lián)尣坏芥i的線程進行阻塞狀態(tài),線程切換消耗大,所以性能不能讓人滿意,所以Java 5.0引入了新的機制:ReentrantLock,其性能可以達到內(nèi)置鎖的數(shù)倍。到Java 6.0使用了改進的算法來管理內(nèi)置鎖,使得它能夠根據(jù)不同鎖競爭的激烈程度采取不同的策略處理,提高了可伸縮性,性能大幅提升到與 ReentrantLock 不相上下的水平。

為什么 sychronized 能實現(xiàn)線程同步?

??sychronized 使用的是JVM內(nèi)置的監(jiān)視器對象作為鎖,當(dāng)一個線程訪問同步代碼塊或同步方法時,需要先拿到鎖才能執(zhí)行同步代碼,退出或拋出異常時釋放鎖,sychronized 的用法主要有幾種:

  • (1)普通同步方法,鎖是當(dāng)前實例對象
  • (2)靜態(tài)同步方法,鎖是當(dāng)前類的class對象
  • (3)同步方法塊,鎖是括號里面的對象
// 普通同步方法
public sychronzied test() {
    // ...
}

// 靜態(tài)同步方法
public static sychronized test() {
    // ...
}

// 同步代碼塊
public void test() {
    sychronized (lock) {
        // ...
    }
}

??sychronized 鎖處理機制比較復(fù)雜,在完整描述該機制之前,先來了解兩個重要的概念:Java對象頭monitor

1、Java對象頭

??synchronized是悲觀鎖,在操作同步資源之前需要給同步資源先加鎖,這把鎖就是存在Java對象頭里的,那么什么是對象頭?以Hotspot虛擬機為例,Hotspot的對象頭主要包括兩部分數(shù)據(jù):Mark Word(標(biāo)記字段)Klass Pointer(類型指針)。如果對象是數(shù)組類型,那么還會多一個Array Length(數(shù)組長度)。

(1)Mark Word

??用于存儲對象自身的運行數(shù)據(jù),如哈希碼、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時間戳等,一般占用一個機器碼(機器碼占多少位取決于虛擬機位數(shù),32位的虛擬機中1個機器碼為4個字節(jié)即32位)。這些信息都是與對象自身定義無關(guān)的數(shù)據(jù),所以Mark Word被設(shè)計成一個非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存存儲盡量多的數(shù)據(jù)。它會根據(jù)對象的狀態(tài)復(fù)用自己的存儲空間,也就是說在運行期間Mark Word里存儲的數(shù)據(jù)會隨著鎖標(biāo)志位的變化而變化。

??對象頭信息是與對象自身定義的數(shù)據(jù)無關(guān)的額外存儲成本,但是考慮虛擬機的空間效率,Mark Word被設(shè)計成一個非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存存儲盡量多的數(shù)據(jù),它會根據(jù)對象的狀態(tài)復(fù)用自己的存儲空間,也就是說,Mark Word會隨著程序的運行發(fā)生變化,以占32位的機器碼為例,Mark Word的存儲結(jié)構(gòu)如下圖所示:


Mark Word數(shù)據(jù)結(jié)構(gòu)

(2)Klass Pointer

??對象指向它的類元數(shù)據(jù)的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。


??如果對象是普通對象類型,JVM可以通過Java對象的元數(shù)據(jù)信息確定Java對象的大小,對象頭只需要兩個機器碼,一個存儲 Mark Word,另一個存儲 Klass Pointer;如果對象是數(shù)據(jù),則對象頭需要三個機器碼的存儲空間,因為JVM無法從數(shù)組的元數(shù)據(jù)來確認數(shù)組的大小,需要一個額外的機器碼來記錄數(shù)據(jù)長度 Array Length,對象頭在JVM內(nèi)存中所處位置如圖所示。



2、Monitor

??Monitor可以理解為一個同步工具或一種同步機制,通常被描述為一個對象。與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都自帶一把看不見的鎖,成為內(nèi)部鎖或Monitor鎖。

??Monitor是線程私有的數(shù)據(jù)結(jié)構(gòu),每一個線程都有一個可用的 monitor record 列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個 monitor 關(guān)聯(lián)(對象頭中的 monitor address 指向monitor的起始位置),同時 monitor 中有一個 Owner 字段存放擁有該鎖的線程的唯一標(biāo)識,表示這個鎖被這個線程占用。其數(shù)據(jù)結(jié)構(gòu)如下:


Monitor數(shù)據(jù)結(jié)構(gòu)
  • Owner:初始時為NULL表示當(dāng)前沒有任何線程擁有該monitor record,當(dāng)線程成功擁有該鎖后保存線程唯一標(biāo)識,當(dāng)鎖又被釋放時又重置為NULL。
  • EntryQ:關(guān)聯(lián)一個系統(tǒng)互斥鎖(semaphore),阻塞所有試圖鎖住 monitor record 失敗的線程,當(dāng)一個搶鎖的線程判斷到Owner非空后就會進入阻塞隊列中排隊等待獲得鎖。
  • RcThis:表示 blocked 或 waiting 在該monitor record上的所有線程的個數(shù)。
  • Nest:sychronzied是可重入鎖,用來實現(xiàn)重入鎖的計數(shù)。
  • HashCode:保存從對象頭拷貝過來的HashCode(可能還包含GC age)。
  • Wait Set:當(dāng)持有鎖的線程在sychronized修飾的同步方法或同步代碼塊中調(diào)用對象繼承自 Objectwait() 方法時,Owner線程就會釋放鎖,并進入等待線程集合中,等待后面獲得鎖的線程通知。
  • Candidate:用來避免不必要的阻塞或等待線程喚醒,因為每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然后因為競爭鎖失敗又被阻塞)從而導(dǎo)致性能嚴(yán)重下降。Candidate只有兩種可能的值,0表示沒有需要喚醒的線程,1表示要喚醒一個繼任線程來競爭鎖。

3、內(nèi)置鎖的四種狀態(tài)

??在Java6.0之前,由于內(nèi)置的監(jiān)視器鎖處理復(fù)雜,阻塞或喚醒線程需要操作系統(tǒng)切換CPU狀態(tài)來完成,這種狀態(tài)轉(zhuǎn)換需要耗費處理時間性能底下,如果同步代碼塊中的內(nèi)容比較簡單,狀態(tài)轉(zhuǎn)換消耗的時間可能比用戶代碼執(zhí)行的時間還要長。因此在Java6.0中引入了大量優(yōu)化,包括上面提到的鎖粗化、鎖消除、自旋鎖、適應(yīng)性自旋鎖,還有下面講的偏向鎖、輕量級鎖等技術(shù)來減少鎖操作的開銷。

??鎖主要存在四種狀態(tài),依次是:無鎖狀態(tài)、偏向鎖狀態(tài)、輕量級鎖狀態(tài)、重量級鎖狀態(tài)。鎖狀態(tài)會隨著競爭的激烈而逐漸升級,并且只能升級不能降級,這種策略是為了提高獲得鎖和釋放鎖的效率。

(1)無鎖

無鎖沒有對資源進行鎖定,所有的線程都能訪問并修改同一個資源,但同時只有一個線程能修改成功。

??無鎖的特點就是修改操作在循環(huán)內(nèi)進行,線程會不斷地嘗試修改共享資源。如果沒有沖突就修改成功并退出,否則繼續(xù)循環(huán)嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。CAS原理和應(yīng)用就是經(jīng)典的無鎖的實現(xiàn),無鎖無法全面代替有鎖,但無鎖在修改操作比較簡單的情況下(比如原子地加1)性能很高。

(2)偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖,降低獲取鎖的代價。

??假如有一個線程每次在獲取鎖的時候都沒有其他線程跟它競爭,這種場景適合使用偏向鎖。當(dāng)一個線程訪問同步代碼并獲得偏向鎖時,會在 Mark Word 里存儲鎖偏向的線程ID,在線程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測 Mark Word 里是否存儲著指向當(dāng)前線程的偏向鎖。引入偏向鎖的目的:為了在無多線程競爭的情況下盡量減少不必要的輕量級鎖執(zhí)行路徑。因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。

??線程獲取偏向鎖的流程如下:

  • a、檢測 Mark Word 是否為可偏向狀態(tài),即是否為偏向鎖1,此時的鎖標(biāo)識為 01;
  • b、若為可偏向狀態(tài),則測試線程ID是否為當(dāng)前線程ID,如果是,則執(zhí)行步驟(e),否則執(zhí)行步驟(c);
  • c、如果線程ID不為當(dāng)前線程ID,則通過CAS操作競爭鎖,競爭成功,則將 Mark Word 中的線程ID替換為當(dāng)前線程ID,否則執(zhí)行步驟(d);
  • d、通過CAS競爭鎖失敗,證明當(dāng)前存在多線程競爭情況,當(dāng)達到全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞的安全點的線程繼續(xù)往下執(zhí)行同步代碼塊;
  • e、執(zhí)行同步代碼塊

??偏向鎖的釋放采用了一種只有競爭才會釋放的機制,持有偏向鎖的線程在同步代碼塊執(zhí)行完畢之后不會主動釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點上是沒有正在執(zhí)行的代碼),其步驟如下:

  • a、暫停擁有偏向鎖的線程,判斷鎖對象是否還處于被鎖定狀態(tài)(即當(dāng)前線程是否在執(zhí)行同步代碼塊);
  • b、撤銷偏向鎖后,如果處于未鎖定狀態(tài),則恢復(fù)到無鎖狀態(tài)(01),否則升級到輕量級鎖狀態(tài)(00)
偏向鎖的獲取和釋放

(3)輕量級鎖

??引入輕量級鎖的主要目的是在沒有多線程競爭的前提下,減少傳統(tǒng)的重量級鎖使用操作系統(tǒng)互斥量產(chǎn)生的性能消耗。當(dāng)偏向鎖發(fā)生競爭時,偏向鎖會升級為輕量級鎖,這是一種樂觀鎖,沒有獲得鎖的線程會通過自旋的方式嘗試獲取鎖,而不會直接進入阻塞狀態(tài),從而提高性能。獲取鎖的步驟如下:

  • a、判斷當(dāng)前對象是否處于無鎖狀態(tài)(hashcode、0、01),若是,則JVM首先將在當(dāng)前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的 Mark Word 的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word),否則執(zhí)行步驟(c);
  • b、JVM利用CAS操作嘗試將對象的 Mark Word 更新為指向Lock Record的指正,如果成功表示競爭到鎖,
    則將鎖標(biāo)志為變?yōu)?0(表示此對象處于輕量級鎖狀態(tài)),執(zhí)行同步操作,如果失敗則執(zhí)行步驟(c);
  • c、判斷當(dāng)前對象的Mark Word是否指向當(dāng)前線程的棧幀,如果是則表示當(dāng)前線程已經(jīng)持有當(dāng)前對象的鎖,則直接執(zhí)行同步代碼塊;否則只能說明該鎖對象已經(jīng)被其他線程搶占了,這時線程會不斷重復(fù)嘗試獲取鎖,達到一定的次數(shù)輕量級鎖會膨脹為重量級鎖,鎖標(biāo)志位變?yōu)?0,后面等待的線程將會進入阻塞狀態(tài)

??釋放鎖輕量級鎖的釋放也是通過CAS操作來進行的,步驟如下:

  • a、取出在獲取輕量級鎖保存在Displaced Mark Word中的數(shù)據(jù);
  • b、用CAS操作將取出的數(shù)據(jù)替換對象頭中的Mark Word,如果成功,則說明鎖釋放成功,否則執(zhí)行(c);
  • c、如果CAS操作替換失敗,說明有其他線程嘗試獲取鎖(此時鎖已經(jīng)膨脹為重量級鎖),那就要在釋放鎖的同時,喚醒被掛起的線程。

??對于輕量級鎖,其性能提升的依據(jù)是“對于絕大部分的鎖,在整個生命周期內(nèi)都是不會存在競爭的”,如果打破這個依據(jù)則除了互斥的開銷外,還有額外的CAS操作,因此在有多線程競爭的情況下,輕量級鎖比重量級鎖更慢。

輕量級鎖的獲取和釋放過程

(4)重量級鎖

??輕量級鎖升級為重量級鎖時,鎖狀態(tài)的標(biāo)志值會變?yōu)?“10”,重量級鎖的實現(xiàn)依賴于對象內(nèi)部監(jiān)視器(monitor),此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞隊列(線程狀態(tài)為Blocked),如果持有鎖的線程在同步代碼塊中調(diào)用了 Object.wait() 方法,就會釋放鎖進入等待線程集合中(線程狀態(tài)為Waiting),如下圖所示:

monitor

??線程想持有重量級鎖,需要先判斷Mark Word執(zhí)行的Monitor對象中的Owner是否為空,如果為空,則可以嘗試獲取鎖,將線程ID寫入Owner中;否則線程會阻塞,并進入阻塞隊列中排隊等待獲得鎖。由于競爭的線程是先判斷Owner再進入EntryQ,因此從這點看,重量級鎖是一個非公平鎖。monitor的本質(zhì)是依賴于底層操作系統(tǒng)的Mutex Lock實現(xiàn),操作系統(tǒng)實現(xiàn)線程之間的切換需要從用戶態(tài)到內(nèi)核態(tài)的切換,切換成本非常高。


??sychronized 整體的鎖狀態(tài)升級如下:

綜上,偏向鎖通過對比Mark Word解決加鎖問題,避免執(zhí)行CAS操作;而輕量級鎖通過用CAS加鎖和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能;重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。

【注意】sychronized 的鎖升級到輕量級鎖后不可逆,即只能升級不能降級。


五、公平鎖 vs 非公平鎖

公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖。

【優(yōu)點】等待鎖的線程不會餓死。
【缺點】整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。

非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到才會到等待隊列的隊尾等待。

??但如果此時鎖剛好可用,那么這個線程可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現(xiàn)后申請鎖的線程先獲取鎖的情況。
【優(yōu)點】減少喚醒線程的開銷,整體吞吐率高,因為線程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。
【缺點】處于等待隊列中的線程可能會餓死,或者等很久才會獲得鎖。


??舉個例子說明以下公平鎖和非公平鎖,如下圖所示,假設(shè)有一口水井,有管理員看守,管理員有一把鎖,只有拿到鎖的人才能夠打水,打完水要把鎖還給管理員。每個過來打水的人都要管理員允許并拿到鎖之后才能去打水,如果前面有人正在打水,那么這個想要打水的人就必須先排隊。管理員會查看下一個要去打水的人是不是隊伍里排最前面的人,如果是的話,才會給他鎖去打水;如果不是排第一的人,就必須去隊尾排隊,這就是公平鎖。

公平鎖

??但是對于非公平鎖,管理員對打水的人沒有要求。即使等待隊伍里有排隊等待的人,但如果在上一個人剛打完睡把鎖還給管理員而且管理員還沒有允許等待隊伍里下一個人去打水時,剛好來了一個插隊的人,這個插隊的人是可以直接從管理員那里拿到鎖去打水,不需要排隊,原本排隊等待的人只能繼續(xù)等待,如下圖所示:
非公平鎖

??ReentrantLock 實現(xiàn)了公平鎖和非公平鎖,其內(nèi)部定義了一個繼承了AQS(AbstractQueuedSychronizer)的類 Sync,添加鎖和釋放鎖的大部分操作實際上都是在Sycn中實現(xiàn)的。它有公平鎖 FailSync 和非公平鎖 NonfailSync 兩個子類,ReentrantLock默認使用非公平鎖,也可以通過構(gòu)造器來顯示的指定使用公平鎖。


??從下面公平鎖和非公平鎖實現(xiàn)的源碼中可以看出,公平鎖多了一步判斷是否存在排隊的前繼者,如果不存在并且使用CAS操作搶鎖成功,則將執(zhí)行Owener線程指向當(dāng)前線程,否則返回false表示嘗試搶鎖失敗。

公平鎖和非公平鎖源碼

??hasQueuedPredecessors() 方法的源碼如下:


??t是指向隊尾的指針,h是指向隊頭的指針,如果h = t,表示隊列為空,沒有需要排隊等待獲取鎖的線程,返回false;如果隊列非空,則判斷隊列中等待鎖的第一個節(jié)點對應(yīng)的線程是否為當(dāng)前線程,如果不是返回true,如果是返回false。

??綜上,公平鎖就是通過同步隊列來實現(xiàn)多個線程按照申請鎖的順序來獲取鎖,從而實現(xiàn)公平的特性。非公平鎖加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,所以存在后申請卻先獲得鎖的情況。


六、可重入鎖 vs 非可重入鎖

??可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內(nèi)層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因為之前已經(jīng)獲取過還沒釋放而阻塞。Java中ReentrantLocksynchronized都是可重入鎖,可重入鎖的一個優(yōu)點是可一定程度避免死鎖。

可重入鎖

??在上面的代碼中,test1、test2兩個方法都被sychronized所修飾,在test1的方法體中調(diào)用了test2,如果sychronized不是可重入鎖,會導(dǎo)致死鎖,從執(zhí)行結(jié)果可以看出,sychronized是可重入鎖。

??為什么可重入鎖可以在嵌套調(diào)用時自動獲得鎖?下面通過示例和源碼分析。

??還是打水的例子,有多個人在排隊打水,此時管理員允許鎖和同一個人的多個水桶綁定。這個人用多個水桶打水時,第一個水桶和鎖綁定并打完水后,第二個水桶也可以直接和鎖綁定并開始打水,所有的水桶都打完水之后打水人才會將鎖還給管理員。這個人的所有打水流程都能成功執(zhí)行,后續(xù)等待的人也能夠打到水,這就是可重入鎖。


??但如果是非可重入鎖,此時管理員只允許鎖和同一個人的一個水桶綁定。第一個水桶和鎖綁定打完水之后并不會釋放鎖,導(dǎo)致第二個水桶不能和鎖綁定也無法打水。當(dāng)前線程出現(xiàn)死鎖,整個等待隊列中的所有線程都無法被喚醒。
非可重入鎖

??之前我們說過ReentrantLock和synchronized都是重入鎖,那么我們通過重入鎖ReentrantLock以及非可重入鎖NonReentrantLock的源碼來對比分析一下為什么非可重入鎖在重復(fù)調(diào)用同步資源時會出現(xiàn)死鎖。

??首先ReentrantLock和NonReentrantLock都繼承父類AQS,其父類AQS中維護了一個同步狀態(tài)status來計數(shù)重入次數(shù),status初始值為0。

??先來看看ReentrantLock,當(dāng)線程嘗試獲取鎖時,先判斷status是否等于0,如果為0則嘗試CAS操作修改status值,如果成功表示獲得了鎖,否則失??;如果status不為0,則判斷已獲得該鎖的線程是否為當(dāng)前線程,如果是執(zhí)行status+1,表示當(dāng)前線程獲取鎖重入次數(shù)+1。

可重入鎖獲取鎖

??釋放鎖時,可重入鎖同樣先獲取當(dāng)前status的值,在當(dāng)前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當(dāng)前線程所有重復(fù)獲取鎖的操作都已經(jīng)執(zhí)行完畢,然后該線程才會真正釋放鎖。
可重入鎖釋放鎖

??NonReentrantLock (org.jboss.netty.util.internal)的獲取釋放鎖的代碼很簡潔,嘗試對status做CAS操作,將其值從0更新為1,如果更新成功表示獲得了鎖,將執(zhí)行線程設(shè)置為當(dāng)前線程,否則獲得鎖失敗;釋放鎖時,先判斷持有鎖的線程是否當(dāng)前線程,再將status的值更新為0,源碼如下:
非可重入鎖獲取和釋放鎖



七、獨享鎖 vs 共享鎖

獨享鎖也叫排他鎖,是指該鎖一次只能被一個線程所持有。

??如果線程T對數(shù)據(jù)A加上排它鎖后,則其他線程不能再對A加任何類型的鎖。獲得排它鎖的線程即能讀數(shù)據(jù)又能修改數(shù)據(jù)。JDK中的synchronized和JUC中Lock的實現(xiàn)類就是互斥鎖。

共享鎖是指該鎖可被多個線程所持有。

??如果線程T對數(shù)據(jù)A加上共享鎖后,則其他線程只能對A再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數(shù)據(jù),不能修改數(shù)據(jù)。

??獨享鎖與共享鎖也是通過AQS來實現(xiàn)的,通過實現(xiàn)不同的方法,來實現(xiàn)獨享或者共享。ReentrantReadWriteLock 中持有了兩把鎖:ReadLock和WriteLock,由詞知意,一個讀鎖一個寫鎖,合稱“讀寫鎖”。再進一步觀察可以發(fā)現(xiàn)ReadLock和WriteLock是靠內(nèi)部類Sync實現(xiàn)的鎖。Sync是AQS的一個子類,這種結(jié)構(gòu)在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

??在ReentrantReadWriteLock里面,讀鎖和寫鎖的鎖主體都是Sync,但讀鎖和寫鎖的加鎖方式不一樣。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證并發(fā)讀非常高效,而讀寫、寫讀、寫寫的過程互斥,因為讀鎖和寫鎖是分離的。所以ReentrantReadWriteLock的并發(fā)性相比一般的互斥鎖有了很大提升。

??ReentrantReadWriteLock類內(nèi)部定義的 ReadLockWriteLock 都實現(xiàn)了Lock接口,并內(nèi)置了Sync成員,根據(jù)ReentrantReadWriteLock構(gòu)造方法指定的公平或非公平策略,注入FairSync或NonfairSync對象,F(xiàn)airSync、NonfairSync繼承了內(nèi)部類Sync,Sync又繼承了 AbstractQueuedSychronizer,AQS內(nèi)部有個status成員,是個int類型有32位,state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(tài)(讀鎖個數(shù)),低16位表示寫鎖狀態(tài)(寫鎖個數(shù))。

??獲取寫鎖時,會調(diào)用 tryAcquire 方法,源碼如下:

  • c是當(dāng)前鎖的個數(shù),w是獲取到的寫鎖的個數(shù)(通過位運算對后16位做與運算獲取)

  • 如果c不為0,且w為0,證明當(dāng)前存在讀鎖;如果w不為0,但是持有寫鎖的線程不是當(dāng)前線程,無法獲得鎖,這兩種情況下獲取寫鎖都失敗

  • 如果當(dāng)前有寫鎖且持有鎖的線程是當(dāng)前線程,則可以增加鎖的可重入數(shù),但是由于存儲寫鎖次數(shù)的空間為16位(最大存儲2的16次方-1即65535),所以如果重入數(shù)大于該數(shù)字,則拋出一個Error;否則可以正常獲取鎖成功

  • 如果c為0,說明既沒有寫鎖也沒有讀鎖,需要先判斷線程是否需要阻塞,如果是非公平鎖,不需要阻塞,直接嘗試CAS操作增加寫線程次數(shù),成功則獲取鎖成功;如果是公平鎖,在并發(fā)條件下雖然當(dāng)前還沒有線程擁有寫鎖,但是所有爭搶鎖的線程搶鎖之前都要先進入隊列排隊,能否搶到鎖,取決于是否在隊列中排第一位,如果是才能進行CAS操作增加寫線程次數(shù)

??在寫鎖的實現(xiàn)中,跟讀鎖實現(xiàn)了互斥,如果存在讀鎖,則寫鎖不能被獲取,原因在于:必須確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那么正在運行的其他讀線程就無法感知到當(dāng)前寫線程的操作。

??因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當(dāng)前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的后續(xù)訪問均被阻塞。寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態(tài),當(dāng)寫狀態(tài)為0時表示寫鎖已被釋放,然后等待的讀寫線程才能夠繼續(xù)訪問讀寫鎖,同時前次寫線程的修改對后續(xù)的讀寫線程可見。

??獲取讀鎖時,會調(diào)用 tryAcquireShared 方法,源碼如下:

??可以看到在tryAcquireShared(int unused)方法中,如果其他線程已經(jīng)獲取了寫鎖,則當(dāng)前線程獲取讀鎖失敗,進入等待狀態(tài)。如果當(dāng)前線程獲取了寫鎖或者寫鎖未被獲取,則當(dāng)前線程(線程安全,依靠CAS保證)增加讀狀態(tài),成功獲取讀鎖,讀鎖每次都是增加“1<<16”。讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態(tài),減少的值是“1<<16”。所以讀寫鎖才能實現(xiàn)讀讀的過程共享,而讀寫、寫讀、寫寫的過程互斥。

??回頭看一下互斥鎖ReentrantLock中公平鎖和非公平鎖的加鎖源碼:


公平鎖和非公平鎖源碼

??不管是公平鎖還是非公平鎖,添加的都是獨享鎖。根據(jù)源碼所示,當(dāng)某一個線程調(diào)用lock方法獲取鎖時,如果同步資源沒有被其他線程鎖住,那么當(dāng)前線程在使用CAS更新state成功后就會成功搶占該資源。而如果公共資源被占用且不是被當(dāng)前線程占用,那么就會加鎖失敗。所以可以確定ReentrantLock無論讀操作還是寫操作,添加的鎖都是都是獨享鎖。



[參考文獻]

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容