Java 進階——併發編程必知必會之需要掌握的進程、線程、Java內存模型、synchronized、volatile等基本理論(一)

引言

一個計算機操作系統主要由I/O設備、總線、主存與中央處理器、外設等組成,由於CPU 的運算速度遠遠遠遠超過總線上的其他設備,如果想要充分利用計算機處理器的能力,原則上就不能讓他控線下來,否則就太浪費資源了,於是就設計讓計算機處理器“同時”處理多項任務…

“同時”——宏觀上可以執行多個應用程序;但微觀只有一個CPU,一個時間片內只能運行一條進程,只不過切換的速度非常非常地快,看起來同時。

設備之間輪流佔有CPU的資源(以時間片爲最小單位)執行各自的任務

時間片——由內核分配的,每個線程被分配到一個時間段(若干個時間片),即該進程允許運行的時間,從而使得各個程序從表面上看是同時進行的。若在自己的時間段結束時進程還在運行,則CPU將被剝奪並分配給另一個進;如果進程在時間片結束前阻塞或結束,則CPU當即進行上下文切換,這樣就不會導致CPU資源浪費。

進入併發前你需要掌握一些理論知識,系列文章如下:

一、進程和線程

1、進程和線程設計思想概述

爲了管理好CPU資源分配,於是乎進程Process這一概念應運而生,以進程爲CPU資源分配的最小單位,可以把進程理解對CPU工作時間段的描述,而執行執行一段程序代碼,在相關前置條件(即所有的程序上下文)都滿足後,就需要申請CPU資源,當得到CPU時開始執行,早程序執行完了或者分配給他的CPU執行時間到了,那麼爲了公平它就要被切換出去,等待下一次獲取CPU資源。通常在被切換出去的最後一步工作就是保存程序上下文,因此進程=上下文切換的程序執行時間總和 = CPU加載上下文+CPU執行+CPU保存上下文,一個進程最基本的內容包含:PCB、程序段、數據段等。

通俗來說,先加載A程序片段的上下文接着真正執行A,然後保存程序A的上下文;再調入下一個要執行的程序B的程序上下文,然後開始執行B,保存程序B的上下文…

雖然已經有了進程這個概念,但是粒度還是太大且進程切換性能略大,於是線程應運而生,線程(最基本的內容包含:線程Id、當前指令指針PC、寄存器集合、堆棧)是輕量級的進程,線程是進程中的一個實體,所以也可以看成是CPU工作時間段的另一種更小描述,是被系統獨立調度和分派的基本單位,線程自己不擁有自己的系統資源,只擁有一點在運行中必不可少的資源,但它可以與同屬一個進程的其他線程共享進程所擁有的全部資源一個進程至少有一個線程,一個進程可以運行多個線程,多個線程可共享數據。在同一線程內,代碼是嚴格遵照邏輯順序執行的,但是在多線程則不然,而且進程必定多個線程,當進程得到CPU資源之後理論上會依次執行各部分的線程,這些線程共享進程的上下文環境,CPU在跳轉執行時無需進行上下文切換的。簡而言之,CPU 把時間片分配到進程中,再獲取線程作爲最小單位執行。(僅供參考)。

2、Windows 下的進程和線程

Windows中進程只是作爲資源的擁有者,並不是實際任務的執行者,實際的執行靠線程實現。一個進程可以擁有多個線程,多個線程共享進程擁有的資源,在具體執行任務時由線程來使用處理機。Windows中,進程實現靠createProcess實現。而createProcess有一大堆的參數,不過很多時候都默認爲null。其作用相當於創建一個進程的同時創建一個線程(一般一個),其作用相當於fork+execv。

3、Linux 下的進程

Linux起源於Unix,而早期Unix在大型機器上使用,沒有進程的概念,直到後來也不明確區分進程、線程,根據長期總結經驗,得到Linux快速複製父進程的方法——fork進程本身clone一個新的出來,快速複製父進程,子進程直接使用父進程的地址,開銷小,效率高。基本上Linux的進程創建靠fork實現。Linux中進程本身是可以執行的(區別Windows),通過fork創建新進程後,即fork之後子進程和父進程都執行同樣的一段代碼,想要區分,必須通過fork的返回值來區分。

int pid = fork();
if (pid < 0) { printf("error");} //小於0,發生錯誤
else if( pid == 0 ) { printf("子進程!");} //==0,爲子進程
else{  printf("父進程! ");} // >0,父進程

那麼子進程需要執行其他的資源呢?用exevc族,此時才爲子進程開闢自己的地址空間,再傳入需要執行的文件,就可以執行具體的內容了。從Linux內核角度而言,基本沒有什麼線程和進程的區別(本質上都是進程)一個進程的多個線程只是多個特殊的進程他們雖然有各自的進程描述結構,卻共享了同一個代碼上下文,這樣的進程稱爲輕量級進程Light weight process。在Linux 2.4內核的多線程無法使用多個cpu,一個進程的線程都被限制在同一個cpu上運行。因此多線程庫pthread的實現是建立在特有的線程模型之上,2.4內核使用了一個內核線程來處理用戶態進程中的多個線程的上下文切換(線程切換)。內核中也並沒有線程組(一個進程的多個線程)的概念,因此必須依在pthread庫中實現一個額外的線程來管理其他用戶線程(即用戶程序生成的線程)的建立、退出、資源分配和回首以及線程的切換。加上早期硬件並沒有線程寄存器之類的東西支持多線程,因此多線程切換效率低下,並且需要引入複雜的機制在進程的棧中爲各個線程劃分出各自的棧數據所在位置,並且在切換時進行棧數據拷貝,而最大的問題是內核中缺乏對線程間的同步機制的支持,因pthread庫不得不在底層依靠信號量的方式來實現同步,因此線程互斥中的互斥操作和條件量操作都轉換爲進程的信號操作(信號量是比較低俗的通信方式,勢必降低線程的實際性能。最後的問題是信號處理,內核對線程並不清楚,必須由管理線程接收信號投遞給相應線程,造成了效率低下以及不必要的問題)。在後來的2.5內核之後,硬件結構中已經大爲發展,出現了對線程寄存器的支持,因此pthread的切換速度已經大大提高,不過受硬件限制,線程數量小於8192個。直到2.6內核中已經使用了NPTL(Native POSIX Thread Library),linux線程的一種新實現,在性能和穩定性方面都提供了重大的改進.

  • NPTL不在使用管理線程.現在內核本身就可以實現這些功能,內核還可以處理線程堆棧所使用內存的回首工作.

  • NPTL沒有了管理線程,使得其在NUMA和SMP系統上有了更好的伸縮性和同步機制.

  • NPTL線程庫可以避免使用信號量來實現線程同步,它引入了一種名爲futex的機制,在共享內存區域上盡心工工作,可以實現進程之間共享,就可以提供進程間posix同步機制.它使得線程間、進程間共享成爲可能。Posix兼容,對信號處理等機制相應處理更爲健全向後兼容,至此,linux已經可以支持多處理機下的多線程操作。

4、Java多線程

Java編寫的程序都運行在Java虛擬機(JVM)中,而在JVM的內部,程序的多任務是通過線程來實現的。每用java命令啓動一個java應用程序,就會相應啓動一個JVM進程。在同一個JVM進程中有且只有一個進程(就是它自身),在這個JVM進程裏所有程序代碼的運行都是以線程來運行的。JVM找到程序程序的入口點main(),然後運行main()方法,隨即產生了一個稱之爲主線程的線程,所以當main方法結束後,主線程運行完成,JVM進程也隨即退出。所以其實並不是完全Java語言自帶的多線程機制,只不過是通過在編譯器級別使用多線程內存模型,利用其一般的順序一致性原則來編寫高效率的多線程程序,底層往往調用系統的api,中間過程做了自動優化。C++新標準使用了名爲sequential consistency for data race free programs的多線程模型機制;而Java的緩存一致性模型(包含順序一致性模型、釋放一致性模型)來實現其多線程。

5、多任務vs 多進程、多線程

計算機的早期,多任務被稱作多道程序.多任務處理是指計算機同時運行多個程序的能力.多任務的一般方法是運行第一個程序的一段代碼,保存工作環境;再運行第二個程序的一段代碼,保存環境;……恢復第一個程序的工作環境,執行第一個程序的下一段代碼……現代的多任務,每個程序的時間分配相對平均.對於實時操作系統,任務調度直接影響性能. 任務切換、調度方式等也都類似於進程、線程調度方式,其實這裏的任務在windows下可能就是一堆等待執行的線程,也可能像linux下的多個進程等方式.實際上,任務調度就是在對進程或線程調度.

二、Runnable

Runnable 意爲可被執行的意思,Runnable 通過公開的run接口交由實現類(可以理解爲任務)去實現,相當於是提供了外部應用程序可以通過對應的實例對象調用到run方法,因此無論是何種方式都需要通過Thread的run方法來真正啓動線程。當Runnable 被傳入到Thread 之後時候,會把它設置到Thread#target屬性之上,而Thread#run、方法執行時就是調用這個target的run方法,可以說Runnable 是Thread與其要執行的任務的隔離機制——Thread 可以執行各種不同的任務,只要傳入不同的Runnable實現即可。

三、線程創建的原理概述

在java中,創建線程有三種形式:

  • 直接繼承Thread並重寫run方法

  • 實現Runnable接口並傳入Thread,通過Thread 來執行Runnale 任務。

  • 實現Callable 配合FutrueTask 傳入Thread。

如果你大量創建線程之後,可能會報”unable to create new native thread“的異常,因爲線程使用的是堆外的內存空間,而通過構造函數得到Thread對象本身只是JVM內的一個普通對象,可以看成是一個線程操作的外殼,只有調用了Thread#start方法(JVM內部會通過JNI創建)由OS 真正分配線程資源之後的Thread對象纔是真正的線程對象,所以真正創建線程有兩大步驟:

  1. 在JVM的堆中創建了一個Thread普通對象(外殼)
  2. 調用start方法,通過JNI 給這個外殼賦予真正的線程能力,才能被OS 調度
Thread t=new Thread(){...};
t.start();

簡而言之,new Thread只是創建外殼,還需要調用start方法才真正完成線程的創建,才能被OS調度。

四、線程的狀態

對線程的每個操作,都可能會使線程處於不同的工作機制下,嚴格地來說線程的狀態有兩方面,OS底層真正的狀態和JDK 上層封裝的狀態,通過Thread#getState()方法可以得到當前線程的狀態值(對應java.lang.Thread.State 枚舉)
在這裏插入圖片描述

1、新建(NEW)

剛創建未執行start方法時的狀態,需要注意調用了start方法,並不意味着狀態立即就改變,因爲期間還有一些步驟,只有這些步驟完成了之後狀態值纔會改變。

2、運行(RUNNABLE)

操作系統中的RUNNING和READY態時,此狀態的線程有可能正在執行,也有可能在等待着CPU爲它分配執行時間,
在這裏插入圖片描述
主要場景有:

  • 當處於NEW的線程執行start方法結束後

  • 當前正在正常運行的線程或當某個運行的線程發生了yield操作

RUNNABLE 態可以由其他狀態轉成,同樣RUNNABLE態的也可以執行很多操作變成其他態,這也是爲什麼不能連續對同一線程多次start的原因之一,因爲RUNNABLE不能轉爲RUNNABLE。但是Java 的RUNNABLE態並不一定代表它一定處於運行中的狀態,比如在BIO中即使線程正阻塞在網絡等待時,對應的線程狀態依然爲RUNNABLE,又比如yield操作時,對應的狀態也是RUNNABLE,另外對於處於RUNNABLE 態的線程interrupt無效。

yield——主動放棄當前CPU資源,讓其他線程去執行,放棄的時間不確定,可能剛剛放棄馬上又重新獲得

3、阻塞(BLOCKED)

在等待鎖被阻塞導致線程被掛起時的狀態,得到鎖之後馬上變爲RUNNABLE。一旦處於BLOCKED,線程就像什麼都沒做一樣,Java層面無法喚醒它,用interrupt 也沒用,因爲interrupt 只是在裏面做一個標記,並不是真正喚醒處於阻塞態的線程。BLOCKED狀態是JVM認爲程序還不能進入某臨界區,因爲同時訪問會有問題。

4、無限期等待(WAITING)

WAITING表示線程已經進入了臨界區切拿到了鎖,在即將開始真正的工作時候,發現有些前置條件不滿足,自己主動等等去做其他事。處於WAITING態的可以用interrupt 重新喚醒,因爲當執行這個方法時內部會拋出一個InterruptedException的異常,進而被run方法捕獲,使得run方法正常地執行完成。若在某個線程內執行了鎖對象的notify/notifyAll方法候,該線程狀態變爲RUNNABLE。主要場景有:

  • Thread.sleep()
  • 不設置Timeout 參數的Object.wait()和Thread.join() 方法
  • LockSuport.park方法

5、限期等待(TIMED_WAITING)

相當於把某個時間資源作爲鎖,進而到達等待的目的,時間到達時或者被重新喚醒自動觸發線程回到RUNNABLE,主要場景有:

  • Thread.sleep(ms)
  • 傳遞了Timeout 參數的Object.wait(ms)和Thread.join(ms) 方法
  • LockSuport.parkUtil 或者parkNanos方法

6、結束(TERMINATED)

run方法執行結束後,這種狀態是Java 層面的,OS底層可能已經註銷了相應的線程或者已經複用給其他線程的請求

五、調度的優先級和線程中斷

1、調度的優先級

線程的調度優先級可以通過setPriority方法實現,JVM爲了兼容各種OS 平臺設定了110個優先級(有些OS只有35個優先級,JVM 會自動去建立對應的映射關係),數字越大優先級越大,另外線程的優先級具有繼承性,A線程啓動B線程則B線程的優先級和A的是一樣的,一般說來高優先級的會比低優先級的先執行完畢,單並不是所有的高優先級線程都比低優先級線程先執行完畢。此外除了用戶線程,JVM中有一種優先級極低的後臺(守護)線程(調用setDaemon(true))的線程,不會去搶佔別人的CPU,當JVM進程中活着的線程只剩下後臺進程時,意味着整個進程要結束了。比如GC 線程,如果main線程一直活着,那麼GC 線程就不會銷燬,main線程一旦死亡GC 也會自動銷燬。

2、線程的中斷

當線程的run()方法執行方法體中的最後一條語句後,並經由執行return語句返回時或者在方法體執行過程中出現沒有捕獲的異常時線程將終止,即線程的中斷。Java爲提供了一種調用interrupt()方法(interrupt方法不會中斷一個正在運行的線程,僅僅設置一個線程中斷標誌位,若程序中你不檢測線程中斷標誌位,即使設置了中斷標誌位爲true,線程也一樣照常運行。)來嘗試終止線程的方法,在每一個線程都有一個boolean類型標誌,用來表明當前線程是否請求中斷,當一個線程調用interrupt() 方法時,線程的中斷標誌將被設置爲true。可通過isInterrupted()或者Thread.interrupted()方法來判斷線程的是否請求中斷,而拋出異常是爲了線程從阻塞狀態醒過來,並在結束線程前讓程序員有足夠的時間來處理中斷請求。但是如果每次迭代之後都調用sleep方法(或者其他可中斷的方法),isInterrupted檢測就沒必要也沒用處了,因爲假如在中斷狀態被置位時調用sleep方法,它不會休眠反而會清除這一休眠狀態並拋出InterruptedException。所以如果在循環中調用sleep,不要去檢測中斷狀態,只需捕獲InterruptedException。

六、JVM定義的Java內存模型(Java Memory Model, JMM)

1、JMM概述

由於內存中的運算速度遠遠大於I/O操作,爲了解決這個弊端,主流的計算機系統中引入了高速緩存的概念:
在這裏插入圖片描述

Cache存儲器_電腦中爲高速緩衝存儲器,是位於CPU和主存儲器DRAM(Dynamic Random Access Memory)之間,規模較小,但速度很高的存儲器,通常由SRAM(Static Random Access Memory靜態存儲器)組成。Cache的功能是提高CPU數據輸入輸出的速率。Cache容量小但速度快,內存速度較低但容量大。

在變量定義初始化時主動寫入主存並拷貝到高速緩存中,下次再次訪問的時候首先去高速緩存中執行相關操作,通常結果不會馬上寫回到主存。爲了屏蔽不同平臺的內存模型差異,JVM不會去操作計算機系統裏真正的內存,於是乎JVM的內存模型(JVMM),簡而言之,JVMM 就是一套定義程序中各個共享變量(非Java編程語言的變量,包含了除了局部變量和方法參數之外的實例字段、靜態字段和構成數組對象的元素)爲了讓Java的併發操作時線程安全。

局部變量和方法參數是線程私有的,不存在線程安全問題的隱患。

2、JMM的主要內容

  • JMM 規定了所有的非線程私有變量都存在主存Main Memory(類比物理硬件的部分主存,但實際上是JVM內存的一部分)中,每條線程還定義了工作內存Working Memory(類比於高速緩存,用於保存了該線程所有使用到的變量的主存副本拷貝)。

  • 線程對變量的所有操作都必須在工作內存中,而不能直接讀寫主存的變量。

  • 不同的線程間無法直接訪問彼此的工作內存的變量,線程間變量值的傳遞均需要通過主存來完成。

  • 定義了主存和工作內存的交互協議。

此處的的主存和工作內存,與Java 運行時內存區域中的Java堆、棧、方法區並不是同一層次的概念,主存可以簡單對應到Java 堆中的對象實例數據部分;而工作內存則對應於JVM 棧部分區域,即程序運行時的主要區域。

3、主存和工作內存的交互規範

一個變量如何從主存拷貝到工作內存,反之亦然,JMM定義了8種原子操作(對應字節碼指令):

原子操作 說明
lock(鎖定) 作用於主內存,它把一個變量標記爲一條線程獨佔狀態。
unlock(解鎖) 作用於主內存,它將一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其他線程鎖定。
store(存儲) 作用於工作內存,它把工作內存中的一個變量傳送給主內存中,以備隨後的write操作使用。
load(載入) 作用於工作內存,它把read操作的值放入工作內存中的變量副本中。
read(讀取) 作用於主內存,它把變量值從主內存傳送到線程的工作內存中,以便隨後的load動作使用。
write(寫入) 作用於主內存,它把store傳送值放到主內存中的變量中。
use(使用) 作用於工作內存,它把工作內存中的值傳遞給執行引擎,每當虛擬機遇到一個需要使用這個變量的指令時候,將會執行這個動作。
assign(賦值) 作用於工作內存,它把從執行引擎獲取的值賦值給工作內存中的變量,每當虛擬機遇到一個給變量賦值的指令時候,執行該操作。

如果要把變量從主存拷貝到工作內存,就要順序執行read和load操作;而要把工作內存的變量同步到主存則順序執行store和write操作(僅僅是邏輯上的先後,並沒有規定必須是連續執行的,意味着中間可以插入其他指令)Java內存模型還規定了執行上述8種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現(即不允許一個變量從主存讀取了但是工作內存不接受,或者從工作內存發起會寫了但是主存不接受的情況),以上兩個操作必須按順序執行,但沒有保證必須連續執行,也就是說,read與load之間、store與write之間是可插入其他指令的。

  • 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。

  • 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。

  • 一個新的變量只能從主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。

  • 一個變量在同一個時刻只允許一條線程對其執行lock操作,但lock操作可以被同一個條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。

  • 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。

  • 如果一個變量實現沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。

  • 對一個變量執行unlock操作之前,必須先把此變量同步回主內存(執行store和write操作)

七、原子性、可見性和有序性

1、原子性(Atomicity)

由Java內存模型來直接保證的原子性變量操作包括read,load,assign,use,store,write。我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的(double和long有非原子性協定)如果應用場景需要提供更大範圍的原子性保證,Java內存模型還提供了lock和unlock操作來滿足這種需求,虛擬機未把這兩種操作直接開放給用戶使用,但是提供了更高層次的字節碼指令:monitorenter和monitorexit來隱式的使用這兩個操作,這兩個字節碼反映到Java代碼中就是同步塊——synchronized關鍵字,因此,在synchronized塊之間的操作具備原子性。

2 、可見性(Visibility)

可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。及每次使用前立即從主內存刷新。因此volatile變量保證了多線程操作時變量的可見性。除了volatile,Java還有兩個關鍵字也實現了可見性:synchronized和final。
synchronized同步塊的可見性,是由"對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write)"這條規則獲得的,而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦初始化完成,其他字段就能看見final字段的值,即final域能確保初始化過程的安全性。

3、有序性(Ordering)

如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指"線程內表現爲串行的語義";後半句是指"指令重排序"現象和"工作內存與主內存同步延遲"現象。

部分理論知識整理自《深入理解Java虛擬機:JVM高級特性與最佳實踐》

八、synchronized和volatile

未完待續

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