關于使用線程需要注意地方,你都知道嗎?
本文轉載自微信公眾號「懷夢追碼」,可以通過以下二維碼關注。轉載本文請聯(lián)系懷夢追碼公眾號。
1. 同步訪問共享數(shù)據(jù)
問題
并發(fā)程序要比單線程程序的設計更加復雜,并且失敗難以重現(xiàn)。但是又無法避免采用多線程,因為采用多線程并發(fā)是能夠從多核計算機獲得最佳性能的一個有效途徑。在并發(fā)時,如果涉及到可變數(shù)據(jù)的時候,就是我們需要著重去思考的地方,在面對可變數(shù)據(jù)的并發(fā)訪問的時候,有哪些方式可以保證線程安全性?
答案
- 當一個對象被一個線程修改的時候,可以阻止另一個線程觀察到對象內部不一致的狀態(tài);
- 同步不僅可以阻止一個線程看到對象處于不一致的狀態(tài),還可以保證進入同步方法或者同步代碼塊的每個線程,都看到由同一個鎖保護的之前所有的修改效果。
1.關鍵字synchronized:synchronized是保證線程安全的一大利器,它可以保證同一時刻,只有一個線程可以執(zhí)行某個方法和修改某一個可變數(shù)據(jù),但是僅僅將它理解成是互斥的也是不完全正確的,它主要有兩種意義:
另外,java語言規(guī)范保證讀寫一個變量是原子的,除非這個變量是double或者long,即使沒有在保證同步的情況下也是如此。
考慮到這樣一個示例,線程通過輪詢標志位而達到優(yōu)雅的停止線程的功能,示例代碼如下:
- 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(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested()) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- requestStop();
- }
可變數(shù)據(jù)也就是狀態(tài)變量stopRequested,被同步方法修改,這里也就是保證stopRequested被修改后,能夠被其他線程立即可見。
2.關鍵字volatile:volatile最重要的功能是能夠保證數(shù)據(jù)可見性,當一個線程修改可變數(shù)據(jù)后,另一個線程會立刻知道最新的數(shù)據(jù)。在上面的例子中,因為stopRequested變量的讀寫本身就是原子的,因此利用synchronized只是利用到它的數(shù)據(jù)可見性,但是由于synchronized會加鎖,如果想性能更優(yōu)的話,上面的例子就可以采用volatile進行修改:
- private static volatile boolean stopRequested;
- public static void main(String[] args) throws InterruptedException {
- Thread backgroundThread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- stopRequested = true;
- }
但是需要注意到volatile并不能保證原子性,例如下面的例子:
- private static volatile int nextSerialNumber = 0;
- public static int generateSerialNumber() {
- return nextSerialNumber++;
- }
盡管使用了volatile,但是由于++運算符不是原子的,因此在多線程的時候會出錯。++運算符執(zhí)行兩項操作:1、讀取值;2、寫回新值(相當于原值+1)。如果第二個線程在第一個線程讀取舊值和寫會新值的時候讀取了這個域,就會產生錯誤,他們會得到相同的SerialNumber。這個時候就需要使用synchorized來使得線程間互斥訪問,從而保證原子性。
總結
解決這一問題的最好辦法其實是盡量避免在線程間共享可變數(shù)據(jù),將可變數(shù)據(jù)限制在單線程中。如果想要多個線程共享可變數(shù)據(jù),那么讀寫都需要進行同步。
2.慎用創(chuàng)建線程的方式
問題
由于并發(fā)程序很容易出現(xiàn)線程安全的問題,并且線程的管理也是件很復雜的事情,所以當創(chuàng)建一個線程時,不要通過Thread的方式手動創(chuàng)建,可以使用Executor框架進行管理。Executor的優(yōu)點是什么?
答案
- 等待任務執(zhí)行完成的方式多樣:當前線程可以等待提交到executor中的線程集合全部執(zhí)行完成(invokeAll()或invokeAny()),也可以優(yōu)雅的等待結束(awaitTermination()),也可以在任務完成時逐個獲取這些任務的結果(利用ExecutorCompletionService)等等;
- 創(chuàng)建多種類型的線程池:可以創(chuàng)建單個線程、固定的多個線程以及線程個數(shù)可變的線程池,也可以通過ThreadPoolExecutor類創(chuàng)建適合應用場景的線程池;
- 線程和執(zhí)行間的解耦:使用executor最大的好處在于將線程執(zhí)行機制和任務解耦開,之前的Thread類既充當了工作單元又是執(zhí)行機制,更好管理和使用起來更加安全可靠。
結論
在涉及到多線程程序時,不要使用Thread的方式創(chuàng)建線程,應該使用executor來管理和創(chuàng)建線程,它最大的好處在于工作單元(線程)和任務之間的解耦。
3.優(yōu)先使用并發(fā)工具
問題
高并發(fā)程序既很難保證線程安全的問題,而且一旦出現(xiàn)問題之后,也很難排錯和分析出來原因。而j.u.c包中提供了很多線程安全的工具,應該在實際開發(fā)中多使用這些性能已經得到了驗證的工具,這使得我們的開發(fā)能夠十分方便又能保證我們代碼的穩(wěn)定性。常用的并發(fā)工具有哪些?
答案
j.u.c包下的并發(fā)工具分為三類:1.負責管理線程的executor框架;2.并發(fā)集合;3.同步器。其中,負責管理線程的executor在第68條已經說過,不再單獨描述。
- 并發(fā)集合:并發(fā)集合針對標準的集合接口(如List、Queue和Map)做了進一步的處理,提供了高性能的并發(fā)實現(xiàn),常用的有CourrentHashMap,它就擴展了Map接口并保證了線程安全。另外,BlockingQueue實現(xiàn)了可阻塞的操作,即當隊列為空的時候,會阻塞“取數(shù)據(jù)”線程,直至隊列不為空位置,當隊列滿時,會阻塞“插入數(shù)據(jù)”的線程,直至隊列未滿。BlockingQueue被廣泛的應用在“生產者-消費者”中;
- 同步器:同步器能夠完成線程之間的協(xié)調,最常用的有CountdownLatch和Semaphore,較不常用的有CyclicBarrier和Exechanger。
結論
j.u.c包下跟我們提供了多種保證線程安全的數(shù)據(jù)結構,在實際開發(fā)中應該使用這些性能和安全性已經得到保證的工具,而不是重復造輪子,并且很難保證安全性。比如,在之前的代碼中“生產者-消費者”使用wait和notify的方式去實現(xiàn),代碼就很難維護,如果使用可阻塞操作的BlockingQueue代碼更加簡潔,邏輯也更加清晰。
4.線程安全文檔化
問題
有這樣幾種錯誤的說法:
這是兩種普遍錯誤的觀點,事實上,線程安全性是有多種級別的,那么,應該如何建立線程安全性的文檔?
- 通過查看文檔是否出現(xiàn)synchronized修飾符,來判斷當前方法是否是安全的。這種說話的錯誤在于,synchronized并不會通過javadoc輸出,成為api文檔的一部分,這是因為synchronized是方法具體的實現(xiàn)細節(jié),并不屬于導出API和外界模塊通信的一部分;
- “只要是加了synchronized關鍵字的方法或者代碼塊就一定是線程安全的,而沒有加這個關鍵字的代碼就不是線程安全的”。這種觀點將synchronized于線程安全等同起來,并且認為線程安全只有兩種極端的情況,要么是線程安全的,要么是線程不安全的。
答案
- 不可變的(Immutable):類的實例不可變(不可變類),一定線程安全,如String、Long、BigInteger等。
- 無條件的線程安全(Unconditionally ThreadSafe):該類的實例是可變的,但是這個類有足夠的的內部同步。所以,它的實例可以被并發(fā)使用,無需任何外部同步,如Random和ConcurrentHashMap。
- 有條件的線程安全(Conditionally ThreadSafe):某些方法需要為了線程安全需要在外部使用的時候進行同步。如Collection.synchronized返回的集合,對它們進行迭代時就需要外部同步。如下代碼,當對synchronizeColletcion返回的 collection進行迭代時,用戶必須手工在返回的 collection 上進行同步,不遵從此建議將導致無法確定的行為:
- Collection c = Collections.synchronizedCollection(myCollection);
- synchronized(c) {
- Iterator i = c.iterator(); // Must be in the synchronized block
- while (i.hasNext())
- foo(i.next());
- }
- 非線程安全(UnThreadSafe):該類是實例可變的,如需安全地并發(fā)使用,必須外部手動同步。如HashMap和ArrayList;
- 線程對立的(thread-hostile):即便所有的方法都被外部同步保衛(wèi),這個類仍不能安全的被多個線程并發(fā)使用。這種類或者方法非常少,比如System.runFinalizersOnExit方法是線程隊里的,但已經廢除了。
- 線程的安全性級別:
- 在文檔中描述有條件的線程安全類要特別小心,必須指明哪個調用方法需要外部同步,并且需要獲得哪一把鎖;
- 如果使用類使用的是“一個可公有訪問的鎖對象”的話,很可能被其他線程超時地保持公有可訪問鎖,而造成當前線程一直無法獲得鎖對象,這種行為被稱為“拒絕服務攻擊”,為了避免這種攻擊可以采用 私有鎖對象,例如:
- private final Object lock = new Object();
- public void foo(){
- synchronized(lock){
- ...
- }
- }
這時,私有鎖對象只能被當前類內部訪問到,并不能被外部訪問到,因此不可能妨礙到當前類的同步,就可以避免“拒絕服務攻擊”。但是,這種方式只適合“無條件線程安全”級別,并不能適用于“有條件性的線程安全”的級別,有條件的線程安全級別,必須在文檔中說明,在調用方法時應該獲得哪把鎖。
總結
每個類都應該利用嚴謹?shù)恼f明或者線程安全注解,清楚地在文檔中說明它的線程安全屬性。有條件的線程安全類,應該說明哪些方法需要同步訪問,以及獲得哪把鎖。無條件的線程安全類可以采用私有鎖對象來防止“拒絕服務攻擊”。涉及到線程安全的問題,應該嚴格按照規(guī)范編寫文檔。
5.慎用延遲初始化
- 問題
延遲初始化(lazy initialization)是延遲到需要域的值時才將它初始化的這種行為。如果永遠不需要這個值,這個域就永遠不會被初始化。這種方法既適用于靜態(tài)域,也適用于實例域。和大多數(shù)優(yōu)化一樣,不成熟的優(yōu)化是大部分錯誤的源頭。那么針對線程安全的延遲初始化有哪些可靠的方式?
- 答案
下面是正常初始化實例域的方式,但是要注意采用了final修飾符:
- private final FildType field= computeFieldValue();
現(xiàn)在要對這個實例域進行延遲初始化,有這樣幾種方式:
1.同步方法:在實例化域值得時候,可以使用同步方法從而保證線程安全性,如:
- private FieldType field;
- synchronized FieldType getField(){
- if(field == null){
- field = computeFieldValues();
- }
- return field;
- }
2.靜態(tài)內部類:為了減小上面這種方式的同步訪問成本,可以采用靜態(tài)內部類的方式,被稱之為lazy initialization holder class 模式。在jvm的優(yōu)化下,這種方式不僅可以達到延遲初始化的效果,也能保證線程安全。示例代碼為:
- private static class FieldHolder{
- static final FieldType field = computeFieldValue();
- }
- static FieldType getField(){
- return FieldType.field;
- }
3.雙重檢測:這種模式避免了在初始化之后,再次訪問這個域時的鎖定開銷(在普通的方法里面,會使用synchronized對方法進行同步,每次訪問方法的時候都要進行鎖定)。這種模式的思想是:兩次檢查域的值,第一次檢查時不鎖定,看看其是否初始化;第二次檢查時鎖定。只用當?shù)诙螜z查時,表明其沒有被初始化,才會調用computeFieldValue方法對其進行初始化。如果已經被初始化了,就不會鎖定了,另外該域被聲明為volatile非常重要,示例代碼為:
- private volatile FieldType field;
- public FieldType getField() {
- FieldType result = field;
- if (result == null) {
- synchronized (this) {
- result = field;
- if (result == null) {
- field = result = computeFieldValue();
- }
- }
- }
- return result;
- }
結論
大多數(shù)正常的初始化都要優(yōu)于延遲初始化。如果非要進行延遲初始化的話,針對實例域采用雙重檢測方式,針對靜態(tài)域,可以利用靜態(tài)內部類的第一次訪問才進行初始化的特性,使用靜態(tài)內部類來完成延遲初始化。
6.不要依賴線程調度器
- 問題
當有多個線程運行時,由線程調度器決定哪些線程將會運行,分配CPU時間片。但是,在大多數(shù)系統(tǒng)采用的調度策略都是不太相同的,因此,任何依賴于線程調度器來達到程序性能和正確性的并發(fā)程序都是不安全和不可移植的。那么,在編寫可移植的,健壯性強的并發(fā)程序有哪些好的方法?
- 答案
- 最好的方式是,保證可運行的線程盡可能少,或不明顯高于處理器的數(shù)量。如果,可運行的線程足夠少,對線程調度器而言就不需要“糾結”為哪個線程分配時間片,只需要讓多核處理器處理這些線程就好了。從側面來說,就降低了對線程調度器的調度策略的依賴。那么,保證盡可能少的線程數(shù)唯一的方法就是,讓每個線程都做有意義任務,從整體而言,就會降低總線程的個數(shù);
- 當程序不正確的時候,是因為線程無法獲得足夠的時間片的話,不要企圖使用Thread.yield的方式,讓其他線程讓出時間片,來滿足自身的需求。這是因為,不同的JVM上對Thread.yield語義的是不相同的,這樣就失去了可移值性。另外,在測試期間,使用Thread.yield人為地來增加線程并發(fā)性,應該由Thread.sleep(1)來代替Thread.yield;
- 千萬不要企圖通過調整線程優(yōu)先級來達到程序的正確性,線程的優(yōu)先級是最不可移植的特性。
- 結論
千萬不能讓程序依賴線程調度器,這樣會失去健壯性和可移植性。而Thread.yield和線程優(yōu)先級這些特性,是最不具有可移植性,程序中不應該使用它們。
7.避免使用線程組
- 問題
除了線程、鎖和監(jiān)視器外,線程系統(tǒng)還提供了另外一個抽象單元:線程組。線程組的設計初衷是作為隔離applet的機制,達到安全性。但是,實際上并未達到所期待的安全性,甚至都差到在JAVA安全模型上都未提及。除了安全性的糟點外,還有哪些缺陷?
- 答案
除了安全性沒有達到預期外,可用的基本功能很少;
ThreadGroup的API非常脆弱;
- 結論
線程組并沒有提供太多有用的功能,而且它們提供的許多功能還都是有缺陷的。當管理線程或處理線程組邏輯時,應該考慮使用executor。