《深入理解Java虛擬機》之Java內存模型與線程

閱讀《深入理解Java虛擬機》第2版,結合JDK8的讀書筆記。當前文章爲書本的第12章節。

12.1.概述

本章節將介紹虛擬機如何實現多線程,多線程之間由於線程共享和競爭數據而導致的一系列問題以及解決方案。

TPS(Transactions Per Second):它代表着一秒內服務端平均能相應的請求總數。

12.2.硬件的效率與一致性

計算機的存儲設備和處理器的運算速度存在比較大的差異,爲了彌補這個差異引入了緩存來作爲內存與處理器之間的緩衝。

將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。

每個處理器都有自己的高速緩存,但是它們共享同一主內存,因此各個處理器訪問緩存時都遵循一些協議。例如MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol。

下圖爲處理器、高速緩存、主內存之間的交互關係圖,拍攝於周志明老師的《深入理解Java虛擬機 第2版》處理器、高速緩存、主內存之間的交互關係

12.3.Java內存模型

12.3.1.主內存與工作內存

Java內存模型規定了所有的變量(這裏的變量是指實例字段,靜態字段和構成數組對象的元素,並非局部變量和方法參數)都存儲在主內存中,每條線程都有自己的工作內存,線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝(虛擬機實現不會將整個對象都拷貝一次,可能是拷貝使用到的字段),線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。

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

下圖爲線程、主內存、工作內存三者的交互關係圖,拍攝於周志明老師的《深入理解Java虛擬機 第2版》
線程、主內存、工作內存三者的交互關係圖

12.3.2.內存間交互操作

Java內存模型中定義了以下8種操作來完成。

  • lock

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

  • unlock

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

  • read

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

  • load

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

  • use

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

  • assign

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

  • store

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

  • write

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

把一個變量從主內存賦值到工作內存,需要順序(不要求連續執行,中間可以接其它操作)地執行read和load操作。如果要把變量從工作內存同步回主內存,需要順序地執行store和write操作。

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

  1. 不允許read和load、store和write操作之一單獨出現。即不允許一個變量從主內存讀取但工作內存不接受,或者從工作內存發起回寫但主內存不接受的情況出現。
  2. 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  3. 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
  4. 一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變量實施use、store操作之前,必須先執行過load和assign操作
  5. 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
  6. 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
  7. 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlick操作,也不允許去unlock一個被其他線程鎖定住的變量。
  8. 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)

12.3.3.對於volatile型變量的特殊規則

關鍵字volatile可以說是java虛擬機提供的最輕量級的同步機制。當一個變量定義爲volatile之後,它將具備兩種特性:

  1. 保證此變量對所有線程的可見性。這裏的可見性是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
  2. 禁止指令重排序優化。

對所有線程的可見性

我們來看一段代碼,編譯成字節碼之後的效果。

volatile變量編譯成字節碼

只有一行代碼的increase()方法在Class文件中由4條字節碼指令構成(return指令不是由race++產生的)。

  • getstatic

獲取類的靜態域,並將其值壓入棧頂

  • iconst_1

將int型1推送到棧頂

  • iadd

將棧頂兩int型數值相加並將結果壓入棧頂

  • putstatic

爲類的靜態域賦值

這就是對所有線程可見性的原因。

禁止指令重排序優化

從硬件架構上講,指令重排序是指CPU採用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理。但並不是說指令任意重排,CPU需要能正確處理指令依賴情況以保證程序能得出正確的執行結果。

例如:指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值減去3,這時指令1和指令2是有依賴的,它們之間的順序不能重排,因爲(A+10)x2不等於Ax2+10,但是指令3可以重排到指令1、2之前或中間,只要保證CPU執行後面依賴到A、B值的操作時能獲取正確的A和B值即可。

volatile變量在寫的時候會插入許多內存屏障指令來保證處理器不會發生亂序執行。

內存屏障是指重排序時不能把後面的指令重排序到內存屏障之前的位置。

非原子性

volatile變量並不能保證原子性,因此在不符合以下兩條規則的運算場景時,仍然需要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性。

  1. 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值
  2. 變量不需要與其他的狀態變量共同參與不變約束

12.3.4.對於long和double型變量的特殊規則

對於64位的數據類型(long和double),在模型中特別定義了一條相對寬鬆的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分爲兩次32位的操作來進行。

12.3.5.原子性、可見性與有序性

  • 原子性(Atomicity)

由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write。基本數據類型的訪問讀寫時具備原子性的。

如果應用場景需要更大範圍的原子性保證,可以通過同步塊-使用synchronized關鍵字,隱式地使用monitorenter和monitorexit兩個字節碼指令。

  • 可見性(Visibility)

當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。除了volatile,還有synchronized和final也能實現可見性。

synchronized可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)”這條規則獲得

final可見性是指:被final修飾的字段在構造器中一旦初始化完成,並且沒有發生this引用逃逸,那在其他線程中就能看見final字段的值。

  • 有序性

Java程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。

前半句是指“線程內表現爲串行的語義”,後半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。

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

12.3.6.先行發生原則

Java內存模型存在以下“天然的”先行發生關係。

  • 程序次序規則:在一個線程內,按照控制流的代碼順序執行
  • 管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作
  • volatile變量規則:對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每一個動作
  • 線程終止規則:線程中的所有操作都先行發生於對此線程的終止檢測
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始
  • 傳遞性:操作A先發生於操作B,操作B先行發生於操作C,則操作A先行發生於操作C

12.4 Java與線程

12.4.1.線程的實現

線程的引入,可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源(內存地址、文件IO等),又可以獨立調度(線程是CPU調度的基本單位)。

在JAVA API中,一個Native方法往往意味着這個方法沒有使用或無法使用平臺無關的手段來實現。也有可能是爲了執行效率而使用Native方法,不過,通常高效率的手段就是平臺相關的手段。

實現線程主要有三種方式:

  1. 使用內核線程實現
  2. 使用用戶線程實現
  3. 使用用戶線程加輕量級進程混合實現

Java線程的實現

虛擬機規範並未限定Java線程需要使用哪種線程模型來實現。

12.4.2.Java線程調度

線程調度是指系統爲線程分配處理器使用權的過程。主要分兩種調度方式:協同式線程調度和搶佔式線程調度。

  • 協同式線程調度

線程的執行時間由線程自己控制,線程完成自己的工作後,主動通知系統切換到另一個線程。

實現簡單,但是線程執行時間不可控。

  • 搶佔式線程調度

線程的執行時間由系統來分配,線程的切換不由線程本身來決定。

Java線程調度方式

Java線程使用搶佔式調度方式。

  1. 在Java中,Thread.yield()可以讓線程出讓執行時間
  2. 通過設置優先級-Thread.setPriority(),來建議系統多給指定線程分配一點執行時間

Java線程優先級爲1-10,10爲最高級,1爲最低級。因爲Java線程是通過映射到系統的原生線程上實現的,所以線程調度最終還是取決於操作系統。而且操作系統提供的線程優先級可能不一定能夠Java的線程優先級對應。

12.4.3.狀態轉換

Java語言定義了6種線程狀態,在任意一個時間點,一個線程只能有且只有其中的一種狀態,這6種狀態分別爲:

  • 新建(new)

創建後尚未啓動的線程處於該狀態。

  • 運行(runable)

該狀態包括了操作系統線程狀態中的running和ready兩種狀態,處於該狀態的線程有可能正在執行,也有可能等待CPU給它分配執行時間。

  • 無限期等待(waiting)

處於該狀態的線程不會被分配執行時間,需要等待其他線程顯式的喚醒。以下方法會讓線程進入無限期等待狀態:

  1. 沒有設置timeout參數的Object.wait()
  2. 沒有設置timeout參數的Thread.join()
  3. LockSupport.park()
  • 限期等待(timed waiting)

處於該狀態的線程不會被分配執行時間,不需要其他線程喚醒,在一定時間後會由系統自動喚醒。以下方法會讓線程進入限期等待狀態:

  1. Thread.sleep()
  2. 設置了timeout參數的Object.wait()
  3. 設置了timeout參數的Thread.join()
  4. LockSupport.parkNanos()
  5. LockSupport.parkUntil()
  • 阻塞(blocked)

阻塞狀態是指線程在等待着獲取一個排他鎖,這個事件將在另外一個線程放棄這個鎖的時候發生。

  • 結束(terminated)

已終止線程的線程狀態。

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