2.實戰java高併發程序設計--java並行程序基礎

2.1 有關線程你必須知道的事

進程(Process)是計算機中的程序關於某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎。在早期面向進程設計的計算機結構中,進程是程序的基本執行實體;在當代面向線程設計的計算機結構中,進程是線程的容器。程序是指令、數據及其組織形式的描述,進程是程序的實體。

用一句簡單的話來說,你在Windows中看到的後綴爲.exe的文件都是程序。不過程序是“死”的,靜態的。當你雙擊這個.exe程序的時候,這個.exe文件中的指令就會被加載,那麼你就能得到一個有關這個.exe程序的進程。進程是“活”的,或者說是正在被執行的。

進程中可以容納若干個線程。

線程就是輕量級進程,是程序執行的最小單位。使用多線程而不是用多進程去進行併發程序的設計,是因爲線程間的切換和調度的成本遠遠小於進程

NEW狀態表示剛剛創建的線程,這種線程還沒開始執行。等到線程的start()方法調用時,才表示線程開始執行。當線程執行時,處於RUNNABLE狀態,表示線程所需的一切資源都已經準備好了。如果線程在執行過程中遇到了synchronized同步塊,就會進入BLOCKED阻塞狀態,這時線程就會暫停執行,直到獲得請求的鎖。WAITING和TIMED_WAITING都表示等待狀態,它們的區別是WAITING會進入一個無時間限制的等待,TIMED_WAITING會進行一個有時限的等待。那麼等待的線程究竟在等什麼呢?一般來說,WAITING的線程正是在等待一些特殊的事件。比如,通過wait()方法等待的線程在等待notify()方法,而通過join()方法等待的線程則會等待目標線程的終止。一旦等到了期望的事件,線程就會再次執行,進入RUNNABLE狀態。當線程執行完畢後,則進入TERMINATED狀態,表示結束。

注意:從NEW狀態出發後,線程不能再回到NEW狀態,同理,處於TERMINATED狀態的線程也不能再回到RUNNABLE狀態。

2.2 初始線程:線程的基本操作

2.2.1 新建線程

這裏要注意,下面的代碼通過編譯,也能正常執行。但是,卻不能新建一個線程,而是在當前線程中調用run()方法,只是作爲一個普通的方法調用。[插圖]因此,在這裏希望大家特別注意,調用start()方法和直接調用run()方法的區別。

注意:不要用run()方法來開啓新線程。它只會在當前線程中串行執行run()方法中的代碼。

上述代碼使用匿名內部類,重寫了run()方法,並要求線程在執行時打印“Hello, I am t1”的字樣。如果沒有特別的需要,都可以通過繼承線程Thread,重寫run()方法來自定義線程。但考慮到Java是單繼承的,也就是說繼承本身也是一種很寶貴的資源,因此,我們也可以使用Runnable接口來實現同樣的操作。Runnable接口是一個單方法接口,它只有一個run()方法:

2.2.2 終止線程

一般來說,線程執行完畢就會結束,無須手工關閉。但是,凡事都有例外。一些服務端的後臺線程可能會常駐系統,它們通常不會正常終結。比如,它們的執行體本身就是一個大大的無窮循環,用於提供某些服務。那麼如何正常地關閉一個線程呢?查閱JDK,你不難發現線程Thread提供了一個stop()方法。如果你使用stop()方法,就可以立即將一個線程終止,非常方便。但如果你使用Eclipse之類的IDE寫代碼,就會發現stop()方法是一個被標註爲廢棄的方法。也就是說,在將來,JDK可能就會移除該方法。爲什麼stop()方法被廢棄而不推薦使用呢?原因是stop()方法過於暴力,強行把執行到一半的線程終止,可能會引起一些數據不一致的問題.

Thread.stop()方法在結束線程時,會直接終止線程,

並立即釋放這個線程所持有的鎖,而這些鎖恰恰是用來維持對象一致性的。如果此時,寫線程寫入數據正寫到一半,並強行終止,那麼對象就會被寫壞,同時,由於鎖已經被釋放,另外一個等待該鎖的讀線程就順理成章地讀到了這個不一致的對象,悲劇也就此發生。可以使用break終止方法,線程會在適當的時候停止.最直接的方法就是設一個boolean類型的標誌,並通過設置這個標誌爲true或false來控制while循環是否退出

2.2.3 線程中斷

      線程中斷並不會使線程立即退出,而是給線程發送一個通知,告知目標線程,有人希望你退出啦!至於目標線程接到通知後如何處理,則完全由目標線程自行決定。這點很重要,如果中斷後,線程立即無條件退出,我們就又會遇到stop()方法的老問題。

注意:Thread.sleep()方法由於中斷而拋出異常,此時,它會清除中斷標記,如果不加處理,那麼在下一次循環開始時,就無法捕獲這個中斷,故在異常處理中,再次設置中斷標記位。

2.2.4 等待(wait)和通知(notify)

爲了支持多線程之間的協作,JDK提供了兩個非常重要的接口線程:等待wait()方法和通知notify()方法。這兩個方法並不是在Thread類中的,而是輸出Object類。這也意味着任何對象都可以調用這兩個方法。

那麼wait()方法和notify()方法究竟是如何工作的呢?圖2.5展示了兩者的工作過程。如果一個線程調用了object.wait()方法,那麼它就會進入object對象的等待隊列。這個等待隊列中,可能會有多個線程,因爲系統運行多個線程同時等待某一個對象。當object.notify()方法被調用時,它就會從這個等待隊列中隨機選擇一個線程,並將其喚醒。這裏希望大家注意的是,這個選擇是不公平的,並不是先等待的線程就會優先被選擇,這個選擇完全是隨機的。

注意:Object.wait()方法和Thread.sleep()方法都可以讓線程等待若干時間。除wait()方法可以被喚醒外,另外一個主要區別就是wait()方法會釋放目標對象的鎖,而Thread.sleep()方法不會釋放任何資源。

2.2.5 掛起(suspend)和繼續執行(resume)線程

如果你閱讀JDK有關Thread類的API文檔,可能還會發現兩個看起來非常有用的接口,即線程掛起(suspend)和繼續執行(resume)。這兩個操作是一對相反的操作,被掛起的線程,必須要等到resume()方法操作後,才能繼續指定。乍看之下,這對操作就像Thread.stop()方法一樣好用。但如果你仔細閱讀文檔說明,會發現它們也早已被標註爲廢棄方法,並不推薦使用

不推薦使用suspend()方法去掛起線程是因爲suspend()方法在導致線程暫停的同時,並不會釋放任何鎖資源。此時,其他任何線程想要訪問被它佔用的鎖時,都會被牽連,導致無法正常繼續運行(如圖2.7所示)。直到對應的線程上進行了resume()方法操作,被掛起的線程才能繼續,從而其他所有阻塞在相關鎖上的線程也可以繼續執行。但是,如果resume()方法操作意外地在suspend()方法前就執行了,那麼被掛起的線程可能很難有機會被繼續執行。並且,更嚴重的是:它所佔用的鎖不會被釋放,因此可能會導致整個系統工作不正常。而且,對於被掛起的線程,從它的線程狀態上看,居然還是Runnable,這也會嚴重影響我們對系統當前狀態的判斷。

2.2.6 等待線程結束(join)和謙讓(yeild)

一個線程的輸入可能非常依賴於另外一個或者多個線程的輸出,此時,這個線程就需要等待依賴線程執行完畢,才能繼續執行。JDK提供了join()操作來實現這個功能。如下所示,顯示了兩個join()方法:

第一個join()方法表示無限等待,它會一直阻塞當前線程,直到目標線程執行完畢。第二個方法給出了一個最大等待時間,如果超過給定時間目標線程還在執行,當前線程也會因爲“等不及了”,而繼續往下執行。

有關join()方法,我還想再補充一點,join()方法的本質是讓調用線程wait()方法在當前線程對象實例上.

可以看到,它讓調用線程在當前線程對象上進行等待。當線程執行完成後,被等待的線程會在退出前調用notifyAll()方法通知所有的等待線程繼續執行。因此,值得注意的一點是:不要在應用程序中,在Thread對象實例上使用類似wait()方法或者notify()方法等,因爲這很有可能會影響系統API的工作,或者被系統API所影響。

Thread.yield()

這是一個靜態方法,一旦執行,它會使當前線程讓出CPU。但要注意,讓出CPU並不表示當前線程不執行了。當前線程在讓出CPU後,還會進行CPU資源的爭奪,但是是否能夠再次被分配到就不一定了。因此,對Thread.yield()方法的調用就好像是在說:“我已經完成了一些最重要的工作了,我可以休息一下了,可以給其他線程一些工作機會啦!

如果你覺得一個線程不那麼重要,或者優先級非常低,而且又害怕它會佔用太多的CPU資源,那麼可以在適當的時候調用Thread.yield()方法,給予其他重要線程更多的工作機會。

Thread.yield() 方法,使當前線程由執行狀態,變成爲就緒狀態,讓出cpu時間,在下一個線程執行時候,此線程有可能被執行,也有可能沒有被執行。翻譯成中文就是讓步的意思.

java多線程編程join的作用是等待線程結束,這個作用可以產生很多特定的場景。 
1)A線程中調用B線程的join方法,那麼A線程需要等待B線程執行完成後才能完成 
2)主線程中依次調用A線程的join方法,B線程的join方法,可以保證A,B線程順序執行;

是主線程進入等待狀態,子線程在運行,子線程運行完成後會通知主線程繼續運行,或者join也可以設置主線程的等待時間,當主線程等待超時時,即使子線程沒有運行完,主線程也會開始繼續執行,


2.3 volatile與Java內存模型(JMM)

爲了在適當的場合,確保線程間的有序性、可見性和原子性。Java使用了一些特殊的操作或者關鍵字來聲明、告訴虛擬機,在這個地方,要尤其注意,不能隨意變動優化目標指令。關鍵字volatile就是其中之一

 當你用關鍵字volatile聲明一個變量時,就等於告訴了虛擬機,這個變量極有可能會被某些程序或者線程修改。爲了確保這個變量被修改後,應用程序範圍內的所有線程都能夠“看到”這個改動,虛擬機就必須採用一些特殊的手段,保證這個變量的可見性等特點。

比如,根據編譯器的優化規則,如果不使用關鍵字volatile聲明變量,那麼這個變量被修改後,其他線程可能並不會被通知到,甚至在別的線程中,看到變量的修改順序都會是反的。一旦使用關鍵字volatile,虛擬機就會特別小心地處理這種情況。

大家應該對上一章中介紹原子性時,給出的MultiThreadLong案例還記憶猶新吧!我想,沒有人願意就這麼把數據“寫壞”。那這種情況,應該怎麼處理才能保證每次寫進去的數據不壞呢?最簡單的一種方法就是加入關鍵字volatile聲明,告訴編譯器,這個long型數據,你要格外小心,因爲它會不斷地被修改。

從這個案例中,我們可以看到,關鍵字volatile對於保證操作的原子性是有非常大的幫助的。但是需要注意的是,關鍵字volatile並不能代替鎖,它也無法保證一些複合操作的原子性。比如下面的例子,通過關鍵字volatile是無法保證i++的原子性操作的。

此外,關鍵字volatile也能保證數據的可見性和有序性。如下一段代碼:主線程中修改了變量希望另一個線程能夠看到修改了變量

 

在虛擬機的Client模式下,由於JIT並沒有做足夠的優化,在主線程修改ready變量的狀態後,ReaderThread可以發現這個改動,並退出程序。但是在Server模式下,由於系統優化的結果,ReaderThread線程無法“看到”主線程中的修改,導致ReaderThread永遠無法退出(因爲代碼第7行判斷永遠不會成立),這顯然不是我們想看到的結果。這個問題就是一個典型的可見性問題

注意:可以使用Java虛擬機參數-server切換到Server模式


2.4 分門別類的管理:線程組(ThreadGroup,注意:是線程組不是線程池,可以創建線程加入線程組中)


2.5 駐守後臺:守護線程(Daemon)(守護線程守護的是用戶線程,xxThread.setDaemom(true))

守護線程是一種特殊的線程,就和它的名字一樣,它是系統的守護者,在後臺默默地完成一些系統性的服務,比如垃圾回收線程、JIT線程就可以理解爲守護線程。與之相對應的是用戶線程,用戶線程可以認爲是系統的工作線程,它會完成這個程序應該要完成的業務操作。如果用戶線程全部結束,則意味着這個程序實際上無事可做了。守護線程要守護的對象已經不存在了,那麼整個應用程序就應該結束。因此,當一個Java應用內只有守護線程時,Java虛擬機就會自然退出

上述第16行代碼將線程t設置爲守護線程。這裏注意,設置守護線程必須在線程start()之前設置,否則你會得到一個類似以下的異常,告訴你守護線程設置失敗。但是你的程序和線程依然可以正常執行,只是被當作用戶線程而已。因此,如果不小心忽略了下面的異常信息,你就很可能察覺不到這個錯誤,你就會詫異爲什麼程序永遠停不下來了呢?

在這個例子中,由於t被設置爲守護線程,系統中只有主線程main爲用戶線程,因此在main線程休眠2秒後退出時,整個程序也隨之結束。但如果不把線程t設置爲守護線程,那麼main線程結束後,t線程還會不停地打印,永遠不會結束。


2.6 先做重要的事:線程優先級(xxxThread.setPriority())

Java中的線程可以有自己的優先級。優先級高的線程在競爭資源時會更有優勢,更可能搶佔資源,當然,這只是一個概率問題。如果運氣不好,那麼高優先級線程可能也會搶佔失敗。由於線程的優先級調度和底層操作系統有密切的關係,在各個平臺上表現不一,並且這種優先級產生的後果也可能不容易預測,無法精準控制,比如一個低優先級的線程可能一直搶佔不到資源,從而始終無法運行,而產生飢餓(雖然優先級低,但是也不能餓死它呀)。因此,在要求嚴格的場合,還是需要自己在應用層解決線程調度問題。

在Java中,使用1到10表示線程優先級。一般可以使用內置的三個靜態標量表示:


2.7 線程安全的概念與關鍵字synchronized

線程安全就是並行程序的根基。大家還記得那個多線程讀寫long型數據的案例吧!它就是一個典型的反例。但在使用volatile關鍵字後,這種錯誤的情況有所改善。但是,volatile關鍵字並不能真正保證線程安全。它只能確保一個線程修改了數據後,其他線程能夠看到這個改動。但當兩個線程同時修改某一個數據時,依然會產生衝突。

兩個線程同時對i進行累加操作,各執行10 000 000次。我們希望的執行結果當然是最終i的值可以達到20 000 000,但事實並非總是如此。如果你多執行幾次下述代碼就會發現,在很多時候,i的最終值會小於20 000 000。這是因爲兩個線程同時對i進行寫入時,其中一個線程的結果會覆蓋另外一個的(雖然這個時候i被聲明爲volatile變量)。

關鍵字synchronized的作用是實現線程間的同步。它的工作是對同步的代碼加鎖,使得每一次,只能有一個線程進入同步塊,從而保證線程間的安全性(也就是說在上述代碼的第5行,每次應該只有一個線程可以執行)。如下圖有兩種寫法

關鍵字synchronized可以有多種用法,這裏做一個簡單的整理。● 指定加鎖對象:對給定對象加鎖,進入同步代碼前要獲得給定對象的鎖。● 直接作用於實例方法:相當於對當前實例加鎖,進入同步代碼前要獲得當前實例的鎖。● 直接作用於靜態方法:相當於對當前類加鎖,進入同步代碼前要獲得當前類的鎖。

在本例中就是instance對象。我不厭其煩地給出main函數的實現,是希望強調第14、15行代碼,也就是Thread的創建方式。這裏使用Runnable接口創建兩個線程,並且這兩個線程都指向同一個Runnable接口實例(instance對象),這樣才能保證兩個線程在工作時,能夠關注到同一個對象鎖上去,從而保證線程安全。

而下圖是錯誤的

上述代碼犯了一個嚴重的錯誤。雖然在第3行的increase()方法中,聲明這是一個同步方法,但很不幸的是,執行這段代碼的兩個線程指向了不同的Runnable實例。由第13、14行代碼可以看到,這兩個線程的Runnable實例並不是同一個對象。因此,線程t1會在進入同步方法前加鎖自己的Runnable實例,而線程t2也關注於自己的對象鎖。換言之,這兩個線程使用的是兩把不同的鎖。因此,線程安全是無法保證的。

但我們只要簡單地修改上述代碼,就能使其正確執行。那

就是使用關鍵字synchronized的第三種用法,將其作用於靜態方法。將increase()方法修改如下

這樣,即使兩個線程指向不同的Runnable對象,但由於方法塊需要請求的是當前類的鎖,而非當前實例,因此,線程間還是可以正確同步

除了用於線程同步、確保線程安全外,關鍵字synchronized還可以保證線程間的可見性和有序性。從可見性的角度上講,關鍵字synchronized可以完全替代關鍵字volatile的功能,只是使用上沒有那麼方便。就有序性而言,由於關鍵字synchronized限制每次只有一個線程可以訪問同步塊,因此,無論同步塊內的代碼如何被亂序執行,只要保證串行語義一致,那麼執行結果總是一樣的。而其他訪問線程,又必須在獲得鎖後方能進入代碼塊讀取數據,因此,它們看到的最終結果並不取決於代碼的執行過程,有序性問題自然得到了解決(換言之,被關鍵字synchronized限制的多個線程是串行執行的)。


2.8 程序中的幽靈:隱蔽的錯誤

2.8.1 無提示的錯誤案例

 

2.8.2 併發下的ArrayList

我們都知道,ArrayList是一個線程不安全的容器。如果在多線程中使用ArrayList,可能會導致程序出錯。究竟可能引起哪些問題呢?試看下面的代碼:

第一,程序正常結束,ArrayList的最終大小確實200萬。這說明即使並行程序有問題,也未必會每次都表現出來。第二,程序拋出異常。[插圖]這是因爲ArrayList在擴容過程中,內部一致性被破壞,但由於沒有鎖的保護,另外一個線程訪問到了不一致的內部狀態,導致出現越界問題。第三,出現了一個非常隱蔽的錯誤,比如打印如下值作爲ArrayList的大小。

注意:改進的方法很簡單,使用線程安全的Vector代替ArrayList即可。

2.8.3 併發下詭異的HashMap

HashMap同樣不是線程安全的。當你使用多線程訪問HashMap時,也可能會遇到意想不到的錯誤。不過和ArrayList不同,HashMap的問題似乎更加詭異

最簡單的解決方案就是使用ConcurrentHashMap代替HashMap。

2.8.4 初學者常見的問題:錯誤的加鎖

如下錯誤代碼

結果卻得到了一個比20 000 000小很多的數字,比如15 992 526。這說明什麼問題呢?一定是這段程序並沒有真正做到線程安全!但把鎖加在變量i上又有什麼問題呢?似乎加鎖的邏輯也是無懈可擊的。要解釋這個問題,得從Integer說起。在Java中,Integer屬於不變對象,即對象一旦被創建,就不可能被修改。也就是說,如果你有一個Integer對象代表1,那麼它就永遠表示1,你不可能修改Integer對象的值,使它爲2。那如果你需要2怎麼辦呢?也很簡單,新建一個Integer對象,並讓它表示2即可。

兩個線程每次加鎖可能都加在了不同的對象實例上,從而導致對臨界區代碼控制出現問題。

解決很簡單,對變量枷鎖改成對對象枷鎖

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章