碼農會鎖,synchronized 物件頭結構(mark-word、Klass Pointer)、指標壓縮、鎖競爭,原始碼解毒、深度分析!

小傅哥發表於2020-10-29

作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

感覺什麼都不會,從哪開始呀!

這是最近我總能被問到的問題,也確實是。一個初入程式設計職場的新人,或是一個想重新努力學習的老司機,這也不會,那也不會,總會犯愁從哪開始。

講道理,畢竟 Java 涉及的知識太多了,要學應該是學會學習的能力,而不是去背題、背答案,拾人牙慧是不會有太多收益的。

學習的過程要找對方法,遇到問題時最好能自己想想,你有哪些方式學會這些知識。是不感覺即使讓你去百度搜,你都不知道應該拿哪個關鍵字搜!只能拿著問題直接找人問,這樣缺少思考,缺少大腦撞南牆的過程,其實最後也很難學會。

所以,你要學會的是自我學習的能力,之後是從哪開始都可以,重要的是開始和堅持!

二、面試題

謝飛機,小記,週末逛完奧特萊斯,回來就跑面試官家去了!

謝飛機:duang、duang、duang,我來了!

面試官:來的還挺準時,洗洗手吃飯吧!

謝飛機:嘿嘿…

面試官:你看我這塊魚豆腐,像不像 synchronized 鎖!

謝飛機:啊!?

面試官:飛機,正好問你。synchronized、volatile,有什麼區別呀?

謝飛機:嗯,volatile 保證可見性,synchronized 保證原子性!

面試官:那不用 volatile,只用 synchronized 修飾方式,能保證可見性嗎?

謝飛機:這…,我沒驗證過!

面試官:吃吧,吃吧!一會給你個 synchronized 學習大綱,照著整理知識點!

三、synchronized 解毒

圖 15-0 面試官給謝飛機的,synchronized 學習大綱

1. 物件結構

1.1 物件結構介紹

圖 15-1 64位JVM物件結構描述

HotSpot虛擬機器 markOop.cpp 中的 C++ 程式碼註釋片段,描述了 64bits 下 mark-word 的儲存狀態,也就是圖 15-1 的結構示意。

這部分的原始碼註釋如下:

64 bits:
--------
unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)

unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

原始碼地址jdk8/hotspot/file/vm/oops/markOop.hpp

HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為三塊區域:物件頭(Header)例項資料(Instance Data)對齊填充(Padding)

  • mark-word:物件標記欄位佔4個位元組,用於儲存一些列的標記位,比如:雜湊值、輕量級鎖的標記位,偏向鎖標記位、分代年齡等。
  • Klass Pointer:Class物件的型別指標,Jdk1.8預設開啟指標壓縮後為4位元組,關閉指標壓縮(-XX:-UseCompressedOops)後,長度為8位元組。其指向的位置是物件對應的Class物件(其對應的後設資料物件)的記憶體地址。
  • 物件實際資料:包括物件的所有成員變數,大小由各個成員變數決定,比如:byte佔1個位元組8位元位、int佔4個位元組32位元位。
  • 對齊:最後這段空間補全並非必須,僅僅為了起到佔位符的作用。由於HotSpot虛擬機器的記憶體管理系統要求物件起始地址必須是8位元組的整數倍,所以物件頭正好是8位元組的倍數。因此當物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。

另外,在mark-word鎖型別標記中,無鎖,偏向鎖,輕量鎖,重量鎖,以及GC標記,5種類中沒法用2位元標記(2位元最終有4種組合00011011),所以無鎖、偏向鎖,前又佔了一位偏向鎖標記。最終:101為無鎖、001為偏向鎖。

1.2 驗證物件結構

為了可以更加直觀的看到物件結構,我們可以藉助 openjdk 提供的 jol-core 進行列印分析。

引入POM

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-cli</artifactId>
    <version>0.14</version>
</dependency>

測試程式碼

public static void main(String[] args) {
    System.out.println(VM.current().details());
    Object obj = new Object();
    System.out.println(obj + " 十六進位制雜湊:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
1.2.1 指標壓縮開啟(預設)

執行結果

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

圖 15-2 指標壓縮開啟,物件頭佈局

  • Object物件,總共佔16位元組
  • 物件頭佔 12 個位元組,其中:mark-down 佔 8 位元組、Klass Point 佔 4 位元組
  • 最後 4 位元組,用於資料填充找齊
1.2.2 指標壓縮關閉

Run-->Edit Configurations->VM Options 配置引數 -XX:-UseCompressedOops 關閉指標壓縮。

執行結果

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 12 0c 53 (00000001 00010010 00001100 01010011) (1393299969)
      4     4        (object header)                           02 00 00 00 (00000010 00000000 00000000 00000000) (2)
      8     4        (object header)                           00 1c b9 1b (00000000 00011100 10111001 00011011) (465116160)
     12     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

圖 15-3 指標壓縮關閉,物件頭佈局

  • 關閉指標壓縮後,mark-word 還是佔 8 位元組不變。
  • 重點在型別指標 Klass Point 的變化,由原來的 4 位元組,現在擴增到 8 位元組。
1.2.3 物件頭雜湊值儲存驗證

接下來,我們調整下測試程式碼,看下雜湊值在物件頭中具體是怎麼存放的。

測試程式碼

public static void main(String[] args) {
    System.out.println(VM.current().details());
    Object obj = new Object();
    System.out.println(obj + " 十六進位制雜湊:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
  • 改動不多,只是把雜湊值和物件列印出來,方便我們驗證物件頭關於雜湊值的存放結果。

執行結果

圖 15-3 物件頭雜湊值存放

  • 如圖 15-3,物件的雜湊值是16進位制的,0x2530c12
  • 在物件頭雜湊值存放的結果上看,也有對應的數值。只不過這個結果是倒過來的。

關於這個倒過來的問題是因為,大小端儲存導致;

  • Big-Endian:高位位元組存放於記憶體的低地址端,低位位元組存放於記憶體的高地址端
  • Little-Endian:低位位元組存放於記憶體的低地址端,高位位元組存放於記憶體的高地址端

mark-down結構

圖 15-5 無鎖狀態,64位虛擬機器mark-down結構

如圖 15-5 最右側的 3 Bit(1 Bit標識偏向鎖,2 Bit描述鎖的型別)是跟鎖型別和GC標記相關的,而 synchronized 的鎖優化升級膨脹就是修改的這三位上的標識,來區分不同的鎖型別。從而採取不同的策略來提升效能。

1.3 Monitor 物件

在HotSpot虛擬機器中,monitor是由C++中ObjectMonitor實現。

synchronized 的執行機制,就是當 JVM 監測到物件在不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。

那麼三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖。當一個 Monitor 被某個執行緒持有後,它便處於鎖定狀態。

Monitor 主要資料結構如下

// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;       // 記錄個數
    _waiters      = 0,
    _recursions   = 0;       // 執行緒重入次數
    _object       = NULL;    // 儲存 Monitor 物件
    _owner        = NULL;    // 持有當前執行緒的 owner
    _WaitSet      = NULL;    // 處於wait狀態的執行緒,會被加入到 _WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 單向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 處於等待鎖block狀態的執行緒,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

原始碼地址jdk8/hotspot/file/vm/runtime/objectMonitor.hpp

  • ObjectMonitor,有兩個佇列:_WaitSet_EntryList,用來儲存 ObjectWaiter 物件列表。
  • _owner,獲取 Monitor 物件的執行緒進入 _owner 區時, _count + 1。如果執行緒呼叫了 wait() 方法,此時會釋放 Monitor 物件, _owner 恢復為空, _count - 1。同時該等待執行緒進入 _WaitSet 中,等待被喚醒。

鎖?執行效果如下

圖 15-06,鎖?執行效果

如圖 15-06,每個 Java 物件頭中都包括 Monitor 物件(儲存的指標的指向),synchronized 也就是通過這一種方式獲取鎖,也就解釋了為什麼 synchronized() 括號裡放任何物件都能獲得鎖?!

2. synchronized 特性

2.1 原子性

原子性是指一個操作是不可中斷的,要麼全部執行成功要麼全部執行失敗。

案例程式碼

private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(() -> {
            for (int i1 = 0; i1 < 10000; i1++) {
                add();
            }
        });
        thread.start();
    }
    // 等10個執行緒執行完畢
    Thread.sleep(1000);
    System.out.println(counter);
}
public static void add() {
    counter++;
}

這段程式碼開啟了 10 個執行緒來累加 counter,按照預期結果應該是 100000。但實際執行會發現,counter 值每次執行都小於 10000,這是因為 volatile 並不能保證原子性,所以最後的結果不會是10000。

修改方法 add(),新增 synchronized:

public static void add() {
    synchronized (AtomicityTest.class) {
        counter++;
    }
}

這回測試結果就是:100000 了!

因為 synchronized 可以保證統一時間只有一個執行緒能拿到鎖,進入到程式碼塊執行。

反編譯檢視指令碼

javap -v -p AtomicityTest

public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: ldc           #12                 // class org/itstack/interview/AtomicityTest
         2: dup
         3: astore_0
         4: monitorenter
         5: getstatic     #10                 // Field counter:I
         8: iconst_1
         9: iadd
        10: putstatic     #10                 // Field counter:I
        13: aload_0
        14: monitorexit
        15: goto          23
        18: astore_1
        19: aload_0
        20: monitorexit
        21: aload_1
        22: athrow
        23: return
      Exception table:

同步方法

ACC_SYNCHRONIZED 這是一個同步標識,對應的16進位制值是 0x0020

這10個執行緒進入這個方法時,都會判斷是否有此標識,然後開始競爭 Monitor 物件。

同步程式碼

  • monitorenter,在判斷擁有同步標識 ACC_SYNCHRONIZED 搶先進入此方法的執行緒會優先擁有 Monitor 的 owner ,此時計數器 +1。
  • monitorexit,當執行完退出後,計數器 -1,歸 0 後被其他進入的執行緒獲得。

2.2 可見性

在上一章節 volatile 篇中,我們知道它保證變數對所有執行緒的可見性。最終的效果就是在新增 volatile 的屬性變數時,執行緒A修改值後,執行緒B使用此變數可以做出相應的反應,比如 while(!變數) 退出。

那麼,synchronized 具備可見性嗎,我們做給例子。

public static boolean sign = false;
public static void main(String[] args) {
    Thread Thread01 = new Thread(() -> {
        int i = 0;
        while (!sign) {
            i++;
            add(i);
        }
    });
    Thread Thread02 = new Thread(() -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException ignore) {
        }
        sign = true;
        logger.info("vt.sign = true  while (!sign)")
    });
    Thread01.start();
    Thread02.start();
}

public static int add(int i) {
    return i + 1;
}

這是兩個執行緒操作一個變數的例子,因為執行緒間對變數 sign 的不可見性,執行緒 Thread01 中的 while (!sign) 會一直執行,不會隨著執行緒 Thread02 修改 sign = true 而退出迴圈。

現在我們給方法 add 新增 synchronized 關鍵字修飾,如下:

public static synchronized int add(int i) {
    return i + 1;
}

新增後執行結果

23:55:33.849 [Thread-1] INFO  org.itstack.interview.VisibilityTest - vt.sign = true  while (!sign)

Process finished with exit code 0

可以看到當執行緒 Thread02 改變變數 sign = true 後,執行緒 Thread01 立即退出了迴圈。

注意:不要在方法中新增 System.out.println() ,因為這個方法中含有 synchronized 會影響測試結果!

那麼為什麼新增 synchronized 也能保證變數的可見性呢?

因為:

  1. 執行緒解鎖前,必須把共享變數的最新值重新整理到主記憶體中。
  2. 執行緒加鎖前,將清空工作記憶體中共享變數的值,從而使用共享變數時需要從主記憶體中重新讀取最新的值。
  3. volatile 的可見性都是通過記憶體屏障(Memnory Barrier)來實現的。
  4. synchronized 靠作業系統核心互斥鎖實現,相當於 JMM 中的 lock、unlock。退出程式碼塊時重新整理變數到主記憶體。

2.3 有序性

as-if-serial,保證不管編譯器和處理器為了效能優化會如何進行指令重排序,都需要保證單執行緒下的執行結果的正確性。也就是常說的:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒觀察另一個執行緒,所有的操作都是無序的。

這裡有一段雙重檢驗鎖(Double-checked Locking)的經典案例:

public class Singleton {
    private Singleton() {
    }

    private volatile static Singleton instance;

    public Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

為什麼,synchronized 也有可見性的特點,還需要 volatile 關鍵字?

因為,synchronized 的有序性,不是 volatile 的防止指令重排序。

那如果不加 volatile 關鍵字可能導致的結果,就是第一個執行緒在初始化初始化物件,設定 instance 指向記憶體地址時。第二個執行緒進入時,有指令重排。在判斷 if (instance == null) 時就會有出錯的可能,因為這會可能 instance 可能還沒有初始化成功。

2.4 可重入性

synchronized 是可重入鎖,也就是說,允許一個執行緒二次請求自己持有物件鎖的臨界資源,這種情況稱為可重入鎖?。

那麼我們就寫一個例子,來證明這樣的情況。

public class ReentryTest extends A{

    public static void main(String[] args) {
        ReentryTest reentry = new ReentryTest();
        reentry.doA();
    }

    public synchronized void doA() {
        System.out.println("子類方法:ReentryTest.doA() ThreadId:" + Thread.currentThread().getId());
        doB();
    }

    private synchronized void doB() {
        super.doA();
        System.out.println("子類方法:ReentryTest.doB() ThreadId:" + Thread.currentThread().getId());
    }

}


class A {
    public synchronized void doA() {
        System.out.println("父類方法:A.doA() ThreadId:" + Thread.currentThread().getId());
    }
}

測試結果

子類方法:ReentryTest.doA() ThreadId:1
父類方法:A.doA() ThreadId:1
子類方法:ReentryTest.doB() ThreadId:1

Process finished with exit code 0

這段單例程式碼是遞迴呼叫含有 synchronized 鎖的方法,從執行正常的測試結果看,並沒有發生死鎖。所有可以證明 synchronized 是可重入鎖。

synchronized鎖物件的時候有個計數器,他會記錄下執行緒獲取鎖的次數,在執行完對應的程式碼塊之後,計數器就會-1,直到計數器清零,就釋放鎖了。

之所以,是可以重入。是因為 synchronized 鎖物件有個計數器,會隨著執行緒獲取鎖後 +1 計數,當執行緒執行完畢後 -1,直到清零釋放鎖。

3. 鎖升級過程

關於 synchronized 鎖?升級有一張非常完整的圖,可以參考:

圖 15-7 synchronized 鎖升級過程

synchronized 鎖有四種交替升級的狀態:無鎖、偏向鎖、輕量級鎖和重量級,這幾個狀態隨著競爭情況逐漸升級。

3.1 偏向鎖

synchronizer原始碼:/src/share/vm/runtime/synchronizer.cpp

// NOTE: must use heavy weight monitor to handle jni monitor exit
void ObjectSynchronizer::jni_exit(oop obj, Thread* THREAD) {
  TEVENT (jni_exit) ;
  if (UseBiasedLocking) {
    Handle h_obj(THREAD, obj);
    BiasedLocking::revoke_and_rebias(h_obj, false, THREAD);
    obj = h_obj();
  }
  assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");

  ObjectMonitor* monitor = ObjectSynchronizer::inflate(THREAD, obj);
  // If this thread has locked the object, exit the monitor.  Note:  can't use
  // monitor->check(CHECK); must exit even if an exception is pending.
  if (monitor->check(THREAD)) {
     monitor->exit(true, THREAD);
  }
}
  • UseBiasedLocking 是一個偏向鎖檢查,1.6之後是預設開啟的,1.5中是關閉的,需要手動開啟引數是 XX:-UseBiasedLocking=false

偏斜鎖會延緩 JIT 預熱程式,所以很多效能測試中會顯式地關閉偏斜鎖,偏斜鎖並不適合所有應用場景,撤銷操作(revoke)是比較重的行為,只有當存在較多不會真正競爭的 synchronized 塊兒時,才能體現出明顯改善。

3.2 輕量級鎖

當鎖是偏向鎖的時候,被另一個執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,提高效能。

在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),JVM虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。

3.3 自旋鎖

自旋鎖是指嘗試獲取鎖的執行緒不會立即阻塞,而是採用迴圈的方式去嘗試獲取鎖,這樣的好處是減少執行緒上下文切換的消耗,缺點是迴圈會消耗CPU。

自旋鎖的預設大小是10次,可以調整:-XX:PreBlockSpin

如果自旋n次失敗了,就會升級為重量級的鎖。重量級的鎖,在 1.3 Monitor 物件中已經介紹。

3.4 鎖會降級嗎?

之前一直了解到 Java 不會進行鎖降級,但最近整理了大量的資料發現鎖降級確實是會發生。

When safepoints are used?

Below are few reasons for HotSpot JVM to initiate a safepoint:
Garbage collection pauses
Code deoptimization
Flushing code cache
Class redefinition (e.g. hot swap or instrumentation)
Biased lock revocation
Various debug operation (e.g. deadlock check or stacktrace dump)

Biased lock revocation,當 JVM 進入安全點 SafePoint的時候,會檢查是否有閒置的 Monitor,然後試圖進行降級。

四、總結

  • 本章關於 synchronized 鎖涉及到了較多的C++原始碼分析學習,原始碼地址:https://github.com/JetBrains/jdk8u_hotspot
  • 關於鎖的細節挖掘除了本文提到的還有很多知識點可以繼續學習,可以結合 ifeve、併發程式設計、深入理解JVM虛擬機器,等系列知識整理。
  • 學習過程中結合C++原始碼中關於鎖的實現,更容易理解可能原本晦澀難懂的概念。在結合實際的案例驗證,會容易接受這部分知識。
  • 好了,這篇就寫到這裡了,如果有觀點和文章不準確的表達歡迎留言,互相學習,互相掃盲,互相進步。

五、傅詩一手

  • 會所?,裡的碼農會鎖。
  • 擁擠?‍♂️,就需加價升級。
  • 專案?,按摩物件頭皮。
  • 效果?,可見原子有序。

六、系列推薦

相關文章