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

有很多的朋友問到這個互斥鎖解決多線程的問題,我們得知在32位多核CPU上讀寫long型數據出現問題的根本原因是

線程切換帶來的原子性問題

如何保證原子性?

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

鎖模型

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

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

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

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

改進的鎖模型

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

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

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

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

Java中的synchronized鎖

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

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

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

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

  • 當使用synchronized關鍵字修飾代碼塊時,鎖定的是實際傳入的對象。
  • 當使用synchronized關鍵字修飾非靜態方法時,鎖定的是當前實例對象this。
  • 當使用synchronized關鍵字修飾靜態方法時,鎖定的是當前類的Class對象。

synchronized揭秘

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

<code>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
}
/<init>/<init>/<code>

分析反編譯代碼塊

從反編譯的結果來看,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關鍵字修飾,如下所示。

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

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

這裡,我們還要思考另一個問題:上面的代碼是否存在可見性問題呢?對一個鎖的解鎖操作 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類的代碼如下所示。

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

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

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

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

修改測試用例

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

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

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

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

修改後的代碼會存在併發問題

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

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

總結

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

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

如果覺得文章對你有點幫助,請給小編一個關注+點贊,同時還分享一次Java架構進階學習資料,私信【資料】免費獲取領取方式!

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

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

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


分享到:


相關文章: