1. 程式人生 > >Effective Java 第三版——78. 同步訪問共享的可變數據

Effective Java 第三版——78. 同步訪問共享的可變數據

修改 一秒 exce 深入 現象 bool ogr safe 情況下

Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
註意,書中的有些代碼裏方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。

技術分享圖片

並發

線程允許多個活動同時進行。 並發編程比單線程編程更難,因為更多的事情可能會出錯,並且失敗很難重現。 你無法避免並發。 它是平臺中固有的,也是要從多核處理器獲得良好性能的要求,現在無處不在。本章包含的建議可幫助你編寫清晰,正確,文檔完備的並發程序。

78. 同步訪問共享的可變數據

synchronized關鍵字確保一次只有一個線程可以執行一個方法或代碼塊。許多程序員認為同步只是一種互斥的方法,以防止一個線程在另一個線程修改對象時看到對象處於不一致的狀態。在這個觀點中,對象以一致的狀態創建(條目 17),並由訪問它的方法鎖定。這些方法觀察狀態,並可選地引起狀態轉換,將對象從一個一致的狀態轉換為另一個一致的狀態。正確使用同步可以保證沒有任何方法會觀察到處於不一致狀態的對象。

這種觀點是正確的,但它只說明了一部分意義。如果沒有同步,一個線程的更改可能對其他線程不可見。同步不僅阻止線程觀察處於不一致狀態的對象,而且確保每個進入同步方法或塊的線程都能看到由同一鎖保護的所有之前修改的效果。

語言規範保證讀取或寫入變量是原子性(atomic)的,除非變量的類型是long或double [JLS, 17.4, 17.7]。換句話說,讀取long或double以外的變量,可以保證返回某個線程存儲到該變量中的值,即使多個線程在沒有同步的情況下同時修改變量也是如此。

你可能聽說過,為了提高性能,在讀取或寫入原子數據時應該避免同步。這種建議大錯特錯。雖然語言規範保證線程在讀取屬性時不會看到任意值,但它不保證由一個線程編寫的值對另一個線程可見。同步是線程之間可靠通信以及互斥所必需的

。這是語言規範中稱之為內存模型(memory model)的一部分,它規定了一個線程所做的更改何時以及如何對其他線程可見[JLS, 17.4;Goetz06, 16)。

即使數據是原子可讀和可寫的,未能同步對共享可變數據的訪問的後果也是可怕的。 考慮從另一個線程停止一個線程的任務。 Java類庫提供了Thread.stop方法,但是這個方法很久以前就被棄用了,因為它本質上是不安全的——它的使用會導致數據損壞。 不要使用Thread.stop。 從另一個線程中停止一個線程的推薦方法是讓第一個線程輪詢一個最初為false的布爾類型的屬性,但是第二個線程可以設置為true以指示第一個線程要自行停止。 因為讀取和寫入布爾屬性是原子的,所以一些程序員在訪問屬性時不需要同步:

// Broken! - How long would you expect this program to run?
public class StopThread {
    private static boolean stopRequested;

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

你可能希望這個程序運行大約一秒鐘,之後主線程將stoprequired設置為true,從而導致後臺線程的循環終止。然而,在我的機器上,程序永遠不會終止:後臺線程永遠循環!

問題是在沒有同步的情況下,無法確保後臺線程何時(如果有的話)看到主線程所做的stopRequested值的變化。 在沒有同步的情況下,虛擬機將下面代碼:

   while (!stopRequested)
        i++;

轉換成這樣:

if (!stopRequested)
    while (true)
        i++;

這種優化稱為提升(hoisting,它正是OpenJDK Server VM所做的。 結果是活潑失敗( liveness failure):程序無法取得進展。 解決問題的一種方法是同步對stopRequested屬性的訪問。 正如預期的那樣,該程序大約一秒鐘終止:

// Properly synchronized cooperative thread termination
public class StopThread {
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested())
                i++;
        });

        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

註意,寫方法(requestStop)和讀方法(stop- required)都是同步的。僅同步寫方法是不夠的!除非讀和寫操作同步,否則不能保證同步工作。有時,只同步寫(或讀)的程序可能在某些機器上顯示有效,但在這種情況下,表面的現象是具有欺騙性的。

即使沒有同步,StopThread中同步方法的操作也是原子性的。換句話說,這些方法上的同步僅用於其通信效果,而不是互斥。雖然在循環的每個叠代上同步的成本很小,但是有一種正確的替代方法,它不那麽冗長,而且性能可能更好。如果stoprequest聲明為volatile,則可以省略StopThread的第二個版本中的鎖定。雖然volatile修飾符不執行互斥,但它保證任何讀取屬性的線程都會看到最近寫入的值:

// Cooperative thread termination with a volatile field
public class StopThread {
    private static volatile boolean stopRequested;

    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

在使用volatile時一定要小心。考慮下面的方法,該方法應該生成序列號:

// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
    return nextSerialNumber++;
}

該方法的目的是保證每次調用都返回一個唯一值(只要調用次數不超過232次)。 方法的狀態由單個可原子訪問的屬性nextSerialNumber組成,該屬性的所有可能值都是合法的。 因此,不需要同步來保護其不變量。 但是,如果沒有同步,該方法將無法正常工作。

問題是增量運算符(++)不是原子的。 它對nextSerialNumber屬性執行兩個操作:首先它讀取值,然後它寫回一個新值,等於舊值加1。 如果第二個線程在線程讀取舊值並寫回新值之間讀取屬性,則第二個線程將看到與第一個線程相同的值並返回相同的序列號。 這是安全性失敗(safety failure):程序計算錯誤的結果。

修復generateSerialNumber的一種方法是將synchronized修飾符添加到其聲明中。 這確保了多個調用不會交叉讀取,並且每次調用該方法都會看到所有先前調用的效果。 完成後,可以並且應該從nextSerialNumber中刪除volatile修飾符。 要保護該方法,請使用long而不是int,或者在nextSerialNumber即將包裝時拋出異常。

更好的是,遵循條目 59條中建議並使用AtomicLong類,它是java.util.concurrent.atomic包下的一部分。 這個包為單個變量提供了無鎖,線程安全編程的基本類型。 雖然volatile只提供同步的通信效果,但這個包還提供了原子性。 這正是我們想要的generateSerialNumber,它可能強於同步版本的代碼:

// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber() {
    return nextSerialNum.getAndIncrement();
}

避免此條目中討論的問題的最佳方法是不共享可變數據。 共享不可變數據(條目 17)或根本不共享。 換句話說,將可變數據限制在單個線程中。 如果采用此策略,則必須對其進行文檔記錄,以便在程序發展改進時維護此策略。 深入了解正在使用的框架和類庫也很重要,因為它們可能會引入你不知道的線程。

一個線程可以修改一個數據對象一段時間後,然後與其他線程共享它,只同步共享對象引用的操作。然後,其他線程可以在不進一步同步的情況下讀取對象,只要不再次修改該對象。這些對象被認為是有效不可變的( effectively immutable)[Goetz06, 3.5.4]。將這樣的對象引用從一個線程轉移到其他線程稱為安全發布(safe publication )[Goetz06, 3.5.3]。安全地發布對象引用的方法有很多:可以將它保存在靜態屬性中,作為類初始化的一部分;也可以將其保存在volatile屬性、final屬性或使用正常鎖定訪問的屬性中;或者可以將其放入並發集合中(條目 81)。

總之,當多個線程共享可變數據時,每個讀取或寫入數據的線程都必須執行同步。 在沒有同步的情況下,無法保證一個線程的更改對另一個線程可見。 未能同步共享可變數據的代價是活性失敗和安全性失敗。 這些失敗是最難調試的。 它們可以是間歇性的和時間相關的,並且程序行為可能在不同VM之間發生根本的變化。如果只需要線程間通信,而不需要互斥,那麽volatile修飾符是一種可接受的同步形式,但是正確使用它可能會比較棘手。

Effective Java 第三版——78. 同步訪問共享的可變數據