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
修飾符就是一種可以接受的同步形式。