【高併發】如何使用互斥鎖解決多線程的原子性問題?這次終於明白了!

前言

在《【高併發】如何解決可見性和有序性問題?這次徹底懂了!》一文中,我們瞭解了Java是如何解決多線程之間的可見性和有序性問題。另外,通過《【高併發】爲何在32位多核CPU上執行long型變量的寫操作會出現詭異的Bug問題?看完這篇我懂了!》一文,我們得知在32位多核CPU上讀寫long型數據出現問題的根本原因是線程切換帶來的原子性問題

如何保證原子性?

那麼,如何解決線程切換帶來的原子性問題呢?答案是 保證多線程之間的互斥性。也就是說,在同一時刻只有一個線程在執行! 如果我們能夠保證對共享變量的修改是互斥的,那麼,無論是單核CPU還是多核CPU,都能保證多線程之間的原子性了。

鎖模型

說到線程之間的互斥,我們可以想到在併發編程中使用鎖來保證線程之前的互斥性。我們可以鎖模型簡單的使用下圖來表示。
在這裏插入圖片描述

我們可以將上圖中受保護的資源,也就是需要多線程之間互斥執行的代碼稱爲臨界區。線程進入臨界區之前,會首先嚐試加鎖操作lock(),如果加鎖成功,則進入臨界區執行臨界區中的代碼,則當前線程持有鎖;如果加鎖失敗,就會等待,直到持有鎖的線程釋放鎖後,當前線程獲取到鎖進入臨界區;進入臨界區的線程執行完代碼後,會執行解鎖操作unlock()。

其實,在這個鎖模型中,我們忽略了一些非常重要的內容:那就是我們對什麼東西加了鎖?需要我們保護的資源又是什麼呢?

改進的鎖模型

在併發編程中對資源進行加鎖操作時,我們需要明確對什麼東西加了鎖?而需要我們保護的資源又是什麼?只有明確了這兩點,才能更好的利用Java中的互斥鎖。所以,我們需要將鎖模型進行修改,修改後的鎖模型如下圖所示。

在這裏插入圖片描述

在改進的鎖模型中,首先創建一把保護資源的鎖,使用這個保護資源的鎖進行加鎖操作,然後進入臨界區執行代碼,最後進行解鎖操作釋放鎖。其中,創建的保護資源的鎖,就是對臨界區特定的資源進行保護。

這裏需要注意的是:我們在改進的鎖模型中,特意將創建保護資源的鎖用箭頭指向了臨界區中的受保護的資源。目的是爲了說明特定資源的鎖是爲了保護特定的資源,如果一個資源的鎖保護了其他的資源,那麼就會出現詭異的Bug問題,這樣的Bug非常不好調試,因爲我們自身會覺得,我明明已經對代碼進行了加鎖操作,可爲什麼還會出現問題呢?如果出現了這種問題,你就要排查下你創建的鎖,是不是真正要保護你需要保護的資源了。

Java中的synchronized鎖

說起,Java中的synchronized鎖,相信大家並不陌生了,synchronized關鍵字可以用來修飾方法,也可以用來修飾代碼塊。例如,下面的代碼片段所示。

public class LockTest{
    //創建需要加鎖的對象
    private Object obj = new Object();
    //修飾代碼塊
    public void run(){
        synchronized(obj){
            //臨界區:受保護的資源
            System.out.println("測試run()方法的同步");
        }
    }
    //使用synchronized修飾非靜態方法
    public synchronized void execute(){
        //臨界區:受保護的資源
        System.out.println("測試execute()方法的同步");
    }

    //使用synchronized修飾靜態方法
    public synchronized static void submit(){
        //臨界區:受保護的資源
        System.out.println("測試submit方法的同步");
    }
}

在上述的代碼中,我們只是對方法(包括靜態方法和非靜態方法)和代碼塊使用了synchronized關鍵字,並沒有執行lock()和unlock()操作。本質上,synchronized的加鎖和解鎖操作都是由JVM來完成的,Java編譯器會在synchronized修飾的方法或代碼塊的前面自動加上加鎖操作,而在其後面自動加上解鎖操作。

在使用synchronized關鍵字加鎖時,Java規定了一些隱式的加鎖規則。

  • 當使用synchronized關鍵字修飾代碼塊時,鎖定的是實際傳入的對象。

  • 當使用synchronized關鍵字修飾非靜態方法時,鎖定的是當前實例對象this。

  • 當使用synchronized關鍵字修飾靜態方法時,鎖定的是當前類的Class對象。

synchronized揭祕

使用synchronized修飾代碼塊和方法時JVM底層實現的JVM指令有所區別,我們以LockTest類爲例,對LockTest類進行反編譯,如下所示。

D:\>javap -c LockTest.class
Compiled from "LockTest.java"
public class io.mykit.concurrent.lab03.LockTest {
  public io.mykit.concurrent.lab03.LockTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: new           #2                  // class java/lang/Object
       8: dup
       9: invokespecial #1                  // Method java/lang/Object."<init>":()V
      12: putfield      #3                  // Field obj:Ljava/lang/Object;
      15: return

  public void run();
    Code:
       0: aload_0
       1: getfield      #3                  // Field obj:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      10: ldc           #5                  // String 測試run()方法的同步
      12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      15: aload_1
      16: monitorexit
      17: goto          25
      20: astore_2
      21: aload_1
      22: monitorexit
      23: aload_2
      24: athrow
      25: return
    Exception table:
       from    to  target type
           7    17    20   any
          20    23    20   any

  public synchronized void execute();
    Code:
       0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #7                  // String 測試execute()方法的同步
       5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return

  public static synchronized void submit();
    Code:
       0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #8                  // String 測試submit方法的同步
       5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

分析反編譯代碼塊

從反編譯的結果來看,synchronized在run()方法中修飾代碼塊時,使用了monitorenter 和monitorexit兩條指令,如下所示。

在這裏插入圖片描述

對於monitorenter指令,查看JVM的技術規範後,可以得知:

每個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

1、如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者。

2、如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.

3.如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權。

對於monitorexit指令,JVM技術規範如下:

執行monitorexit的線程必須是objectref所對應的monitor的所有者。

指令執行時,monitor的進入數減1,如果減1後進入數爲0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。

通過這兩段描述,我們應該能很清楚的看出synchronized的實現原理,synchronized的語義底層是通過一個monitor的對象來完成,其實wait/notify等方法也依賴於monitor對象,這就是爲什麼只有在同步的塊或者方法中才能調用wait/notify等方法,否則會拋出java.lang.IllegalMonitorStateException的異常的原因。

分析反編譯方法

從反編譯的代碼來看,synchronized無論是修飾非靜態方法還是修飾靜態方法,其執行的流程都是一樣,例如,我們這裏對非靜態方法execute()和靜態方法submit()的反編譯結果如下所示。
在這裏插入圖片描述

注意:我這裏使用的JDK版本爲1.8,其他版本的JDK可能結果不同。

再次深究count+=1的問題

如果多個線程併發的對共享變量count執行加1操作,就會出現問題。此時,我們可以使用synchronized鎖來嘗試解決下這個問題。

例如,TestCount類中有兩個方法,一個是getCount()方法,用來獲取count的值;另一個是incrementCount()方法,用來給count值加1,並且incrementCount()方法使用synchronized關鍵字修飾,如下所示。

public class TestCount{
    private long count = 0L;
    public long getCount(){
        return count;
    }
    public synchronized void incrementCount(){
        count += 1;
    }
}

通過上面的代碼,我們肯定的是incrementCount()方法被synchronized關鍵字修飾後,無論是單核CPU還是多核CPU,此時只有一個線程能夠執行incrementCount()方法,所以,incrementCount()方法一定可以保證原子性。

這裏,我們還要思考另一個問題:上面的代碼是否存在可見性問題呢?回答這個問題之間,我們還需要看下《【高併發】如何解決可見性和有序性問題?這次徹底懂了!》一文中,Happens-Before原則的【原則四】鎖定規則:對一個鎖的解鎖操作 Happens-Before於後續對這個鎖的加鎖操作。

在上面的代碼中,使用synchronized關鍵字修飾的incrementCount()方法是互斥的,也就是說,在同一時刻只有一個線程執行incrementCount()方法中的代碼;而Happens-Before原則的【原則四】鎖定規則:對一個鎖的解鎖操作 Happens-Before於後續對這個鎖的加鎖操作。 指的是前一個線程的解鎖操作對後一個線程的加鎖操作可見,再綜合Happens-Before原則的【原則三】傳遞規則:如果A Happens-Before B,並且B Happens-Before C,則A Happens-Before C。我們可以得出一個結論:前一個線程在臨界區修改的共享變量(該操作在解鎖之前),對後面進入這個臨界區(該操作在加鎖之後)的線程是可見的。

經過上面的分析,如果多個線程同時執行incrementCount()方法,是可以保證可見性的,也就是說,如果有100個線程同時執行incrementCount()方法,count變量的最終結果爲100。

但是,還沒完,TestCount類中還有一個getCount()方法,如果執行了incrementCount()方法,count變量的值對getCount()方法是可見的嗎?

在《【高併發】如何解決可見性和有序性問題?這次徹底懂了!》一文中,Happens-Before原則的【原則四】鎖定規則:對一個鎖的解鎖操作 Happens-Before於後續對這個鎖的加鎖操作。 只能保證後續對這個鎖的加鎖的可見性。而getCount()方法沒有執行加鎖操作,所以,無法保證incrementCount()方法的執行結果對getCount()方法可見。

如果需要保證incrementCount()方法的執行結果對getCount()方法可見,我們也需要爲getCount()方法使用synchronized關鍵字修飾。所以,TestCount類的代碼如下所示。

public class TestCount{
    private long count = 0L;
    public synchronized long getCount(){
        return count;
    }
    public synchronized void incrementCount(){
        count += 1;
    }
}

此時,爲getCount()方法也添加了synchronized鎖,而且getCount()方法和incrementCount()方法鎖定的都是this對象,線程進入getCount()方法和incrementCount()方法時,必須先獲得this這把鎖,所以,getCount()方法和incrementCount()方法是互斥的。也就是說,此時,incrementCount()方法的執行結果對getCount()方法可見。

我們也可以簡單的使用下圖來表示這個互斥的邏輯。

在這裏插入圖片描述

修改測試用例

我們將上面的測試代碼稍作修改,將count的修改爲靜態變量,將incrementCount()方法修改爲靜態方法。此時的代碼如下所示。

public class TestCount{
    private static long count = 0L;
    public synchronized long getCount(){
        return count;
    }
    public synchronized static void incrementCount(){
        count += 1;
    }
}

那麼,問題來了,getCount()方法和incrementCount()方法是否存在併發問題呢?

接下來,我們一起分析下這段代碼:其實這段代碼中是在用兩個不同的鎖來保護同一個資源count,兩個鎖分別爲this對象和TestCount.class對象。也就是說,getCount()方法和incrementCount()方法獲取的是兩個不同的鎖,二者的臨界區沒有互斥關係,incrementCount()方法對count變量的修改無法保證對getCount()方法的可見性。所以,修改後的代碼會存在併發問題

我們也可以使用下圖來簡單的表示這個邏輯。

在這裏插入圖片描述

總結

保證多線程之間的互斥性。也就是說,在同一時刻只有一個線程在執行!如果我們能夠保證對共享變量的修改是互斥的,那麼,無論是單核CPU還是多核CPU,都能保證多線程之間的原子性了。

注意:在Java中,也可以使用Lock鎖來實現多線程之間的互斥,大家可以自行使用Lock鎖實現。

如果覺得文章對你有點幫助,請微信搜索並關注「 冰河技術 」微信公衆號,跟冰河學習高併發編程技術。

最後,附上併發編程需要掌握的核心技能知識圖,祝大家在學習併發編程時,少走彎路。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章