【搞定面試官】- Synchronized如何實現同步?鎖優化?(1)

店小不二發表於2020-02-15

前言

說起Java面試中最高頻的知識點非多執行緒莫屬。每每提起多執行緒都繞不過一個Java關鍵字——synchronized。我們都知道該關鍵字可以保證在同一時刻,只有一個執行緒可以執行某個方法或者某個程式碼塊以保證多執行緒的安全性。那麼,本篇文章我們就來揭開這個synchronized的面紗。

執行緒安全的實現方法

在詳細介紹synchronized之前,我們首先了解一下實現執行緒安全的不同方式,瞭解synchronized是如何實現執行緒安全的理論基礎,做到心中有數。目前主要有三種執行緒安全實現方法:互斥同步(阻塞同步)、非阻塞同步以及無需同步的執行緒安全方案。

  • 互斥同步(Mutual Exclusion & Synchnronization)

互斥同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一時刻只被一個(或一些,使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區、互斥量和訊號量都是主要的互斥實現方式。因此在互斥同步四個字中,互斥是因,同步是果;互斥是方法,同步是目的。

Java中最基本的互斥同步手段就是synchronized,具體如何實現的互斥同步請繼續往下看。

btw,除了synchronized,還有另外一種實現同步的方式,那就是java.util.concurrent包中的重入鎖ReentrantLock,具體細節就不細說了,它和synchronized用法幾乎一樣。只是synchronized是原生語法,而ReentrantLock是JDK提供的API層面的互斥鎖。

  • 非阻塞同步

互斥同步主要同步阻塞執行緒來保證執行緒安全,因此也被稱為阻塞同步。它認為只要不去做正確的同步方式(例如加鎖),那就一定會出現問題,無論共享資料是否會出現競爭(悲觀鎖)。

回來隨著*硬體指令集*的發展,我們有了另外一種選擇:先進行操作,如果沒有其他執行緒爭用,那操作就成功了;如果有其他執行緒爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止)。這種樂觀的併發策略的許多實現都不需要把執行緒掛起,所以這種同步方式成為非阻塞同步。

  • 無需同步的執行緒安全方案

要保證執行緒安全,並不一定就要進行同步,兩者並沒有因果關係。如果一個方法本來就不涉及共享資料,那它自然無需任何同步手段去保證正確性,因此會有一些程式碼天生執行緒安全。比如可重入程式碼(Reentrant Code)和執行緒本地儲存(Thread Local Storage)等。

JDK中的synchronized改進

在 JDK1.5 之前,Java 是依靠 Synchronized 關鍵字實現鎖功能來做到執行緒安全。Synchronized 是 JVM 實現的一種內建鎖,鎖的獲取和釋放是由 JVM 隱式實現。

到了 JDK1.5 版本,java.util.concurrent包中新增了 Lock 介面來實現鎖功能,它提供了與 Synchronized 關鍵字類似的同步功能,只是在使用時需要顯示獲取和釋放鎖。前邊我們提到過,Lock 同步鎖是基於 Java 實現的,而 Synchronized 是基於底層作業系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來使用者態和核心態的切換,從而增加系統效能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在效能上就表現得非常糟糕,它也常被大家稱為重量級鎖。特別是在單個執行緒重複申請鎖的情況下,JDK1.5 版本的 Synchronized 鎖效能要比 Lock 的效能差很多。例如,在 Dubbo 基於 Netty 實現的通訊中,消費端向服務端通訊之後,由於接收返回訊息是非同步,所以需要一個執行緒輪詢監聽返回資訊。而在接收訊息時,就需要用到鎖來確保 request session 的原子性。如果我們這裡使用 Synchronized 同步鎖,那麼每當同一個執行緒請求鎖資源時,都會發生一次使用者態和核心態的切換。

到了 JDK1.6 版本之後,Java 對 Synchronized 同步鎖做了充分的優化,甚至在某些場景下,它的效能已經超越了 Lock 同步鎖。

synchronized使用方式

Java中萬物皆物件,而每一個物件都可以加鎖,這是synchronized保證執行緒安全的基礎。

  1. 對於同步方法,鎖是當前例項物件,即this,對該類其他例項物件無影響。
  2. 對於靜態同步方法,鎖是當前物件的 Class 物件, 影響其他該類的例項化物件。
  3. 對於同步方法塊,鎖是 synchronized 括號裡配置的物件。

也就是說,我們可以利用synchronized修飾類,類中的方法或者方法塊。如下面的程式碼,分別對應上述三種情形。

 public class synchronizedTest implements Runnable {
    static synchronizedTest instance=new synchronizedTest();
    public void run() {
        synchronized(instance){ 
            //同步程式碼塊,對應文章中第3點
            //*******
        }
    }
   void synchronized method1() {} //類中的同步方法 對應文章中第1點
   void static synchronized method2() {} ////類中靜態同步方法 對應文章中第2點
}複製程式碼

同步方法塊

當一個執行緒試圖訪問同步程式碼塊時,它首先必須得到鎖,退出或丟擲異常時必須釋放鎖。那麼鎖存在哪裡呢?鎖裡面會儲存什麼資訊呢?我們先來看一段程式碼以及它的位元組碼(我這裡用的Idea的jclasslib外掛)。

package techgo.blog;

public class SynchronizedTest {
    private int i = 0;
    public void fun() {
        synchronized (this) {
            i ++;
        }
    }
}
複製程式碼

Synchronized位元組碼

我們看到monitorenter和monitorexit,之後查閱虛擬機器位元組碼指令表,我們知道這兩個位元組碼操作分別表示獲得和釋放物件的鎖。進入 monitorenter 指令後,執行緒將持有 Monitor 物件,退出 monitorenter 指令後,執行緒將釋放該 Monitor 物件。以上這是同步方法塊的實現方式。

同步方法

對於同步方法來說,如果去檢視其位元組碼,我們會看不到這兩個指令,因為同步方法依靠的是方法修飾符上的ACC_SYNCHRONIZED來實現的:

    public synchronized void fun1() {

    }複製程式碼

當方法呼叫時,呼叫指令將會檢查該方法是否被設定 ACC_SYNCHRONIZED 訪問標誌。如果設定了該標誌,執行執行緒將先持有 Monitor 物件,然後再執行方法。在該方法執行期間,其它執行緒將無法獲取到該 Mointor 物件,當方法執行完成後,再釋放該 Monitor 物件。

synchronized鎖的實現

synchronized的物件鎖,其指標指向的是一個monitor物件(由C++實現)的起始地址。每個物件例項都會有一個 monitor。其中monitor可以與物件一起建立、銷燬;亦或者當執行緒試圖獲取物件鎖時自動生成。需要注意的是monitor不是Java特有的概念,想了解更多monitor的詳細介紹可以檢視這篇文章

在HotSpot虛擬機器中,最終採用ObjectMonitor類實現monitor。

openjdkhotspotsrcsharevmruntimeobjectMonitor.hpp原始碼如下:

ObjectMonitor() {
    _count        = 0;
    _owner        = NULL;//指向獲得ObjectMonitor物件的執行緒或基礎鎖
    _EntryList    = NULL ;//處於等待鎖block狀態的執行緒,會被加入到entry set;
    _WaitSet      = NULL;//處於wait狀態的執行緒,會被加入到wait set;
    _WaitSetLock  = 0 ;
    
    _header       = NULL;//markOop物件頭
    _waiters      = 0,//等待執行緒數
    _recursions   = 0;//重入次數
    _object       = NULL;//監視器鎖寄生的物件。鎖不是平白出現的,而是寄託儲存於物件中。
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock
    _previous_owner_tid = 0;// 監視器前一個擁有者執行緒的ID
  }複製程式碼

當多個執行緒同時訪問一段同步程式碼時,多個執行緒會先被存放在 ContentionList 和 _EntryList 集合中,處於 block 狀態的執行緒,都會被加入到該列表。接下來當執行緒獲取到物件的 Monitor 時,Monitor 是依靠底層作業系統的 Mutex Lock 來實現互斥的,執行緒申請 Mutex 成功,則持有該 Mutex,其它執行緒將無法獲取到該 Mutex,競爭失敗的執行緒會再次進入 ContentionList 被掛起。

如果執行緒呼叫 wait() 方法,就會釋放當前持有的 Mutex,並且該執行緒會進入 WaitSet 集合中,等待下一次被喚醒。如果當前執行緒順利執行完方法,也將釋放 Mutex。

繼續深入(鎖優化)

我們都知道,物件被建立在堆中。並且物件在記憶體中的儲存佈局方式可以分為3塊區域:物件頭、例項資料、對齊填充。

對於物件頭來說,主要是包括倆部分資訊Mark Word和Klass Point:

  • Mark Word用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等等。Java物件頭一般佔有兩個機器碼(在32位虛擬機器中,1個機器碼等於4位元組,也就是32bit),但是如果物件是陣列型別,則需要三個機器碼,因為JVM虛擬機器可以通過Java物件的後設資料資訊確定Java物件的大小,但是無法從陣列的後設資料來確認陣列的大小,所以用一塊來記錄陣列長度。

  • 另一部分是型別指標Klass Point:JVM通過這個指標來確定這個物件是哪個類的例項。

鎖升級功能主要依賴於 Mark Word 中的鎖標誌位和釋放偏向鎖標誌位,Synchronized 同步鎖就是從偏向鎖開始的,隨著競爭越來越激烈,偏向鎖升級到輕量級鎖,最終升級到重量級鎖。好了今天就先到這了,鎖優化的細節還在碼字中。。

參考資料:

《深入理解Java虛擬機器》 第二版

https://blog.csdn.net/wangyadong317/article/details/84065828

https://blog.csdn.net/zjy15203167987/article/details/82531772

https://www.cnblogs.com/JsonShare/p/11433302.html

https://baijiahao.baidu.com/s?id=1612142459503895416&wfr=spider&for=pc

http://cmsblogs.com/?p=2071

https://www.php.cn/java-article-410323.html

本文由部落格一文多發平臺 OpenWrite 釋出!

文章首發:https://zhuanlan.zhihu.com/lovebell

個人公眾號:技術Go

您的點贊與支援是作者持續更新的最大動力!

相關文章