天天看點

深入分析synchronized實作原理

在Java中synchronized關鍵字最主要有以下3種應用方式

修飾執行個體方法,作用于目前執行個體加鎖,進入同步代碼前要獲得目前執行個體的鎖

修飾靜态方法,作用于目前類對象加鎖,進入同步代碼前要獲得目前類對象的鎖

修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。

synchronized和ReentrantLock差別

1.  可重入性
           

字面的意思就是可以再次進入的鎖,synchronized其實也是可重鎖,同一線程每進入一次,鎖的計數器都會加一,在釋放鎖是計數器都會減一,隻有當計數器為0 時才能釋放鎖。

2 .  鎖的實作
           

ReentrantLock是JDK實作的 Synchronized 是JVM實作

前者可以直接看到源碼,後者實作難以看到。

3. 性能的差別
           

在Synchronized優化以前,synchronized的性能是比ReenTrantLock差很多的,但是自從Synchronized引入了偏向鎖,輕量級鎖(自旋鎖)後,兩者的性能就差不多了,在兩種方法都可用的情況下,官方甚至建議使用synchronized,其實synchronized的優化我感覺就借鑒了ReenTrantLock中的CAS技術。都是試圖在使用者态就把加鎖問題解決,避免進入核心态的線程阻塞。

4. 功能的差別
           

便利性:很明顯Synchronized的使用比較友善簡潔,并且由編譯器去保證鎖的加鎖和釋放,而ReenTrantLock需要手工聲明來加鎖和釋放鎖,為了避免忘記手工釋放鎖造成死鎖,是以最好在finally中聲明釋放鎖。

鎖的細粒度和靈活度:很明顯ReenTrantLock優于Synchronized

當你需要時候一下三種功能是需要使用ReentrantLock

  1. ReentranLock 可以指定公平鎖還是非公平鎖

    (公共鎖就是先等待的線程先獲得鎖)

    實作自旋鎖,通過循環調用CAS操作來實作加鎖,性能比較好,避免進入核心态的線程阻塞。

  2. 提供了Condition類,可以分組喚醒需要喚醒的線程
  3. 提供能夠中斷等待鎖的線程的機制,lock.lockInterruptibly()

具體使用場景要根據實際的業務進行分析

使用Synchronized時不需要釋放鎖,jvm會幫助我們做釋放鎖的操作

ReentrantLock的構造函數,預設為false 非公平鎖,可以傳入true,設定為公平鎖,但是性能會變差。

public ReentrantLock() {
        sync = new NonfairSync();
    }
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
           
@Slf4j
@ThreadSafe
public class LockExample2 {

    // 請求總數
    public static int clientTotal = 5000;

    // 同時并發執行的線程數
    public static int threadTotal = 200;

    public static int count = 0;

    private final static Lock lock = new ReentrantLock(false);

    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newCachedThreadPool();
        final Semaphore semaphore = new Semaphore(threadTotal);
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal ; i++) {
            executorService.execute(() -> {
                try {
                    semaphore.acquire();
                    add();
                    semaphore.release();
                } catch (Exception e) {
                    log.error("exception", e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        log.info("count:{}", count);
    }

    private static void add() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

           

synchronized實作

synchronized底層語義原理

Java 虛拟機中的同步(Synchronization)基于進入和退出管程(Monitor)對象實作, 無論是顯式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代碼塊)還是隐式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令來實作同步的,而是由方法調用指令讀取運作時常量池中方法的 ACC_SYNCHRONIZED 标志來隐式實作的,關于這點,稍後詳細分析。下面先來了解一個概念Java對象頭,這對深入了解synchronized實作原理非常關鍵。

了解Java對象頭與Monitor

在JVM中,對象在記憶體中的布局分為三塊區域:對象頭、執行個體資料和對齊填充。

執行個體變量:存放類的屬性資料資訊,包括父類的屬性資訊,如果是數組的執行個體部分還包括數組的長度,這部分記憶體按4位元組對齊。

填充資料:由于虛拟機要求對象起始位址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊,這點了解即可。

而對于頂部,則是Java頭對象,它實作synchronized的鎖對象的基礎,這點我們重點分析它,一般而言,synchronized使用的鎖對象是存儲在Java對象頭裡的,jvm中采用2個字來存儲對象頭(如果對象是數組則會配置設定3個字,多出來的1個字記錄的是數組長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:

虛拟機位數 頭對象結構 說明
32/64bit Mark Word 存儲對象的hashCode、鎖資訊或分代年齡或GC标志等資訊
32/64bit Class Metadata Address 類型指針指向對象的類中繼資料,JVM通過這個指針确定該對象是哪個類的執行個體。

其中Mark Word在預設情況下存儲着對象的HashCode、分代年齡、鎖标記位等以下是32位JVM的Mark Word預設存儲結構

鎖狀态 25bit 4bit 1bit是否是偏向鎖 2bit 鎖标志位
無鎖 對象的hashCode 分代年齡 01

由于對象頭的資訊是與對象自身定義的資料沒有關系的額外存儲成本,是以考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的資料結構,以便存儲更多有效的資料,它會根據對象本身的狀态複用自己的存儲空間,如32位JVM下,除了上述列出的Mark Word預設存儲結構外,還有如下可能變化的結構:

深入分析synchronized實作原理

其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化後新增加的,稍後我們會簡要分析。這裡我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖辨別位為10,其中指針指向的是monitor對象(也稱為管程或螢幕鎖)的起始位址。每個對象都存在着一個 monitor 與之關聯,對象與其 monitor 之間的關系有存在多種實作方式,如monitor可以與對象一起建立銷毀或當線程試圖擷取對象鎖時自動生成,但當一個 monitor 被某個線程持有後,它便處于鎖定狀态。在Java虛拟機(HotSpot)中,monitor是由ObjectMonitor實作的,其主要資料結構如下(位于HotSpot虛拟機源碼ObjectMonitor.hpp檔案,C++實作的)

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //處于wait狀态的線程,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處于等待鎖block狀态的線程,會被加入到該清單
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
           

ObjectMonitor中有兩個隊列,_WaitSet 和 _EntryList,用來儲存ObjectWaiter對象清單( 每個等待鎖的線程都會被封裝成ObjectWaiter對象),_owner指向持有ObjectMonitor對象的線程,當多個線程同時通路一段同步代碼時,首先會進入 _EntryList 集合,當線程擷取到對象的monitor 後進入 _Owner 區域并把monitor中的owner變量設定為目前線程同時monitor中的計數器count加1,若線程調用 wait() 方法,将釋放目前持有的monitor,owner變量恢複為null,count自減1,同時該線程進入 WaitSe t集合中等待被喚醒。若目前線程執行完畢也将釋放monitor(鎖)并複位變量的值,以便其他線程進入擷取monitor(鎖)。如下圖所示

由此看來,monitor對象存在于每個Java對象的對象頭中(存儲的指針的指向),synchronized鎖便是通過這種方式擷取鎖的,也是為什麼Java中任意對象可以作為鎖的原因,同時也是notify/notifyAll/wait等方法存在于頂級對象Object中的原因(關于這點稍後還會進行分析),ok~,有了上述知識基礎後,下面我們将進一步分析synchronized在位元組碼層面的具體語義實作。

public static void main(String[] args)
{
    synchronized (TestMain.class)
    {
         
    }
}



           
public static void main(java.lang.String[]);
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=1, args_size=1
       0: ldc           #1                  // class com/xrq/test53/TestMain
       2: dup
       3: monitorenter
       4: monitorexit
       5: return
    LineNumberTable:
      line 7: 0
      line 11: 5
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
             0       6     0  args   [Ljava/lang/String;

           

synchronizatied關鍵字在修飾方法和類時,位元組碼上略有不同,但是實作方式是基本相同的。

在位元組碼中我們可以看到monitor關鍵字

在執行monitorenter指令時,首先要嘗試擷取對象的鎖,如果這個對象沒有被鎖定,或者目前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應地,在執行monitorexit指令時會将鎖計數器減1,當計數器為0時,鎖就會被釋放。如果擷取對象鎖失敗,那目前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。

關于monitorenter和monitorexit,有兩點是要特别注意的:

1、synchronized同步塊對同一條線程來說是可重入的,不會出現把自己鎖死的問題

2、同步塊在已進入的線程執行完之前,會阻塞後面其它線程的進入

鎖的狀态總共有四種,無鎖狀态、偏向鎖、輕量級鎖和重量級鎖。随着鎖的競争,鎖可以從偏向鎖更新到輕量級鎖,再更新的重量級鎖,但是鎖的更新是單向的,也就是說隻能從低到高更新,不會出現鎖的降級,關于重量級鎖,前面我們已詳細分析過,下面我們将介紹偏向鎖和輕量級鎖以及JVM的其他優化手段,這裡并不打算深入到每個鎖的實作和轉換過程更多地是闡述Java虛拟機所提供的每個鎖的核心優化思想,畢竟涉及到具體過程比較繁瑣,如需了解詳細過程可以查閱《深入了解Java虛拟機原理》。

偏向鎖

偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多線程競争,而且總是由同一線程多次獲得,是以為了減少同一線程擷取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即擷取鎖的過程,這樣就省去了大量有關鎖申請的操作,進而也就提供程式的性能。是以,對于沒有鎖競争的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對于鎖競争比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的線程都是不相同的,是以這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,并不會立即膨脹為重量級鎖,而是先更新為輕量級鎖。下面我們接着了解輕量級鎖。

因為java線程都是映射到作業系統原生的線程之上,如果要阻塞和喚醒一個線程,都需要作業系統從使用者态切換到核心态,需要耗費很多處理器的性能和時間,如果是代碼簡單的同步塊,狀态轉化的消耗可能比代碼執行的時間還要長,是以synchronized是一個重量級鎖

自旋鎖與自适應自旋

互斥同步,對性能影響最大的是阻塞的實作,挂起線程和恢複線程的操作都需要轉入核心狀态完成,這些操作給系統的并發性能帶來了很大的壓力。同時,虛拟機開發團隊也注意到很多應用上,共享資料的鎖定狀态隻會持續很短的一段時間,為了這段時間去挂起和恢複線程并不值得。如果實體機上有一個以上的處理器,能讓兩個或兩個以上的線程同時并行執行,我們就可以讓後面請求鎖的那個線程”稍等一下”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們隻需要讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。

在JDK1.4.2就已經引入了自旋鎖,隻不過預設是關閉的。自旋不能代替阻塞,且先不說處理器數量的要求,自旋等待本身雖然避免了線程切換的開銷,但是它是要占據處理器時間的,是以如果鎖被占用的時間很短,自旋等待的效果就非常好;反之,如果鎖被占用的時間很長,那麼自旋的線程隻會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來性能上的浪費。是以自選等待必須有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去挂起線程了,自旋次數的預設值是10。

在JDK1.6之後引入了自适應的自旋鎖。自适應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀态來決定。如果在同一個鎖對象上,自旋等待剛剛獲得過鎖,并且持有鎖的線程正在運作中,那麼虛拟機就會認為這次自旋也很有可能再次成功,進而它将允許自旋等待持續相對更長的時間,比如100個循環。另外如果對于某一個鎖,自旋很少成功獲得過,那麼在以後要獲得這個鎖時将可能忽略掉自旋過程,以避免浪費處理器資源。有了自适應自旋,随着程式運作和性能監控資訊的不斷完善,虛拟機對程式鎖的狀況預測就會越來越準确。

鎖消除

消除鎖是虛拟機另外一種鎖的優化,這種優化更徹底,Java虛拟機在JIT編譯時(可以簡單了解為當某段代碼即将第一次被執行時進行編譯,又稱即時編譯),通過對運作上下文的掃描,去除不可能存在共享資源競争的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬于一個局部變量,并且不會被其他線程所使用,是以StringBuffer不可能存在共享資源競争的情景,JVM會自動将其鎖消除。