1. 程式人生 > 其它 >談談JVM內部鎖升級過程

談談JVM內部鎖升級過程

簡介:物件在記憶體中的記憶體佈局是什麼樣的?如何描述synchronized和ReentrantLock的底層實現和重入的底層原理?為什麼AQS底層是CAS+volatile?鎖的四種狀態和鎖升級過程應該如何描述?Object o = new Object() 在記憶體中佔用多少位元組?自旋鎖是不是一定比重量級鎖效率高?開啟偏向鎖是否效率一定會提升?重量級鎖到底重在哪裡?重量級鎖什麼時候比輕量級鎖效率高,同樣反之呢?帶著這些問題往下讀。

作者 | 洋鍋
來源 | 阿里技術公眾號

一 為什麼講這個?

總結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個部分:

  • markword這部分其實就是加鎖的核心,同時還包含的物件的一些生命資訊,例如是否GC、經過了幾次Young GC還存活。
  • klass pointer記錄了指向物件的class檔案指標。
  • instance data記錄了物件裡面的變數資料。
  • padding作為對齊使用,物件在64位伺服器版本中,規定物件記憶體必須要能被8位元組整除,如果不能整除,那麼就靠對齊來補。舉個例子:new出了一個物件,記憶體只佔用18位元組,但是規定要能被8整除,所以padding=6。

知道了這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());
        }
    }
}

將結果打印出來:

從輸出結果看:

1)物件頭包含了12個位元組分為3行,其中前2行其實就是markword,第三行就是klass指標。值得注意的是在加鎖前後輸出從001變成了000。Markword用處:8位元組(64bit)的頭記錄一些資訊,鎖就是修改了markword的內容8位元組(64bit)的頭記錄一些資訊,鎖就是修改了markword的內容位元組(64bit)的頭記錄一些資訊。從001無鎖狀態,變成了00輕量級鎖狀態。

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去調整,但是一般我們不會動。

三 鎖的升級過程

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());
        }
    }
}

這兩份程式碼會不會有什麼區別?執行之後看看結果:


有點意思的是,讓主執行緒睡了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不住。1、如果執行緒數太多,比如上來就是10000個,那麼這裡CAS要轉多久才可能交換值,同時CPU光在這10000個活著的執行緒中來回切換中就耗費了巨大的資源,這種情況下自然就升級為重量級鎖,直接叫給作業系統入隊管理,那麼就算10000個執行緒那也是處理休眠的情況等待排隊喚醒。2、CAS如果自旋10次依然沒有獲取到鎖,那麼也會升級為重量級。

總的來說2種情況會從輕量級升級為重量級,10次自旋或等待cpu排程的執行緒數超過cpu核數的一半,自動升級為重量級鎖。看伺服器CPU的核數怎麼看,輸入top指令,然後按1就可以看到。

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

JVM偷懶把任何跟執行緒有關的操作全部交給作業系統去做,例如排程鎖的同步直接交給作業系統去執行,而在作業系統中要執行先要入隊,另外作業系統啟動一個執行緒時需要消耗很多資源,消耗資源比較重,重就重在這裡。

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

四 synchronized的底層實現

上面我們對物件的記憶體佈局有了一些瞭解之後,知道鎖的狀態主要存放在markword裡面。這裡我們看看底層實現。

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

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

首先我們能確定的是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底層已經做好了我們直接用就行。

最後再看看開頭的幾個問題,是不是都理解了呢。帶著問題去研究,往往會更加清晰。希望對大家有所幫助。

原文連結
本文為阿里雲原創內容,未經允許不得轉載。