四、聊聊併發 - 看完你應該就明白synchronized是怎麼回事了

volador發表於2020-04-06

對於Java開發者來說synchronized關鍵字肯定不陌生,對它的用法我們可能已經能信手扭來了,但是我們真的對它深入瞭解嗎?雖然網上有很多文章都已經將synchronized關鍵字的用法和原理講明白了,但是我還是想根據我個人的認識,來跟大傢伙來聊一聊這個關鍵字。我不想上來就搞什麼實現原理,我們來一起看看synchronized的用法,再由淺到深的聊聊synchronized的實現原理,從而徹底來徹底掌握它。

一、前言

我們都知道synchronized關鍵字是Java語言級別提供的鎖,它可以為程式碼提供有序性和可見性的保。synchronized作為一個互斥鎖,一次只能有一個執行緒在訪問,我們也可以把synchronized修飾的區域看作是一個臨界區,臨界區內只能有一個執行緒在訪問,當訪問執行緒退出臨界區,另一個執行緒才能訪問臨界區資源。

二、synchronized關鍵字的用法

1. 怎麼用

synchronized一般有兩種用法:synchronized 修飾方法和 synchronized 程式碼塊。

我們就通過下面的例子,一起感受一下synchronized的使用,感受一下synchronized這個鎖到底鎖的是什麼。

public class TestSynchronized {
    private final Object object = new Object();

    //修飾靜態方法
    public synchronized static void methodA() {
        System.out.println("methodA.....");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //程式碼塊synchronized(object)
    public void methodB() {
        synchronized (this) {
            System.out.println("methodB.....");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    //程式碼塊synchronized(class)
    public void methodC() {
        synchronized (TestSynchronized.class) {
            System.out.println("methodC.....");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    //修飾普通法法
    public synchronized void methodD() {
        System.out.println("methodD.....");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    } 
    //修飾普通的object
    public void methodE() {
        synchronized (object) {
            System.out.println("methodE.....");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

我們上面的例子基本上包含了synchronized的所有使用方法,我們通過執行這個例子,看一下方法的列印順序是怎麼樣的。

  1. 首先我們呼叫同一個物件的 methodB和methodD 方法,來對比下 synchronized (this)和 synchronized method(){} 這兩種方式。
 final TestSynchronized obj = new TestSynchronized();
        new Thread(() -> {
            obj.methodB();
        }).start();

        new Thread(() -> {
            //obj.methodB();
            obj.methodD();
        }).start();
複製程式碼

不管是兩個執行緒呼叫同一個方法,還是不同的方法,我們通過執行程式碼可以看到,控制檯都是先列印methodB.....等了一秒鐘才列印出另一個執行緒呼叫的方法輸出結果。

為什麼會先列印了methodB過一會才列印methodD呢?先看下圖,我們就以呼叫不同方法為例。

WechatIMG142.png

我們文章剛開始也介紹了synchronized的作用其實相當於一把鎖,其實我們也可以看做是一個臨界區,通過程式碼的執行結果,我們看到這裡先列印了methodB....,過了一會才列印了methodD方法。我們可以感覺到這兩個執行緒訪問的好像是同一塊臨界區,不然的話,控制檯應該幾乎同時列印出來methodB....和methodD...,這個的話我們也可以自己執行上面的例子,來看一下列印的先後順序。

我們也可以通過程式碼來分析一下,this指的是什麼呢?this指的是呼叫這個方法的物件,那呼叫synchronized method()的又是被例項化出來的物件,所以當在同一個例項物件呼叫synchronized method()和synchronized(this)的時候,使用的是一個臨界區,也就是我們所說的使用的同一個鎖。

這裡的話我個人覺得臨界區的這個概念應該會比較好理解一點。我們可以把synchronized method()和 synchronized()修飾的程式碼都當做一個臨界區,如果呼叫synchronized修飾的方法物件和synchronized程式碼塊裡面傳入的引數是同一個物件(這裡我們說的是同一個物件是指他們的hashCode是相等的),則表明使用的是同一個臨界區,否則就不是。

  1. 那我們來繼續看看下面的這個
   final TestSynchronized obj = new TestSynchronized();
   final TestSynchronized obj1= new TestSynchronized();
        new Thread(() -> {
            //obj.methodC();
             obj1.methodC();
        }).start();

        new Thread(() -> {
            //obj.methodC();
            TestSynchronized.methodA();
        }).start();
複製程式碼

不管我們使用obj1 還是 obj 呼叫methodC方法,或者是obj呼叫methodC()和obj1呼叫methodC()方法,列印的順序都是先列印methodC.....過一秒才列印出來另外的一個輸出。

那這裡的話其實和上面使用object物件類似,只不過這裡換成了Class物件。程式碼在執行時,只會生成一個Class物件。我們知道static修飾方法時,那方法就屬於類方法,所以這裡的話synchronized(Object.Class)和 statci synchronized method()都是使用的Object.class作為鎖。

這裡我們就不一一去舉例說明了,可能大傢伙也能知道我想傳達的意思,有興趣的小夥伴可以自己動手跑一跑程式碼,看一下結果。在這裡的話我就直接給出了結論了。

  1. 當一個執行緒訪問同一個object物件中的synchronized(this)程式碼塊或synchronized method()方法時,或者一個執行緒訪問object的synchronized(this)同步程式碼塊,另外執行緒訪問synchronized method()時都會被阻塞。一次只能有一個執行緒得到執行。另一個執行緒必須等待當前執行緒執行完這個程式碼塊以後才能執行該程式碼塊。

    當一個執行緒訪問一個物件的synchronized(object)程式碼塊或synchronized method()時,其他執行緒可以同時訪問這個物件的非synchronized(obj)或synchronized method()方法。 這裡需要注意的是對於***同一個物件***

  2. 當一個執行緒訪問static synchronized method()修飾的靜態方法或synchronized()程式碼塊,裡面的引數是一個Classs時,另外一個執行緒訪問 synchronized(Object.class)或 訪問 static synchronized method()都會被阻塞。

    當一個執行緒訪問synchronized修飾的靜態方法是,其他執行緒可以同時訪問其他synchronized 修飾的非靜態方法,或者者是非synchronized(Object.class)。注意的是這裡的class必須是同一個class

    這裡要說明一下其實 Object.class == Object.getClass(); .class 代表了一個Class的物件。這裡的Class不是Java中的關鍵字,而是一個類。

2. 可以解決什麼問題

我們上面已經瞭解synchronized的一些用法,我們前面其實也介紹過synchronized可以解決多執行緒併發訪問共享變數時帶來可見性、原子性問題。除此之外呢,其實還可以利用synchronized和wait()/notify()來實現執行緒的交替順序執行。我們就通過下面的例子或者圖片看一下。

下面一個例子是經典的經典的列印ABC的問題

public class TestABC implements Runnable{
    private String name;
    private Object pre;
    private Object self;

    public TestABC(String name,Object pre,Object self){
        this.name = name;
        this.pre = pre;
        this.self = self;
    }

    @Override
    public void run(){

        int count = 10;
        while(count>0){
            synchronized (pre) {
                synchronized (self) {
                    System.out.print(name);
                    count --;
                    //釋放鎖,開啟下一次的條件
                    self.notify();
                }
                try {
                    //給之前的資料加鎖
                    pre.wait();
                } catch (Exception e) {
                    // TODO: handle exception
                }

            }

        }

    }

    public static void main(String[] args) throws InterruptedException {
        Object a = new Object();
        Object b = new Object();
        Object c = new Object();

        Thread pa = new Thread(new TestABC("A",c,a));
        Thread pb = new Thread(new TestABC("B",a,b));
        Thread pc = new Thread(new TestABC("C",b,c));
        pa.start();
        TimeUnit.MILLISECONDS.sleep(100);
        pb.start();
        TimeUnit.MILLISECONDS.sleep(100);
        pc.start();
    }
}
複製程式碼

可能有人一開始理解不了,這是什麼鬼程式碼,其實我剛開始學習這個synchronized關鍵字時,也沒有很好地能理解這個快程式碼,那就來一起分析看一下。

wait.gif

上圖是我利用一個桌面程式跑出來的效果,但是它這邊只能是在同一個鎖上進行的,沒有辦法模擬多個鎖,但是我們可以看到wait()/notify()帶來的效果。當正在執行的執行緒呼叫wait()的時候,執行緒會主動讓出來鎖的歸屬權,我們也可以理解為離開了臨界區,那其他執行緒就可以進入到這個臨界區。呼叫wait()的執行緒,只能通過被呼叫notify()才能喚醒,喚醒之後又可以重新去或者取臨界區的執行權。

waitandnotify.png

那通過上圖就更好解釋示例程式碼是如何執行的了。

我們啟動執行緒的時候是按照A、B、C這樣的先後順序來啟動的。當A執行緒執行完以後,這裡會在c臨界區等待被喚醒,也就是左上角的步驟3,同樣執行緒B執行完以後會在a臨界區等待被喚醒,同樣執行緒C會在b臨界區等待被喚醒。

當執行緒按照這個順序啟動完成以後,之後的執行緒排程就交由CPU去進行執行順序是不確定的,但是當執行緒C執行完以後,會喚醒在c臨界區等待的執行緒A,而執行緒B會一直被阻塞,直到在a臨界區上等待的執行緒被喚醒(也就是執行a.notify()),才能重新執行。同理,其他兩個執行緒也是如此,這樣就完成了執行緒的順序執行。

三、synchronized的實現原理

通過上述所說,我們可能大概也許對synchronized有那麼一點感覺了。其實synchronized就是一個鎖(也可以理解為臨界區),那synchronized到底是如何實現的呢?synchronized到底鎖住的是什麼呢?這是我們接下來要說的主要內容

我們在說記憶體模型的時候,提到了Java記憶體模型中提供了8個原子操作,其中有兩個操作是lock和unlock,這兩個原子操作在Java中使用了兩個更高階的指令moniterenter和moniterexit來實現的,synchronized實現執行緒的互斥就是通過這兩個指令實現的,但是synchronized 修飾方法 以及synchronized程式碼塊實現還有稍微的有一些區別,那我就來看看這兩個實現的區別。

同步方法

我們通過javap命令對我們的Java程式碼進行反編譯一下,我們可以看到如下圖的位元組碼

image-20200405195112889.png

我們通過反編譯以後的位元組碼沒有發現任何和鎖有關的線索。不要著急,我們通過javap -v 命令來反編譯看一下

image-20200406131148183.png

我們發現methodD()方法中有一個flags屬性,裡面有一個ACC_SYNCHRONIZED,這個看起來好像和synchronized有些關係。

通過查資料發現JVM規範對於synchronized同步方法的一些說明:資料1資料2

其大致意思可以概括為以下幾點

  • 同步方法的實現不是基於monitorenter和monitorexit指令來實現的
  • 同步方法在執行時,常量池裡通過ACC_SYNCHRONIZED來區分是否是同步方法,方法執行時會檢查該標誌
  • 當一個方法有這個標誌的時候,進入的執行緒首先需要獲得監視器才能執行該方法

這裡給出了method_info的一些詳細說明,可以參官方文件。

同步程式碼塊

我們通過反編譯我們上面的程式碼,得到methodC的位元組碼如下。

image-20200405195836529.png

這裡我們可以看到有兩個指令moniterenter和moniterexit,JVM規範對於這兩個指令的給出了說明:官方資料

Monitorenter

Each object has a monitor associated with it. The thread that executes monitorenter gains ownership of the monitor associated with objectref. If another thread already owns the monitor associated with objectref, the current thread waits until the object is unlocked,

每個物件都有一個監視器(Monitor)與它相關聯,執行moniterenter指令的執行緒將獲得與objectref關聯的監視器的所有權,如果另一個執行緒已經擁有與objectref關聯的監視器,則當前執行緒將等待直到物件被解鎖為止。

Monitorexit

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的時候,和該執行緒關聯的監視器的計數就減1,如果計數為0則退出監視器,該執行緒則不再是監視器的所有者。

synchronized的實現

JVM規範中也說到每一個物件都有一個與之關聯的Monitor,接下來我們來看看到底他們之間有什麼關聯。

物件記憶體結構

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

HotSpot虛擬機器物件的物件頭部分包括兩類資訊。第一類是用於儲存物件自身的執行時資料,如哈 希碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。另外一部分是型別指標,即物件指向它的型別後設資料的指標,Java虛擬機器通過這個指標來確定該物件是哪個類的例項。

我們所說的鎖標識就儲存在Mark Word中,其結構如下。

object.png

其中標誌位10對應的指標,就是指向Monitor物件的,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,同時該執行緒進入 WaitSet集合中等待被喚醒。若當前執行緒執行完畢也將釋放monitor(鎖)並復位變數的值,以便其他執行緒進入獲取monitor(鎖)。如下圖所示

monitor.png

由此看來,monitor物件存在於每個Java物件的物件頭中(儲存的指標的指向),synchronized鎖便是通過這種方式互斥的。

總結

上述我們分析了synchronized的基本使用,以及synchronized的實現原理,我們對synchronized的這個關鍵字應該有了大致的掌握,上面說的那個小程式,如果有朋友感興趣可以給我留言,我可以分享給大夥,大家也自己操作感受一下。希望看完有收穫的小夥伴點個贊。

參考:

《深入理解Java虛擬機器》

https://www.jianshu.com/p/7f8a873d479c

https://blog.csdn.net/jinjiniao1/article/details/91546512

相關文章