Java 虛擬機摘要

參照周志明老師的《深入理解Java虛擬機》做的摘要

Java 內存區域

運行時數據區域

  • 程序計數器:當前線程所執行的字節碼的行號指示器

  • Java 虛擬機棧:線程私有。每個方法被執行的時候,Java 虛擬機會同步創建一個棧幀用于存儲局部變量表、操作數棧、動態連接、方法出口等信息。方法的從調用到執行完畢對應一個棧幀在虛擬機棧中從入棧到出棧的過程。

  • Java 堆:線程共享。對象和數組實例都是在這分配內存,是 GC 重點照顧的對象。以前很多虛擬機的垃圾收集器是基于分代收集理論設計的,所以也經常分為新生代、老年代、Eden 空間、From Survivor 空間、To Survivor 空間等。現在很多垃圾收集器設計理念已經發生了變化,所以以后基于分代思維可能需要改變了。

  • 方法區:線程共享。用于存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。

  • 運行時常量池:方法區的一部分、存放編譯器生產的各種字面量和符號引用,也能在運行期將新的常量放入池中。

java 運行時數據區.png
  • 對象創建:

    1. 分配內存
    2. 執行構造函數和 <init> 方法將對象初始化
    3. 將內存地址指向引用的指針;
  • 對象內存布局:

    1. 對象頭:GC 分代年齡、哈希嗎、鎖狀態標志、線程持有的鎖、偏向線程 ID、偏向時間戳。
    2. 實例數據
    3. 對齊填充

垃圾收集器與內存分配

判斷對象是否存活(標記垃圾)

  1. 引用計數算法:虛擬機為每一個對象加一個引用計數器,當有一個地方引用該對象時,計數器加一;當引用失效時,計數器減一。
    引用計數算法雖然占用了一些額外內存空間來計數,但它的原理簡單,判斷效率也很高,在大多數情況下都是一個不錯的算法。
    缺點:必須要配合大量額外處理才能保證正確工作,而且對于對象之間相互循環引用也很難解決。

  2. 可達性分析算法:通過一系列“GC Roots”的根對象作為起始節點集,從這些節點開始根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”,如果某個對象到 GC Roots 間沒有任何引用鏈相連,則證明此對象不可達,可進行垃圾標志等待回收。

固定可作為 GC Roots 的對象包括以下幾種:

  • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  • 在方法區中類靜態屬性引用的對象,譬如 Java 類的引用類型靜態變量;
  • 在方法區中常量引用的對象,如字符串常量池里的引用;
  • 在本地方法棧中 JNI(即常所說的 Native 方法)引用的對象。
  • Java 虛擬機內部的引用,如基本數據類型對應的 Class 對象,一些常駐的異常對象 (比如 NullPointExcepiton)等,還有系統類加載器;
  • 所有同步鎖持有的對象;
  • 反映 Java 虛擬機內部情況的 JMXBean、JVMTI 中注冊的回調、本地代碼緩存等

垃圾收集算法:

  • 標記-清除算法:首先標記出所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象;
    缺點:一個是執行效率不穩定, 如果 Java 堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;第二個是內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作;
  • 標記-復制算法:將內存區域按比例劃分為不同的區域,當一塊內存用完了,就進行 GC 然后把存活的對象復制到另一塊上面,然后再把已使用過的內存空間一次清理掉,這樣能有效避免內存空間碎片的問題。
    缺點:可用內存變小,無法完全利用內存造成空間浪費。
    主流商用 Java 虛擬機用于新生代的收集算法,因為新生代中的對象有 98% 熬不過第一輪收集,所以需要復制的對象不多。同時 Eden 和 Survivor 的比例是8:1,浪費的內存空間相對較少。
  • 標記-整理算法: 標記清除后讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存。相比標記清除算法,解決了內存空間碎片的問題,但相對的引入了整理的過程,提升了復雜度和性能花銷。
    缺點:如果每次回收后存活的對象都很多,就會給系統帶來極大的負重操作。

根節點枚舉:所有收集器在這一步驟都必須暫停用戶線程

安全點:收到“Stop The World”后,用戶程序需要到達安全點才能暫停,不能隨意暫停;

并發的可達性分析:

  • 增量更新:把新插入的引用記錄下來,掃描結束后針對新插入的重新掃描一次;
  • 原始快照:將要刪除的引用對象記錄下來,掃描結束后以這些對象作為根再掃描一遍;

垃圾收集器:

  • Serial:新生代收集器,標記-復制,會暫停其它所有線程,簡單高效;
  • ParNew: Serial 的多線程版本,可與 CMS 收集器配合工作;
  • Parallel Scavenge:新生代收集器,跟 ParNew 非常相似,但是更在意吞吐量;
  • Serial Old:老年代收集器,標記-整理,會暫停其它所有線程,簡單高效,更多的是客戶端模式下使用
  • Parallel Old:老年代收集器,標記-整理,多線程并發收集;
  • CMS: 老年代收集器,以獲取最短回收停頓時間為目標,常跟 ParNew 配合使用,從JDK 9開始不推薦,推薦 G1 取代。
  • Garbage First(G1):面向服務端的垃圾收集器,不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的 Java 堆劃分為多個大小相等的獨立區域(Region),每一個 Region 都可以根據需要,扮演新生代的 Eden 空間或 Survivor 空間或老年代空間,還會為大對象劃分專屬的 Humongous 區域。允許用戶選擇期望的停頓時間

G1運作過程:

  • 初始標記:標記 GC Roots 能直接關聯的對象,需要停頓所有線程,但耗時很短;
  • 并發標記:從 GC Roots 開始對對象進行可達性分析,耗時較長,但可與用戶程序并發執行;
  • 最終標記:通過原始快照算法處理并發階段遺留的記錄;
  • 帥選標記:更新 Region 的統計數據,對各個 Region 的回收價值和成本進行排序,根據用戶所期望的最短時間來制定回收計劃。將選擇的回收 Region 中的存活對象復制到空 Region 中,然后清理掉整個舊的 Region 的全部空間。

低延遲垃圾收集器:

  • Shenandoah:RedHat 公司開發的低延遲收集器
  • ZGC:尚在實驗中的低延遲垃圾收集器,由 Oracle 公司研發。使用了染色指針的新技術。

虛擬機類加載機制

一個類型的生命周期有加載、連接(驗證、準備、解析)、初始化、使用、卸載。其中加載、驗證、準備、初始化和卸載這五個階段的開始順序是確定的,而解析則不一定,因為有些符號引用需要運行時才能確定調用者類型然后進行解析。

注意:是開始順序確定,結束順序則不一定,比如類加載還沒結束就會開始驗證

Java虛擬機規范規定了有且只有六種情況在類沒有初始化時必須對類進行初始化(加載、驗證、準備自然需要在此之前開始):

  1. 遇到 new、getstatic、putstatic、invokestatic 這四條字節碼指令時,也就是實例化對象、讀取或設置一個靜態字段(被 final 修飾、已在編譯器把結果放入常量池的靜態字段除外)、調用一個類型的靜態方法的時候;(使用靜態字段或者方法的時候,只會初始化定義靜態字段或方法的類,比如通過子類調用父類的靜態字段,只會初始化父類)
  2. 使用 java.lang.reflect 包的方法對類型進行反射調用的時候;
  3. 當初始化類時,父類沒有初始化的時候需要對父類進行初始化;
  4. 虛擬機啟動時,用戶需要指定一個要執行的類(包含 main 方法的類),虛擬機會先初始化這個類;
  5. 使用 java.lang.invoke.MethodHandle 實例解析方法句柄的時候;
  6. 定義了默認方法(被 default 關鍵字修飾的方法)的接口,如果這個接口的實例初始化,該接口也要在此之前被初始化;

類加載的過程

加載:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流(沒有規定從哪里獲取字節流,所以可以是文件,也可以是網絡,動態加載的基礎支持);
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
  3. 在內存中生成一個代表該類的 java.lang.Class 對象,作為方法區這個類的各種數據訪問入口;

加載階段結束后,Java虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區中了。

驗證:

  1. 文件格式驗證:驗證字節流是否符合 Class 文件格式的規范,并且能被當前版本的虛擬機處理。只有保證輸入的字節流能夠正確地解析并存儲于方法區之內,這個段字節流才被允許進入Java虛擬機內存的方法區中進行存儲。所以這個階段會于加載過程中就開始。
  2. 元數據驗證:對字節碼描述的信息(即類的元數據信息)進行語義分析,以保證其描述的信息符合 Java 語義規范的要求;
  3. 字節碼驗證:對類的方法體進行校驗分析,包括數據流分析和控制流分析等復雜流程,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為,是驗證過程中最復雜的階段;
  4. 符號引用驗證:通過對符號引用校驗,檢測是否存在對應的類、方法或字段,以及對該類、方法或字段是否具有訪問權限。該驗證發生在解析階段,目的是確保解析行為能正常執行;

準備:

準備階段是正式為類中定義的變量(即靜態變量)分配內存并設置變量初始值,用戶設置的值需要在初始化階段才會設置。

解析:

解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程,直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用限定符7類符號引用進行。

初始化:

初始化階段就是執行類構造器<clinit>() 方法的過程,該方法是由編譯器自動收集類中所有類變量(靜態變量)的賦值動作和靜態語句塊中的語句合并產生的;靜態語句是按順序收集的,所以只能訪問到定義在靜態語句塊之前的變量。父類方法的初始化先于子類方法的初始化。

類加載器

“通過一個類的全限定名來獲取定義此類的二進制字節流”這個動作就是通過類加載器實現的(通過loadClass 方法)。同一個類比較是否相等必須在同一個類加載器加載,否則無意義。

三層類加載器

  1. 啟動類加載器:Bootstrap Class Loader。使用 C++ 語言實現,無法被 Java 程序直接引用,負責加載存放<JAVA_HOME>\lib目錄,如果需要把加載器請求委派給啟動類加載器,那直接使用 null 代替即可。
  2. 擴展類加載器:Extension Class Loader(ExtClassLoader)。java 實現,開發者可以直接在程序中使用,負責加載<JAVA_HOME>\lib\ext目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中所有的類庫。
  3. 應用程序類加載器:Application Class Loader(AppClassLoader)。負責加載用戶類路徑上所有的類庫,開發者可以直接在程序中使用,如果應用程序中沒有定義過自己的類加載器,一般情況下這個就是程序中的默認類加載器。

雙親委派模式要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。

雙親委派模式

雙親委派模式工作過程:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(搜索范圍內沒有找到該類)時,子加載器才會嘗試自己去完成加載。

類加載器雙親委派模型.jpg

虛擬機字節碼執行引擎

運行時棧幀結構

棧幀是用于支持虛擬機進行方法調用和方法執行背后的數據接口,它也是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。方法的從調用到執行完畢對應一個棧幀在虛擬機棧中從入棧到出棧的過程。在編譯 Java 程序源碼的時候,棧幀需要多大的局部變量表,需要多深的操作數棧就已經被分析計算出來,并寫入到方法表(每個類都有一個方法表記錄定義的方法)的 Code 屬性之中,即棧幀需要的內存在編譯時已經確定了的。

局部變量表

局部變量表是一組變量值的存儲空間,用于存放方法參數和方法內部定義的局部變量。如果執行的是實例方法,那局部變量表中第0位索引的變量槽默認是用于傳遞方法所屬對象實例的引用。

操作數棧

后入先出的棧,方法執行中參數的調用和運算都要依賴于操作數棧。

動態連接

每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,只有這個引用是為了支持方法調用過程中的動態連接。

方法返回地址

方法退出會將棧幀出棧,恢復上層方法的局部變量表和操作數棧,把返回值(有的話)壓入調用者棧幀的操作數棧中,調整 PC 計數器的值以指向方法調用指令后面的一條指令。

方法調用

一切方法在 class 文件里面存儲的都只是符號引用,而不是方法在實際運行時內存布局中的入口地址。

解析

在類加載解析階段,會將其中一部分的符號引用轉化為直接引用,這一類解析前提是方法在程序運行前就有一個可確定的調用版本,并且這個版本在運行期不會改變。

分派

有些方法的符號引用在解析階段并不能確定它的直接引用,所以會在運行時進行分派,通常是虛方法才需要分派,即除靜態方法、私有方法、實例構造器、父類方法和 final 修飾的方法之外的方法。

基于棧的字節碼解釋執行引擎

javac 編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷抽象語法樹生成線性的字節碼指令流的過程

解釋執行

動態產生每條字節碼對應的匯編代碼來運行。

基于棧的解釋執行

Javac 編譯器輸出的字節碼指令流,基本上是一種基于棧的指令集架構,字節碼指令流里面的指令大部分都是零地址指令,它們依賴操作數棧進行工作

前端編譯與優化

前端編譯器:JDK 的 Javac
即時編譯器:HotSpot 虛擬機的 C1、C2 即時編譯器,Graal 編譯器
提前編譯器:JDK 的 Jaotc

前端編譯器做的優化措施更多是為了降低程序員的編碼復雜度、提高編碼效率;后端編譯器則是對程序執行性能進行優化。

Javac 編譯器

Javac編譯過程:

  1. 準備過程:初始化插入式注解處理器;
  2. 解析與填充符號表過程,包括:
  • 詞法、語法分析。將源代碼的字符流轉變為標記集合,構造出抽象語法樹。
  • 填充符號表。產生符地址和符號信息。
  1. 插入式注解處理器的注解處理過程:插入式注解處理器的執行階段,如果產生新的符號就需要回到之前的過程2重新處理。
  2. 分析與字節碼生成過程。包括:
  • 標注檢查。對語法的靜態信息進行檢查。
  • 數據流及控制流分析。對程序動態執行過程進行檢查。
  • 解語法糖。將簡化代碼編寫的語法糖還原為原有的形式。
  • 字節碼生成過程。將前面各個步驟所生成的信息轉化為字節碼。

Java 語法糖

  1. 泛型:只在源碼中存在,經過編譯后的字節碼中,全部泛型都被替換為原來的裸類型,并且在相應的地方插入了強制轉換代碼,即會類型檫除。
  2. 自動裝箱、拆箱:對基本數據類型轉化為對應的包裝類型,或者相反操作。
  3. 遍歷循環:將 for-each 替換為迭代器遍歷
  4. 條件編譯器:根據布爾常量值的真假將分支中不成立的代碼塊消除掉。

后端編譯與優化

后端編譯:將 Class 文件轉換成與本地基礎設施(硬件指令集、操作系統)相關的二進制機器碼。

即時編譯器

即時編譯器:當虛擬機發現某個方法或代碼塊運行特別頻繁,就會把這些代碼認定為“熱點代碼”,為了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成本地機器碼,并以各種手段進行代碼優化,運行時完成這個任務的后端編譯器被稱為即時編譯器。

解釋器:當程序需要迅速啟動和執行的時候,解釋器可以首先發揮作用,省去編譯的時間,立即運行。同時在運行時收集程序性能監控信息,為即時編譯器的優化提供數據參考,解釋器還作為即時編譯器激進優化時的后備逃生門(去優化)。

HotSpot 虛擬機內置了兩個編譯器(或三個,Graal 仍處于實驗中),分別被稱為“客戶端編譯器” - C1 編譯器和“服務端編譯器” - C2 編譯器。

通常優化程度越高的代碼,所需的編譯時間便會越長,解釋器收集的信息要求也更全,這會對程序的執行性能有一定的干擾,所以 Hotspot 虛擬機采用了分層編譯的功能:

  • 第0層。程序純解釋執行,并且解釋器不開啟性能監控功能。
  • 第 1 層。使用客戶端編譯器將字節碼編譯為本地代碼來運行,進行簡單可靠的穩定優化,不開啟性能監控功能。
  • 第 2 層。仍然使用客戶端編譯器執行,僅開啟方法及回邊次數統計等有限性能監控功能。
  • 第 3 層。仍然使用客戶端編譯器執行,開啟全部性能監控功能,除了第 2 層的統計信息外,還會收集如分支跳轉、虛方法調用版本等全部統計信息。
  • 第 4 層。使用服務端編譯器將字節碼編譯為本地代碼,相比起客戶端編譯器,服務端編譯器會啟用更多編譯耗時更長的優化,還會根據性能監控信息進行一些不可靠的激進優化。
分層編譯的交互關系.png

熱點代碼主要有兩類:

  1. 被多次調用的方法。
  2. 被多次執行的循環體

對于這兩種情況,編譯的目標對象都是整個方法體,這也是虛擬機中標準的即時編譯方式。第一種是直接以整個方法作為編譯對象;第二種是在第一種的基礎上傳入執行入口點字節碼序號(從第幾條字節碼指令開始執行編譯),這種編譯因為發生在方法執行的過程中,被稱為棧上替換,即方法的棧幀還在棧上,方法就被替換了。

判斷某段代碼是不是熱點代碼是通過“熱點探測”來進行的,主流熱點探測方式有以下兩種:

  1. 基于采樣的熱點探測。即周期的檢測各個線程的調用棧頂,如果發現某個方法經常出現在棧頂,就判斷為熱點方法。這種實現方式簡單高效,還可以很容易的獲取方法的調用關系(將調用棧頂展開即可),缺點是很難精確的確認一個方法的熱度,容易受到線程阻塞或者別的外界因素干擾。
  2. 基于計數器的熱點探測。虛擬機為每個方法(或者是回邊代碼塊-統計回邊次數)建立計數器,統計方法的執行次數,執行次數超過一定閾值就認為是熱點方法。這種方式雖然實現復雜點,還要為每個方法建立并維護計數器,而且不能直接獲取方法的調用關系,但它的統計結果相對來說更加嚴謹。
方法調用計數器觸發即時編譯.jpg
回邊計數器觸發即時編譯.jpg

提前編譯器

  1. 在程序運行之前把程序代碼直接編譯成機器碼。
  2. 把原本即時編譯器在運行時要做的編譯工作提前做好并保存下來,下次運行到這些代碼(譬如公共代碼在被同一臺機器其它 Java 進程使用)時直接把它加載進來使用。

提前編譯能將編譯過程中最耗時的優化措施如“過程間分析”等以及一些其它耗時的優化措施提前進行,避免運行時對用戶程序的干擾。

即時編譯器要占用程序運行時間和運算資源,而且達到全速運行狀態需要一定的時間。但是由于運行時數據監控的功能,能夠進行熱點代碼分析并制定合適的優化方案,而且一些激進預測性優化也無法脫離運行時的數據參考。

編譯器優化技術

方法內聯

將目標方法的代碼原封不動地“復制”到發起調用的方法之中,避免發生真實的方法調用。方法內聯能夠去除方法調用的成本(查找方法版本、建立棧幀等),為其它優化建立良好的基礎,多數其他優化都是基于方法內聯的基礎上的。

逃逸分析

分析對象動態作用域,當一個對象在方法里面被定義后,它可能被外部方法引用,例如作為參數傳遞到其它方法中,這種稱為方法逃逸;甚至還可能被外部線程訪問到,譬如賦值給可以在其他線程中訪問的實例變量,這種稱為線程逃逸;從不逃逸、方法逃逸到線程逃逸,稱為對象由低到高的不同逃逸程度。

針對不逃逸或者逃逸程度低的對象實例,可采取一下優化措施:

  • 棧上分配:由于垃圾回收需要消耗大量資源,如果確定一個對象不會逃逸出線程之外,那讓這個對象在棧上分配內存將會是一個不錯的主意,對象所占用的內存空間就可以隨棧幀出棧而銷毀。支持方法逃逸不支持線程逃逸。
  • 標量替換:若一個數據無法再分解成更小的數據來表示,如基本的數據類型,那它就可以稱為標量。如果把一個 Java 對象拆散,根據程序訪問的情況,將其用到的成員變量恢復為原始類型直接在方法中定義來訪問,這個過程稱為標量替換。由于去掉對象實例的創建,所以需要對象完全不逃逸。
  • 同步消除:由于線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那么這個實例對象的讀寫就不會有競爭,因此針對這個對象的同步措施就可以安全的消除掉。

公共子表達式消除

如果一個表達式 E 之前已經被計算過了,并且從之前的計算到現在 E 中所有變量的值都沒有發生變化,那么 E 的這次出現就稱為公共子表達式。對于這種公共子表達式,就沒必要花時間重新對其進行計算,只需要直接使用前面計算過的表達式結果代替 E。

數組邊界檢查消除

對于虛擬機的執行子系統來說,每次數組元素的讀寫都帶有一次隱含的條件判斷操作,當數組越界時拋出一個運行時異常以避免溢出攻擊(像 C 語言數組越界會產生不可控的結果),但對于含有大量數組訪問操作的程序代碼,這必定是一種性能負擔。如果編譯器能夠通過數據流分析能夠確定數組的訪問不會越界,如循環讀取數組數據時,那么編譯器就會把數組的上下界檢查消除掉,這可以節省很多次的條件判斷操作。

Java 內存模型與線程

Java 內存模型

主內存與工作內存

Java 內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值的細節。此處的變量包括實例字段、靜態字段和構成數組的對象的元素,但不包括局部變量與方法參數,因為后者是線程私有的,不會被共享。

Java 內存模型規定了所有變量都存儲在主內存中,每條線程還有自己的工作內存,線程的工作中保存了被該線程使用的變量的主內存副本。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據。不同線程之前也無法訪問對方的工作內存中的變量,線程間變量值的傳遞需要通過主內存來完成。

線程、工作內存、主內存之間的交互關系如圖 .jpg

內存間交互操作

關于主內存與工作內存之前具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存這一類細節,Java 內存模型定義了 8 種操作,每一種都是原子的,不可再分的。

  1. lock(鎖定):作用于主內存的變量,它把一個變量識別為一條線程獨占的狀態。
  2. unlock(解鎖):作用于主內存的變量,它把-個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
  3. read(讀取):作用于主內存的變量,他把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的 load 動作使用。
  4. load(載入):作用于工作內存的變量,他把 read 操作從主內存中得到的變量值放入工作內存的變量副本中。
  5. use(使用):作用于工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎. 每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個動作。
  6. assign(賦值):作用于工作內存的變量,他把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  7. store(存儲):作用于工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨后的 write 操作使用。
  8. write(寫入):作用于主內存的變量,他把 store 操作從工作內存中得到的變量的值放入主內存的變量中。

對于這 8 種操作,虛擬機也規定了一系列規則,在執行這 8 種操作的時候必須遵循如下的規則:

  • 不允許 read 和 load、store 和 write 操作之一單獨出現,也就是不允許從主內存讀取了變量的值但是工作內存不接收的情況,或者不允許從工作內存將變量的值回寫到主內存但是主內存不接收的情況。
  • 不允許一個線程丟棄最近的 assign 操作,也就是不允許線程在自己的工作線程中修改了變量的值卻不同步/回寫到主內存。
  • 不允許一個線程回寫沒有修改的變量到主內存,也就是如果線程工作內存中變量沒有發生過任何assign操作,是不允許將該變量的值回寫到主內存變量只能在主內存中產生。
  • 不允許在工作內存中直接使用一個未被初始化的變量,也就是沒有執行 load 或者 assign 操作。也就是說在執行 use、store 之前必須對相同的變量執行了 load、assign 操作。
  • 一個變量在同一時刻只能被一個線程對其進行 lock 操作,也就是說一個線程一旦對一個變量加鎖后,在該線程沒有釋放掉鎖之前,其他線程是不能對其加鎖的,但是同一個線程對一個變量加鎖后,可以繼續加鎖,同時在釋放鎖的時候釋放鎖次數必須和加鎖次數相同。
  • 對變量執行 lock 操作,就會清空工作空間該變量的值,執行引擎使用這個變量之前,需要重新 load 或者 assign 操作初始化變量的值。
  • 不允許對沒有lock的變量執行unlock操作,如果一個變量沒有被 lock 操作,那也不能對其執行unlock操作,當然一個線程也不能對被其他線程 lock 的變量執行 unlock 操作。
  • 對一個變量執行 unlock 之前,必須先把變量同步回主內存中,也就是執行 store 和 write 操作。

當然,最重要的還是如開始所說,這8個動作必須是原子的,不可分割的。

Volatile

volatile 可以說是 Java 虛擬機提供的最輕量的同步機制,它修飾得變量具有兩項特性:

  1. 保證此變量對所有線程的可見性,一個線程修改了這個變量的值,新值對于其他線程來說可以立即得知,即修改變量后需要同步到主內存,同時使用的時候也需要從主內存刷新變量的值。
  2. 禁止指令重排序。由于虛擬機會在保證運算結果跟代碼順序執行的結果一致的情況向進行指令重排序優化。volatile 修飾的變量會要求不被指令重排序優化,保證代碼執行順序跟程序的順序相同。

volatile 缺點是無法保證原子性,這導致 volatile 變量的運算在并發下一樣是不安全的。比如自增運算,將變量取到操作數棧時,會跟主內存同步,保證變量的正確性,但是當執行加一指令時,其他線程可能已經把變量的值改變了,而操作數棧頂的值就變成了過期的數據,執行加一后就可能把不正確的值同步回主內存。

volatile 變量適合以下兩條規則的運算場景:

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

原子性、可見性與有序性

原子性

原子性即操作無法被分解,執行開始到結束不會插入其它指令。基本數據類型的訪問、讀寫都具備原子性。如果要保證大范圍的原子性,需要依賴同步操作。

可見性

可見性就是指當一個線程修改了共享變量的值時,其它線程能夠立刻得知這個變量的修改。volatile、synchronize 和 final 都能實現可見性。

有序性

如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。volatile 和 synchronize 能夠保證線程的有序性。

先行發生規則(Happens-Before)

先行發生原則是 Java 內存模型中定義的兩個操作之間的偏序關系。比如說操作 A 先行發生于操作B,那么在 B 操作發生之前,A 操作產生的“影響”都會被操作 B 感知到。這里的影響是指修改了內存中的共享變量、發送了消息、調用了方法等。它是判斷數據是否存在競爭,線程是否安全的非常有用的手段。

Java內存模型自帶先行發生原則有哪些

  • 程序次序原則:在一個線程內部,按照代碼的順序,書寫在前面的先行發生于后邊的。或者更準確的說是在控制流順序前面的先行發生于控制流后面的,而不是代碼順序,因為會有分支、跳轉、循環等。
  • 管程鎖定規則:一個 unlock 操作先行發生于后面對同一個鎖的lock操作。這里必須注意的是對同一個鎖,后面是指時間上的后面
  • volatile變量規則:對一個 volatile 變量的寫操作先行發生于后面對這個變量的讀操作,這里的后面是指時間上的先后順序
  • 線程啟動規則:Thread對象的 start() 方法先行發生于該線程的每個動作。當然如果你錯誤的使用了線程,創建線程后沒有執行 start 方法,而是執行 run 方法,那此句話是不成立的,但是如果這樣其實也不是線程了
  • 線程終止規則:線程中的所有操作都先行發生于對此線程的終止檢測,可以通過 Thread.join()和 Thread.isAlive() 的返回值等手段檢測線程是否已經終止執行
  • 線程中斷規則:對線程 interrupt() 方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過 Thread.interrupted() 方法檢測到是否有中斷發生。
  • 對象終結規則:一個對象的初始化完成先行發生于他的 finalize 方法的執行,也就是初始化方法先行發生于 finalize 方法
  • 傳遞性:如果操作 A 先行發生于操作 B,操作 B 先行發生于操作 C,那么操作 A 先行發生于操作 C。

Java 線程

Java 線程是與內核線程 1:1 對應的,所有各個線程的操作如創建、析構和同步,都需要進行系統調用,而系統調用需要在用戶態和內核態中來回切換,花銷較大。

Java 線程的狀態:

  • 新建(New)
  • 運行(Runnable)
  • 無限期等待(Waiting)
  • 限期等待(Timed Waiting)
  • 阻塞(Blocked)
  • 結束(Terminated)

線程安全與鎖優化

線程安全

線程安全:當多個線程同時訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行其他額外的協調操作,調用這個對象的行為都可以獲得正確的結果,那就稱這個對象是線程安全的。

Java中各種操作共享的數據按線程安全的由強到弱分為以下五類:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立。

線程安全的實現方法

1. 互斥同步(阻塞同步)

同步是指在多個線程并發訪問共享數據時,保證共享數據在同一時刻只被一條(或者是一些,當使用信號量的時候)線程使用。

Synchronize 是最基本的互斥同步手段,Javac 編譯后由會在同步塊的前后形成 monitorenter 和 monitorexit 兩個字節指令,這兩個字節碼指令都需要一個 reference 類型的參數來指明要鎖定和解鎖的對象。

在執行 monitorenter 指令時,會先嘗試獲取對象的鎖,對象沒有鎖或者當前線程已經持有這個對象鎖,就把鎖的計數器的值加一,執行 monitorexit 指令時會將計數減一。當計數器的值為零,鎖隨即就被釋放了。如果鎖被其他線程持有,當前線程就會被阻塞直到鎖被釋放。

Synchronize 的使用需要注意:

  • 被 Synchronize 修飾的同步塊對同一線程是可重入的,只是計數器的值加一或者減一而已,不會阻塞自己。
  • 被 Synchronize 修飾的同步塊在持有鎖的線程執行完畢釋放之前,會無條件阻塞其他線程的進入,同時獲取鎖的線程無法被強制釋放鎖,阻塞等待的線程也無法被強制中斷等待或者超時退出。

Java 線程是映射到操作系統的原生內核線程之上的,如果阻塞或喚醒一條線程,需要操作系統來完成,這就不可避免地陷入用戶態到核心態的轉換中,進行這種狀態轉換需要耗費很多的處理器時間。所以 Synchronize 在 Java 的一個重量級操作。

重入鎖(ReentrantLook)是 Lock 接口的常見實現,相比 Synchronize 增加了一些高級功能:

  • 等待可中斷:當持有鎖的線程長時間不釋放鎖的時候,等待的線程可以選擇放棄等待,改為處理其他事情。
  • 公平鎖:多個線程在等待鎖的時候,會按照申請鎖的時間順序來依次獲取鎖,即公平鎖。Synchronize 中的鎖是非公平的,ReentrantLook 默認也是非公平的,不過可以通過構造函數進行設置,公平鎖容易導致 ReentrantLook 的性能急劇下降,會明顯影響吞吐量。
  • 鎖綁定多個條件: 一個 ReentrantLook 對象可以綁定多個 Condition 對象。

JDK 6之前,ReentrantLook 的性能是優于 Synchronize 的,不過隨著 Synchronize 的鎖優化,現在兩種性能以及基本無差,而卻 Java 虛擬機更容易針對 Synchronize 進行優化。

2. 非阻塞同步

互斥同步屬于一種悲觀策略,總認為會存在數據競爭,需要進行加鎖,這會導致用戶態到核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等開銷。

非阻塞同步是基于沖突檢測的樂觀并發策略,先進行數據操作,如果沒有出現其他線程爭用共享數據,那操作直接成功了;如果共享數據的確被爭用,產生了沖突,再進行其他補救措施。

樂觀并發策略需要硬件指令的支持,因為我們需要操作和沖突檢測這兩個步驟具備原子性,這類指令常用的有:

  • 測試并設置(Test-and-Set)
  • 獲取并增加(Fetch-and-Increment)
  • 交換(Swap)
  • 比較并交換(Compare-and-Swap,即 CAS)
  • 加載鏈接/條件儲存(Load-Linked / Store-Conditional,即 LL/SC)

上述指令的處理過程都是一個原子操作,執行期間不會被其他線程中斷。

注意:CAS 無法確認變量被改了之后又被改回來的問題

3. 無同步方案-線程本地存儲

每一個線程 Thread 對象都有一個 ThreadLocalMap 對象,可以通過它把數據跟線程綁定,則線程之間的數據就不會存在競爭了。

鎖優化

自旋鎖與自適應自旋鎖

由于阻塞導致用戶態到核心態的性能開銷,和統計上發現在許多應用中共享數據的鎖定狀態只會持續很短的時間,為了不在這短暫的時間去阻塞和恢復線程,我們可以讓本來需要阻塞的線程改為執行一個忙循環(自旋),以等待持有鎖的線程處理完,這就是自旋鎖。本質也是基于認為等待時間會很短,屬于樂觀策略的一種。

自旋鎖的缺點是會占用處理器的時間,同時如果持有鎖的線程遲遲不釋放,就會造成自旋時間過長,白白消耗處理器資源。自旋鎖默認自旋的次數是十次,超過次數就會走傳統的阻塞方式掛起線程。

自適應自旋鎖是對自旋鎖的優化,自旋的時間不再固定,會根據性能監控信息以及上一次自旋等待是否成功獲得鎖等進行自旋時間的優化,會隨著程序的運行進行自適應。

鎖消除

即時編譯器會對不存在數據競爭的同步代碼的鎖進行消除。

鎖粗化

如果虛擬機探測到有一串零碎的操作都對同一個對象加鎖,將會把鎖同步的范圍擴展到整個操作序列的外部,比如循環內提到循環外。

輕量鎖

輕量鎖并不是用來代替重量級鎖的,他設計的初衷是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。

下表是 HotSpot 虛擬機對象頭 MarkWord .png

輕量級鎖的工作過程:在代碼即將進入同步塊的時候,如果此對象沒有被鎖定(鎖標志為“01”狀態),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的 Mark Word 的拷貝(官方稱為 Displaced Mark Word),這時候線程堆棧與對象頭的狀態如圖

下圖是輕量級鎖 CAS 操作之前堆棧與對象的狀態.jpg

然后,虛擬機將使用 CAS 操作嘗試把對象的的 Mark Word 更新為指向 Lock Record 的指針。如果這個更新動作成功了,即代表改線程擁有了這個對象的鎖,并且對象 Mark Word 的鎖標志位將轉變為“00”,表示此對象處于輕量級鎖定狀態。

下圖是輕量級鎖 CAS 操作之前堆棧與對象的狀態 .jpg

如果這個更新操作失敗了,就意味著至少存在一條線程與當前線程競爭獲取該對象的鎖。當出現兩條線程以上爭用同一個鎖的情況,那輕量級鎖就不在有效,必須要膨脹為重量級鎖,鎖標志的狀態值變為“10”。

輕量級鎖解鎖過程也同樣是通過 CAS 操作來進行的,如果對象的 Mark Word 仍然指向線程的鎖記錄,那就用 CAS 操作把對象當前的 Mark Word 和線程中復制的 Displaced Mark Word 替換回來。假如能夠替換成功,那即解鎖成功,如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的線程。

輕量級鎖能夠提升程序同步性能的依據是“對于絕大部分的鎖,在整個同步周期內都是不存在競爭的”這一經驗法則,也是一種樂觀策略。如果沒有競爭,通過 CAS 操作成功避免了互斥同步的開銷,如果確實存在競爭,除了互斥同步本身開銷外,還額外發生了 CAS 操作的開銷,所以競爭頻繁的時候開銷比重量級鎖還大。

偏向鎖

輕量級鎖是在無競爭的情況下使用 CAS 操作去消除同步使用的互斥量,偏向鎖是在無競爭的情況下把整個同步都消除掉,連 CAS 操作都不去做了。

當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標志位設置為“01”、把偏向模式設置為“1”,表示進入偏向模式。同時使用 CAS 操作把獲取到這個鎖的線程的 ID 記錄在對象的 Mark Word 之中。如果 CAS 操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作(例如加鎖、解鎖及對 Mark Word 的更新操作等)。

一旦出現另外一個線程去嘗試獲取這個鎖的情況,偏向模式就馬上宣告結束。根據鎖對象目前是否處于被鎖定的狀態決定是否撤銷偏向(偏向模式設置為“0”),撤銷后標志位恢復到未鎖定(標志位位“01”)或輕量級鎖定(標志位“00”)的狀態,后續同步操作就按照上面介紹的輕量級鎖那樣去執行。

偏向鎖、輕量級鎖的狀態轉化即對象 Mark Word 的關系如下圖.jpg

由于偏向鎖在對象的 Mark Word 中存儲線程 ID 的位置是用于存放哈希碼的,所以一旦對象計算過哈希碼后,就再也無法進入偏向鎖狀態了,同時如果對象處于偏向鎖狀態,當收到哈希碼計算請求時,偏向狀態會立即撤銷,并且鎖會膨脹為重量級鎖。

偏向鎖可以提高帶有同步但無競爭的程序性能,并非總是對程序有利,如果程序中大多數的鎖都總是被多個不同的線程訪問,那偏向模式就是多余的。

偏向鎖 -> 輕量級鎖 -> 重量級鎖

每一種同步模式都有它適合的場景,需要具體來分析,不能片面思考。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。