摘要:有的情況下我們希望自己設計的類可以讓客戶端程序員們不需要使用額外的同步操作就可以放心的在多線程環境下使用,我們就把這種類成為線程安全類。
設計線程安全的類
前邊我們對線程安全性的分析都停留在一兩個可變共享變量的基礎上,真實并發程序中可變共享變量會非常多,在出現安全性問題的時候很難準確定位是哪塊兒出了問題,而且修復問題的難度也會隨著程序規模的擴大而提升(因為在程序的各個位置都可以隨便使用可變共享變量,每個操作都可能導致安全性問題的發生)。比方說我們設計了一個這樣的類:
public class Increment { private int i; public void increase() { i++; } public int getI() { return i; } }
然后有很多客戶端程序員在多線程環境下都使用到了這個類,有的程序員很聰明,他在調用increase方法時使用了適當的同步操作:
public class RightUsageOfIncrement { public static void main(String[] args) { Increment increment = new Increment(); Thread[] threads = new Thread[20]; //創建20個線程 for (int i = 0; i < threads.length; i++) { Thread t = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 100000; i++) { synchronized (RightUsageOfIncrement.class) { // 使用Class對象加鎖 increment.increase(); } } } }); threads[i] = t; t.start(); } for (int i = 0; i < threads.length; i++) { try { threads[i].join(); } catch (InterruptedException e) { throw new RuntimeException(e); } } System.out.println(increment.getI()); } }
在調用Increment的increase方法的時候,使用RightUsageOfIncrement.class這個對象作為鎖,有效的對i++操作進行了同步,的確不錯,執行之后的結果是:
2000000
可是并不是每個客戶端程序員都會這么聰明,有的客戶端程序員壓根兒不知道啥叫個同步,所以寫成了這樣:
public class WrongUsageOfIncrement { public static void main(String[] args) { Increment increment = new Increment(); Thread[] threads = new Thread[20]; //創建20個線程 for (int i = 0; i < threads.length; i++) { Thread t = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 100000; i++) { increment.increase(); //沒有進行有效的同步 } } }); threads[i] = t; t.start(); } for (int i = 0; i < threads.length; i++) { try { threads[i].join(); } catch (InterruptedException e) { throw new RuntimeException(e); } } System.out.println(increment.getI()); } }
沒有進行有效同步的執行結果是(每次執行都可能不一樣):
1815025
其實對于Increment這個類的開發者來說,本質上是把對可變共享變量的必要同步操作轉嫁給客戶端程序員處理。有的情況下我們希望自己設計的類可以讓客戶端程序員們不需要使用額外的同步操作就可以放心的在多線程環境下使用,我們就把這種類成為線程安全類。其實就是類庫設計者把一些在多線程環境下可能導致安全性問題的操作封裝到類里邊兒,比如Increment的increase方法,我們可以寫成這樣:
public synchronized void increase() { i++; }
也就是說把對可變共享變量i可能造成多線程安全性問題的i++操作在Increment類內就封裝好,其他人直接調用也不會出現安全性問題。使用封裝也是無奈之舉:你無法控制其他人對你的代碼調用,風險始終存在,封裝使無意中破壞設計約束條件變得更難。
封裝變量訪問找出共享、可變的字段
設計線程安全類的第一步就是要找出所有的字段,這里的字段包括靜態變量也包括成員變量,然后再分析這些字段是否是共享并且可變的。
首先辨別一下字段是否是共享的。由于我們無法控制客戶端程序員以怎樣的方式來使用這個類,所以我們可以通過訪問權限,也就是public權限、protected權限、 默認權限以及private權限來控制哪些代碼是可以被客戶端程序員調用的,哪些是不可以調用的。一般情況下,我們需要把所有字段都聲明為 private 的,把對它們的訪問都封裝到方法中,對這些方法再進行必要的同步控制,也就是說我們只暴露給客戶端程序員一些可以調用的方法來間接的訪問到字段,因為如果直接把字段暴露給客戶端程序員的話,我們無法控制客戶端程序員如何使用該字段,比如他可以隨意的在多線程環境下對字段進行累加操作,從而不能保證把所有同步邏輯都封裝到類中。所以如果一個字段是可以通過對外暴露的方法訪問到,那這個字段就是共享的。
然后再看一下字段是否是可變的。如果該字段的類型是基本數據類型,可以看一下類所有對外暴露的方法中是否有修改該字段值的操作,如果有,那這個字段就是可變的。如果該字段的類型是非基本數據類型的,那這個字段可變就有兩層意思了,第一是在對外暴露的方法中有直接修改引用的操作,第二是在對外暴露的方法中有直接修改該對象中字段的操作。比如一個類長這樣:
public class MyObj { private Listlist; public void m1() { list = new ArrayList<>(); //直接修改字段指向的對象 } public void m2() { list[0] = "aa"; //修改該字段指向對象的字段 } }
代碼中的m1和m2都可以算做是修改字段list,如果類暴露的方法中有這兩種修改方式中的任意一種,就可以算作這個字段是可變的。
小貼士:是不是把字段聲明成final類型,該字段就不可變了呢? 如果該字段是基本數據類型,那聲明為final的確可以保證在程序運行過程中不可變,但是如果該字段是非基本數據類型,那么需要讓該字段代表的對象中的所有字段都是不可變字段才能保證該final字段不可變。
所以在使用字段的過程中,應該盡可能的讓字段不共享或者不可變,不共享或者不可變的字段才不會引起安全性問題哈哈。
這讓我想起了一句老話:只有死人才不會說話~
用鎖來保護訪問
確定了哪些字段必須是共享、可變的之后,就要分析在哪些對外暴露的方法中訪問了這些字段,我們需要在所有的訪問位置都進行必要的同步處理,這樣才可以保證這個類是一個線程安全類。通常,我們會使用鎖來保證多線程在訪問共享可變字段時是串行訪問的。
但是一種常見的錯誤就是:只有在寫入共享可變字段時才需要使用同步,就像這樣:
public class Test { private int i; public int getI() { return i; } public synchronized void setI(int i) { this.i = i; } }
為了使Test類變為線程安全類,也就是需要保證共享可變字段i在所有外界能訪問的位置都是線程安全的,而上邊getI方法可以訪問到字段i,卻沒有進行有效的同步處理,由于內存可見性問題的存在,在調用getI方法時仍有可能獲取的是舊的字段值。所以再次強調一遍:我們需要在所有的訪問位置都進行必要的同步處理。
使用同一個鎖
還有一點需要強調的是:如果使用鎖來保護共享可變字段的訪問的話,對于同一個字段來說,在多個訪問位置需要使用同一個鎖。
我們知道如果多個線程競爭同一個鎖的話,在一個線程獲取到鎖后其他線程將被阻塞,如果是使用多個鎖來保護同一個共享可變字段的話,多個線程并不會在一個線程訪問的時候阻塞等待,而是會同時訪問這個字段,我們的保護措施就變得無效了。
一般情況下,在一個線程安全類中,我們使用同步方法,也就是使用this對象作為鎖來保護字段的訪問就OK了~。
封不封裝取決于你的心情
雖然面向對象技術封裝了安全性,但是打破這種封裝也沒啥不可以,只不過安全性會更脆弱,增加開發成本和風險。也就是說你把字段聲明為public訪問權限也沒人攔得住你,當然你也可能因為某種性能問題而打破封裝,不過對于我們實現業務的人來說,還是建議先使代碼正確運行,再考慮提高代碼執行速度吧~。
不變性條件現實中有些字段之間是有實際聯系的,比如說下邊這個類:
public class SquareGetter { private int numberCache; //數字緩存 private int squareCache; //平方值緩存 public int getSquare(int i) { if (i == numberCache) { return squareCache; } int result = i*i; numberCache = i; squareCache = result; return result; } public int[] getCache() { return new int[] {numberCache, squareCache}; } }
這個類提供了一個很簡單的getSquare功能,可以獲取指定參數的平方值。但是它的實現過程使用了緩存,就是說如果指定參數和緩存的numberCache的值一樣的話,直接返回緩存的squareCache,如果不是的話,計算參數的平方,然后把該參數和計算結果分別緩存到numberCache和squareCache中。
從上邊的描述中我們可以知道,squareCache不論在任何情況下都是numberCache平方值,這就是SquareGetter類的一個不變性條件,如果違背了這個不變性條件的話,就可能會獲得錯誤的結果。
在單線程環境中,getSquare方法并不會有什么問題,但是在多線程環境中,numberCache和squareCache都屬于共享的可變字段,而getSquare方法并沒有提供任何同步措施,所以可能造成錯誤的結果。假設現在numberCache的值是2,squareCache的值是3,一個線程調用getSquare(3),另一個線程調用getSquare(4),這兩個線程的一個可能的執行時序是:
兩個線程執行過后,最后numberCache的值是4,而squareCache的值竟然是9,也就意味著多線程會破壞不變性條件。為了保持不變性條件,我們需要把保持不變性條件的多個操作定義為一個原子操作,即用鎖給保護起來。
我們可以這樣修改getSquare方法的代碼:
public synchronized int getSquare(int i) { if (i == numberCache) { return squareCache; } int result = i*i; numberCache = i; squareCache = result; return result; }
但是不要忘了將代碼都放在同步代碼塊是會造成阻塞的,能不進行同步,就不進行同步,所以我們修改一下上邊的代碼:
public int getSquare(int i) { synchronized(this) { if (i == numberCache) { // numberCache字段的讀取需要進行同步 return squareCache; } } int result = i*i; //計算過程不需要同步 synchronized(this) { // numberCache和squareCache字段的寫入需要進行同步 numberCache = i; squareCache = result; } return result; }
雖然getSquare方法同步操作已經做好了,但是別忘了SquareGetter類的getCache方法也訪問了numberCache和squareCache字段,所以對于每個包含多個字段的不變性條件,其中涉及的所有字段都需要被同一個鎖來保護,所以我們再修改一下getCache方法:
public synchronized int[] getCache() { return new int[] {numberCache, squareCache}; }
這樣修改后的SquareGetter類才屬于一個線程安全類。
使用volatile修飾狀態使用鎖來保護共享可變字段雖然好,但是開銷大。使用volatile修飾字段來替換掉鎖是一種可能的考慮,但是一定要記住volatile是不能保證一系列操作的原子性的,所以只有我們的業務場景符合下邊這兩個情況的話,才可以考慮:
對變量的寫入操作不依賴當前值,或者保證只有單個線程進行更新。
該變量不需要和其他共享變量組成不變性條件。
比方說下邊的這個類:
public class VolatileDemo { private volatile int i; public int getI() { return i; } public void setI(int i) { this.i = i; } }
VolatileDemo中的字段i并不和其他字段組成不變性條件,而且對于可以訪問這個字段的方法getI和setI來說,并不需要以來i的當前值,所以可以使用volatile來修飾字段i,而不用在getI和setI的方法上使用鎖。
避免this引用逸出我們先來看一段代碼:
public class ExplicitThisEscape { private final int i; public static ThisEscape INSTANCE; public ThisEscape() { INSTANCE = this; i = 1; } }
在構造方法中就把this引用給賦值到了靜態變量INSTANCE中,而別的線程是可以隨時訪問INSTANCE的,我們把這種在對象創建完成之前就把this引用賦值給別的線程可以訪問的變量的這種情況稱為 this引用逸出,這種方式是極其危險的!,這意味著在ThisEscape對象創建完成之前,別的線程就可以通過訪問INSTANCE來獲取到i字段的信息,也就是說別的線程可能獲取到字段i的值為0,與我們期望的final類型字段值不會改變的結果是相違背的。所以千萬不要在對象構造過程中使this引用逸出。
上邊的this引用逸出是通過顯式將this引用賦值的方式導致逸出的,也可能通過內部類的方式神不知鬼不覺的造成this引用逸出:
public class ImplicitThisEscape { private final int i; private Thread t; public ThisEscape() { t = new Thread(new Runnable() { @Override public void run() { // ... 具體的任務 } }); i = 1; } }
雖然在ImplicitThisEscape的構造方法中并沒有顯式的將this引用賦值,但是由于Runnable內部類的存在,作為外部類的ImplicitThisEscape,內部類對象可以輕松的獲取到外部類的引用,這種情況下也算this引用逸出。
this引用逸出意味著創建對象的過程是不安全的,在對象尚未創建好的時候別的線程就可以來訪問這個對象。雖然我們不確定客戶端程序員會怎么使用這個逸出的this引用,但是風險始終存在,所以強烈建議千萬不要在對象構造過程中使this引用逸出。
總結客戶端程序員不靠譜,我們有必要把線程安全性封裝到類中,只給客戶端程序員提供線程安全的方法。
認真找出代碼中既共享又可變的變量,并把它們使用鎖來保護起來,同一個字段的多個訪問位置需要使用同一個鎖來保護。
對于每個包含多個字段的不變性條件,其中涉及的所有字段都需要被同一個鎖來保護。
在對變量的寫入操作不依賴當前值以及該變量不需要和其他共享變量組成不變性條件的情況下可以考慮使用volatile變量來保證并發安全。
千萬不要在對象構造過程中使this引用逸出。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/74145.html
摘要:比如需要用多線程或分布式集群統計一堆用戶的相關統計值,由于用戶的統計值是共享數據,因此需要保證線程安全。如果類是無狀態的,那它永遠是線程安全的。參考探索并發編程二寫線程安全的代碼 線程安全類 保證類線程安全的措施: 不共享線程間的變量; 設置屬性變量為不可變變量; 每個共享的可變變量都使用一個確定的鎖保護; 保證線程安全的思路: 1. 通過架構設計 通過上層的架構設計和業務分析來避...
摘要:并發模塊本身有兩種不同的類型進程和線程,兩個基本的執行單元。調用以啟動新線程。在大多數系統中,時間片發生不可預知的和非確定性的,這意味著線程可能隨時暫?;蚧謴?。 大綱 什么是并發編程?進程,線程和時間片交織和競爭條件線程安全 策略1:監禁 策略2:不可變性 策略3:使用線程安全數據類型 策略4:鎖定和同步 如何做安全論證總結 什么是并發編程? 并發并發性:多個計算同時發生。 在現代...
摘要:對象的組合介紹一些組合模式,這些模式能夠使一個類更容易成為線程安全的,并且維護這些類時不會無意破壞類的安全性保證。狀態變量的所有者將決定采用何種加鎖協議來維持變量狀態的完整性。所有權意味著控制權。 對象的組合 介紹一些組合模式,這些模式能夠使一個類更容易成為線程安全的,并且維護這些類時不會無意破壞類的安全性保證。 設計線程安全的類 在設計線程安全類的過程中,需要包含以下三個基本要素: ...
閱讀 2940·2023-04-26 01:52
閱讀 3468·2021-09-04 16:40
閱讀 3629·2021-08-31 09:41
閱讀 1764·2021-08-09 13:41
閱讀 555·2019-08-30 15:54
閱讀 2959·2019-08-30 11:22
閱讀 1612·2019-08-30 10:52
閱讀 947·2019-08-29 13:24