java多線程:基礎原理

java多線程:基礎原理


java支持多線程編程,爲了能夠深入理解java多線程機制,以及解決多線程的安全問題,本文介紹多線程的基礎知識和原理分析。

Part1 概念總結

線程的概念

線程(Thread)是操作系統能夠進行運算調度的最小單位。它被包含在進程之中,是進程中的實際運作單位。一條線程指的是進程中一個單一順序的控制流,一個進程中可以併發多個線程,每條線程並行執行不同的任務。

同一進程中的多條線程將共享該進程中的全部系統資源,如虛擬地址空間,文件描述符和信號處理等等。但同一進程中的多個線程有各自的調用棧(call stack),自己的寄存器環境(register context),自己的線程本地存儲(thread-local storage)。

 

線程vs進程:

A.多個進程的內部數據和狀態都是完全獨立的,(進程就是執行中的程序,程序是靜態的概念,進程是動態的概念)。而多線程是共享一塊內存空間和一組系統資源的,有可能互相影響(多個線程可以在一個進程中)

B.線程本身的數據通常只有寄存器數據,以及一個程序執行時使用的堆棧,所以線程的切換比進程切換的負擔要小。

 

多線程編程

在單個程序中可以同時運行多個不同的線程執行不同的任務。但是具體執行哪個線程是由cpu隨機決定的。

多線程編程的目的,就是“最大限度地利用CPU資源”,當某一線程的處理不需要佔用CPU而只和I/O等資源打交道時,讓需要佔用CPU資源的其他線程有機會獲得CPU資源。

Java中如果我們自己沒有產生線程,系統會自動產生一個線程,該線程爲主線程 ,main方法就在主線程上運行,我們的程序都是由線程來執行的。

 多任務處理被所有的現代操作系統所支持。然而,多任務處理有兩種截然不同的類型:基於進程的和基於線程的。


基於進程的多任務處理

(1)基於進程的多任務處理是更熟悉的形式。

進程(process)質上是一個執行的程序。因此基於進程的務處理的特點允許你的計算機同時運行兩個或更多的程序。舉例來說,基於進程的多任務處理使你在運用文本編輯器的時候可以同時運行java編譯器。在基於進程的多任務處理中,程序是調度程序所分派的最小代碼單位。

(2)進程是重量級的任務,需要分配給他們獨立的地址空間。進程間通信是昂貴和受限的。進程的轉換也是很需要花費的。


VS


基於線程的多任務處理

(1)基於線程的多任務處理環境中,線程是最小的執行單位。這意味着一個程序可以同時執行兩個或者多個任務的功能。例如,一個文本編輯器可以在打印的同時格式化文本。

(2)多線程程序比多進程程序需要更少的代價。多線程是輕量級的任務。它們共享相同的地址空間並且共同分享同一個進程。線程間通信是便宜的,線程間的轉換也是低成本的。

(3)多線程使CPU的利用率提高


Part2 JVM的內存模型

要想對java多線程的相關問題理解透徹,那麼就必須知道JVM的內存模型。
在多處理器系統中,內存是由所有CPU共同訪問的,每個處理器會有自己的高效緩存,處理器可以直接從高效緩存中獲得數據,而不需要直接從內存中獲取數據,這樣加速了對數據的訪問,而且減少了內存總線的阻塞,從而提升了系統性能。
多處理器中存在的一個問題是:如果兩個處理器同時處理同一塊內存區域的時候會發生什麼?即緩存一致性問題。解決緩存一致性問題的方法有兩種:使用緩存一致性協議和在總線使用LOCK鎖。
所以,在處理器層,相應的內存模型提供規範,保證由其他處理器對同一內存的寫操作對當前處理器是可見的,而當前處理器對該內存的寫操作對其他處理器來說也是可見的。
在強內存模型中,所有的處理器在任何時候都可以即時看到所給內存區域內數據最近變化值。
在弱內存模型中,爲了看到其他處理器執行的寫操作結果或者是本處理器的寫操作被其他處理器可見,處理器需要執行特定的指令來刷新局部的處理器緩存區,或者是使局部處理器緩存不可見。這些特定的指令被看做是一種內存屏蔽(Memory Barrier)技術。當出現上鎖和解鎖操作的時候就會出現內存屏蔽。

接下來分析一下JVM中的內存模型與安全


Java作爲平臺無關性語言,JLS(Java語言規範)定義了一個統一的內存管理模型JMM(Java Memory Model),JMM屏蔽了底層平臺內存管理細節,在多線程環境中必須解決可見性和有序性的問題。java中的線程安全問題無非是要控制多個線程對某個資源的有序訪問或修改。

與現有的硬件存儲模型相似,JMM規定了jvm有主內存(Main Memory)和工作內存(Working Memory),主內存存放程序中所有的類實例、靜態數據等變量,是多個線程共享的,而工作內存存放的是該線程從主內存中拷貝過來的變量以及訪問方法所取得的局部變量,是每個線程私有的,其他線程不能訪問,每個線程對變量的操作都是以先從主內存將其拷貝到工作內存再對其進行操作的方式進行,多個線程之間不能直接互相傳遞數據通信,只能通過共享變量來進行。
由於多個線程並不是直接操作主存中的數據,而是操作各自的工作內存的數據,這樣就導致了數據不一致性問題。


(上圖給出了jvm的存儲模型)

爲了實現主內存和工作內存之間具體的交互協議,JVM規範定義了8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的:lock、unlock、read,load,use,assign,store,write。

lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。

unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

read(讀取):作用於主內存中的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。

load(載入):作用於工作內存中的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。

use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。

assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。

store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。

write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

java內存模型還規定了在執行上述8種基本操作時必須滿足以下的規則:

(1)不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存中讀取了但是工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現。

(2)不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存,但是什麼時候同步,是由實際的執行情況決定的。

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

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

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

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

(7)如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。

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


JVM的特性:


1.共享性

在上面提到的java內存模型中,數據是存儲在主內存中,各個線程可操作性的數據是共享的,但是各個線程擁有自己的工作內存,工作內存中存放的是主內存數據的拷貝。


2. 互斥性

資源互斥是指同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。在多線程編程中,允許多個線程同時對數據進行讀操作,但同一時間內只允許一個線程對數據進行寫操作,所以可以將鎖分爲共享鎖和排它鎖,也叫作讀鎖和寫鎖。java中可以使用synchronized關鍵字來保證數據的互斥性。


3. 可見性

當線程操作某個對象時,執行順序如下:

(1)從主存複製變量到當前工作內存(read & load)

(2)執行代碼,改變共享變量值(use & assign)

(3)用工作內存數據刷新主存中的數據(store & write)

當一個共享變量在多個線程的工作內存中都有副本時,如果一個線程修改了這個共享變量,那麼其他線程應該能夠看到這個被修改後的值,但是主內存和工作內存的同步不是立馬進行的,這樣就會出現其他線程不能立馬發現,共享的數據已經被修改了,這就是多線程的可見性問題。

java中可以通過synchronized或volatile關鍵字來保證可見性。對volatile變量操作時,如果修改了volatile變量的值,那麼會立馬將工作內存的值寫入到主內存中,這樣就保證了可見性。


4. 原子性

原子性就是指一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。換句話說,就是一次操作,是一個連續不可中斷的過程,數據不會執行到一半的時候被其他線程所修改。

保證原子性的最簡單方法是操作系統指令,就是如果一次操作對應一條操作系統指令,這樣肯定可以保證原子性。但是很多操作不能通過一條指令完成:比如,對long型和double型數據的操作。還有就是i++這樣的操作,實際也是分爲三個步驟完成的:(1)讀取整數i的值;(2)對i進行加一操作;(3)將結果寫回內存。所以在多線程中會出現下面的問題:


java內存模型裏直接保證的原子性變量操作包括:read、load、use、assign、store、write,在基本數據類型上執行這些操作是具備原子性的。但是對於(沒有被volatile修飾的)64位的數據類型:long和double類型數據來說,虛擬機會將讀寫操作分爲兩次32位的操作。這樣就不具備了原子性。所以可以使用volatile修飾變量來保證原子性。

如果應用程序需要一個更大範圍的原子性保證,java內存模型還提供了顯式鎖和synchronized關鍵字。java內存模型中提供了lock和unlock操作,但是虛擬機並沒有將lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和moniterexit來隱式地使用這兩個操作,這兩個字節碼指令反映到java代碼中就是synchronized修飾的代碼。


5. 有序性

重排序通常是編譯器或運行時環境爲了優化程序性能而採取的對指令進行重新排序執行的一種手段。重排序分爲三類:

(1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序,這樣可以儘可能減少寄存器的讀取、存儲次數,充分複用寄存器的存儲值。

(2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行,如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。(數據依賴:如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。)

(3)內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行的

從java源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序:(直接盜一下圖)


上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序都可能會導致多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定類型的內存屏障(memory barriers)指令,通過內存屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。

JMM屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

現代的處理器使用寫緩衝區來臨時保存向內存寫入的數據。寫緩衝區可以保證指令流水線持續運行,它可以避免由於處理器停頓下來等待向內存寫入數據而產生的延遲。同時,通過以批處理的方式刷新寫緩衝區,以及合併寫緩衝區中對統一內存地址的多次寫,可以減少對內存總線的佔用。然而寫緩衝區有很多好處,但是每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對內存操作的執行順序產生重要的影響:處理器對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致。


假設處理器A和處理器B按程序的順序並行執行內存訪問,最終卻可能得到x = y = 0的結果。具體的原因如下圖所示:


這裏處理器A和處理器B可以同時把共享變量寫入自己的寫緩衝區(A1,B1),然後從內存中讀取另一個共享變量(A2,B2),最後才把自己寫緩存區中保存的髒數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就可以得到x = y = 0的結果。

 從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1纔算真正執行了。雖然處理器A執行內存操作的順序 爲:A1->A2,但內存操作實際發生的順序卻是:A2->A1。此時,處理器A的內存操作順序被重排序了(處理器B的情況和處理器A一樣, 這裏就不贅述了)。

 這裏的關鍵是,由於寫緩衝區僅對自己的處理器可見,它會導致處理器執行內存操作的順序可能會與內存實際的操作執行順序不一致。由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對寫-讀操做重排序。


java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

happens-before

從JDK5開始,java使用新的JSR -133內存模型(本文除非特別說明,針對的都是JSR- 133內存模型)。JSR-133提出了happens-before的概念,通過這個概念來闡述操作之間的內存可見性。如果一個操作執行的結果需要對另 一個操作可見,那麼這兩個操作之間必須存在happens-before關係。這裏提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。 與程序員密切相關的happens-before規則如下:

  • 程序順序規則:一個線程中的每個操作,happens- before於該線程中的任意後續操作。
  • 監視器鎖規則:對一個監視器鎖的解鎖,happens- before於隨後對這個監視器鎖的加鎖。
  • volatile變量規則:對一個volatile域的寫,happens- before於任意後續對這個volatile域的讀。
  • 傳遞性:如果A happens- before B,且B happens- before C,那麼A happens- before C

注意,兩個操作之間具有happens-before關係,並不意味着前一個操作必須要在後一個操作之前執行!happens-before僅僅要 求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)。happens- before的定義很微妙,後文會具體說明happens-before爲什麼要這麼定義。

 

happens-before與JMM的關係如下圖所示:


如上圖所示,一個happens-before規則通常對應於多個編譯器重排序規則和處理器重排序規則。對於java程序員來說,happens- before規則簡單易懂,它避免程序員爲了理解JMM提供的內存可見性保證而去學習複雜的重排序規則以及這些規則的具體實現。

參考資源

以下爲本文的參考資源列表,其中一些講解很詳細,很精彩,所以會有部分直接引用原文。

JVM的重排序

《深入理解java虛擬機》之內存模型與安全

深入理解Java內存模型

Java 併發編程:核心理論


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