1. 程式人生 > 其它 >Java最佳實踐經驗第78條:同步訪問共享的可變資料

Java最佳實踐經驗第78條:同步訪問共享的可變資料

技術標籤:effective java同步synchronizedvolatile

本來計劃一天分享一條,但是博主最近臨近面試,時間上實在不太充裕,博主會盡量保證最高的效率撰寫。

#2020年度徵文

摘要

眾所周知,我們在設計程式的時候,設計一個良好可正常執行的併發程式的難度,是遠大於設計一個單執行緒程式的,因為有更多的可能會產生錯誤,有些失敗想要復現也是很困難的。但只要注意一些多執行緒的編寫規範,很多問題是可以有效避免的。

1、同步的二重含義

1.1、互斥訪問

關鍵字synchronized可以保證在同一時刻,只有一個執行緒可以執行一個方法,或者某一個程式碼塊。這是一個互斥 的概念,但很多程式設計師把同步的概念僅僅理解為一種互斥的方式,這是不完整的。

1.2、狀態一致和可見性

如果沒有同步,一個執行緒的變化就不能被其他執行緒看到,同步不僅可以阻止物件處於不一致的狀態之中,它還可以保證進入同步方法或者同步程式碼塊的每個執行緒,都能看到由同一個鎖保護的之前的所有的修改效果。

2、對於各執行緒共享的可變資料,一定要能夠同步訪問

Java語言規範保證讀或者寫一個變數是原子的(atomic),除非這個變數的型別為long或者double。換句話說,讀取一個非long或者double型別的變數,可以保證返回值是某個執行緒儲存在該變數中的,即使多個執行緒在沒有同步的情況下併發地修改這個變數也是如此。

為了提高效能,在讀或寫原子資料的時候,應該避免使用同步。這個建議是非常危險而錯誤的
雖然語言規範保證了執行緒在讀取原子資料的時候,不會看到任意的數值,但是它並不保證一個執行緒寫入的值對於另一個執行緒將是可見的,這是由Java語言規範中的記憶體模型決定的,它規定了一個執行緒所做的變化何時及如何變成對其他執行緒可見。

舉一個例子:阻止一個執行緒妨礙另一個執行緒的任務。Java類庫中雖然提供了Thread stop方法,但是在很久以前就不提倡使用該方法了,因為它本質上是不安全的——使用它會導致資料被破壞,所以千萬不要使用·Thread stiop·方法去阻止一個執行緒。我們可以採用使用另一個執行緒poll(輪詢)的方式來實現。

public class Demo1 {
    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;
    }
}

stopRequested是一個布林型別的屬性,初始值為false,通過主執行緒將其設定為true,來終止backgroundThread執行緒的迴圈。從邏輯上乍一看好像沒有問題,你是否期待這個程式會在執行1秒後停止呢?
執行結果如下所示:
在這裡插入圖片描述
永遠不會停止,因為我的後臺執行緒在一直迴圈

問題出在哪呢?

由於沒有同步,就不能保證後臺執行緒何時“看到”主執行緒對stopRequested = true的改變,JVM會將

while(!stopRequested){
	i++;
}

這一程式碼轉變為:

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

導致後臺執行緒永遠看不到主執行緒對stopRequested屬性發出的值改變命令,這種優化稱作為提升(hoisting),正是JVM的工作,導致的結果是一個活性失敗問題:這個程式事實上並沒有得到提升。修正這個問題是方式有兩種。

2.1 同步訪問stopRequested屬性

public class Demo1_2 {
    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();
    }
}

在這裡插入圖片描述
可以發現在1s後程序停止了執行。這裡要注意的是讀和寫方法都需要被同步,否則無法100%保證同步能起作用

2.1 使用volatile宣告stopRequested屬性

如果使用volatile宣告stopRequested屬性,就可以省略synchronized同步鎖。volatile修飾符不執行互斥訪問,但可以保證另一個執行緒能立刻看到屬性值的改變。

public class Demo1_3 {
    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;
    }
}

3、謹慎地使用volatile修飾符

來看下面這個例子:

public class Demo1_4 {
    private static volatile int nextSerialNumber = 0;

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

這個方法的目的是要確保每次呼叫都返回不同的值(只要不超過int的上限2^32次呼叫)。這個方法的狀態只包含了一個可原子訪問的屬性nextSerialNumber,看起來似乎不需要任何同步來保護它。然而,如果沒有同步來保護方法generateSerialNumber,這個方法仍然無法正確地工作。

其中的問題出在操作符(++)不是原子的,它包含了兩項操作:先讀取nextSerialNumber的原值,再把+1的結果寫回nextSerialNumber。如果在這兩步操作之間有第二個執行緒讀取了這個屬性,那麼就會讀到錯誤的結果。這就是安全性失敗

對於安全性失敗也有兩種解決辦法。

3.1 為方法上synchronized修飾符

只要為方法加上同步,就可以嚴格保證++過程中只有一個執行緒訪問,確保每個呼叫都會看到呼叫之前的效果。這樣volatile也就自然地可以刪去了。為了使得這個方法更加可靠,我們用long來代替int

public class Demo1_5 {
    private static long nextSerialNumber = 0;

    public static synchronized long generateSerialNumber() {
        return nextSerialNumber++;
    }
}

3.2 使用AtomicLong

它是java.util.concurrent.atomic的組成部分,這個包為單個變數上進行免鎖定、執行緒安全的程式設計提供了基本型別。它同時提供了同步的通訊效果和原子性。這正是讓generateSerialNumber順利執行的完美方案。

public class Demo1_6 {
    private static final AtomicLong nextSerialNumber = new AtomicLong();

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

4、安全釋出

避免本篇前文中所涉及到的一系列問題的最佳辦法是:不共享可變的資料。要麼共享不可變資料,要麼壓根不共享資料。換句話說,就是將可變的資料限制在單個執行緒內,不對外開放。如果採用了這一策略,對它建立文件就很重要。

總結前面的案例,我們可以發現導致執行緒不安全的主因主要出在資料寫入不可見,所以如果我們讓一個執行緒在短時間內修改一個數據物件,然後與其他執行緒共享,這是可以接受的,它只同步共享物件引用的動作。這種物件被稱作高效不可變。將這種物件引用從一個執行緒傳遞到其他執行緒被稱作安全釋出。安全釋出物件引用有很多種方法:可以將它儲存在靜態屬性中,作為類初始化的一部分;可以將它儲存在volatile屬性、final屬性或者通過正常鎖定訪問的屬性中;或者可以將它放到併發的集合中。

缺乏同步會導致無法實現可見性,這使得確定何時寫入物件引用而不是原語值變得更加困難。實現安全釋出物件的一種技術就是將物件引用定義為 volatile 型別,並將生成物件和讀取物件分離到不同的執行緒中,下面展示了一個示例,其中後臺執行緒在啟動階段從資料庫載入一些資料。其他執行緒在能夠利用這些資料時,在使用之前將檢查這些資料是否已經準備完畢,並無權對其進行修改。

public class Demo1_7 {
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            BackgroundFloobleLoader.initInBackground();
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1); //靜止一秒讓後臺程序執行完
        SomeOtherClass.doWork();
    }
}

class BackgroundFloobleLoader {
    private static volatile Flooble theFlooble;

    public static Flooble getFlooble(){
        return theFlooble;
    }
    public static void initInBackground() {
        theFlooble = new Flooble();
    }
}

class SomeOtherClass {
    private static void doSomething(Object obj){
        System.out.println("我成功做了接收到了後臺執行緒釋出的資料: " + obj);
    }

    public static void doWork() {
        if (BackgroundFloobleLoader.getFlooble() != null) {
            doSomething(BackgroundFloobleLoader.getFlooble());
        } else {
            System.out.println("後臺執行緒還沒準備好要釋出的資料!");
        }
    }
}

class Flooble{

}

看下輸出的結果:
在這裡插入圖片描述
因為我們這裡沒有私有了BackgroundFloobleLoader類中的theFlooble屬性,只提供了getter方法,也就相當於把這個屬性變成了不可變的物件,防止了其他執行緒的寫入,確保執行緒安全。

總結

總而言之,當多個執行緒共享可變資料的時候,每個讀或寫資料的執行緒都必須執行同步。如果沒有同步,就無法保證一個執行緒所做的修改可以被另一個執行緒獲知。未能同步共享可變資料會造成程式的活性失敗安全性失敗。這樣的失敗是最難除錯的,它們可能是間接性的,且與時間相關,且程式的行為在不同的虛擬機器上也會有根本不同。

如果只需要實現執行緒之間的互動通訊,而不需要實現互斥,那麼volatile修飾符就是一種可以接受的同步形式。