談談JVM內部鎖升級過程

簡介: 對象在內存中的內存佈局是什麼樣的?如何描述synchronized和ReentrantLock的底層實現和重入的底層原理?爲何AQS底層是CAS+volatile?鎖的四種狀態和鎖升級過程應該如何描述?Object o = new Object() 在內存中佔用多少字節?自旋鎖是否是必定比重量級鎖效率高?打開偏向鎖是否效率必定會提高?重量級鎖到底重在哪裏?重量級鎖何時比輕量級鎖效率高,一樣反之呢?帶着這些問題往下讀。
image.pngjava

做者 | 洋鍋
來源 | 阿里技術公衆號編程

一 爲何講這個?

總結AQS以後,對這方面順帶的複習一下。本文從如下幾個高頻問題出發:安全

  • 對象在內存中的內存佈局是什麼樣的?
  • 描述synchronized和ReentrantLock的底層實現和重入的底層原理。
  • 談談AQS,爲何AQS底層是CAS+volatile?
  • 描述下鎖的四種狀態和鎖升級過程?
  • Object o = new Object() 在內存中佔用多少字節?
  • 自旋鎖是否是必定比重量級鎖效率高?
  • 打開偏向鎖是否效率必定會提高?
  • 重量級鎖到底重在哪裏?
  • 重量級鎖何時比輕量級鎖效率高,一樣反之呢?

二 加鎖發生了什麼?

無心識中用到鎖的狀況:服務器

//System.out.println都加了鎖
public void println(String x) {
  synchronized (this) {
    print(x);
    newLine();
  }
}

簡單加鎖發生了什麼?多線程

要弄清楚加鎖以後到底發生了什麼須要看一下對象建立以後再內存中的佈局是個什麼樣的?併發

一個對象在new出來以後在內存中主要分爲4個部分:jvm

  • markword這部分其實就是加鎖的核心,同時還包含的對象的一些生命信息,例如是否GC、通過了幾回Young GC還存活。
  • klass pointer記錄了指向對象的class文件指針。
  • instance data記錄了對象裏面的變量數據。
  • padding做爲對齊使用,對象在64位服務器版本中,規定對象內存必需要能被8字節整除,若是不能整除,那麼就靠對齊來補。舉個例子:new出了一個對象,內存只佔用18字節,可是規定要能被8整除,因此padding=6。

image.png

知道了這4個部分以後,咱們來驗證一下底層。藉助於第三方包 JOL = Java Object Layout java內存佈局去看看。很簡單的幾行代碼就能夠看到內存佈局的樣式:佈局

public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {
        o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

將結果打印出來:優化

image.png

從輸出結果看:this

1)對象頭包含了12個字節分爲3行,其中前2行其實就是markword,第三行就是klass指針。值得注意的是在加鎖先後輸出從001變成了000。Markword用處:8字節(64bit)的頭記錄一些信息,鎖就是修改了markword的內容8字節(64bit)的頭記錄一些信息,鎖就是修改了markword的內容字節(64bit)的頭記錄一些信息。從001無鎖狀態,變成了00輕量級鎖狀態。

image.png

2)New出一個object對象,佔用16個字節。對象頭佔用12字節,因爲Object中沒有額外的變量,因此instance = 0,考慮要對象內存大小要被8字節整除,那麼padding=4,最後new Object() 內存大小爲16字節。

拓展:什麼樣的對象會進入老年代?不少場景例如對象太大了能夠直接進入,可是這裏想探討的是爲何從Young GC的對象最多經歷15次Young GC還存活就會進入Old區(年齡是能夠調的,默認是15)。上圖中hotspots的markword的圖中,用了4個bit去表示分代年齡,那麼能表示的最大範圍就是0-15。因此這也就是爲何設置新生代的年齡不能超過15,工做中能夠經過-XX:MaxTenuringThreshold去調整,可是通常咱們不會動。

image.png

三 鎖的升級過程

1 鎖的升級驗證

探討鎖的升級以前,先作個實驗。兩份代碼,不一樣之處在於一箇中途讓它睡了5秒,一個沒睡。看看是否有區別。

public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {
        o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}
----------------------------------------------------------------------------------------------
public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {
      try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); }
        o = new Object();
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

這兩份代碼會不會有什麼區別?運行以後看看結果:

image.png
image.png

有點意思的是,讓主線程睡了5s以後輸出的內存佈局跟沒睡的輸出結果竟然不同。

Syn鎖升級以後,jdk1.8版本的一個底層默認設置4s以後偏向鎖開啓。也就是說在4s內是沒有開啓偏向鎖的,加了鎖就直接升級爲輕量級鎖了。

那麼這裏就有幾個問題了?

  • 爲何要進行鎖升級,之前不是默認syn就是重量級鎖麼?要麼不用要麼就用別的不行麼?
  • 既然4s內若是加了鎖就直接到輕量級,那麼能不能不要偏向鎖,爲何要有偏向鎖?
  • 爲何要設置4s以後開始偏向鎖?

問題1:爲何要進行鎖升級?鎖了就鎖了,不就要加鎖麼?

首先明確早起jdk1.2效率很是低。那時候syn就是重量級鎖,申請鎖必需要通過操做系統老大kernel進行系統調用,入隊進行排序操做,操做完以後再返回給用戶態。

內核態:用戶態若是要作一些比較危險的操做直接訪問硬件,很容易把硬件搞死(格式化,訪問網卡,訪問內存幹掉、)操做系統爲了系統安全分紅兩層,用戶態和內核態 。申請鎖資源的時候用戶態要向操做系統老大內核態申請。Jdk1.2的時候用戶須要跟內核態申請鎖,而後內核態還會給用戶態。這個過程是很是消耗時間的,致使早期效率特別低。有些jvm就能夠處理的爲何還交給操做系統作去呢?能不能把jvm就能夠完成的鎖操做拉取出來提高效率,因此也就有了鎖優化。

問題2:爲何要有偏向鎖?

其實這本質上歸根於一個機率問題,統計表示,在咱們平常用的syn鎖過程當中70%-80%的狀況下,通常都只有一個線程去拿鎖,例如咱們常使用的System.out.println、StringBuffer,雖然底層加了syn鎖,可是基本沒有多線程競爭的狀況。那麼這種狀況下,沒有必要升級到輕量級鎖級別了。偏向的意義在於:第一個線程拿到鎖,將本身的線程信息標記在鎖上,下次進來就不須要在拿去拿鎖驗證了。若是超過1個線程去搶鎖,那麼偏向鎖就會撤銷,升級爲輕量級鎖,其實我認爲嚴格意義上來說偏向鎖並不算一把真正的鎖,由於只有一個線程去訪問共享資源的時候纔會有偏向鎖這個狀況。

無心使用到鎖的場景:

/***StringBuffer內部同步***/
public synchronized int length() {
  return count;
} 

//System.out.println 無心識的使用鎖
public void println(String x) {
   synchronized (this) {
     print(x);
     newLine();
   }
 }

問題3:爲何jdk8要在4s後開啓偏向鎖?

其實這是一個妥協,明確知道在剛開始執行代碼時,必定有好多線程來搶鎖,若是開了偏向鎖效率反而下降,因此上面程序在睡了5s以後偏向鎖纔開放。爲何加偏向鎖效率會下降,由於中途多了幾個額外的過程,上了偏向鎖以後多個線程爭搶共享資源的時候要進行鎖升級到輕量級鎖,這個過程還的把偏向鎖進行撤銷在進行升級,因此致使效率會下降。爲何是4s?這是一個統計的時間值。

固然咱們是能夠禁止偏向鎖的,經過配置參數-XX:-UseBiasedLocking = false來禁用偏向鎖。jdk15以後默認已經禁用了偏向鎖。本文是在jdk8的環境下作的鎖升級驗證。

2 鎖的升級流程

上面已經驗證了對象從建立出來以後進內存從無鎖狀態->偏向鎖(若是開啓了)->輕量級鎖的過程。對於鎖升級的流程繼續往下,輕量級鎖以後就會變成重量級鎖。首先咱們先理解什麼叫作輕量級鎖,從一個線程搶佔資源(偏向鎖)到多線程搶佔資源升級爲輕量級鎖,線程若是沒那麼多的話,其實這裏就能夠理解爲CAS,也就是咱們說的Compare and Swap,比較並交換值。在併發編程中最簡單的一個例子就是併發包下面的原子操做類AtomicInteger。在進行相似++操做的時候,底層其實就是CAS鎖。

public final int getAndIncrement() {
  return unsafe.getAndAddInt(this, valueOffset, 1);
}

public final int getAndAddInt(Object var1, long var2, int var4) {
   int var5;
   do {
       var5 = this.getIntVolatile(var1, var2);
   } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

   return var5;
}

問題4:什麼狀況下輕量級鎖要升級爲重量級鎖呢?

首先咱們能夠思考的是多個線程的時候先開啓輕量級鎖,若是它carry不了的狀況下才會升級爲重量級。那麼什麼狀況下輕量級鎖會carry不住。一、若是線程數太多,好比上來就是10000個,那麼這裏CAS要轉多久纔可能交換值,同時CPU光在這10000個活着的線程中來回切換中就耗費了巨大的資源,這種狀況下天然就升級爲重量級鎖,直接叫給操做系統入隊管理,那麼就算10000個線程那也是處理休眠的狀況等待排隊喚醒。二、CAS若是自旋10次依然沒有獲取到鎖,那麼也會升級爲重量級。

總的來講2種狀況會從輕量級升級爲重量級,10次自旋或等待cpu調度的線程數超過cpu核數的一半,自動升級爲重量級鎖。看服務器CPU的核數怎麼看,輸入top指令,而後按1就能夠看到。

問題5:都說syn爲重量級鎖,那麼到底重在哪裏?

JVM偷懶把任何跟線程有關的操做所有交給操做系統去作,例如調度鎖的同步直接交給操做系統去執行,而在操做系統中要執行先要入隊,另外操做系統啓動一個線程時須要消耗不少資源,消耗資源比較重,重就重在這裏。

整個鎖升級過程如圖所示:

image.png

四 synchronized的底層實現

上面咱們對對象的內存佈局有了一些瞭解以後,知道鎖的狀態主要存放在markword裏面。這裏咱們看看底層實現。

public class RnEnterLockDemo {
     public void method() {
         synchronized (this) {
             System.out.println("start");
         }
     }
}

對這段簡單代碼進行反解析看看什麼狀況。javap -c RnEnterLockDemo.class

image.png

首先咱們能肯定的是syn確定是還有加鎖的操做,看到的信息中出現了monitorenter和monitorexit,主觀上就能夠猜到這是跟加鎖和解鎖相關的指令。有意思的是1個monitorenter和2個monitorexit。爲何呢?正常來講應該就是一個加鎖和一個釋放鎖啊。其實這裏也體現了syn和lock的區別。syn是JVM層面的鎖,若是異常了不用本身釋放,jvm會自動幫助釋放,這一步就取決於多出來的那個monitorexit。而lock異常須要咱們手動補獲並釋放的。

關於這兩條指令的做用,咱們直接參考JVM規範中描述:

monitorenter :
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows: • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor. • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count. • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership

翻譯一下:

每一個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的全部權,過程以下:

  • 若是monitor的進入數爲0,則該線程進入monitor,而後將進入數設置爲1,該線程即爲monitor的全部者。
  • 若是線程已經佔有該monitor,只是從新進入,則進入monitor的進入數加1。
  • 若是其餘線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再從新嘗試獲取monitor的全部權。

monitorexit: 
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref. The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

翻譯一下:

執行monitorexit的線程必須是objectref所對應的monitor的全部者。指令執行時,monitor的進入數減1,若是減1後進入數爲0,那線程退出monitor,再也不是這個monitor的全部者。其餘被這個monitor阻塞的線程能夠嘗試去獲取這個 monitor的全部權。

經過這段話的描述,很清楚的看出Synchronized的實現原理,Synchronized底層經過一個monitor的對象來完成,wait/notify等方法其實也依賴於monitor對象,這就是爲何只有在同步的塊或者方法中才能調用wait/notify等方法,不然會拋出java.lang.IllegalMonitorStateException的異常。

每一個鎖對象擁有一個鎖計數器和一個指向持有該鎖的線程的指針。

當執行monitorenter時,若是目標對象的計數器爲零,那麼說明它沒有被其餘線程所持有,Java虛擬機會將該鎖對象的持有線程設置爲當前線程,而且將其計數器加i。在目標鎖對象的計數器不爲零的狀況下,若是鎖對象的持有線程是當前線程,那麼Java虛擬機能夠將其計數器加1,不然須要等待,直至持有線程釋放該鎖。當執行monitorexit時,Java虛擬機則需將鎖對象的計數器減1。計數器爲零表明鎖已被釋放。

總結

以往的經驗中,只要用到synchronized就覺得它已經成爲了重量級鎖。在jdk1.2以前確實如此,後來發現過重了,消耗了太多操做系統資源,因此對synchronized進行了優化。之後能夠直接用,至於鎖的力度如何,JVM底層已經作好了咱們直接用就行。

最後再看看開頭的幾個問題,是否是都理解了呢。帶着問題去研究,每每會更加清晰。但願對你們有所幫助。

原文連接本文爲阿里雲原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索