線程安全與鎖優化

一、線程安全的實現方法

(一)互斥同步

  • 互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)、信號量(Semaphore)都是主要的互斥實現方式。

    互斥量和信號量在系統中的任何進程都是可見的,臨界區的作用范圍僅限于本進程。

  • java中,最基本的互斥同步手段就是synchronized關鍵字,該關鍵字經過編譯之后,會在同步塊的前后分別形成monitorentermonitorexit這兩個字節碼指令,這兩個字節碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。
  • 根據虛擬機規范的要求,在執行monitorenter指令時,首先要嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,則把鎖的計數器加1,相應的在執行monitorexit指令時將鎖計數器減1,當計數器為0時,鎖就被釋放。如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到對象鎖被另一個線程釋放位置。
    package tystudy.javabasic.jvm;
    
    public class MonitorTest {
        public static void main(String[] args) {
            final Object lock = new Object();
            synchronized(lock) {
                System.out.println("hello");
            }
        }
    }
    
    E:\myworkspace\my-study\common-project\java-basic\target\classes>javap -c tystudy.javabasic.jvm.MonitorTest
    Compiled from "MonitorTest.java"
    public class tystudy.javabasic.jvm.MonitorTest {
      public tystudy.javabasic.jvm.MonitorTest();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public static void main(java.lang.String[]);
        Code:
           0: new           #2                  // class java/lang/Object
           3: dup
           4: invokespecial #1                  // Method java/lang/Object."<init>":()V
           7: astore_1
           8: aload_1
           9: dup
          10: astore_2
          11: monitorenter
          12: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
          15: ldc           #4                  // String hello
          17: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          20: aload_2
          21: monitorexit
          22: goto          30
          25: astore_3
          26: aload_2
          27: monitorexit
          28: aload_3
          29: athrow
          30: return
        Exception table:
           from    to  target type
              12    22    25   any
              25    28    25   any
    }
    
  • java的線程是映射到操作系統的原生線程之上的,如果要阻塞或喚醒一個線程,都需要操作系統來幫忙完成,這就需要從用戶態轉換到核心態(內核態)中,因此狀態轉換需要耗費很多的處理器時間。
    • 內核態:控制計算機的硬件資源,并提供上層應用程序的運行環境。
    • 用戶態:上層應用程序的活動空間,應用程序的執行必須依托于內核態提供的資源。
    • 系統調用:為了使上層應用能夠訪問到這些資源,內核為上層應用提供訪問的接口。

    用戶態與內核態

  • 對于代碼簡單的同步塊,狀態轉換消耗的時間可能比用戶代碼執行的時間還長。所以synchronized是java語言中一個重量級的操作。虛擬機本身也會進行一些優化,比如在通知操作系統阻塞線程之前加入一段自旋等待過程,避免頻繁地切入到核心態之中。
  • 除了synchronized還可以使用ReentrantLock實現同步,它是表現為api層面的互斥鎖。相比synchronized增加了幾個高級功能:等待可中斷、公平鎖、綁定條件。jdk1.6之前ReentrantLock性能更優,jdk1.6后對synchronized進行了優化,性能與ReentrantLock差不多。

(二)非阻塞同步

  • 隨著硬件指令集的發展,我們有了另外一個選擇:基于沖突檢測的樂觀并發策略。通俗的說,就是先進行操作,如果沒有其他線程爭用共享數據,那操作就成功了,如果共享數據有爭用,產生了沖突,那就再采取其他的補償措施(最常見的補償措施就是不斷重試,直到成功為止),這種樂觀的并發策略的許多實現都不需要把線程掛起,因此這種同步操作被稱為非阻塞同步(non-blocking synchronization)。

  • 硬件保證一個從語義看起來需要多次操作的行為通過一條處理器指令就能完成,這類指令常用的有:

    • 測試并設置(test-and-set)
    • 獲取并增加(fetch-and-increment)
    • 交換(swap)
    • 比較并交換(compare-and-swap,cas)
    • 加載鏈接/條件存儲(load-linked/store-conditional,ll/sc)
  • cas指令需要有3個操作數,分別是內存位置(V)、預期值(A)和新值(B)。cas指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,上述處理過程是一個原子操作。

  • jdk1.5之后,java程序中才可以使用cas操作,該操作由sun.misc.Unsafe類里面的compareAndSwapInt()compareAndSwapLong()等幾個方法包裝提供,虛擬機在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器cas指令,沒有方法調用的過程,或者可以認為是無條件內聯進去了。

  • cas存在ABA問題,juc為了解決這個問題提供了一個帶有標記的原子引用類AtomicStampedReference,它可以通過控制變量值的版本來保證cas的正確性,如果要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

二、鎖優化

(一)自旋鎖與自適應自旋

  • 掛起和恢復線程的操作都需要轉入內核態完成,這些操作給系統的并發性帶來很大的壓力。所以可以讓后面請求鎖的那個線程忙循環(自旋)等待,每次自旋一次就查看持有鎖的線程是否已經釋放鎖。而不需要進入內核態掛起線程。
  • 自旋鎖在jdk1.4.2中就已經引入,不過默認是關閉的,可以通過-XX:+UseSpinning參數來開啟,==jdk1.6中默認開啟==。
  • 自旋等待本身雖然避免了線程切換的開銷,但它是要占用處理器時間的,因此,如果鎖被占用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被占用的時間很長,那么自旋的線程只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來性能上的浪費。所以自旋超過指定次數仍然沒有獲取鎖應該使用傳統方式掛起線程。==自旋次數的默認值是10次,用戶可以使用參數-XX:PreBlockSpin來更改。==
  • jdk1.6中引入了自適應的自旋鎖,即對于經常很快就可以獲取鎖的情況會多自旋一會,對于很少能夠通過自旋獲取鎖的就盡早或直接進入內核態掛起線程。

(二)鎖消除

  • 虛擬機即時編譯器在運行時會對一些代碼上要求同步,但是會對被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除主要判斷依據來源于逃逸分析的數據支持。如果判斷在一段代碼中,堆上的所有數據都不會逃逸出去被其他線程訪問到,那么就可以把它當做棧上數據來對待,認為它們是線程私有的,同步加鎖自然就無須進行。

  • 我們也知道,對于String是一個不可變類,對字符串的連接操作總是通過生成新的String對象來進行的,因此Javac編譯器會對String連接做自動優化。在jdk1.5之前,會轉化為StringBuffer對象的連續append操作,在jdk1.5之后會轉化為StringBuilder對象的連續append。對于StringBuffer的連續append,這個方法是同步的,鎖就是this即StringBuffer對象。虛擬機會觀察這個鎖,發現它的攻臺作用域被限制在concatString方法內部。也就是說,鎖對象的所有引用永遠不會“逃逸”到concatString方法之外,其他線程無法訪問到它,因此,雖然這里有鎖,但是可以被安全地消除掉,在即時編譯后,這段代碼就會忽略掉所有的同步而直接執行了。

    public String concatString(String s1, String s2, String s3) {
        return s1 + s2 + s3;
    }
    

(三)鎖粗化

  • 原則上,我們編寫代碼的時候,總是推薦將同步塊的作用范圍限制得盡量小。但是對于一系列的連續操作都是對同一對象反復加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能消耗。
  • StringBuffer的連續append方法就屬于這類情況。如果虛擬機探測到有這樣一串零碎的操作都對同一對象加鎖,將會把加鎖同步的范圍擴展(粗化)到整個操作序列的外部。

(四)輕量級鎖

  • 輕量級鎖是相對于使用操作系統互斥量來實現的傳統鎖而言的。
  • 對象頭分為三個部分:
    • mark word:hashcode、gc分代年齡等信息、指向鎖記錄的指針、指向重量級鎖的指針、偏向線程id、偏向時間戳等
    • 指向方法區對象類型數據的指針
    • 如果是數組,這里會存儲數組長度
  • 輕量級鎖能提升程序同步性能的依據是“對于絕大部分的鎖,在整個同步周期內都是不存在競爭的”,這是一個經驗數據。如果沒有競爭,輕量級鎖使用cas操作避免了使用互斥量的開銷,如果存在鎖競爭,除了互斥量的開銷外,還額外發生了cas操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。
1、輕量級鎖的加鎖過程:

在代碼進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標識為01狀態),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝(官方把這個拷貝叫Displaced Mark Word)。然后虛擬機將使用cas操作嘗試將對象的mark word更新為指向Lock Record的指針。

如果這個更新動作成功了,那么這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖標志位將轉變為00,即表示此對象處于輕量級鎖定狀態。

如果這個更新動作失敗了,虛擬機首先會檢查對象Mark Word是否指向當前線程的棧幀,如果只說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶占了。

如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標志位的狀態變為10Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也要進入阻塞狀態。

2、輕量級鎖的解鎖過程

解鎖過程也是通過cas操作來進行的,如果對象的Mark Word仍然指向線程的鎖記錄,那就用cas操作把對象當前的Mark Word和線程中復制的Displaced Mark Word替換回來,如果替換成功了,整個同步過程就完成了。如果替換失敗,說明有其他線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。

(五)偏向鎖

  • 偏向鎖也是jdk1.6中引入的一項鎖優化,它的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。如果說輕量級鎖是在無競爭的情況下使用cas操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連cas操作都不做了。
1、偏向鎖原理

假設當前虛擬機啟用了偏向鎖(==-XX:+UseBiasedLocking,這是jdk1.6的默認值==),那么當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標志位設為01,即偏向模式。同時使用cas操作把獲取到這個鎖的線程的id記錄在對象Mark Word之中,如果cas操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作(例如LockingUnlocking及對Mark WordUpdate等)。

當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處于被鎖定的狀態,撤銷偏向后恢復到未鎖定(標志位為01)或輕量級鎖(標志位為00)的狀態。后續的同步操作就如上面介紹的輕量級鎖那樣執行。
如果程序中大多數的鎖總是被多個不同的線程訪問,那偏向模式就是多余的,使用-XX:-UseBiasedLocking來禁止偏向鎖優化反而可以提升性能。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容