1. 程式人生 > >Java 併發程式設計(二):如何保證共享變數的原子性?

Java 併發程式設計(二):如何保證共享變數的原子性?

執行緒安全性是我們在進行 Java 併發程式設計的時候必須要先考慮清楚的一個問題。這個類在單執行緒環境下是沒有問題的,那麼我們就能確保它在多執行緒併發的情況下表現出正確的行為嗎?

我這個人,在沒有副業之前,一心撲在工作上面,所以處理的蠻得心應手,心態也一直保持的不錯;但有了副業之後,心態就變得像坐過山車一樣。副業收入超過主業的時候,人特別亢奮,像打了雞血一樣;副業遲遲打不開局面的時候,人就變得惶惶不可終日。

彷彿我就只能是個單執行緒,副業和主業並行開啟多執行緒模式的時候,我就變得特別沒有安全感,儘管整體的收入比沒有副業之前有了很大的改善。

怎麼讓我自己變得有安全感,我還沒想清楚(你要是有好的方法,請一定要告訴我)。但怎麼讓一個類在多執行緒的環境下是安全的,有 3 條法則,讓我來告訴你:

1、不線上程之間共享狀態變數。
2、將狀態變數改為不可變。
3、訪問狀態變數時使用同步。

那你可能要問,狀態變數是什麼?

我們先來看一個沒有狀態變數的類吧,程式碼示例如下。

class Chenmo {
    public void write() {
        System.out.println("我尋了半生的春天,你一笑便是了。");
    }
}

Chenmo 這個類就是無狀態變數的,它只有一個方法,既沒有成員變數,也沒有類變數。任何訪問它的執行緒都不會影響另外一個執行緒的結果,因為兩個執行緒之間沒有共享任何的狀態變數。所以可以下這樣一個結論:無狀態變數的類一定是執行緒安全的。

然後我們再來看一個有狀態變數的類。假設沉默(Chenmo 類)每寫一行字(write() 方法),就要做一次統計,這樣好找出版社索要稿費。我們為 Chenmo 類增加一個統計的欄位,程式碼示例如下。

class Chenmo {
    private long count = 0;
    public void write() {
        System.out.println("我尋了半生的春天,你一笑便是了。");
        count++;
    }
}

Chenmo 類在單執行緒環境下是可以準確統計出行數的,但多執行緒的環境下就不行了。因為遞增運算 count++ 可以拆分為三個操作:讀取 count,將 count 加 1,將計算結果賦值給 count。多執行緒的時候,這三個操作發生的時序可能是混亂的,最終統計出來的 count 值就會比預期的值小。

PS:具體的原因可以回顧上一節《Java 併發程式設計(一):摩拳擦掌》

寫作不易,咱不能虧待了沉默,對不對?那就想點辦法吧。

假定執行緒 A 正在修改 count 變數,這時候就要防止執行緒 B 或者執行緒 C 使用這個變數,從而保證執行緒 B 或者執行緒 C 在使用 count 的時候是執行緒 A 修改過後的狀態。

怎麼防止呢?可以在 write() 方法上加一個 synchronized 關鍵字。程式碼示例如下。

class Chenmo {
    private long count = 0;
    public synchronized void write() {
        System.out.println("我尋了半生的春天,你一笑便是了。");
        count++;
    }
}

關鍵字 synchronized 是一種最簡單的同步機制,可以確保同一時刻只有一個執行緒可以執行 write(),也就保證了 count++ 在多執行緒環境下是安全的。

在編寫併發應用程式時,我們必須要保持一種正確的觀念,那就是——首先要確保程式碼能夠正確執行,然後再是如何提高程式碼的效能。

但眾所周知,synchronized 的代價是昂貴的,多個執行緒之間訪問 write() 方法是互斥的,執行緒 B 訪問的時候必須要等待執行緒 A 訪問結束,這無法體現出多執行緒的核心價值。

java.util.concurrent.atomic.AtomicInteger 是一個提供原子操作的 Integer 類,它提供的加減操作是執行緒安全的。於是我們可以這樣修改 Chenmo 類,程式碼示例如下。

class Chenmo {
    private AtomicInteger count = new AtomicInteger(0);
    public void write() {
        System.out.println("我尋了半生的春天,你一笑便是了。");
        count.incrementAndGet();
    }
}

write() 方法不再需要 synchronized 關鍵字保持同步,於是多執行緒之間就不再需要以互斥的方式來呼叫該方法,可以在一定程度上提升統計的效率。

某一天,出版社統計稿費的形式變了,不僅要統計行數,還要統計字數,於是 Chenmo 類就需要再增加一個成員變量了。程式碼示例如下。

class Chenmo {
    private AtomicInteger lineCount = new AtomicInteger(0);
    private AtomicInteger wordCount = new AtomicInteger(0);
    public void write() {
        String words = "我這一輩子,走過許多地方的路,行過許多地方的橋,看過許多次的雲,喝過許多種類的酒,卻只愛過一個正當年齡的人。";
        System.out.println(words);
        lineCount.incrementAndGet();
        wordCount.addAndGet(words.length());
    }
}

你覺得這段程式碼是執行緒安全的嗎?

結果顯而易見,這段程式碼不是執行緒安全的。因為 lineCount 和 wordCount 是兩個變數,儘管它們各自是執行緒安全的,但執行緒 A 進行 lineCount 加 1 的時候,並不能夠保證執行緒 B 是線上程 A 執行完 wordCount 統計後開始 lineCount 加 1 的。

 

 

該怎麼辦呢?方法也很簡單,程式碼示例如下。

class Chenmo {
    private int lineCount = 0;
    private int wordCount = 0;
    public void write() {
        String words = "我這一輩子,走過許多地方的路,行過許多地方的橋,看過許多次的雲,喝過許多種類的酒,卻只愛過一個正當年齡的人。";
        System.out.println(words);

        synchronized (this) {
            lineCount++;
            wordCount++;
        }
    }
}

對行數統計(lineCount++)和字數統計(wordCount++)的程式碼進行加鎖,保證這兩行程式碼是原子性的。也就是說,執行緒 B 在進行統計的時候,必須要等待執行緒 A 統計完之後再開始。

synchronized (lock) {...} 是 Java 提供的一種簡單的內建鎖機制,用於保證程式碼塊的原子性。執行緒在進入加鎖的程式碼塊之前自動獲取鎖,並且退出程式碼塊的時候釋放鎖,可以保證一組語句作為一個不可分割的單元被執行。


上一篇:Java 併發程式設計(一):摩拳擦掌

&n