高併發之——你知道如何解決多線程的原子性問題嗎?

前言

在《高併發之——你知道Java設計者是如何解決可見性和有序性問題的嗎?》一文中,我們瞭解了Java是如何解決多線程之間的可見性和有序性問題。在《高併發之——你知道爲何在32位多核CPU上執行long型變量的寫操作會出現詭異的Bug問題嗎?》我們已經明確了產生這個問題的根本原因是線程切換帶來的原子性問題,而且在32位多核CPU上併發寫64位數據類型的數據,基本上都會遇到這個問題。

如何保證原子性

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

鎖模型

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

在這裏插入圖片描述

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

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

改進的鎖模型

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

在這裏插入圖片描述

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

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

Java中的synchronized鎖

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

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

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

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

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

再次深究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()方法一定可以保證原子性。

這裏,我們還要思考另一個問題:上面的代碼是否存在可見性問題呢?回答這個問題之間,我們還需要看下《高併發之——你知道Java設計者是如何解決可見性和有序性問題的嗎?》一文中,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()方法是可見的嗎?

在《高併發之——你知道Java設計者是如何解決可見性和有序性問題的嗎?》一文中,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鎖實現。

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