《深入理解 Java 虛擬機》學習筆記
zhouer / 2022-04-29
走近 Java
自動內存管理
Java 內存區域與內存溢出異常
運行時數據區域

程序計數區
Program Counter Register,可以看做是當前線程所執行的字節碼的行號指示器。字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控製流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
由於Java虛擬機的多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,為了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為「線程私有」的內存。
當實現 Native 方法時,這個計數器值為Undefined.
Java 虛擬機棧
Java Virtual Machine Stack,線程私有的,生命週期與線程相同。每個方法被執行時,虛擬機就同步創建一個棧幀用於存儲局部變量表、操作數棧、動態連接、方法出口等信息。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
局部變量表存放了編譯器可知的各種 Java 虛擬機基本數據類型、對象引用和 returnAddress 類型(指向了一條字節碼指令的地址)。局部變量表以 Slot 的基本單元儲存數據,局部變量表所需的內存空間在編譯期間完成分配在方法運行期間不會改變局部變量表的大小(指 Slot 的數量)。
本地方法棧
Native Method Stack,為調用本地方法服務。
Java 堆
Java Heap,”幾乎“所有的對象實例都在這裡分配內存。所有線程共享的 Java 堆中可以劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer, TLAB),以提升對象分配時的效率。
方法區
Method Area,用於存儲已被虛擬機加載的類型信息、常量、靜態變量、及時編譯器編譯後的代碼緩存等數據。作為一個概念,在 HotSpot 中以永久代/元空間實現。
運行時常量池
Runtime Constant Pool,屬於方法區的一部分。Class 文件中用於存放編譯期生成的各種字面量與符號引用的常量池表在類加載後放到方法區的運行時常量池中。
直接內存
Direct Memory,在 NIO 的引入後虛擬機可以使用 Native 函數庫直接分配堆外內存,然後通過通過一個 DirectByteBuffer 對象作為這塊內存的引用進行操作。
對象
對象的創建
- 虛擬機遇到 new 指令;
- 檢查這一指令的藏書是否能在常量池中定位的到一個類的符號引用,並檢查這個類是否被加載、解析和初始化。沒有的話就進行類加載。
- 為新對象在堆中分配內存;(多線程情況下,採用 CAS 或 TLAB)
- 將分配到的內存空間(不包括對象頭)都初始化為零值;
- 對對象進行必要的設置,信息存儲在對象頭裡。
此時Class 文件中的 <init> 方法尚未執行,所有字段都為零值。
對象的內存佈局
包括三部分對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。
對象的訪問定位
句柄訪問:堆內劃分出一塊句柄池,reference 中存儲的就是對象的句柄地址,句柄中包含了對象示例數據與類型數據各自的地址信息。

直接指針訪問:reference 中存儲的就是對象地址,訪問速度快,HotSpot 中主要使用。

垃圾收集器與內存分配策略
對象存活判定算法
引用計數算法
Reference Counting,在對象中添加一個引用計數器,計數為0時即為垃圾。需要額外工作配合例外狀態,比如循環引用。
可達性分析算法
通過一系列被稱為 “GC Roots” 的根對象作為起始點集,從這些節點開始根據引用關係向下搜索,走過的路程稱為“引用鏈”(Reference Chain),如果有搞對象和 “GC Roots” 間沒有任何引用鏈相連,或者不可達時,證明此對象可被回收。Java 虛擬機中採用該算法。
Java中,GC Roots 對象包括:
- 在虛擬機棧中(棧幀中的本地變量表)引用的對象;
- 在方法區中類靜態屬性引用的對象;
- 在方法區中常量引用的對象;
- 在本地方法棧中 JNI(Native 方法)引用的對象;
- 虛擬機的內部引用;
- 被同步鎖(synchronized)持有的對象;
- 反映虛擬機內部請款的 JMXBean、JVMTI 中註冊的回調、本地代碼緩存等。
引用
- 強引用(Strongly Reference):在程序代碼中普遍存在的引用賦值,垃圾收集器永遠不會回收;
- 軟引用(Soft Reference):用以描述一些還有用,但非必要的對象,在將要發生內存溢出前,會把這些對象列進回收範圍進行二次回收;
- 弱引用(Weak Reference):無論內存是否足夠都會被回收;
- 虛引用(Phantom Reference):一個對象是否有虛引用不影響其生存時間,也無法通過虛引用來獲取對象,設置虛引用的唯一目只是為了在這個對象被回收時收到一個通知。
回收方法區
一個類被允許回收需要同時滿足三個判定條件:
- 該類所有實例都已被回收;
- 加載該類的類加載器已被回收;
- 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類方法。
垃圾收集算法
分代收集理論
Generational Collection,建立在三個分代假說之上:
- 弱分代假說:絕大多數對象都是朝生夕死;
- 強分代假說:熬過越多次 CG 的對象就越消亡;
- 跨代引用假說:跨代引用相對于同代引用來說佔極少數。
收集器應該將堆劃分出不同的區域,然後將回收對象依據年齡分配到不同區域之中存儲。對於跨代引用,只在新生代建立一個全局的數據結構(記憶集),這個結構把老年代劃分成若干小塊,標識出老年代的哪塊內存會存在跨代引用,發生 Minor GC 時只把這一塊內存加入 GC Roots 進行掃描,減少 STW 時間。
標記-清除算法
Mark-Sweep,缺點在於執行效率不穩定和內存空間碎片化問題。

標記-複製算法

標記-整理算法
標記-複製算法在對象存活率高時要進行較多的複製操作並且為了防止內存溢出需要有額外的空間進行分配擔保,所以老年代不能採用標記-複製算法。標記-整理算法缺點在於需要暫停用戶進程,產生 “Stop The World”。

//TODO HotSpot 的算法細節實現
經典垃圾收集器

Serial 收集器
單線程收集器,進行垃圾收集時必須暫停其他所有工作線程。簡單高效(與其他收集器的單線程比起來),額外內存消耗小,對於小內存服務有優勢。
ParNew 收集器
Serial 收集器的多線程版,在 JDK 9以後只能和 CMS 搭配使用。
Parallel Scavenge 收集器
主要目標是達到一個可控的吞吐量,兼具自適應功能。
Serial Old 收集器
作為 CMS 的後備預案。
Parallel Old 收集器
搭配 Parallel Scavenge 收集器使用。
CSM 收集器
Concurrent Mark Sweep,以獲取最短停段時間為目標的收集器,適用于關注響應時間的服務端。運作過程包括:1)初始標記;2)並發標記;3)重新標記;4)並發清除。初始標記和重新標記需要"Stop The World"。
缺點在於:1)對處理器資源敏感,會降低總吞吐量;2)無法處理浮動垃圾(Floating Garbage,在標記過程結束後產生的垃圾),有可能出現"Concurrent Mode Failure" 失敗而導致另一次 Full GC,因此 CMS 收集器需要預留一部分空間造成 GC 頻率提高;3)採用標記-清除算法,產生空間碎片,會提高引發 Full GC 的頻率。
Garbage First 收集器
面向堆內存任何部分來組成回收集(Collection Set,CSet)進行回收。G1 基於 Region 的堆內存佈局,不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的堆內存劃分成多個大小相等的獨立區域(Region),每一個 Region 都可以根據需要扮演 Eden 區、Survivor 區、老年代。Region 中還有一類特殊的 Humongous 區域,用於存儲大對象。
G1 收集器將 Region 作為單次回收的最小單元,避免了對整個堆進行的全區域回收,具體處理思路就是讓 G1 收集器去跟蹤各個 Region 裡面垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及所需時間的經驗值,然後在後台維護一個優先隊列,根據用戶設定的允許的收集停頓時間(-XX:MaxGCPauseMillis 默認200毫秒),優先處理價值大的 Region 。

運作大致劃分為四個步驟:1)初始標記;2)並發標記;3)最終標記;4)篩選回收:採用標記-複製算法整體處理 Region 到一個空的 Region。除了並發標記外都需要 STW 。
G1 從整體來看基於標記-整理算法,局部(兩個 Region 之間)上基於標記-複製算法,意味著 G1 不會產生內存碎片。G1 比 CMS 處理器需要更多的內存佔用和程序運行時的額外執行負載。
//TODO低延遲垃圾收集器
垃圾收集器調優參數

虛擬機性能監控、故障處理工具
基礎故障處理工具
jps:虛擬機進程狀況工具
jps [ options ] [ hostid ]
jstat:虛擬機統計信息監視工具
jstat [ option vmid [ interval [ s|ms ] [ count ] ] ]
jinfo:Java 配置信息工具
實時查看和調整虛擬機的各項參數。
jinfo [ option ] pid
jmap:Java 內存映像工具
jinfo [ option ] vmid
jhat:虛擬機堆轉儲快照分析工具
與 jmap 搭配使用,別用。
jstack:Java 堆棧跟蹤工具
用於生成虛擬機當前時刻的線程快照。
jstack [ option ] vmid
小結
可視化故障處理工具
JHSDB:基於服務性代理的調試工具
JConsole:Java 監視與管理控制台
VisualVM:多合-故障處理工具
Java Mission Control:可持續在線的監控工具
虛擬機執行子系統
類文件結構
Class 類文件的結構
Class 文件格式採用一種類似於 C 語言結構體的偽結構來存儲數據,這種偽結構只有兩種數據類型:“無符號數”和“表”。
- 無符號數屬於基本的數據類型,以 u1、u2、u4、u8 來分別表示1、2、4、8個字節的無符號數,可以用來描述數字、索引引用、數量值或者按照 UTF-8 編碼構成的字符串值;
- 表是由多個無符號數或者其他表作為數據項構成的復合數據類型,整個 Class 文件就可以被視作是一張表。
//TODO
虛擬機類加載機制
Java 虛擬機吧描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換解析和初始化,最總形成可以被虛擬機直接使用的 Java 類型,這個過程被稱作虛擬機的類加載機制。
類加載的時機
對於初始化階段有嚴格規定有且只有六種情況必須立即對類進行初始化(加載、驗證、準備自然需要在此之前開始):
- 遇到 new、getstatic、putstatic 或 invokestatic 這四條字節碼指令時,若類沒有初始化過就需要進行初始化,具體表示如下:
- 使用 new 關鍵字實例化對象時;
- 讀取或設置一個類型的靜態字段(被 finial 修飾、已在編譯期把結果放入常量池的靜態字段除外)時;
- 調用一個類型的靜態方法時。
- 使用 java.lang.reflect 包方法對類型進行反射調用時;
- 當初始化類時,發現其父類沒有初始化,就要先觸發父類的初始化;
- 當虛擬機啟動時,虛擬機會先初始化用戶指定的主類;
- 當時用 JDK 7 的動態語言支持時,如果 java.lang.invoke.MethodHandle 實例最後的解析結果為 REF_getStatic、REF_putStatic、REF_newInvokeSpecial 四種類型的方法句柄,並且這個方法句柄沒有被初始化;
- 當一個接口中定義了 default 方法,如果該接口的實現類發生初始化,那該接口需要在此之前被初始化。
這六種場景中的行為被稱作對一個類型的主動引用,除此之外,所有的引用類型的方式都不會出發初始化,稱作被動引用。
examples:
- 通過子類引用父類的靜態字段時,不會導致子類初始化;
- 通過數組定義來應用類,不會出發此類的初始化;
- 常量在編譯期間會存入常量池,在調用時不會觸發對該類的初始化。
類加載的過程
加載
- 通過一個類的全限定名來獲取定義此類的二進制字節流;
- 將這個字節流所代表的的靜態存儲結構轉化為方法區的運行時數據結構;
- 在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。
驗證
- 文件格式驗證
- 元數據驗證
- 字節碼驗證
- 符號引用驗證
準備
正式對類中定義的類變量分配內存並設置類變量初始值。
解析
將常量池內的符號引用替換為直接引用的過程。
- 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定是已經加載到虛擬機內存當中的內容。符號引用的字面量形式被虛擬機規範明確定義。
- 直接引用:可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用與虛擬機實現的內存佈局直接相關。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符。
初始化
初始化階段就是執行類構造器 <clinit>() 方法的過程,<clinit>() 方法是 Javac 編譯期的自動生成物。
類加載器
- 啟動類加載器(Bootstrap Class Loader):負責加載存放在<JAVA_HOME>\lib 目錄,或者被 -Xbootclasspath 參數所指定路徑中存放的,而且是虛擬機能識別(按照文件名識別)的類庫加載到虛擬機的內存中。啟動類加載器無法被 Java 程序直接引用;
- 擴展類加載器(Extension Class Loader):這個類加載器在類 sun.misc.Launcher$ExtClassLoader 中以 Java 代碼的形式實現。負責加載<JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫。
- 應用程序類加載器(Application Class Loader):這個類加載器由 sun.misc.Launcher$AppClassLoader 來實現。負責加載用戶路徑上所有的類庫,一般情況下的默認類加載器。
雙親委派機制:如果一個類加載器收到了類加載的請求,他首先不會自己去嘗試加載這個類,而是把請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都會傳遞到最頂層的啟動類加載器,只有當父加載器自己無法完成這個加載請求時,自加載類才會嘗試自己完成加載。
##虛擬機字節碼執行引擎
運行時棧幀結構
- 局部變量表 Local Variables Table,一組變量值的存儲空間,用於存放方法參數和方法內部定義的局部變量。容量以變量槽為最小單位。
- 操作數棧 Operand Stack
- 動態聯機 Dynamic Linking,每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,部分符號引用在每一次運行期間都轉化為直接引用,這部分就叫做動態鏈接。
- 方法返回地址 Return Address
- 附加信息
方法調用
解析
調用目標在程序代碼寫好、編譯器進行編譯的時候就已經確定下來了,這類方法的調用被稱作解析。主要包括靜態方法、私有方法、實例構造器、父類方法和 final 修飾的方法,這些方法被稱作非虛方法。
分派
- 靜態分派 所有依賴靜態類型來決定方法執行版本的分派動作。靜態分派發生在編譯階段,典型表現為重載。
- 動態分派
- 單分派與多分
//TODO程序編譯與代碼優化
高效並發
Java 內存模式與線程
Java 內存模型
所有變量都存儲在主內存(Main Memory)中,每個線程有自己私有的工作內存(Working Memory)。線程的工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作都必須在工作內存中進行。
volatile
volatile 保證了變量對所有線程的可見性(當一條線程修改了這個變量的值,新值對於其他線程來說是立即得知的);禁止了指令重排優化。JMM 要求:
- 在工作內存中,每次使用 volatile 修飾的變量前都必須從主內存刷新最新的值,用於保證能看見其他線程對其所做的修改;
- 在工作內存中,每次對 volatile 修飾的變量做出修改後,都必須立刻同步回主內存,用於保證其他線程能看到這個改變;
- 要求 volatile 修飾的變量不會被指令重排優化,保證代碼執行順序與程序執行順序相同。
先行發生原則
Happens-Before,先行發生是 JMM 中定義的兩項操作之間的偏序關係。
- 程序次序規則(Program Order Rule):在一個線程內,按照控制流順序(不是代碼順序,要考慮分支、循環等結構),書寫在前面的操作先行發生與書寫在後面的操作;
- 管程鎖定規則(Monitor Lock Rule):一個 unlock 操作先行發生與後面(時間先後意義上的)對同一個鎖的 lock 操作;
- volatile 變量規則(Volatile Variable Rule):對一個 volatile 變量的寫操作先行發生與後面對整個變量的讀操作;
- 線程啟動規則(Thread Start Rule):Thread 對象的 start() 方法先行發生於此線程的每個動作;
- 線程終止規則(Thread Termination Rule):線程中所有操作都先行發生於對此線程的終止檢測;
- 線程中斷規則(Thread Interruption Rule):對線程 interruption() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
- 對象終結規則(Finalizer Rule):一個對象的初始化完成先行於它的 finalize() 方法的開始;
- 傳遞性(Transitivity):如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,可以得出操作 A 先行發生於操作 C。
Java 與線程
Java 定義了6种線程狀態:
- 新建(New):創建後尚未啟動的線程;
- 運行(Runnable):包括操作系統線程狀態中的 Running 和 Ready,可能正在執行或正在等待操作系統分配執行時間;
- 無期限等待(Waiting):該狀態的線程不會被分配處理器執行時間,需要等待被其他線程顯式喚醒,進入該狀態的方法有:沒有設置 Timeout 參數的 Object.wait() 方法、沒有設置 Timeout 參數的 Thread.join() 方法、LockSupport.park() 方法;
- 期限等待(Timed Waiting):不會被分配處理器執行時間,不需要等待被其他線程顯式喚醒,在一定時間後悔由系統自動喚醒,進入該狀態的方法有:Thread.sleep() 方法、設置 了Timeout 參數的 Object.wait() 方法、設置了 Timeout 參數的 Thread.join() 方法、LockSupport.parkNanos() 方法、LockSupport.parkUntil() 方法;
- 阻塞(Blocked):在等待著獲取到一個排他鎖。
- 結束(Terminated):已終止線程,執行結束。
線程安全與鎖優化
當多個線程同時訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方法進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那就稱這個對象是線程安全的。