讀書筆記之《深入理解Java虛擬機器:JVM高階特性與最佳實踐》(下)

蔡不菜丶發表於2022-07-18

? 學而不思則罔,思而不學則殆。 —— 孔子

? 微信公眾號已開啟,菜農曰,沒關注的同學們記得關注哦!

本篇帶來的是周志明老師編寫的《深入理解Java虛擬機器:JVM高階特性與最佳實踐》,十分硬核!

全書共分為 5 部分,圍繞記憶體管理執行子系統程式編譯與優化高效併發等核心主題對JVM進行了全面而深入的分析,深刻揭示了JVM工作原理。

全書整體5個部分,十三章,共 358929 字。整體結構相當清晰,以至於寫讀書筆記的時候無從摘抄(甚至想把全書複述一遍),以下是全書第三部分的內容,望讀者細細品嚐!

一、第三部分 虛擬機器執行子系統

程式碼編譯的結果從本地機器碼轉變為位元組碼,是儲存格式發展的一小步,卻是程式語言發展的一大步

第六章 類檔案結構

計算機只認識 0 和 1,所以我們寫的程式需要經編譯器翻譯成由 0 和 1 構成的二進位制格式才能由計算機執行。

1)無關性的基石

各種不同平臺的虛擬機器與所有平臺都統一使用的程式儲存格式 — 位元組碼(ByteCode)是構成平臺無關性的即時。

2)Class類檔案的結構

任何一個Class 檔案都對應著唯一一個類或介面的定義資訊,但反過來,類或介面並不一定都得定義在檔案裡(譬如類或介面也可以通過類載入器直接生成)

Class 檔案是一組以 8 位位元組為基礎單位的二進位制流,各個資料專案嚴格按照順序緊湊地排列在 Class 檔案之中,中間沒有任何分隔符,這使得整個 Class 檔案中儲存的內容幾乎全部是程式執行的必要資料。

Class 檔案格式採用一種類似C語言結構體的偽結構來儲存資料,這種偽結構中只有兩種資料型別:

  • 無符號數 :基本的資料型別,可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字串值
  • :由多個無符號數或者其他表作為資料項構成的複合資料型別,所有表都習慣性地以 _info 結尾。
1. 魔數與Class檔案的版本

每個 Class 檔案的頭4個位元組稱為 魔數(0xCAFEBABE),它的唯一作用是確定這個檔案是否為一個能被虛擬機器接受的 Class 檔案。

緊接著魔數的4個位元組儲存的是 Class 檔案的版本號:第 5 和第 6 個位元組是次版本好,第 7 和第 8 個位元組是主版本號。

Java的版本號是從45開始的,JDK 1.1之後的每個JDK大版本釋出主版本號向上加 1

2. 常量池

主次版本號之後便是常量池的入口

常量池中常量的數量是不固定的,所以在常量池的入口會放置一項 u2 型別的資料,代表常量池容量計數值。

常量池容量(偏移地址:0x00000008)為十六進位制數0x0016,即十進位制的22,這就代表常量池中有21項常量,索引值範圍為1~21。

常量池主要存放兩大類常量:字面量符號引用

符號引用包括了三類常量:

  • 類和介面的全限定名
  • 欄位的名稱和描述符
  • 方法的名稱和描述符
3. 訪問標誌

在常量池結束之後,緊接著的兩個位元組代表訪問標誌(access_flags),這兩個標誌用於識別一些類或介面層次的訪問資訊

  • 這個Class是類還是介面
  • 是否定義為 public 型別
  • 是否定義為 abstract 型別
  • 如果是類的話是否宣告為 final

訪問標誌

4. 類索引、父類索引和介面索引集合

類索引和父類索引都是一個 u2 型別的資料,而介面索引集合是一組 u2 型別的資料集合,Class 檔案中由這三項資料來確定這個類的繼承關係。

類索引、父類索引、介面索引集合

從偏移地址0x000000F1開始的3個u2型別的值分別為0x00010x00030x0000,也就是類索引為1,父類索引為3,介面索引集合大小為0,然後通過javap命令計算出來的常量池,找出對應的類和父類的常量

5. 欄位表集合

欄位表用於描述介面或類中宣告的變數。

欄位訪問標誌

相對於全限定名和簡單名稱來說,方法和欄位的描述符就要複雜一些。描述符的作用是用來描述欄位的資料型別、方法的引數列表(包括數量、型別以及順序)和返回值。根據描述符規則,基本資料型別(byte、char、double、float、int、long、short、boolean)以及代表無返回值的void型別都用一個大寫字元來表示,而物件型別則用字元L加物件的全限定名來表。

描述符表示字元含義

對於陣列型別,每一維度將使用一個前置的“[”字元來描述

6. 方法表集合

方法表的結構如同欄位表一樣,依次包括了訪問標誌(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)幾項。

而方法裡面的程式碼,經過編譯器編譯器編譯成位元組碼指令後,存放在方法屬性表集合中一個名為 Code 的屬性裡,屬性表作為 Class 檔案格式中最具擴充套件性的一種資料專案。

如果父類方法在子類中沒有被重寫(Override),方法表集合中就不會出現來自父類的方法資訊。但同樣的,有可能會出現由編譯器自動新增的方法,最典型的便是類構造器 \<clinit> 方法和例項構造器 \<init> 方法。

7. 屬性表集合

屬性表集合的限制稍微寬鬆一些,不再要求各個屬性表具有嚴格順序,只要不與已有屬性名重複,任何人實現的編譯器都可以向屬性表中寫入自己定義的屬性資訊。

3)位元組碼指令簡介

Java虛擬機器的指令由一個位元組長度的、代表著某種特定操作含義的數字(稱為操作碼,Opcode)以及跟隨其後的零至多

個代表此操作所需引數(稱為運算元,Operands)而構成。

第七章 虛擬機器類載入機制

虛擬機器把描述類的資料從 Class 檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這就是虛擬機器的類載入機制。

1)類載入的時機

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading) 7個階段。

類的生命週期

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結)

初始化的時機

  1. 遇到new、getstatic、putstatic 或 invokestatic 這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發初始化。通俗來說也就是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及呼叫一個類的靜態方法的時候
  2. 使用 java.lang.reflect 包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先初始化
  3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化
  4. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機器會先初始化這個主類
  5. 當使用JDK 1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化

2)類載入的過程

1. 載入

在載入階段,虛擬機器需要完成以下 3 件事情:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構
  • 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的訪問入口
2. 驗證

驗證是連線階段的第一步,這一階段的目的是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

Class 檔案並不一定要求用 Java 原始碼編譯而來,可以使用任何途徑產生,甚至包括用十六進位制編輯器直接編寫來產生 Class 檔案。

驗證階段會完成 4 個階段的檢驗動作:

  • 檔案格式驗證
  1. 是否以魔數 OxCAFEBABE 開頭
  2. 主、次版本是否在當前虛擬機器處理範圍之內
  3. 常量池的常量中是否有不被支援的常量型別(檢查常量 tag 標誌)
  4. ...

該驗證階段的主要目的是保證輸入的位元組流能正確地解析並儲存在方法區之中,是基於二進位制位元組流進行的。

  • 後設資料驗證
  1. 該類是否有父類
  2. 這個類的父類是否繼承了不允許被繼承的類(final 修飾的類)
  3. 如果類不是抽象的,是否實現了其父類或介面之中要求實現的所有方法
  4. ...

該階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求。主要目的是對類的後設資料進行語義校驗,保證不存在不符合 Java 語言規範的後設資料資訊。

  • 位元組碼驗證
  1. 保證任意時刻運算元棧的資料型別與指令程式碼序列都能來配合工作
  2. 保證跳轉指令不會跳轉到方法體以外的位元組碼
  3. ...

該階段的主要目的是通過資料流和控制流分析,確定程式語義是合法的,符合邏輯的。

  • 符號引用驗證
  1. 符號引用中通過字串描述的全限定名是否能找到對應的類
  2. 在指定類中是否存在符合方法的欄位描述以及簡單名稱所描述的方法和欄位
  3. ...

該階段的主要目的是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,確保解析動作能夠正常執行。

3. 準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。

4. 解析

解析階段是虛擬機器將常量池的符號引用替換成直接引用的過程。

  • 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。
  • 直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同、如果有了直接引用,那引用的目標必定已經在記憶體中存在。
5. 初始化

類初始化階段是類載入過程的最後一步。初始化階段是執行類構造器 \<clinit> 方法的過程。

3)類載入器

類載入階段中 通過一個類的全限定名來獲取描述此類的二進位制位元組流 這個動作放到了 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為 類載入器

1. 類與類載入器

每一個類載入都擁有一個獨立的類名稱空間。比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個Class檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

2. 雙親委派模型

類載入器可以劃分為 3 類:

  • 啟動類載入器(Bootstrap ClassLoader)

這個類載入器負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的

  • 擴充套件類載入器(Extension ClassLoader)

這個載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。

  • 應用程式類載入器(Application ClassLoader)

這個類載入器由sun.misc.Launcher$App-ClassLoader實現。由於這個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱它為系統類載入器。如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器

這裡類載入器之間的父子關係一般不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父載入器的程式碼。

雙親委派模型的工作過程

如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。

3. 破壞雙親委派模型
  1. JDK 1.2之後已不提倡使用者再去覆蓋loadClass()方法,而應當把自己的類載入邏輯寫到findClass()方法中,在loadClass()方法的邏輯裡如果父類載入失敗,則會呼叫自己的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派規則的。
  2. 執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

第八章 虛擬機器位元組碼執行引擎

1)執行時棧幀結構

棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧的棧元素。

棧幀儲存了方法的區域性變數表運算元棧動態連線和方法返回地址等資訊。

一個執行緒方法中的呼叫鏈可能會很長,對於執行引擎來說,只有位於棧頂的棧幀才是有效的,稱為 當前棧幀

1. 區域性變數表

區域性變數表是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。

區域性變數表的容量以變數槽 為最小單位。

2. 運算元棧

運算元棧也常稱為操作棧,是一個後入先出棧。運算元棧的最大深度會在編譯的時候寫入到 Code 屬性的 max_stacks 資料項中。

在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全獨立的。但在大多數虛擬機器的實現中會有一部分優化重疊。這樣在進行方法呼叫時就可以共用一部分資料,無須進行額外的引數複製傳遞。

棧幀之間的資料共享

3. 動態連線

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

4. 方法返回地址

停止方法的執行有兩種方式:

  • 執行引擎遇到任意一個方法返回的位元組碼指令。這種退出方式稱為正常完成出口
  • 在方法執行過程中遇到了異常,並且這個異常沒有在方法體內遇到處理。這種退出方法的方式稱為異常完成出口

2)方法呼叫

方法呼叫並不等同於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。

在編譯時期,一切方法呼叫在 Class 檔案裡面儲存的都只是符號引用,只有在類載入期間,甚至到執行期間才能確定目標方法的直接引用

解析

在類載入的解析階段,會將其中一部分符號引用轉化為直接引用,而這種解析成立的條件為:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。

二、第四部分 程式編譯與程式碼優化

從計算機程式出現的第一天起,對效率的追求就是程式天生的堅定信仰,這個過程猶如一場沒有終點、永不停歇的F1方程式競賽,程式設計師是車手,技術平臺則是在賽道上飛馳的賽車

第十章 早期(編譯器)優化

1)Javac 編譯器

Javac 編譯器本身就是一個由Java 語言編寫的程式

編譯過程大致可以分為 3 個過程,分別是:

  • 解析與填充符號表過程
  • 插入式註解處理器的註解處理過程
  • 分析與位元組碼生成過程

Javac 編譯過程

Javac 編譯動作的入口是 com.sun.tools.javac.main.JavaCompiler

1. 解析與填充符號表

解析:

解析步驟包括了經典程式編譯原理中的 詞法分析語法分析 兩個過程

  • 詞法分析過程由 com.sun.tools.javac.parser.Scanner 類來實現,根據 Token 序列構造抽象語法樹的過程。
  • 語法分析過程由 com.sun.tools.javac.parser.Parser 類來實現,這個階段產出的抽象語法樹由 com.sun.tools.javac.tree.JCTree 類表示。

經過這個步驟之後,編譯器基本就不會再對原始碼進行操作了,後續的操作都建立在抽象語法樹上。

填充符號表

填充符號表的動作由 enterTrees() 方法實現。

符號表就是由一組符號地址和符號資訊構成的表格,其中所登記的資訊在編譯的不同階段都要用到。

填充符號表的過程由com.sun.tools.javac.comp.Enter類實現,此過程的出口是一個待處理列表(To Do List),包含了

每一個編譯單元的抽象語法樹的頂級節點,以及package-info.java(如果存在的話)的頂級節點

2. 註解處理器

Java 語言提供了對註解的支援,這些註解與普通的 Java 程式碼一樣,是在執行期間發揮作用的。

3. 語義分析與位元組碼生成

在上述步驟結束後,可以得到一個抽象語法樹,但是無法保證源程式是符合邏輯的,語義分析的主要任務是對結構上正確的源程式進行上下文有關性質的審查。

  • 標註檢查
  • 資料及控制流分析
  • 解語法糖
  • 位元組碼生成

第十一章 晚期(執行期)優化

1)直譯器與編譯器

當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。在程式執行後,隨著時間的推移,編譯器逐漸開始發揮作用,把越來越多的程式碼編譯成原生程式碼之後,可以獲取更高的執行效率。當程式執行環境中記憶體限制較大(如嵌入式系統),可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率。

HotSpot 虛擬機器中內建了兩個即時編譯器,分別稱為 Client Compiler 和 Server Compiler,簡稱為 C1編譯器 和 C2 編譯器。使用者可以使用 -client 或 -server 來指定執行在 Client 模式還是 Server 模式

為了在程式啟動響應速度與執行速度之間達到最佳平衡,引入了 分層編譯

  • 第 0 層:程式解釋執行,直譯器不開啟效能監控功能,可觸發第1層編譯
  • 第 1 層:也稱為 C1 編譯,將位元組碼編譯為原生程式碼,進行簡單,可靠的優化,如有必要將加入效能監控的邏輯
  • 第 2 層:也稱為 C2 編譯,也是將位元組碼編譯為原生程式碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化

2)編譯物件與觸發條件

在執行過程中會被即時編譯器的 熱點程式碼 有兩種:

  • 被多次呼叫的方法
  • 被多次執行的迴圈體

判斷一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這樣的行為稱為 熱點探測

  • 基於取樣的熱點探測:虛擬機器會週期性地檢查各個執行緒的棧頂,如果發現某個方法經常出現在棧頂,那這個方法就是熱點方法。
  • 基於計數器的熱點探測:採用這種方法的虛擬機器會為每個方法(甚至是程式碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認為它是熱點方法。(方法呼叫計數器 和 回邊計數器)
回邊計數器:作用是統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為回邊。建立回邊計數器統計的目的就是為了觸發 OSR 編譯。

方法呼叫計數器

回邊計數器

3)編譯優化技術

優化技術總覽

1. 公共子表示式消除

如果一個表示式E已經計算過了,並且從先前的計算到現在 E 中所有變數的值都沒有發生變化,那麼 E 的這次出現就成為了公共子表示式。

2. 陣列邊界檢查消除

為了安全,陣列邊界檢查是必須要做的,但不是在每一次執行期間都會進行檢查。

3. 方法內聯

方法內聯的行為是把目標方法的程式碼複製到發起呼叫的方法之中,避免發生真實的方法呼叫。為了解決 Java 中虛方法內聯的問題,引入了一種名為 "型別繼承關係分析(CHA)" 的 技術,這是一種基於整個應用程式的型別分析技術,它用於確定在目前已載入的類中,某個介面是否有多於一種的實現,某個類是否存在子類,子類是否為抽象類等資訊。

4. 逃逸分析

逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他方法中,稱為方法逃逸。甚至還有可能被外部執行緒訪問到,稱為執行緒逃逸。

要證明一個物件不會逃逸到方法或執行緒之外,需要對這個變數作一些優化:

  • 棧上分配
  • 同步消除
  • 標量替換

三、第五部分 高效併發

併發處理的廣泛應用是使得 Amdahl 定律代替摩爾定律稱為計算機效能發展原動力的根本原因,也是人類壓榨計算機運算能力的最有力的武器

第十二章 Java 記憶體模型與執行緒

1)硬體的效率與一致性

當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,因此在讀寫時要根據協議來操作,如 MSI、MESI、MOSI等

2)主記憶體與工作記憶體

Java 記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

3)記憶體間互動操作

Java 記憶體模型中定義了以下 8 種操作來完成,虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態
  • unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用
  • load(載入):作用於工作記憶體的變數,它把 read 操作從主記憶體得到的變數值放入工作記憶體的變數副本中
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數使用到變數的值的位元組碼指令時將會執行這個操作
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作
  • store(儲存):作用於主記憶體的變數,它把 store 操作從工作記憶體中得到的變數的值放入主記憶體的變數
  • write(寫入):作用於主記憶體的變數,它把 store 操作從工作記憶體中得到的變數的值放入主記憶體的變數中

如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行store和write操作。

Java 記憶體模型規定了在執行上述 8 種基本操作時需滿足的以下規則:

  1. 不允許 read 和 load、store 和 write 操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現
  2. 不允許一個執行緒丟棄它的最近的 assign 操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體
  3. 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中
  4. 一個新的變數只能在主記憶體中產生,不允許在工作記憶體中直接使用一個未被初始化(load 或 assign) 的變數
  5. 一個變數在同一時刻只允許一條執行緒對其進行 lock 操作,但 lock 操作可以被同一條執行緒重複執行多次,多次執行 lock 後,只有執行相同次數的 unlock 操作,變數才會被解鎖
  6. 如果對一個變數執行 lock 操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行 load 或 assign 操作初始化變數的值
  7. 如果一個變數事先沒有被 lock 操作鎖定,那就不允許對它執行 unlock 操作,也不允許去 unlock 一個被其他執行緒鎖定住的變數
  8. 對一個變數執行 unlock 操作之前,必須先把此變數同步回主記憶體中。
1. 原子性、可見性與有序性
Java 記憶體模型是圍繞著在併發過程中如何處理 原子性、可見性和有序性 這三個特徵建立的

1. 原子性

Java 記憶體模型的 read、load、assign、use、store 和 write 可以直接保證原子性變數操作。

2. 可見性

可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。除了 volatile 之外,在 Java 中還可以通過 synchronize 和 final 來保證可見性。

3. 有序性

如果在本執行緒內觀察,所有的操作都是有序的,如果在一個執行緒中觀察另一個執行緒,所有操作都是無序的。

前半句是指:執行緒內表現為序列的語義,後半句是指:指令重排序現象和工作記憶體與主記憶體同步延遲現象

4)Java 與執行緒

1. 執行緒的實現

各個執行緒之間既可以共享程式資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是 CPU 排程的基本單位)

實現執行緒的主要有 3 種方式:

  1. 核心執行緒實現

直接由作業系統核心支援的執行緒,這種執行緒由核心來完成執行緒切換。程式一般不會直接取使用核心執行緒,而是去使用核心執行緒的一種高階介面— 輕量級程式 LWP = 執行緒

侷限性:

由於基於核心執行緒實現,各種執行緒間的操作(建立、析構及同步)都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態核心態中來回切換。

  1. 使用者執行緒實現

一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒。輕量級程式也屬於使用者執行緒。使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒存在的實現。使用者執行緒的建立、同步、銷燬和排程完全在使用者態上完成,不需要核心的幫助。

侷限性:

沒有系統核心的支援,執行緒的所有操作都需要使用者程式自己處理,在 阻塞、排程之類的問題處理起來會異常困難

  1. 使用者執行緒加輕量級程式混合實現

在這種混合實現下,既存在使用者執行緒、也存在輕量級程式。使用者程式完全是建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。

2. Java 執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種:協同式執行緒排程搶佔式執行緒排程

  • 協同式執行緒排程

執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上。

特點:實現簡單,但執行緒執行的時間不可控制,如果一個執行緒編寫有問題,就會導致一直阻塞

  • 搶佔式執行緒排程

每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定。

特點:執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程式阻塞的問題。

3. 執行緒狀態切換

Java 語言中定義了 5 種執行緒狀態:

  • 新建(New):創鍵後尚未啟動的執行緒處於這種狀態
  • 執行(Runable):Runable 包括了作業系統狀態中 Running 和 Ready,也就是處於此狀態的執行緒有可能正在執行,也有可能正在等待著 CPU 為它分配執行時間
  • 無限期等待(Waiting):處於這種狀態的執行緒不會被分配 CPU 執行時間,它們要等待被其他執行緒顯式地喚醒
  • 限期等待(Timed Waiting):處於這種狀態的執行緒也不會被分配 CPU 執行時間,不過無須等待被其他執行緒顯式喚醒,在一定時間之後它們會由系統自動喚醒
  • 阻塞(Blocked):執行緒被阻塞了,等待著獲取一個排他鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生
  • 結束(Terminated):已終止執行緒的執行緒狀態,執行緒已經結束執行

第十三章 執行緒安全與鎖優化

1)執行緒安全

當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件就是執行緒安全的。

我們可以將 Java 語言中各種操作共享的資料分為 5 類:

  1. 不可變

不可變的物件一定是執行緒安全的。保證物件行為不影響自己狀態的途徑有很多種,其中最簡單的就是把物件中帶有狀態的變數都宣告為 final,這樣在建構函式結束之後,它就是不可變的。

  1. 絕對執行緒安全

在 Java API 中標註自己是執行緒安全的類,大多數都不是絕對執行緒安全的。

  1. 相對執行緒安全

相對的執行緒安全就是我們通常意義上的執行緒安全,它需要保證對這個物件單獨的操作是執行緒安全的,我們在呼叫的時候不需要做額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。

  1. 執行緒相容

執行緒相容是指物件本身並不是執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用。

  1. 執行緒對立

執行緒對立是指無論呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用的程式碼。

2)執行緒安全的實現方法

1. 互斥同步

互斥是方法,同步是目的。最基本的手段就是使用 synchronized 關鍵字,經過編譯之後,會在同步塊的前後分別形成 monitorentermonitorexit 這兩個位元組碼指令。

除了使用 synchronized 關鍵字還可以使用 J.U.C 包下的 ReentrantLock 來實現同步。相比synchronizedReentrantLock增加了一些高階功能,主要有以下3項:等待可中斷、可實現公平鎖,以及鎖可以繫結多個條件。

2. 非阻塞同步

非阻塞同步是一種基於衝突檢測的樂觀併發策略。通常可以使用 CAS 來完成操作。

大部分情況下ABA問題不會影響程式併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

3. 無同步方案

如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正確性。

3)鎖優化

HotSpot虛擬機器開發團隊在這個版本上花費了大量的精力去實現各種鎖優化技術,如 適應性自旋鎖消除鎖粗化輕量級鎖偏向鎖


這篇我們們主要是針對 《深入理解Java虛擬機器:JVM高階特性與最佳實踐》 下半部分做了相關的讀書筆記。請讀者慢慢閱讀,轉化成自己的知識~!??

不要空談,不要貪懶,和小菜一起做個 吹著牛X做架構 的程式猿吧~點個關注做個伴,讓小菜不再孤單。我們們下文見!

? 今天的你多努力一點,明天的你就能少說一句求人的話!

?? 微信公眾號:菜農曰,沒關注的同學們記得關注哦!

相關文章