1. 程式人生 > 實用技巧 >精通java併發-synchronized關鍵字和鎖

精通java併發-synchronized關鍵字和鎖

目前CSDN,部落格園,簡書同步發表中,更多精彩歡迎訪問我的gitee pages

synchronized關鍵字和鎖

示例程式碼

public class MyThreadTest2 {

    public static void main(String[] args) {
        MyClass myClass = new MyClass();
        MyClass myClass2 = new MyClass();

        Thread t1 = new Thread1(myClass);
        //Thread t2 = new Thread2(myClass);
        Thread t2 = new Thread2(myClass2);

        t1.start();

        try {
            Thread.sleep(700);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t2.start();
    }
}

class MyClass {

    public synchronized void hello () {
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("hello");
    }

    public synchronized void world() {
        System.out.println("world");
    }
}

class Thread1 extends Thread {

    private MyClass myClass;

    public Thread1(MyClass myClass) {
        this.myClass = myClass;
    }

    @Override
    public void run() {
        myClass.hello();
    }
}

class Thread2 extends Thread {

    private MyClass myClass;

    public Thread2(MyClass myClass) {
        this.myClass = myClass;
    }

    @Override
    public void run() {
        myClass.world();
    }
}




結論

如圖,synchronized可以用在方法上也可以使用在程式碼塊中,其中方法是例項方法和靜態方法分別鎖的是該類的例項物件和該類的物件。而使用在程式碼塊中也可以分為三種,具體的可以看上面的表格。這裡的需要注意的是:如果鎖的是類物件的話,儘管new多個例項物件,但他們仍然是屬於同一個類依然會被鎖住,即執行緒之間保證同步關係

透過位元組碼理解synchronized關鍵字

修飾程式碼塊

public class MyTest1 {

    private Object object = new Object();

    public void method() {
        synchronized (object) {
            System.out.println("hello world");
            throw new RuntimeException();
        }
    }

    public void method2() {
        synchronized (object) {
            System.out.println("welcome");
        }
    }
}
  • method方法為一定拋異常的方法

  • method2方法為一個普通方法

  • 通過javap -v MyTest1.class檢視位元組碼檔案


當我們使用synchronized關鍵字來修飾程式碼塊時,位元組碼層面上是通過monitorentermonitorexit指令來實現的鎖的獲取與釋放動作。
當執行緒進入到monitorenter指令後,執行緒將會持有Monitor物件,退出monitorenter指令後,執行緒將會釋放Monitor物件

修飾普通方法和靜態方法

public class MyTest2 {
    public synchronized void method() {
        System.out.println("hello world");
    }
}

public class MyTest3 {
    public static synchronized void method() {
        System.out.println("hello world");
    }
}

對於synchronized關鍵字修飾方法來說,並沒有出現monitorenter與monitorexit指令,而是出現了一個ACC_SYNCHRONIZED標誌。

JVM使用了ACC_SYNCHRONIZED訪問標誌來區分一個方法是否為同步方法;當方法被呼叫時,呼叫指令會檢查該方法是否擁有ACC_SYNCHRONIZED標誌,
如果有,那麼執行執行緒將會先持有方法所在物件的Monitor物件,然後再去執行方法體;在該方法執行期間,其他任何執行緒均無法再獲取到這個Monitor物件, 當執行緒執行完該方法後,它會釋放掉這個Monitor物件。

monitor

JVM中的同步是基於進入與退出監視器物件(管程物件)(Monitor)來實現的,每個物件例項都會有一個Monitor物件,Monitor物件會和
Java物件一同建立並銷燬。Monitor物件是由C++來實現的。

當多個執行緒同時訪問一段同步程式碼時,這些執行緒會被放到一個EntryList集合中,處於阻塞狀態的執行緒都會被放到該列表當中。接下來,當執行緒
獲取到物件的Monitor時,Monitor是依賴於底層作業系統的mutex lock來實現互斥的,執行緒獲取mutex成功,則會持有該mutex,這時其他
執行緒就無法再獲取到該mutex。

如果執行緒呼叫了wait方法,那麼該執行緒就會釋放掉所持有的mutex,並且該執行緒會進入到WaitSet集合(等待集合)中,等待下一次被其他執行緒
呼叫notify/notifyAll喚醒。如果當前執行緒順利執行完畢方法,那麼它也會釋放掉所持有的mutex

  • 總結一下:同步鎖在這種實現方式當中,因為Monitor是依賴於底層的作業系統實現,這樣就存在使用者態與核心態之間的切換,所以會增加效能開銷。

通過物件互斥鎖的概念來保證共享資料操作的完整性。每個物件都對應於一個可稱為『互斥鎖』的標記,這個標記用於保證在任何時刻,只能有一個
執行緒訪問該物件。

那些處於EntryListWaitSet中的執行緒均處於阻塞狀態,阻塞操作是由作業系統來完成的,在linux下是通過pthread_mutex_lock函式實現的。
執行緒被阻塞後便會進入到核心排程狀態,這會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能。

  • 自旋(Spin)

解決上述問題的辦法便是自旋(Spin)。其原理是:當發生對Monitor的爭用時,若Owner能夠在很短的時間內釋放掉鎖,則那些正在爭用的執行緒就可以稍微
等待一下(即所謂的自旋),在Owner執行緒釋放鎖之後,爭用執行緒可能會立刻獲取到鎖,從而避免了系統阻塞。不過,當Owner執行的時間超過了臨界值
後,爭用執行緒自旋一段時間後依然無法獲取到鎖,這時爭用執行緒則會停止自旋而進入到阻塞狀態。所以總體的思想是:先自旋,不成功再進行阻塞,儘量
降低阻塞的可能性,這對那些執行時間很短的程式碼塊來說有極大的效能提升。顯然,自旋在多處理器(多核心)上才有意義。

互斥鎖

普通鎖(PTHREAD_MUTEX_TIMED_NP)

  • 當一個執行緒加鎖以後,其餘請求鎖的執行緒將會形成一個等待佇列,並且在解鎖後按照優先順序獲取到鎖。這種策略可以確保資源分配的公平性。(預設值)

巢狀鎖(PTHREAD_MUTEX_RECURSIVE_NP)

  • 允許一個執行緒對同一個鎖成功獲取多次,並通過unlock解鎖。如果是不同執行緒請求,則在加鎖執行緒解鎖時重新進行競爭。

檢錯鎖(PTHREAD_MUTEX_ERRORCHECK_NP)

  • 如果一個執行緒請求同一個鎖,則返回EDEADLK,否則與PTHREAD_MUTEX_TIMED_NP型別動作相同,這樣就保證了當不允許多次加鎖時不會出現最簡單情況下的死鎖。

適應鎖(PTHREAD_MUTEX_ADAPTIVE_NP)

  • 適應鎖,動作最簡單的鎖型別,僅僅等待解鎖後重新競爭。

synchronized關鍵字的優化

  • 在JDK 1.5之前,我們若想實現執行緒同步,只能通過synchronized關鍵字這一種方式來達成;底層,Java也是通過synchronized關鍵字來做到資料的原子性維護的;synchronized關鍵字是JVM實現的一種內建鎖,從底層角度來說,這種鎖的獲取與釋放都是由JVM幫助我們隱式實現的。

  • 從JDK 1.5開始,併發包引入了Lock鎖,Lock同步鎖是基於Java來實現的,因此鎖的獲取與釋放都是通過Java程式碼來實現與控制的;然而,synchronized是基於底層作業系統的Mutex Lock來實現的,每次對鎖的獲取與釋放動作都會帶來使用者態與核心態之間的切換,這種切換會極大地增加系統的負擔;在併發量較高時,也就是說鎖的競爭比較激烈時,synchronized鎖在效能上的表現就非常差。

  • 從JDK 1.6開始,synchronized鎖的實現發生了很大的變化;JVM引入了相應的優化手段來提升synchronized鎖的效能,這種提升涉及到偏向鎖、輕量級鎖及重量級鎖等,從而減少鎖的競爭所帶來的使用者態與核心態之間的切換;這種鎖的優化實際上是通過Java物件頭中的一些標誌位來去實現的;對於鎖的訪問與改變,實際上都與Java物件頭息息相關。

  • 從JDK 1.6開始,物件例項在堆當中會被劃分為三個組成部分:物件頭、例項資料與對齊填充。

    • 物件頭主要也是由3塊內容來構成:

      • Mark Word

      • 指向類的指標

      • 陣列長度

    • 其中Mark Word(它記錄了物件、鎖及垃圾回收相關的資訊,在64位的JVM中,其長度也是64bit)的位資訊包括瞭如下組成部分:

      • 無鎖標記
      • 偏向鎖標記
      • 輕量級鎖標記
      • 重量級鎖標記
      • GC標記

對於synchronized鎖來說,鎖的升級主要都是通過Mark Word中的鎖標誌位與是否是偏向鎖標誌位來達成的;synchronized關鍵字所對應的鎖都是先從偏向鎖開始,隨著鎖競爭的不斷升級,逐步演化至輕量級鎖,最後則變成了重量級鎖。

  • 對於鎖的演化來說,它會經歷如下階段:

無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

偏向鎖

針對於一個執行緒來說的,它的主要作用就是優化同一個執行緒多次獲取一個鎖的情況;如果一個synchronized方法被一個執行緒訪問,那麼這個方法所在的物件
就會在其Mark Word中將偏向鎖進行標記,同時還會有一個欄位來儲存該執行緒的ID;當這個執行緒再次訪問同一個synchronized方法時,它會檢查這個物件的Mark Word的偏向鎖標記以及是否指向了其執行緒ID,如果是的話,那麼該執行緒就無需再去進入管程(Monitor)了,而是直接進入到該方法體中。

如果是另外一個執行緒訪問這個synchronized方法,那麼實際情況會如何呢?

偏向鎖會被取消掉。

輕量級鎖

若第一個執行緒已經獲取到了當前物件的鎖,這時第二個執行緒又開始嘗試爭搶該物件的鎖,由於該物件的鎖已經被第一個執行緒獲取到,因此它是偏向鎖,而第二個執行緒在爭搶時,會發現該物件頭中的Mark Word已經是偏向鎖,但裡面儲存的執行緒ID並不是自己(是第一個執行緒),那麼它會進行CAS(Compare and Swap),從而獲取到鎖,這裡面存在兩種情況:

獲取鎖成功:那麼它會直接將Mark Word中的執行緒ID由第一個執行緒變成自己(偏向鎖標記位保持不變),這樣該物件依然會保持偏向鎖的狀態。

獲取鎖失敗:則表示這時可能會有多個執行緒同時在嘗試爭搶該物件的鎖,那麼這時偏向鎖就會進行升級,升級為輕量級鎖

自旋鎖

若自旋失敗(依然無法獲取到鎖),那麼鎖就會轉化為重量級鎖,在這種情況下,無法獲取到鎖的執行緒都會進入到Monitor(即核心態)

自旋最大的一個特點就是避免了執行緒從使用者態進入到核心態。

重量級鎖

執行緒最終從使用者態進入到了核心態。

編譯器對於鎖的優化措施

鎖消除技術

JIT編譯器(Just In Time編譯器)可以在動態編譯同步程式碼時,使用一種叫做逃逸分析的技術,來通過該項技術判別程式中所使用的鎖物件是否只被一個執行緒所使用,而沒有散佈到其他執行緒當中;如果情況就是這樣的話,那麼JIT編譯器在編譯這個同步程式碼時就不會生成synchronized關鍵字所標識的鎖的申請與釋放機器碼,從而消除了鎖的使用流程。

public class MyTest4 {
//    private Object object = new Object();
    public void method() {
        Object object = new Object();
        synchronized (object) {
            System.out.println("hello world");
        }
    }
}

鎖粗化

JIT編譯器在執行動態編譯時,若發現前後相鄰的synchronized塊使用的是同一個鎖物件,那麼它就會把這幾個synchronized塊給合併為一個較大的同步塊,這樣做的好處在於執行緒在執行這些程式碼時,就無需頻繁申請與釋放鎖了,從而達到申請與釋放鎖一次,就可以執行完全部的同步程式碼塊,從而提升了效能

public class MyTest5 {
    private Object object = new Object();
    public void method() {
        synchronized (object) {
            System.out.println("hello world");
        }
        synchronized (object) {
            System.out.println("welcome");
        }
        synchronized (object) {
            System.out.println("person");
        }
    }
}

死鎖,活鎖和餓死

  • 死鎖:執行緒1等待執行緒2互斥持有的資源,而執行緒2也在等待執行緒1互斥持有的資源,兩個執行緒都無法繼續執行

  • 活鎖:執行緒持續重試一個總是失敗的操作,導致無法繼續執行

  • 餓死:執行緒一直被排程器延遲訪問其賴以執行的資源,也許是排程器先於低優先順序的執行緒而執行高優先順序的執行緒,同時總是會有一個高優先順序的執行緒可以執行,餓死也叫做無限延遲