《深入理解Java虚拟机:JVM高级特性与最佳实践》 (第3版)周志明 著
深入理解Java虛擬機:JVM高級特性與最佳實踐
- 第一部分 走近Java
- 第1章 走近Java
- 1.2 Java技術體系
- 1.4 Java虛擬機家族
- 第二部分 自動內存管理
- 第2章 Java內存區域與內存溢出異常
- 2.2 運行時數據區域
- 2.2.1 程序計數器
- 2.2.2 Java虛擬機棧
- 2.2.3 本地方法棧
- 2.2.4 Java堆
- 2.2.5 方法區
- 2.2.6 運行時常量池
- 2.2.7 直接內存
- 2.3 HotSpot虛擬機對象探秘
- 2.3.1 對象的創建
- 2.3.2 對象的內存布局
- 2.3.3 對象的訪問定位
- 2.4 實戰:OutOfMemoryError異常
- 2.4.1 Java堆溢出
- 2.4.2 虛擬機棧和本地方法棧溢出
- 2.4.3 方法區和運行時常量池溢出
- 2.4.4 本機直接內存溢出
- 第3章 垃圾收集器與內存分配策略
- 3.1 概述
- 3.2 對象已死?
- 3.2.1 引用計數算法
- 3.2.2 可達性分析算法
- 3.2.3 再談引用
- 3.2.4 生存還是死亡?
- 3.2.5 回收方法區
- 3.3 垃圾收集算法
- 3.3.1 分代收集理論
- 3.3.2 標記-清除算法
- 3.3.3 標記-復制算法
- 3.3.4 標記-整理算法
- 3.4 HotSpot的算法細節實現
- 3.4.1 根節點枚舉
- 3.4.2 安全點
- 3.4.3 安全區域
- 3.4.4 記憶集與卡表
- 3.4.5 寫屏障
- 3.4.6 并發的可達性分析
- 3.5 經典垃圾收集器
- 3.5.1 Serial收集器
- 3.5.2 ParNew收集器
- 3.5.3 Parallel Scavenge收集器
- 3.5.4 Serial Old收集器
- 3.5.5 Parallel Old收集器
- 3.5.6 CMS收集器
- 3.5.7 Garbage First收集器
- 3.6 低延遲垃圾收集器
- 3.6.1 Shenandoah收集器
- 3.6.2 ZGC收集器
- 3.7 選擇合適的垃圾收集器
- 3.8 實戰:內存分配與回收策略
- 3.8.1 對象優先在Eden分配
- 3.8.2 大對象直接進入老年代
- 3.8.3 長期存活的對象將進入老年代
- 3.8.4 動態對象年齡判定
- 3.8.5 空間分配擔保
- 第4章 虛擬機性能監控、故障處理工具
- 4.2 基礎故障處理工具
- 4.2.1 jps:虛擬機進程狀況工具
- 4.2.2 jstat:虛擬機統計信息監視工具
- 4.2.3 jinfo:Java配置信息工具
- 4.2.4 jmap:Java內存映像工具
- 4.2.5 jhat:虛擬機堆轉儲快照分析工具
- 4.2.6 jstack:Java堆棧跟蹤工具
- 4.2.7 基礎工具總結
- 4.3 可視化故障處理工具
- 4.4 HotSpot虛擬機插件及工具
- 第5章 調優案例分析與實戰
- 5.3 實戰:Eclipse運行速度調優
- 第三部分 虛擬機執行子系統
- 第6章 類文件結構
- 6.2 無關性的基石
- 6.3 Class類文件的結構
- 6.3.1 魔數與Class文件的版本
- 6.3.2 常量池
- 6.3.3 訪問標志
- 6.3.4 類索引、父類索引與接口索引集合
- 6.3.5 字段表集合
- 6.3.6 方法表集合
- 6.3.7 屬性表集合
- 6.4 字節碼指令簡介
- 6.4.1 字節碼與數據類型
- 6.4.2 加載和存儲指令
- 6.4.3 運算指令
- 6.4.4 類型轉換指令
- 6.4.5 對象創建與訪問指令
- 6.4.6 操作數棧管理指令
- 6.4.7 控制轉移指令
- 6.4.8 方法調用和返回指令
- 6.4.9 異常處理指令
- 6.4.10 同步指令
- 6.5 公有設計,私有實現
- 第7章 虛擬機類加載機制
- 7.1 概述
- 7.2 類加載的時機
- 7.3 類加載的過程
- 7.3.1 加載
- 7.3.2 驗證
- 7.3.3 準備
- 7.3.4 解析
- 7.3.5 初始化
- 7.4 類加載器
- 7.4.1 類與類加載器
- 7.4.2 雙親委派模型
- 7.4.3 破壞雙親委派模型
- 7.5 Java模塊化系統
- 第8章 虛擬機字節碼執行引擎
- 8.1 概述
- 8.2 運行時棧幀結構
- 8.2.1 局部變量表
- 8.2.2 操作數棧
- 8.2.3 動態連接
- 8.2.4 方法返回地址
- 8.2.5 附加信息
- 8.3 方法調用
- 8.3.1 解析
- 8.3.2 分派
- 8.4 動態類型語言支持
- 8.4.1 動態類型語言
- 8.5 基于棧的字節碼解釋執行引擎
- 第9章 類加載及執行子系統的案例與實戰
- 第四部分 程序編譯與代碼優化
- 第10章 前端編譯與優化
- 10.1 概述
- 10.2 Javac編譯器
- 10.2.1 Javac的源碼與調試
- 10.2.2 解析與填充符號表
- 10.2.3 注解處理器
- 10.2.4 語義分析與字節碼生成
- 10.3 Java語法糖的味道
- 10.3.1 泛型
- 10.3.2 自動裝箱、拆箱與遍歷循環
- 10.3.3 條件編譯
- 10.4 實戰:插入式注解處理器
- 第11章 后端編譯與優化
- 11.1 概述
- 11.2 即時編譯器
- 11.3 提前編譯器
- 11.4 編譯器優化技術
- 11.5 實戰:深入理解Graal編譯器
- 第五部分 高效并發
- 第12章 Java內存模型與線程
- 12.2 硬件的效率與一致性
- 12.3 Java內存模型 Java Memory Model(JMM)
- 12.3.1 主內存與工作內存
- 12.3.2 內存間交互操作
- 12.3.3 對于volatile型變量的特殊規則
- 12.3.4 針對long和double型變量的特殊規則
- 12.3.5 原子性、可見性與有序性
- 12.3.6 先行發生原則
- 12.4 Java與線程
- 12.4.1 線程的實現
- 12.4.2 Java線程調度
- 12.4.3 狀態轉換
- 12.5 Java與協程
- 第13章 線程安全與鎖優化
- 13.1 概述
- 13.2 線程安全
- 13.2.1 Java語言中的線程安全
- 13.2.2 線程安全的實現方法
- 13.3 鎖優化
- 13.3.1 自旋鎖與自適應鎖
- 13.3.2 鎖消除
- 13.3.3 鎖粗化
- 13.3.4 輕量級鎖
- 13.3.5 偏向鎖
- 參考鏈接
第一部分 走近Java
第1章 走近Java
1.2 Java技術體系
- JDK:把Java程序設計語言、Java虛擬機、Java類庫這三部分統稱為JDK(JavaDevelopment Kit),JDK是用于支持Java程序開發的最小環境。
- JRE:把Java類庫API中的Java SE API子集和Java虛擬機這兩部分統稱為JRE(Java Runtime Environment),JRE是支持Java程序運行的標準環境。
1.4 Java虛擬機家族
- HotSpot: 是Sun/OracleJDK和OpenJDK中默認的java虛擬機,也是目前使用范圍最廣的虛擬機。
第二部分 自動內存管理
第2章 Java內存區域與內存溢出異常
2.2 運行時數據區域
Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分為若干個不同的數據區域。根據《Java虛擬機規范》的規定,Java虛擬機所管理的內存將會包括以下幾個運行時數據區域,如圖2-1所示。
2.2.1 程序計數器
程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。
在Java虛擬機的概念模型里,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
由于Java虛擬機的多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。
如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。此內存區域是唯一一個在《Java虛擬機規范》中沒有規定任何OutOfMemoryError情況的區域。
2.2.2 Java虛擬機棧
Java虛擬機棧(Java Virtual Machine Stack)也是線程私有的,它的生命周期與線程相同。
虛擬機棧描述的是Java方法執行的線程內存模型:每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態連接、方法出口等信息。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。
局部變量表存放了編譯期可知的各種Java虛擬機基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它并不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。這些數據類型在局部變量表中的存儲空間以局部變量槽(Slot)來表示,其中64位長度的long和double類型的數據會占用兩個變量槽,其余的數據類型只占用一個。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的局部變量空間是完全確定的,**在方法運行期間不會改變局部變量表的大小。**請讀者注意,這里說的“大小”是指變量槽的數量,虛擬機真正使用多大的內存空間(譬如按照1個變量槽占用32個比特、64個比特,或者更多)來實現一個變量槽,這是完全由具體的虛擬機實現自行決定的事情。
在《Java虛擬機規范》中,對Java虛擬機棧規定了兩類異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常;如果Java虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出OutOfMemoryError異常。
HotSpot虛擬機的棧容量不可動態擴展, 不會由于虛擬機無法擴展而出現OutOfMemoryError,但是如果申請時就失敗,還是會出現OutOfMemoryError。
2.2.3 本地方法棧
本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,其區別只是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。
《Java虛擬機規范》對本地方法棧中方法使用的語言、使用方式與數據結構并沒有任何強制規定,因此具體的虛擬機可以根據需要自由實現它,甚至有的Java虛擬機(譬如Hot-Spot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。
與虛擬機棧一樣,本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出StackOverflowError和OutOfMemoryError異常。
2.2.4 Java堆
Java堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建,是虛擬機所管理的內存中最大的一塊。此內存區域的唯一目的就是存放對象實例。
在《Java虛擬機規范》中對Java堆的描述是:“所有的對象實例以及數組都應當在堆上分配”。
Java堆是垃圾收集器管理的內存區域,因此也被稱作GC堆。從回收內存的角度看,由于現代垃圾收集器大部分都是基于分代收集理論設計的,所以Java堆中經常會出現“新生代”“老年代”“永久代”“Eden空間”“From Survivor空間”“To Survivor空間”等名詞。這些區域劃分是垃圾收集器的共同特性,而非某個java虛擬機具體實現的固有內存分布。
如果從分配內存的角度看,所有線程共享的Java堆中可以劃分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB),以提升對象分配時的效率。不過無論從什么角度,無論如何劃分,都不會改變Java堆中存儲內容的共性,無論是哪個區域,存儲的都只能是對象的實例,將Java堆細分的目的只是為了更好地回收內存,或者更快地分配內存。
根據《Java虛擬機規范》的規定,Java堆可以處于物理上不連續的內存空間中,但在邏輯上它應該被視為連續的,但對于大對象(典型的如數組對象),多數虛擬機實現出于實現簡單、存儲高效的考慮,很可能會要求連續的內存空間。
Java堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(通過參數-Xmx和-Xms設定)。如果在Java堆中沒有內存完成實例分配,并且堆也無法再擴展時,Java虛擬機將會拋出OutOfMemoryError異常。
2.2.5 方法區
方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用于存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。
雖然《Java虛擬機規范》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作“非堆”(Non-Heap),目的是與Java堆區分開來。
在JDK 8以前,許多Java程序員都習慣在HotSpot虛擬機上開發、部署程序,很多人都更愿意把方法區稱呼為“永久代”(Permanent Generation),或將兩者混為一談。本質上這兩者并不是等價的,因為僅僅是當時的HotSpot虛擬機設計團隊選擇把收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已,這樣使得HotSpot的垃圾收集器能夠像管理Java堆一樣管理這部分內存,省去專門為方法區編寫內存管理代碼的工作。
當年使用永久代來實現方法區的決定并不是一個好主意,這種設計導致了Java應用更容易遇到內存溢出的問題(永久代有-XX:MaxPermSize的上限,即使不設置也有默認大小,而J9和JRockit只要沒有觸碰到進程可用內存的上限,例如32位系統中的4GB限制,就不會出問題),而且有極少數方法(例如String::intern())會因永久代的原因而導致不同虛擬機下有不同的表現。
在JDK 6的時候HotSpot開發團隊就有放棄永久代,逐步改為采用本地內存(NativeMemory)來實現方法區的計劃了。到了JDK 7的HotSpot,已經把原本放在永久代的字符串常量池、靜態變量等移出,而到了JDK 8,終于完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地內存中實現的元空間(Meta-space)來代替,把JDK 7中永久代還剩余的內容(主要是類型信息)全部移到元空間中。
根據《Java虛擬機規范》的規定,如果方法區無法滿足新的內存分配需求時,將拋出OutOfMemoryError異常。
2.2.6 運行時常量池
運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表(Constant Pool Table),用于存放編譯期生成的各種字面量與符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中。
運行時常量池相對于Class文件常量池的另外一個重要特征是具備動態性,Java語言并不要求常量一定只有編譯期才能產生,也就是說,并非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。
既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出OutOfMemoryError異常。
2.2.7 直接內存
直接內存(Direct Memory)并不是虛擬機運行時數據區的一部分,也不是《Java虛擬機規范》中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現,所以我們放到這里一起講解。
在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基于通道(Channel)與緩沖區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外內存,然后通過一個存儲在Java堆里面的DirectByteBuffer對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在Java堆和Native堆中來回復制數據。
2.3 HotSpot虛擬機對象探秘
基于實用優先的原則,筆者以最常用的虛擬機HotSpot和最常用的內存區域Java堆為例,深入探討一下HotSpot虛擬機在Java堆中對象分配、布局和訪問的全過程。
2.3.1 對象的創建
當Java虛擬機遇到一條字節碼new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。
在類加載檢查通過后,接下來虛擬機將為新生對象分配內存。對象所需內存的大小在類加載完成后便可完全確定,為對象分配空間的任務實際上便等同于把一塊確定大小的內存塊從Java堆中劃分出來。假設Java堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間方向挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”(Bump The Pointer)。但如果Java堆中的內存并不是規整的,已被使用的內存和空閑的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄,這種分配方式稱為**“空閑列表”(Free List)。選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有空間壓縮整理(Compact)的能力決定**。因此,當使用Serial、ParNew等帶壓縮整理過程的收集器時,系統采用的分配算法是指針碰撞,既簡單又高效;而當使用CMS這種基于清除(Sweep)算法的收集器時,理論上[插圖]就只能采用較為復雜的空閑列表來分配內存。
對象創建在虛擬機中是非常頻繁的行為,即使僅僅修改一個指針所指向的位置,在并發情況下也并不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。解決這個問題有兩種可選方案:一種是對分配內存空間的動作進行同步處理——實際上虛擬機是采用CAS配上失敗重試的方式保證更新操作的原子性;另外一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩沖(Thread Local Allocation Buffer,TLAB),哪個線程要分配內存,就在哪個線程的本地緩沖區中分配,只有本地緩沖區用完了,分配新的緩存區時才需要同步鎖定。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。
內存分配完成之后,虛擬機必須將分配到的內存空間(但不包括對象頭)都初始化為零值,如果使用了TLAB的話,這一項工作也可以提前至TLAB分配時順便進行。這步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型所對應的零值。
接下來,Java虛擬機還要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼(實際上對象的哈希碼會延后到真正調用Object::hashCode()方法時才計算)、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。
在上面工作都完成之后,從虛擬機的視角來看,一個新的對象已經產生了。但是從Java程序的視角看來,對象創建才剛剛開始——構造函數,即Class文件中的()方法還沒有執行,所有的字段都為默認的零值,對象需要的其他資源和狀態信息也還沒有按照預定的意圖構造好。一般來說(由字節碼流中new指令后面是否跟隨invokespecial指令所決定,Java編譯器會在遇到new關鍵字的地方同時生成這兩條字節碼指令,但如果直接通過其他方式產生的則不一定如此),new指令之后會接著執行()方法,按照程序員的意愿對對象進行初始化,這樣一個真正可用的對象才算完全被構造出來。
2.3.2 對象的內存布局
在HotSpot虛擬機里,對象在堆內存中的存儲布局可以劃分為三個部分:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
HotSpot虛擬機對象的第一部分對象頭部分包括兩類信息:
第一類是用于存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機(未開啟壓縮指針)中分別為32個比特和64個比特,官方稱它為“Mark Word”。
對象需要存儲的運行時數據很多,其實已經超出了32、64位Bitmap結構所能記錄的最大限度,但對象頭里的信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個有著動態定義的數據結構,以便在極小的空間內存儲盡量多的數據,根據對象的狀態復用自己的存儲空間。例如在32位的HotSpot虛擬機中,如對象未被同步鎖鎖定的狀態下,Mark Word的32個比特存儲空間中的25個比特用于存儲對象哈希碼,4個比特用于存儲對象分代年齡,2個比特用于存儲鎖標志位,1個比特固定為0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如表2-1所示。
第二類是類型指針,即對象指向它的類型元數據的指針,Java虛擬機通過這個指針來確定該對象是哪個類的實例。如果對象是一個Java數組,那在對象頭中還必須有一塊用于記錄數組長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是如果數組的長度是不確定的,將無法通過元數據中的信息推斷出數組的大小。
第二部分實例數據部分是對象真正存儲的有效信息,即我們在程序代碼里面所定義的各種類型的字段內容,無論是從父類繼承下來的,還是在子類中定義的字段都必須記錄起來。這部分的存儲順序會受到虛擬機分配策略參數(-XX:FieldsAllocationStyle參數)和字段在Java源碼中定義順序的影響。HotSpot虛擬機默認的分配順序為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),從以上默認的分配策略中可以看到,相同寬度的字段總是被分配到一起存放,在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果HotSpot虛擬機的+XX:CompactFields參數值為true(默認就為true),那子類之中較窄的變量也允許插入父類變量的空隙之中,以節省出一點點空間。
第三部分是對齊填充,這并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由于HotSpot虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是任何對象的大小都必須是8字節的整數倍。對象頭部分已經被精心設計成正好是8字節的倍數(1倍或者2倍),因此,如果對象實例數據部分沒有對齊的話,就需要通過對齊填充來補全。
2.3.3 對象的訪問定位
Java程序會通過棧上的reference數據來操作堆上的具體對象。對象訪問方式也是由虛擬機實現而定的,主流的訪問方式主要有使用句柄和直接指針兩種:
·如果使用句柄訪問的話,Java堆中將可能會劃分出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址信息,其結構如圖2-2所示。
如果使用直接指針訪問的話,Java堆中對象的內存布局就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如果只是訪問對象本身的話,就不需要多一次間接訪問的開銷,如圖2-3所示。
使用句柄來訪問的最大好處就是reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。
使用直接指針來訪問最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由于對象訪問在Java中非常頻繁,因此這類開銷積少成多也是一項極為可觀的執行成本,就本書討論的主要虛擬機HotSpot而言,它主要使用第二種方式進行對象訪問。
2.4 實戰:OutOfMemoryError異常
2.4.1 Java堆溢出
將堆的最小值-Xms參數與最大值-Xmx參數設置為一樣即可避免堆自動擴展),通過參數-XX:+HeapDumpOnOutOf-MemoryError可以讓虛擬機在出現內存溢出異常的時候Dump出當前的內存堆轉儲快照以便進行事后分析
要解決這個內存區域的異常,常規的處理方法是首先通過內存映像分析工具(如EclipseMemory Analyzer)對Dump出來的堆轉儲快照進行分析。
第一步首先應確認內存中導致OOM的對象是否是必要的,也就是要先分清楚到底是出現了內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)。
內存泄漏與內存溢出的區別:
[https://blog.csdn.net/tanga842428/article/details/52452369]
如果是內存泄漏,可進一步通過工具查看泄漏對象到GC Roots的引用鏈,找到泄漏對象是通過怎樣的引用路徑、與哪些GC Roots相關聯,才導致垃圾收集器無法回收它們,根據泄漏對象的類型信息以及它到GC Roots引用鏈的信息,一般可以比較準確地定位到這些對象創建的位置,進而找出產生內存泄漏的代碼的具體位置。
如果不是內存泄漏,換句話說就是內存中的對象確實都是必須存活的,那就應當檢查Java虛擬機的堆參數(-Xmx與-Xms)設置,與機器的內存對比,看看是否還有向上調整的空間。再從代碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長、存儲結構設計不合理等情況,盡量減少程序運行期的內存消耗。
2.4.2 虛擬機棧和本地方法棧溢出
棧容量只能由-Xss參數來設定。
關于虛擬機棧和本地方法棧,在《Java虛擬機規范》中描述了兩種異常:
1)如果線程請求的棧深度大于虛擬機所允許的最大深度,將拋出StackOverflowError異常。
2)如果虛擬機的棧內存允許動態擴展,當擴展棧容量無法申請到足夠的內存時,將拋出OutOfMemoryError異常。
2.4.3 方法區和運行時常量池溢出
由于運行時常量池是方法區的一部分,所以這兩個區域的溢出測試可以放到一起進行。
String::intern()是一個本地方法,它的作用是如果字符串常量池中已經包含一個等于此String對象的字符串,則返回代表池中這個字符串的String對象的引用;否則,會將此String對象包含的字符串添加到常量池中,并且返回此String對象的引用。
在JDK 6或更早之前的HotSpot虛擬機中,常量池都是分配在永久代中,我們可以通過-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可間接限制其中常量池的容量。
無論是在JDK 7中繼續使用-XX:MaxPermSize參數或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize參數把方法區容量同樣限制在6MB,也都不會重現JDK 6中的溢出異常,循環將一直進行下去,永不停歇。
在JDK 8以后,永久代便完全退出了歷史舞臺,元空間作為其替代者登場。在默認設置下,前面列舉的那些正常的動態創建新類型的測試用例已經很難再迫使虛擬機產生方法區的溢出異常了。不過為了讓使用者有預防實際應用里出現類似于代碼清單2-9那樣的破壞性的操作,HotSpot還是提供了一些參數作為元空間的防御措施,主要包括:
·-XX:MaxMetaspaceSize:設置元空間最大值,默認是-1,即不限制,或者說只受限于本地內存大小。
·-XX:MetaspaceSize:指定元空間的初始空間大小,以字節為單位,達到該值就會觸發垃圾收集進行類型卸載,同時收集器會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那么在不超過-XX:MaxMetaspaceSize(如果設置了的話)的情況下,適當提高該值。
·-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空間剩余容量的百分比,可減少因為元空間不足導致的垃圾收集的頻率。類似的還有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空間剩余容量的百分比。
2.4.4 本機直接內存溢出
直接內存(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize參數來指定,如果不去指定,則默認與Java堆最大值(由-Xmx指定)一致。
第3章 垃圾收集器與內存分配策略
3.1 概述
程序計數器、虛擬機棧、本地方法棧3個區域線程私有,隨線程生死。棧中的棧幀內存可說是在編譯時便確定的。幾個區域的內存分配與回收都具備有確定性。
Java Heap與方法區的分配是在運行時不斷變化的,分配與回收是動態的,因而需要垃圾收集器。
3.2 對象已死?
在堆里面存放著Java世界中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象之中哪些還“存活”著,哪些已經“死去”(“死去”即不可能再被任何途徑使用的對象)了。
3.2.1 引用計數算法
引用計數法:在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。
引用計數算法雖然占用了一些額外的內存空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的算法。但是單純的引用計數就很難解決對象之間相互循環引用的問題。
3.2.2 可達性分析算法
可達性分析算法:基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GCRoots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。
在Java技術體系里面,固定可作為GC Roots的對象包括以下幾種:
·在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
·在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
·在方法區中常量引用的對象,譬如字符串常量池(String Table)里的引用。
·在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
·Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
·所有被同步鎖(synchronized關鍵字)持有的對象。
·反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。
除了這些固定的GC Roots集合以外,根據用戶所選用的垃圾收集器以及當前回收的內存區域不同,還可以有其他對象“臨時性”地加入,共同構成完整GC Roots集合。
3.2.3 再談引用
在JDK 1.2版之后,Java將引用分為**強引用(Strongly Re-ference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(PhantomReference)**4種,這4種引用強度依次逐漸減弱。
·強引用是最傳統的“引用”的定義,是指在程序代碼之中普遍存在的引用賦值,即類似“Object obj=new Object()”這種引用關系。無論任何情況下,只要強引用關系還存在,垃圾收集器就永遠不會回收掉被引用的對象。
·軟引用是用來描述一些還有用,但非必須的對象。只被軟引用關聯著的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收范圍之中進行第二次回收,如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在JDK 1.2版之后提供了SoftReference類來實現軟引用。
·弱引用也是**用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。**在JDK 1.2版之后提供了WeakReference類來實現弱引用。
·虛引用也稱為“幽靈引用”或者“幻影引用”,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的只是為了能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2版之后提供了PhantomReference類來實現虛引用。
3.2.4 生存還是死亡?
要真正宣告一個對象死亡,至少要經歷兩次標記過程:
如果對象在進行可達性分析后發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記,隨后進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。假如對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,那么虛擬機將這兩種情況都視為“沒有必要執行”。
如果這個對象被判定為確有必要執行finalize()方法,那么該對象將會被放置在一個名為F-Queue的隊列之中,并在稍后由一條由虛擬機自動建立的、低調度優先級的Finalizer線程去執行它們的finalize()方法。
這里所說的“執行”是指虛擬機會觸發這個方法開始運行,但并不承諾一定會等待它運行結束。這樣做的原因是,如果某個對象的finalize()方法執行緩慢,或者更極端地發生了死循環,將很可能導致F-Queue隊列中的其他對象永久處于等待,甚至導致整個內存回收子系統的崩潰。finalize()方法是對象逃脫死亡命運的最后一次機會,稍后收集器將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移出“即將回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的要被回收了。
任何一個對象的finalize()方法都只會被系統自動調用一次,如果對象面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段代碼的自救行動失敗了。
3.2.5 回收方法區
在Java堆中,尤其是在新生代中,對常規應用進行一次垃圾收集通常可以回收70%至99%的內存空間,相比之下,方法區回收囿于苛刻的判定條件,其區域垃圾收集的回收成果往往遠低于此。
方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。
3.3 垃圾收集算法
從如何判定對象消亡的角度出發,垃圾收集算法可以劃分為“引用計數式垃圾收集”(Reference Counting GC)和“追蹤式垃圾收集”(Tracing GC)兩大類,這兩類也常被稱作“直接垃圾收集”和“間接垃圾收集”。
由于引用計數式垃圾收集算法在本書討論到的主流Java虛擬機中均未涉及,所以我們暫不把它作為正文主要內容來講解,本節介紹的所有算法均屬于追蹤式垃圾收集的范疇。
3.3.1 分代收集理論
當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(Generational Collection)的理論進行設計,分代收集名為理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:
1)弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
2)強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。
這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。
在Java堆劃分出不同的區域之后,**垃圾收集器才可以每次只回收其中某一個或者某些部分的區域——因而才有了“Minor GC”“Major GC”“Full GC”這樣的回收類型的劃分。**也才能夠針對不同的區域安排與里面存儲對象存亡特征相匹配的垃圾收集算法——因而發展出了“標記-復制算法”“標記-清除算法”“標記-整理算法”等針對性的垃圾收集算法。
把分代收集理論具體放到現在的商用Java虛擬機里,設計者一般至少會把Java堆劃分為新生代(Young Generation)和老年代(Old Generation)兩個區域。
分代收集并非只是簡單劃分一下內存區域那么容易,它至少存在一個明顯的困難:對象不是孤立的,對象之間會存在跨代引用。
假如要現在進行一次只局限于新生代區域內的收集(Minor GC),但新生代中的對象是完全有可能被老年代所引用的,為了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有對象來確保可達性分析結果的正確性,反過來也是一樣[插圖]。遍歷整個老年代所有對象的方案雖然理論上可行,但無疑會為內存回收帶來很大的性能負擔。為了解決這個問題,就需要對分代收集理論添加第三條經驗法則:
3)跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對于同代引用來說僅占極少數。
這其實是可根據前兩條假說邏輯推理得出的隱含推論:存在互相引用關系的兩個對象,是應該傾向于同時生存或者同時消亡的。
依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需在新生代上建立一個全局的數據結構(該結構被稱為“記憶集”,Remembered Set),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊內存會存在跨代引用。此后當發生Minor GC時,只有包含了跨代引用的小塊內存里的對象才會被加入到GC Roots進行掃描。雖然這種方法需要在對象改變引用關系(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的。
為避免讀者產生混淆,在這里統一定義:
·部分收集(Partial GC):指目標不是完整收集整個Java堆的垃圾收集,其中又分為:
■新生代收集(Minor GC/Young GC):指目標只是新生代的垃圾收集。
■老年代收集(Major GC/Old GC):指目標只是老年代的垃圾收集。目前只有CMS收集器會有單獨收集老年代的行為。另外請注意“Major GC”這個說法現在有點混淆,在不同資料上常有不同所指,讀者需按上下文區分到底是指老年代的收集還是整堆收集。
■混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。目前只有G1收集器會有這種行為。
·整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。
3.3.2 標記-清除算法
該算法分為“標記”和“清除”兩個階段:
首先標記出所有需要回收的對象,標記過程就是對象是否屬于垃圾的判定過程;在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。
該算法主要缺點有兩個:
第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;
第二個是內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
標記-清除算法的執行過程如圖3-2所示。
3.3.3 標記-復制算法
標記-復制算法常被簡稱為復制算法。
為了解決標記-清除算法面對大量可回收對象時執行效率低的問題,1969年Fenichel提出了一種稱為“半區復制”(Semispace Copying)的垃圾收集算法,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷,但對于多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的復雜情況,只要移動堆頂指針,按順序分配即可。
這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種復制回收算法的代價是將可用內存縮小為了原來的一半,空間浪費未免太多了一點。
**現在的商用Java虛擬機大多都優先采用了這種收集算法去回收新生代。**IMB研究新生代中的對象有98%熬不過第一輪收集。因此并不需要按照1∶1的比例來劃分新生代的內存空間。
在1989年,Andrew Appel針對具備“朝生夕滅”特點的對象,提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。HotSpot虛擬機的Serial、ParNew等新生代收集器均采用了這種策略來設計新生代的內存布局。
Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。
當然,98%的對象可被回收僅僅是“普通場景”下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多于10%的對象存活,因此Appel式回收還有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象,這些對象便將通過分配擔保機制直接進入老年代,這對虛擬機來說就是安全的。關于對新生代進行分配擔保的內容,在稍后的3.8.5節介紹垃圾收集器執行規則時還會再進行講解。
3.3.4 標記-整理算法
標記-復制算法在對象存活率較高時就要進行較多的復制操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。
針對老年代對象的存亡特征,1974年Edward Lueders提出了另外一種有針對性的“標記-整理”(Mark-Compact)算法,其中的標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存。
標記-清除算法與標記-整理算法的本質差異在于前者是一種非移動式的回收算法,而后者是移動式的。
是否移動回收后的存活對象是一項優缺點并存的風險決策。
“Stop the world”:移動存活對象,必須全程暫停用戶應用程序才能進行。
Mark-compact:吞吐量(用戶程序與收集器的效率總和)更高, 但內存回收更復雜。
Mark-sweep:停頓時間更短,但是很多碎片空間
3.4 HotSpot的算法細節實現
3.4.1 根節點枚舉
所有收集器在根節點枚舉這一步驟時都是必須暫停用戶線程的,因此毫無疑問根節點枚舉與之前提及的整理內存碎片一樣會面臨相似的“Stop The World”的困擾。
由于目前主流Java虛擬機使用的都是準確式垃圾收集,所以當用戶線程停頓下來之后,其實并不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛擬機應當是有辦法直接得到哪些地方存放著對象引用的。在HotSpot的解決方案里,是使用一組稱為OopMap的數據結構來達到這個目的。一旦類加載動作完成的時候,HotSpot就會把對象內什么偏移量上是什么類型的數據計算出來,在即時編譯(見第11章)過程中,也會在特定的位置記錄下棧里和寄存器里哪些位置是引用。這樣收集器在掃描時就可以直接得知這些信息了,并不需要真正一個不漏地從方法區等GC Roots開始查找。
3.4.2 安全點
在OopMap的協助下,HotSpot可以快速準確地完成GC Roots枚舉,但一個很現實的問題隨之而來:可能導致引用關系變化,或者說導致OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外存儲空間,這樣垃圾收集伴隨而來的空間成本就會變得無法忍受的高昂。
實際上HotSpot也的確沒有為每條指令都生成OopMap,只是在“特定的位置”記錄了這些信息,這些位置被稱為安全點(Safepoint)。
有了安全點的設定,也就決定了用戶程序執行時并非在代碼指令流的任意位置都能夠停頓下來開始垃圾收集,而是強制要求必須執行到達安全點后才能夠暫停。因此,安全點的選定既不能太少以至于讓收集器等待時間過長,也不能太過頻繁以至于過分增大運行時的內存負荷。安全點位置的選取基本上是以“是否具有讓程序長時間執行的特征”為標準進行選定的,因為每條指令執行的時間都非常短暫,程序不太可能因為指令流長度太長這樣的原因而長時間執行,“長時間執行”的最明顯特征就是指令序列的復用,例如方法調用、循環跳轉、異常跳轉等都屬于指令序列復用,所以只有具有這些功能的指令才會產生安全點。
對于安全點,另外一個需要考慮的問題是,如何在垃圾收集發生時讓所有線程(這里其實不包括執行JNI調用的線程)都跑到最近的安全點,然后停頓下來。這里有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
搶先式中斷不需要線程的執行代碼主動去配合,在垃圾收集發生時,系統首先把所有用戶線程全部中斷,如果發現有用戶線程中斷的地方不在安全點上,就恢復這條線程執行,讓它一會再重新中斷,直到跑到安全點上。現在幾乎沒有虛擬機實現采用搶先式中斷來暫停線程響應GC事件。
主動式中斷是**當垃圾收集需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志位,各個線程執行過程時會不停地主動去輪詢這個標志,一旦發現中斷標志為真時就自己在最近的安全點上主動中斷掛起。**輪詢標志的地方和安全點是重合的,另外還要加上所有創建對象和其他需要在Java堆上分配內存的地方,這是為了檢查是否即將要發生垃圾收集,避免沒有足夠內存分配新對象。
由于輪詢操作在代碼中會頻繁出現,這要求它必須足夠高效。HotSpot使用內存保護陷阱的方式,把輪詢操作精簡至只有一條匯編指令的程度。
3.4.3 安全區域
程序“不執行”的時候呢?所謂的程序不執行就是沒有分配處理器時間,典型的場景便是用戶線程處于Sleep狀態或者Blocked狀態,這時候線程無法響應虛擬機的中斷請求,不能再走到安全的地方去中斷掛起自己,虛擬機也顯然不可能持續等待線程重新被激活分配處理器時間。對于這種情況,就必須引入**安全區域(Safe Region)**來解決。
**安全區域是指能夠確保在某一段代碼片段之中,引用關系不會發生變化,因此,在這個區域中任意地方開始垃圾收集都是安全的。**我們也可以把安全區域看作被擴展拉伸了的安全點。
當用戶線程執行到安全區域里面的代碼時,首先會標識自己已經進入了安全區域,那樣當這段時間里虛擬機要發起垃圾收集時就不必去管這些已聲明自己在安全區域內的線程了。當線程要離開安全區域時,它要檢查虛擬機是否已經完成了根節點枚舉(或者垃圾收集過程中其他需要暫停用戶線程的階段),如果完成了,那線程就當作沒事發生過,繼續執行;否則它就必須一直等待,直到收到可以離開安全區域的信號為止。
3.4.4 記憶集與卡表
講解分代收集理論的時候,提到了為解決對象跨代引用所帶來的問題,垃圾收集器在新生代中建立了名為記憶集(Remembered Set)的數據結構,用以避免把整個老年代加進GC Roots掃描范圍。
事實上并不只是新生代、老年代之間才有跨代引用的問題,所有涉及部分區域收集(Partial GC)行為的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都會面臨相同的問題,因此我們有必要進一步理清記憶集的原理和實現方式。
**記憶集是一種用于記錄從非收集區域指向收集區域的指針集合的抽象數據結構。**最簡單的實現可以用非收集區域中所有含跨代引用的對象數組來實現這個數據結構
這種記錄全部含跨代引用對象的實現方案,無論是空間占用還是維護成本都相當高昂。而在垃圾收集的場景中,收集器只需要通過記憶集判斷出某一塊非收集區域是否存在有指向了收集區域的指針就可以了,并不需要了解這些跨代指針的全部細節
那設計者在實現記憶集的時候,便可以選擇更為粗獷的記錄粒度來節省記憶集的存儲和維護成本,下面列舉了一些可供選擇的記錄精度:
·字長精度:每個記錄精確到一個機器字長(就是處理器的尋址位數,如常見的32位或64位,這個精度決定了機器訪問物理內存地址的指針長度),該字包含跨代指針。
·對象精度:每個記錄精確到一個對象,該對象里有字段含有跨代指針。
·卡精度:每個記錄精確到一塊內存區域,該區域內有對象含有跨代指針。
其中,**第三種“卡精度”所指的是用一種稱為“卡表”(Card Table)的方式去實現記憶集。**前面定義中提到記憶集其實是一種“抽象”的數據結構,抽象的意思是只定義了記憶集的行為意圖,并沒有定義其行為的具體實現。卡表就是記憶集的一種具體實現,它定義了記憶集的記錄精度、與堆內存的映射關系等。關于卡表與記憶集的關系,讀者不妨按照Java語言中HashMap與Map的關系來類比理解。
卡表最簡單的形式可以只是一個字節數組
字節數組CARD_TABLE的每一個元素都對應著其標識的內存區域中一塊特定大小的內存塊,這個內存塊被稱作“卡頁”(Card Page)。
一個卡頁的內存中通常包含不止一個對象,只要卡頁內有一個(或更多)對象的字段存在著跨代指針,那就將對應卡表的數組元素的值標識為1,稱為這個元素變臟(Dirty),沒有則標識為0。在垃圾收集發生時,只要篩選出卡表中變臟的元素,就能輕易得出哪些卡頁內存塊中包含跨代指針,把它們加入GC Roots中一并掃描。
3.4.5 寫屏障
我們已經解決了如何使用記憶集來縮減GC Roots掃描范圍的問題,但還沒有解決卡表元素如何維護的問題,例如它們何時變臟、誰來把它們變臟等。
卡表元素何時變臟的答案是很明確的——有其他分代區域中對象引用了本區域對象時,其對應的卡表元素就應該變臟,變臟時間點原則上應該發生在引用類型字段賦值的那一刻。
但問題是如何變臟,即如何在對象賦值的那一刻去更新維護卡表呢?假如是解釋執行的字節碼,那相對好處理,虛擬機負責每條字節碼指令的執行,有充分的介入空間;但在編譯執行的場景中呢?經過即時編譯后的代碼已經是純粹的機器指令流了,這就必須找到一個在機器碼層面的手段,把維護卡表的動作放到每一個賦值操作之中。
在HotSpot虛擬機里是通過寫屏障(Write Barrier)技術維護卡表狀態的。先請讀者注意將這里提到的“寫屏障”,以及后面在低延遲收集器中會提到的“讀屏障”與解決并發亂序執行問題中的“內存屏障”區分開來,避免混淆。
寫屏障可以看作在虛擬機層面對“引用類型字段賦值”這個動作的AOP切面,在引用對象賦值時會產生一個環形(Around)通知,供程序執行額外的動作,也就是說賦值的前后都在寫屏障的覆蓋范疇內。在賦值前的部分的寫屏障叫作寫前屏障(Pre-Write Barrier),在賦值后的則叫作寫后屏障(Post-Write Barrier)。HotSpot虛擬機的許多收集器中都有使用到寫屏障,但直至G1收集器出現之前,其他收集器都只用到了寫后屏障。
應用寫屏障后,虛擬機就會為所有賦值操作生成相應的指令,一旦收集器在寫屏障中增加了更新卡表操作,無論更新的是不是老年代對新生代對象的引用,每次只要對引用進行更新,就會產生額外的開銷,不過這個開銷與MinorGC時掃描整個老年代的代價相比還是低得多的。
除了寫屏障的開銷外,卡表在高并發場景下還面臨著“偽共享”(False Sharing)問題。偽共享是處理并發底層細節時一種經常需要考慮的問題,現代中央處理器的緩存系統中是以緩存行(Cache Line)為單位存儲的,當多線程修改互相獨立的變量時,如果這些變量恰好共享同一個緩存行,就會彼此影響(寫回、無效化或者同步)而導致性能降低,這就是偽共享問題。
在JDK 7之后,HotSpot虛擬機增加了一個新的參數-XX:+UseCondCardMark,用來決定是否開啟卡表更新的條件判斷。開啟會增加一次額外判斷的開銷,但能夠避免偽共享問題,兩者各有性能損耗,是否打開要根據應用實際運行情況來進行測試權衡。
3.4.6 并發的可達性分析
在3.2節中曾經提到了當前主流編程語言的垃圾收集器基本上都是依靠可達性分析算法來判定對象是否存活的,可達性分析算法理論上要求全過程都基于一個能保障一致性的快照中才能夠進行分析,這意味著必須全程凍結用戶線程的運行。在根節點枚舉(見3.4.1節)這個步驟中,由于GC Roots相比起整個Java堆中全部的對象畢竟還算是極少數,且在各種優化技巧(如OopMap)的加持下,它帶來的停頓已經是非常短暫且相對固定(不隨堆容量而增長)的了。
可從GC Roots再繼續往下遍歷對象圖,這一步驟的停頓時間就必定會與Java堆容量直接成正比例關系了:堆越大,存儲的對象越多,對象圖結構越復雜,要標記更多對象而產生的停頓時間自然就更長,這聽起來是理所當然的事情。
要知道包含“標記”階段是所有追蹤式垃圾收集算法的共同特征,如果這個階段會隨著堆變大而等比例增加停頓時間,其影響就會波及幾乎所有的垃圾收集器,同理可知,如果能夠削減這部分停頓時間的話,那收益也將會是系統性的。
想解決或者降低用戶線程的停頓,就要先搞清楚為什么必須在一個能保障一致性的快照上才能進行對象圖的遍歷?為了能解釋清楚這個問題,我們引入**三色標記(Tri-color Marking)**作為工具來輔助推導,把遍歷對象圖過程中遇到的對象,按照“是否訪問過”這個條件標記成以下三種顏色:
·白色:表示對象尚未被垃圾收集器訪問過。顯然在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
·黑色:表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其他對象引用指向了黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
·灰色:表示對象已經被垃圾收集器訪問過,但這個對象上至少存在一個引用還沒有被掃描過。
Wilson于1994年在理論上證明了,當且僅當以下兩個條件同時滿足時,會產生“對象消失”的問題,即原本應該是黑色的對象被誤標為白色:·賦值器插入了一條或多條從黑色對象到白色對象的新引用;·賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。
因此,我們要解決并發掃描時的對象消失問題,只需破壞這兩個條件的任意一個即可。由此分別產生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。
增量更新要破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用關系時,就將這個新插入的引用記錄下來,等并發掃描結束之后,再將這些記錄過的引用關系中的黑色對象為根,重新掃描一次。這可以簡化理解為,黑色對象一旦新插入了指向白色對象的引用之后,它就變回灰色對象了。
原始快照要破壞的是第二個條件,當灰色對象要刪除指向白色對象的引用關系時,就將這個要刪除的引用記錄下來,在并發掃描結束之后,再將這些記錄過的引用關系中的灰色對象為根,重新掃描一次。這也可以簡化理解為,無論引用關系刪除與否,都會按照剛剛開始掃描那一刻的對象圖快照來進行搜索。
以上無論是對引用關系記錄的插入還是刪除,虛擬機的記錄操作都是通過寫屏障實現的。在HotSpot虛擬機中,增量更新和原始快照這兩種解決方案都有實際應用,譬如,CMS是基于增量更新來做并發標記的,G1、Shenandoah則是用原始快照來實現。
3.5 經典垃圾收集器
如果說收集算法是內存回收的方法論,那垃圾收集器就是內存回收的實踐者。
圖3-6展示了七種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用,圖中收集器所處的區域,則表示它是屬于新生代收集器抑或是老年代收集器。
3.5.1 Serial收集器
Serial收集器是一個單線程工作的收集器,它進行垃圾收集時,必須暫停其他所有工作線程,直到它收集結束。
“Stop The World”這個詞語也許聽起來很酷,但這項工作是由虛擬機在后臺自動發起和自動完成的,在用戶不可知、不可控的情況下把用戶的正常工作的線程全部停掉,這對很多應用來說都是不能接受的。圖3-7示意了Serial/Serial Old收集器的運行過程。
迄今為止,它依然是HotSpot虛擬機運行在客戶端模式下的默認新生代收集器, 是所有收集器中額外內存消耗最少的
3.5.2 ParNew收集器
ParNew收集器實質上是Serial收集器的多線程并行版本,除了同時使用多條線程進行垃圾收集之外,其余的行為包括Serial收集器可用的所有控制參數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一致,在實現上這兩種收集器也共用了相當多的代碼。ParNew收集器的工作過程如圖3-8所示。
ParNew收集器除了支持多線程并行收集之外,它也是不少運行在服務端模式下的HotSpot虛擬機,尤其是JDK 7之前的遺留系統中首選的新生代收集器,其中有一個與功能、性能無關但其實很重要的原因是:除了Serial收集器外,目前只有它與能與CMS收集器配合工作。
3.5.3 Parallel Scavenge收集器
Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是盡可能地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。
所謂吞吐量就是處理器用于運行用戶代碼的時間與處理器總消耗時間的比值,即:
Parallel Scavenge收集器提供了兩個參數用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
-XX:MaxGCPauseMillis參數允許的值是一個大于0的毫秒數,收集器將盡力保證內存回收花費的時間不超過用戶設定值。不過大家不要異想天開地認為如果把這個參數的值設置得更小一點就能使得系統的垃圾收集速度變得更快,垃圾收集停頓時間縮短是以犧牲吞吐量和新生代空間為代價換取的:系統把新生代調得小一些,收集300MB新生代肯定比收集500MB快,但這也直接導致垃圾收集發生得更頻繁,原來10秒收集一次、每次停頓100毫秒,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。
-XX:GCTimeRatio參數的值則應當是一個大于0小于100的整數,也就是垃圾收集時間占總時間的比率,相當于吞吐量的倒數。譬如把此參數設置為19,那允許的最大垃圾收集時間就占總時間的5%(即1/(1+19)),默認值為99,即允許最大1%(即1/(1+99))的垃圾收集時間。
Parallel Scavenge收集器還有一個參數-XX:+UseAdaptiveSizePolicy值得我們關注。這是一個開關參數,當這個參數被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象大小(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱為垃圾收集的自適應的調節策略(GC Ergonomics)。
使用Parallel Scavenge收集器配合自適應調節策略,把內存管理的調優任務交給虛擬機去完成也許是一個很不錯的選擇。只需要把基本的內存數據設置好(如-Xmx設置最大堆),然后使用-XX:MaxGCPauseMillis參數(更關注最大停頓時間)或-XX:GCTimeRatio(更關注吞吐量)參數給虛擬機設立一個優化目標,那具體細節參數的調節工作就由虛擬機完成了。
自適應調節策略也是Parallel Scavenge收集器區別于ParNew收集器的一個重要特性。
3.5.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用標記-整理算法。
這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機使用。
如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的后備預案,在并發收集發生Concurrent Mode Failure時使用。
Serial Old收集器的工作過程如圖3-9所示。
3.5.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多線程并發收集,基于標記-整理算法實現。
在注重吞吐量或者處理器資源比較稀缺的場合,可優先考慮Parallel Scavenge+Parallel Old這個組合。
Parallel Old收集器的工作過程如圖3-10所示。
3.5.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網網站或者基于瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間盡可能短,以給用戶帶來良好的交互體驗。CMS收集器就非常符合這類應用的需求。
從CMS收集器是基于標記-清除算法實現的,整個過程分為四個步驟:
1)初始標記(CMS initial mark)
該階段僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快。
2)并發標記(CMS concurrent mark)
該階段從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起并發運行。
3)重新標記(CMS remark)
該階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄(詳見3.4.6節中關于增量更新的講解),這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比并發標記階段的時間短。
4)并發清除(CMS concurrent sweep)
該階段清理刪除掉標記階段判斷的已經死亡的對象,由于不需要移動存活對象,所以該階段也可以與用戶線程同時并發的。
其中,初始標記、重新標記這兩個步驟仍然需要“Stop The World”。
由于在整個過程中耗時最長的并發標記和并發清除階段中,垃圾收集器線程都可以與用戶線程一起工作,所以從總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發執行的。
通過圖3-11可以比較清楚地看到CMS收集器的運作步驟中并發和需要停頓的階段。
CMS最主要的優點在名字上已經體現出來:并發收集、低停頓。
CMS收集器至少有以下三個明顯的缺點:首先,CMS收集器對處理器資源非常敏感。然后,由于CMS收集器無法處理“浮動垃圾”(Floating Garbage),有可能出現“Con-current ModeFailure”失敗進而導致另一次完全“Stop The World”的Full GC的產生。最后一個缺點,在本節的開頭曾提到,CMS是一款基于“標記-清除”算法實現的收集器,如果讀者對前面這部分介紹還有印象的話,就可能想到這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很多剩余空間,但就是無法找到足夠大的連續空間來分配當前對象,而不得不提前觸發一次Full GC的情況。為了解決這個問題,CMS收集器提供了一個-XX:+UseCMS-CompactAtFullCollection開關參數(默認是開啟的,此參數從JDK 9開始廢棄),用于在CMS收集器不得不進行Full GC時開啟內存碎片的合并整理過程,由于這個內存整理必須移動存活對象,(在Shenandoah和ZGC出現前)是無法并發的。這樣空間碎片問題是解決了,但停頓時間又會變長,因此虛擬機設計者們還提供了另外一個參數-XX:CMSFullGCsBefore-Compaction(此參數從JDK 9開始廢棄),這個參數的作用是要求CMS收集器在執行過若干次(數量由參數值決定)不整理空間的Full GC之后,下一次進入Full GC前會先進行碎片整理(默認值為0,表示每次進入Full GC時都進行碎片整理)。
3.5.7 Garbage First收集器
G1是一款主要面向服務端應用的垃圾收集器。
目標:在延遲可控的情況下獲得盡可能高的吞吐量
從整體上來說是基于標記-清除,但從局部上(兩個region之間)是基于復制算法
G1面向堆內存任何部分來組成回收集(CollectionSet,一般簡稱CSet)進行回收,衡量標準不再是它屬于哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大,這就是G1收集器的Mixed GC模式。G1開創的基于Region的堆內存布局是它能夠實現這個目標的關鍵。
雖然G1也仍是遵循分代收集理論設計的,但G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region采用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。
Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值范圍為1MB~32MB,且應為2的N次冪。而對于那些超過了整個Region容量的超級大對象,將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代的一部分來進行看待,如圖3-12所示。
雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的內存空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。這種使用Region劃分內存空間,以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取盡可能高的收集效率。
G1收集器的運作過程大致可劃分為以下四個步驟:
·初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,并且修改TAMS(Top at Mark Start)指針的值,讓下一階段用戶線程并發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際并沒有額外的停頓。
·并發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序并發執行。當對象圖掃描完成以后,還要重新處理SATB(原始快照)記錄下的在并發時有引用變動的對象。
·最終標記(Final Marking):對用戶線程做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄。
·篩選回收(Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活對象復制到空的Region中,再清理掉整個舊Region的全部空間。這里的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程并行完成的。
從G1開始,最先進的垃圾收集器的設計導向為追求能夠應付應用的內存分配速率。
G1 VS CMS
相同:都關注停頓時間的控制
G1優點:可指定最大停頓時間;分region的內存分布;按收益動態確定回收;不會產生垃圾碎片,有利程序長時間運行
G1缺點:垃圾收集產生的內存占用(Footprint)和程序運行時的額外執行負載(Overload)都比CMS高
通常在小內存應用CMS優于G1,而大內存應用G1更優
3.6 低延遲垃圾收集器
衡量垃圾收集器的三項最重要的指標是:內存占用(Footprint)、吞吐量(Throughput)和延遲(Latency)。
淺色必須掛起用戶線程,深色用戶線程和收集器線程并發工作
3.6.1 Shenandoah收集器
Shenandoah作為第一款不由Oracle(包括以前的Sun)公司的虛擬機團隊所領導開發的HotSpot垃圾收集器,不可避免地會受到一些來自“官方”的排擠。
與G1類似,基于region的堆內存布局,用于存放大對象的Humongous Region,默認的回收策略是優先處理回收價值最大的region。
不同點三個:G1的回收過程支持多線程并行,但不能和用戶線程并發;Shenandoah默認不使用分代收集;Shenandoah不用G1的記憶集,而是用connection matrix的全局數據結構來記錄跨region的引用關系。
Shenandoah收集器的工作過程大致可以劃分為以下九個階段:
·初始標記(Initial Marking):與G1一樣,首先標記與GC Roots直接關聯的對象,這個階段仍是“Stop TheWorld”的,但停頓時間與堆大小無關,只與GC Roots的數量相關。
·并發標記(Concurrent Marking):與G1一樣,遍歷對象圖,標記出全部可達的對象,這個階段是與用戶線程一起并發的,時間長短取決于堆中存活對象的數量以及對象圖的結構復雜程度。
·最終標記(Final Marking):與G1一樣,處理剩余的SATB掃描,并在這個階段統計出回收價值最高的Region,將這些Region構成一組回收集(Collection Set)。最終標記階段也會有一小段短暫的停頓。
·并發清理(Concurrent Cleanup):這個階段用于清理那些整個區域內連一個存活對象都沒有找到的Region(這類Region被稱為Immediate Garbage Region)。
·并發回收(Concurrent Evacuation):并發回收階段是Shenandoah與之前HotSpot中其他收集器的核心差異。在這個階段,Shenandoah要把回收集里面的存活對象先復制一份到其他未被使用的Region之中。復制對象這件事情如果將用戶線程凍結起來再做那是相當簡單的,但如果兩者必須要同時并發進行的話,就變得復雜起來了。其困難點是在移動對象的同時,用戶線程仍然可能不停對被移動的對象進行讀寫訪問,移動對象是一次性的行為,但移動之后整個內存中所有指向該對象的引用都還是舊對象的地址,這是很難一瞬間全部改變過來的。對于并發回收階段遇到的這些困難,Shenandoah將會通過讀屏障和被稱為“Brooks Pointers”的轉發指針來解決(講解完Shenandoah整個工作過程之后筆者還要再回頭介紹它)。并發回收階段運行的時間長短取決于回收集的大小。
·初始引用更新(Initial Update Reference):并發回收階段復制對象結束后,還需要把堆中所有指向舊對象的引用修正到復制后的新地址,這個操作稱為引用更新。引用更新的初始化階段實際上并未做什么具體的處理,設立這個階段只是為了建立一個線程集合點,確保所有并發回收階段中進行的收集器線程都已完成分配給它們的對象移動任務而已。初始引用更新時間很短,會產生一個非常短暫的停頓。
·并發引用更新(Concurrent Update Reference):真正開始進行引用更新操作,這個階段是與用戶線程一起并發的,時間長短取決于內存中涉及的引用數量的多少。并發引用更新與并發標記不同,它不再需要沿著對象圖來搜索,只需要按照內存物理地址的順序,線性地搜索出引用類型,把舊值改為新值即可。
·最終引用更新(Final Update Reference):解決了堆中的引用更新后,還要修正存在于GC Roots中的引用。這個階段是Shenandoah的最后一次停頓,停頓時間只與GC Roots的數量相關。
·并發清理(Concurrent Cleanup):經過并發回收和引用更新之后,整個回收集中所有的Region已再無存活對象,這些Region都變成Immediate Garbage Regions了,最后再調用一次并發清理過程來回收這些Region的內存空間,供以后新對象分配使用。
以上對Shenandoah收集器這九個階段的工作過程的描述可能拆分得略為瑣碎,讀者只要抓住其中三個最重要的并發階段(并發標記、并發回收、并發引用更新),就能比較容易理清Shenandoah是如何運作的了。
3.6.2 ZGC收集器
·并發標記(Concurrent Mark):與G1、Shenandoah一樣,并發標記是遍歷對象圖做可達性分析的階段,前后也要經過類似于G1、Shenandoah的初始標記、最終標記(盡管ZGC中的名字不叫這些)的短暫停頓,而且這些停頓階段所做的事情在目標上也是相類似的。與G1、Shenandoah不同的是,ZGC的標記是在指針上而不是在對象上進行的,標記階段會更新染色指針中的Marked 0、Marked 1標志位。
·并發預備重分配(Concurrent Prepare for Relocate):這個階段需要根據特定的查詢條件統計得出本次收集過程要清理哪些Region,將這些Region組成重分配集(Relocation Set)。重分配集與G1收集器的回收集(Collection Set)還是有區別的,ZGC劃分Region的目的并非為了像G1那樣做收益優先的增量回收。相反,ZGC每次回收都會掃描所有的Region,用范圍更大的掃描成本換取省去G1中記憶集的維護成本。因此,ZGC的重分配集只是決定了里面的存活對象會被重新復制到其他的Region中,里面的Region會被釋放,而并不能說回收行為就只是針對這個集合里面的Region進行,因為標記過程是針對全堆的。此外,在JDK 12的ZGC中開始支持的類卸載以及弱引用的處理,也是在這個階段中完成的。
·并發重分配(Concurrent Relocate):重分配是ZGC執行過程中的核心階段,這個過程要把重分配集中的存活對象復制到新的Region上,并為重分配集中的每個Region維護一個轉發表(Forward Table),記錄從舊對象到新對象的轉向關系。得益于染色指針的支持,ZGC收集器能僅從引用上就明確得知一個對象是否處于重分配集之中,如果用戶線程此時并發訪問了位于重分配集中的對象,這次訪問將會被預置的內存屏障所截獲,然后立即根據Region上的轉發表記錄將訪問轉發到新復制的對象上,并同時修正更新該引用的值,使其直接指向新對象,ZGC將這種行為稱為指針的“自愈”(Self-Healing)能力。這樣做的好處是只有第一次訪問舊對象會陷入轉發,也就是只慢一次,對比Shenandoah的Brooks轉發指針,那是每次對象訪問都必須付出的固定開銷,簡單地說就是每次都慢,因此ZGC對用戶程序的運行時負載要比Shenandoah來得更低一些。還有另外一個直接的好處是由于染色指針的存在,一旦重分配集中某個Region的存活對象都復制完畢后,這個Region就可以立即釋放用于新對象的分配(但是轉發表還得留著不能釋放掉),哪怕堆中還有很多指向這個對象的未更新指針也沒有關系,這些舊指針一旦被使用,它們都是可以自愈的。
·并發重映射(Concurrent Remap):重映射所做的就是修正整個堆中指向重分配集中舊對象的所有引用,這一點從目標角度看是與Shenandoah并發引用更新階段一樣的,但是ZGC的并發重映射并不是一個必須要“迫切”去完成的任務,因為前面說過,即使是舊引用,它也是可以自愈的,最多只是第一次使用時多一次轉發和修正操作。重映射清理這些舊引用的主要目的是為了不變慢(還有清理結束后可以釋放轉發表這樣的附帶收益),所以說這并不是很“迫切”。因此,ZGC很巧妙地把并發重映射階段要做的工作,合并到了下一次垃圾收集循環中的并發標記階段里去完成,反正它們都是要遍歷所有對象的,這樣合并就節省了一次遍歷對象圖[插圖]的開銷。一旦所有指針都被修正之后,原來記錄新舊對象關系的轉發表就可以釋放掉了。
3.7 選擇合適的垃圾收集器
3.8 實戰:內存分配與回收策略
Java技術體系的自動內存管理,**最根本的目標是自動化地解決兩個問題:自動給對象分配內存以及自動回收分配給對象的內存。**內存回收,前面已經用大量篇幅進行闡述。接下來進行對象內存分配講解。
對象的內存分配,從概念上講,應該都是在堆上分配(而實際上也有可能經過即時編譯后被拆散為標量類型并間接地在棧上分配)。在經典分代的設計下,新生對象通常會分配在新生代中,少數情況下(例如對象大小超過一定閾值)也可能會直接分配在老年代。
3.8.1 對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。
HotSpot虛擬機提供了-XX:+PrintGCDetails這個收集器日志參數,告訴虛擬機在發生垃圾收集行為時打印內存回收日志,并且在進程退出的時候輸出當前的內存各區域分配情況。
3.8.2 大對象直接進入老年代
大對象就是指需要大量連續內存空間的Java對象,最典型的大對象便是那種很長的字符串,或者元素數量很龐大的數組。
HotSpot虛擬機提供了-XX:PretenureSizeThreshold參數,指定大于該設置值的對象直接在老年代分配,這樣做的目的就是避免在Eden區及兩個Survivor區之間來回復制,產生大量的內存復制操作。
注意 -XX:PretenureSizeThreshold參數只對Serial和ParNew兩款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持這個參數。如果必須使用此參數進行調優,可考慮ParNew加CMS的收集器組合。
3.8.3 長期存活的對象將進入老年代
對象通常在Eden區里誕生,如果經過第一次Minor GC后仍然存活,并且能被Survivor容納的話,該對象會被移動到Survivor空間中,并且將其對象年齡設為1歲。對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。
3.8.4 動態對象年齡判定
為了能更好地適應不同程序的內存狀況,HotSpot虛擬機并不是永遠要求對象的年齡必須達到-XX:MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到-XX:MaxTenuringThreshold中要求的年齡。
3.8.5 空間分配擔保
在發生Minor GC之前,虛擬機必須先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那這一次Minor GC可以確保是安全的。如果不成立,則虛擬機會先查看-XX:HandlePromotionFailure參數的設置值是否允許擔保失敗(Handle PromotionFailure);如果允許,那會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試進行一次Minor GC,盡管這次Minor GC是有風險的;如果小于,或者-XX:HandlePromotionFailure設置不允許冒險,那這時就要改為進行一次FullGC。
解釋一下“冒險”是冒了什么風險:前面提到過,新生代使用復制收集算法,但為了內存利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量對象在Minor GC后仍然存活的情況——最極端的情況就是內存回收后新生代中所有對象都存活,需要老年代進行分配擔保,把Survivor無法容納的對象直接送入老年代。
老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩余空間,但一共有多少對象會在這次回收中活下來在實際完成內存回收之前是無法明確知道的,所以只能取之前每一次回收晉升到老年代對象容量的平均大小作為經驗值,與老年代的剩余空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。
通常情況下都還是會將-XX:HandlePromotionFailure開關打開。
第4章 虛擬機性能監控、故障處理工具
4.2 基礎故障處理工具
4.2.1 jps:虛擬機進程狀況工具
jps(JVM Process Status Tool):
列出正在運行的虛擬機進程,并顯示虛擬機執行主類(Main Class,main()函數所在的類)名稱以及這些進程的本地虛擬機唯一ID(LVMID,Local Virtual Machine Identifier)。
jps還可以通過RMI協議查詢開啟了RMI服務的遠程虛擬機進程狀態,參數hostid為RMI注冊表中注冊的主機名。
4.2.2 jstat:虛擬機統計信息監視工具
jstat(JVM Statistics Monitoring Tool)是用于監視虛擬機各種運行狀態信息的命令行工具。
它可以顯示本地或者遠程[插圖]虛擬機進程中的類加載、內存、垃圾收集、即時編譯等運行時數據,在沒有GUI圖形界面、只提供了純文本控制臺環境的服務器上,它將是運行期定位虛擬機性能問題的常用工具。
4.2.3 jinfo:Java配置信息工具
jinfo(Configuration Info for Java)的作用是實時查看和調整虛擬機各項參數。
4.2.4 jmap:Java內存映像工具
jmap(Memory Map for Java)命令用于生成堆轉儲快照。
jmap的作用并不僅僅是為了獲取堆轉儲快照,它還可以查詢finalize執行隊列、Java堆和方法區的詳細信息,如空間使用率、當前用的是哪種收集器等。
4.2.5 jhat:虛擬機堆轉儲快照分析工具
jhat(JVM Heap Analysis Tool)命令與jmap搭配使用,來分析jmap生成的堆轉儲快照。
jhat內置了一個微型的HTTP/Web服務器,生成堆轉儲快照的分析結果后,可以在瀏覽器中查看。
實際上,jhat工作中不嘗試用。可以使用VisualVM,Eclipse MemoryAnalyzer、IBM HeapAnalyzer。
4.2.6 jstack:Java堆棧跟蹤工具
jstack(Stack Trace for Java)命令用于生成虛擬機當前時刻的線程快照(一般稱為threaddump或者javacore文件)。
線程快照就是當前虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照的目的通常是定位線程出現長時間停頓的原因,如線程間死鎖、死循環、請求外部資源導致的長時間掛起等,都是導致線程長時間停頓的常見原因。
4.2.7 基礎工具總結
下面表4-5~表4-14中羅列了JDK附帶的全部(包括曾經存在但已經在最新版本中被移除的)工具及其簡要用途。
·基礎工具:用于支持基本的程序創建和運行(見表4-5)
·安全:用于程序簽名、設置安全測試等(見表4-6)
·國際化:用于創建本地語言文件(見表4-7)
·遠程方法調用:用于跨Web或網絡的服務交互(見表4-8)
·部署工具:用于程序打包、發布和部署(見表4-10)
·Java Web Start(見表4-11)
·性能監控和故障處理:用于監控分析Java虛擬機運行信息,排查問題(見表4-12
·WebService工具:與CORBA一起在JDK 11中被移除(見表4-13)
·REPL和腳本工具(見表4-14)
4.3 可視化故障處理工具
主要包括JConsole、JHSDB、VisualVM和JMC。
4.3.1 JHSDB:基于服務性代理的調試工具
4.3.2 JConsole:Java監視與管理控制臺
4.3.3 VisualVM:多合-故障處理工具
4.3.4 Java Mission Control:可持續在線的監控工具
4.4 HotSpot虛擬機插件及工具
·Ideal Graph Visualizer:用于可視化展示C2即時編譯器是如何將字節碼轉化為理想圖,然后轉化為機器碼的。
·Client Compiler Visualizer:用于查看C1即時編譯器生成高級中間表示(HIR),轉換成低級中間表示(LIR)和做物理寄存器分配的過程。
·MakeDeps:幫助處理HotSpot的編譯依賴的工具。
·Project Creator:幫忙生成Visual Studio的.project文件的工具。
·LogCompilation:將-XX:+LogCompilation輸出的日志整理成更容易閱讀的格式的工具。
·HSDIS:即時編譯器的反匯編插件。
補充:
·IBM的Support Assistant、Heap Analyzer、Javacore Analyzer、GarbageCollector Analyzer適用于IBM J9/OpenJ9 VM
·HP的HPjmeter、HPjtune適用于HP-UX、SAP、HotSpot VM。
·Eclipse的Memory Analyzer Tool(MAT)適用于HP-UX、SAP、HotSpot VM,安裝IBMDTFJ插件后可支持IBM J9虛擬機。
第5章 調優案例分析與實戰
5.3 實戰:Eclipse運行速度調優
第三部分 虛擬機執行子系統
第6章 類文件結構
6.2 無關性的基石
各種不同平臺的Java虛擬機,以及所有平臺都統一支持的程序存儲格式——字節碼(Byte Code)是構成平臺無關性的基石。
實現語言無關性的基礎仍然是虛擬機和字節碼存儲格式。。Java虛擬機不與包括Java語言在內的任何程序語言綁定,它只與“Class文件”這種特定的二進制文件格式所關聯,Class文件中包含了Java虛擬機指令集、符號表以及若干其他輔助信息。
6.3 Class類文件的結構
Class文件是一組以8個字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在文件之中,中間沒有添加任何分隔符,這使得整個Class文件中存儲的內容幾乎全部是程序運行的必要數據,沒有空隙存在。當遇到需要占用8個字節以上空間的數據項時,則會按照高位在前的方式分割成若干個8個字節進行存儲。
根據《Java虛擬機規范》的規定,Class文件格式采用一種類似于C語言結構體的偽結構來存儲數據,這種偽結構中只有兩種數據類型:“無符號數”和“表”。
·無符號數屬于基本的數據類型,以u1、u2、u4、u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。
·表是由多個無符號數或者其他表作為數據項構成的復合數據類型,所有表的命名都習慣性地以“_info”結尾。表用于描述有層次關系的復合結構的數據,整個Class文件本質上也可以視作是一張表
無論是無符號數還是表,當需要描述同一類型但數量不定的多個數據時,經常會使用一個前置的容量計數器加若干個連續的數據項的形式,這時候稱這一系列連續的某一類型的數據為某一類型的“集合”。
6.3.1 魔數與Class文件的版本
每個Class文件的頭4個字節被稱為魔數(Magic Number),它的唯一作用是確定這個文件是否為一個能被虛擬機接受的Class文件。Class文件的魔數取得很有“浪漫氣息”,值為0xCAFEBABE(咖啡寶貝?)。
緊接著魔數的4個字節存儲的是Class文件的版本號:第5和第6個字節是次版本號(Minor Version),第7和第8個字節是主版本號(Major Version)。Java的版本號是從45開始的,JDK 1.1之后的每個JDK大版本發布主版本號向上加1(JDK 1.0~1.1使用了45.0~45.3的版本號),高版本的JDK能向下兼容以前版本的Class文件,但不能運行以后版本的Class文件。
6.3.2 常量池
由于常量池中常量的數量是不固定的,所以在常量池的入口需要放置一項u2類型的數據,代表常量池容量計數值(constant_pool_count)。與Java中語言習慣不同,這個容量計數是從1而不是0開始的。
如圖6-3所示,常量池容量(偏移地址:0x00000008)為十六進制數0x0016,即十進制的22,這就代表常量池中有21項常量,索引值范圍為1~21。在Class文件格式規范制定之時,設計者將第0項常量空出來是有特殊考慮的,這樣做的目的在于,如果后面某些指向常量池的索引值的數據在特定情況下需要表達“不引用任何一個常量池項目”的含義,可以把索引值設置為0來表示。Class文件結構中只有常量池的容量計數是從1開始,對于其他集合類型,包括接口索引集合、字段表集合、方法表集合等的容量計數都與一般習慣相同,是從0開始。
常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。
字面量比較接近于Java語言層面的常量概念,如文本字符串、被聲明為final的常量值等。
符號引用則屬于編譯原理方面的概念,主要包括下面幾類常量:
·被模塊導出或者開放的包(Package)
·類和接口的全限定名(Fully Qualified Name)
·字段的名稱和描述符(Descriptor)·
方法的名稱和描述符
·方法句柄和方法類型(Method Handle、Method Type、Invoke Dynamic)
·動態調用點和動態常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
6.3.3 訪問標志
訪問標志(access_flags),2個字節,用于識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型;如果是類的話,是否被聲明為final;等等。
6.3.4 類索引、父類索引與接口索引集合
類索引(this_class)和父類索引(super_class)都是一個u2類型的數據,而接口索引集合(interfaces)是一組u2類型的數據的集合,Class文件中由這三項數據來確定該類型的繼承關系。類索引用于確定這個類的全限定名,父類索引用于確定這個類的父類的全限定名。由于Java語言不允許多重繼承,所以父類索引只有一個,除了java.lang.Object之外,所有的Java類都有父類,因此除了java.lang.Object外,所有Java類的父類索引都不為0。接口索引集合就用來描述這個類實現了哪些接口,這些被實現的接口將按implements關鍵字(如果這個Class文件表示的是一個接口,則應當是extends關鍵字)后的接口順序從左到右排列在接口索引集合中。
類索引、父類索引和接口索引集合都按順序排列在訪問標志之后,類索引和父類索引用兩個u2類型的索引值表示,它們各自指向一個類型為CONSTANT_Class_info的類描述符常量,通過CONSTANT_Class_info類型的常量中的索引值可以找到定義在CONSTANT_Utf8_info類型的常量中的全限定名字符串。
對于接口索引集合,入口的第一項u2類型的數據為接口計數器(interfaces_count),表示索引表的容量。如果該類沒有實現任何接口,則該計數器值為0,后面接口的索引表不再占用任何字節。
6.3.5 字段表集合
字段表(field_info)用于描述接口或者類中聲明的變量。Java語言中的“字段”(Field)包括類級變量以及實例級變量,但不包括在方法內部聲明的局部變量。
字段可以包括的修飾符有字段的作用域(public、private、protected修飾符)、是實例變量還是類變量(static修飾符)、可變性(final)、并發可見性(volatile修飾符,是否強制從主內存讀寫)、可否被序列化(transient修飾符)、字段數據類型(基本類型、對象、數組)、字段名稱。上述這些信息中,各個修飾符都是布爾值,要么有某個修飾符,要么沒有,很適合使用標志位來表示。而字段叫做什么名字、字段被定義為什么數據類型,這些都是無法固定的,只能引用常量池中的常量來描述。表6-8中列出了字段表的最終格式。
6.3.6 方法表集合
Class文件存儲格式中對方法的描述與對字段的描述采用了幾乎完全一致的方式,方法表的結構如同字段表一樣,依次包括訪問標志(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)幾項,如表6-11所示。
6.3.7 屬性表集合
屬性表(attribute_info)在前面的講解之中已經出現過數次,Class文件、字段表、方法表都可以攜帶自己的屬性表集合,以描述某些場景專有的信息。
- signature屬性
可是一個選的定長屬性,可出現于類、字段表和方法表結構的屬性表中。任何類、接口、初始化方法或成員的泛型簽名如果包含了類型變量(type variable)或參數化類型(parameterized type),則signature屬性會為它記錄泛型簽名信息。之所以需要這樣一個屬性去記錄泛型類型,是因為Java語言的泛型采用的是擦除法實現的偽泛型,字節碼中的所有泛型信息編譯(類型變量和參數化類型)在編譯之后通通被擦除掉。使用擦除法的好處是實現簡單,非常容易實現backport,運行期也能節省一些類型所占的內存空間。但是壞處是運行期就無法像C#等有真泛型支持的語言那樣,將泛型類型和用戶定義的普通類型同等對待,例如運行期間做反射無法獲得泛型信息。Signature屬性就是為了彌補這個缺陷而增設的,現在java的 api能獲取的泛型類型,最終數據來源也是這個屬性。
6.4 字節碼指令簡介
Java虛擬機的指令由一個字節長度的、代表著某種特定操作含義的數字(稱為操作碼,Opcode)以及跟隨其后的零至多個代表此操作所需的參數(稱為操作數,Operand)構成。
由于Java虛擬機采用面向操作數棧而不是面向寄存器的架構(這兩種架構的執行過程、區別和影響將在第8章中探討),所以大多數指令都不包含操作數,只有一個操作碼,指令參數都存放在操作數棧中。
,由于限制了Java虛擬機操作碼的長度為一個字節(即0~255),這意味著指令集的操作碼總數不能夠超過256條。又由于Class文件格式放棄了編譯后代碼的操作數長度對齊,這就意味著虛擬機在處理那些超過一個字節的數據時,不得不在運行時從字節中重建出具體數據的結構,譬如要將一個16位長度的無符號整數使用兩個無符號字節存儲起來(假設將它們命名為byte1和byte2),那它們的值應該是這樣的:
這種操作在某種程度上會導致解釋執行字節碼時將損失一些性能,但這樣做的優勢也同樣明顯:放棄了操作數長度對齊[插圖],就意味著可以省略掉大量的填充和間隔符號;用一個字節來代表操作碼,也是為了盡可能獲得短小精干的編譯代碼。
6.4.1 字節碼與數據類型
在Java虛擬機的指令集中,大多數指令都包含其操作所對應的數據類型信息。
舉個例子,iload指令用于從局部變量表中加載int型的數據到操作數棧中,而fload指令加載的則是float類型的數據。這兩條指令的操作在虛擬機內部可能會是由同一段代碼來實現的,但在Class文件中它們必須擁有各自獨立的操作碼。
6.4.2 加載和存儲指令
加載和存儲指令用于將數據在棧幀中的局部變量表和操作數棧之間來回傳輸,這類指令包括:
·將一個局部變量加載到操作棧:iload、iload_、lload、lload_、fload、fload_、dload、dload_、aload、aload_
·將一個數值從操作數棧存儲到局部變量表:istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_
·將一個常量加載到操作數棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_
·擴充局部變量表的訪問索引的指令:wide
上面所列舉的指令助記符中,有一部分是以尖括號結尾的(例如iload_),這些指令助記符實際上代表了一組指令(例如iload_,它代表了iload_0、iload_1、iload_2和iload_3這幾條指令)。
6.4.3 運算指令
算術指令用于對兩個操作數棧上的值進行某種特定運算,并把結果重新存入到操作棧頂。
大體上運算指令可以分為兩種:對整型數據進行運算的指令與對浮點型數據進行運算的指令。
無論是哪種算術指令,均是使用Java虛擬機的算術類型來進行計算的,換句話說是不存在直接支持byte、short、char和boolean類型的算術指令,對于上述幾種數據的運算,應使用操作int類型的指令代替。
所有的算術指令包括:
·加法指令:iadd、ladd、fadd、dadd
·減法指令:isub、lsub、fsub、dsub
·乘法指令:imul、lmul、fmul、dmul
·除法指令:idiv、ldiv、fdiv、ddiv
·求余指令:irem、lrem、frem、drem
·取反指令:ineg、lneg、fneg、dneg
·位移指令:ishl、ishr、iushr、lshl、lshr、lushr
·按位或指令:ior、lor
·按位與指令:iand、land
·按位異或指令:ixor、lxor
·局部變量自增指令:iinc
·比較指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
6.4.4 類型轉換指令
Java虛擬機直接支持(即轉換時無須顯式的轉換指令)以下數值類型的寬化類型轉換(Widening NumericConversion,即小范圍類型向大范圍類型的安全轉換):
·int類型到long、float或者double類型
·long類型到float、double類型
·float類型到double類型
與之相對的,處理窄化類型轉換(Narrowing Numeric Conversion)時,就必須顯式地使用轉換指令來完成,這些轉換指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化類型轉換可能會導致轉換結果產生不同的正負號、不同的數量級的情況,轉換過程很可能會導致數值的精度丟失。
6.4.5 對象創建與訪問指令
Java虛擬機對類實例和數組的創建與操作使用了不同的字節碼指令。對象創建后,就可以通過對象訪問指令獲取對象實例或者數組實例中的字段或者數組元素,這些指令包括:
·創建類實例的指令:new
·創建數組的指令:newarray、anewarray、multianewarray
·訪問類字段(static字段,或者稱為類變量)和實例字段(非static字段,或者稱為實例變量)的指令:getfield、putfield、getstatic、putstatic
·把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
·將一個操作數棧的值儲存到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
·取數組長度的指令:arraylength
·檢查類實例類型的指令:instanceof、checkcast
6.4.6 操作數棧管理指令
如同操作一個普通數據結構中的堆棧那樣,Java虛擬機提供了一些用于直接操作操作數棧的指令,包括:
·將操作數棧的棧頂一個或兩個元素出棧:pop、pop2
·復制棧頂一個或兩個數值并將復制值或雙份的復制值重新壓入棧頂:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
·將棧最頂端的兩個數值互換:swap
6.4.7 控制轉移指令
控制轉移指令可以讓Java虛擬機有條件或無條件地從指定位置指令(而不是控制轉移指令)的下一條指令繼續執行程序,從概念模型上理解,可以認為控制指令就是在有條件或無條件地修改PC寄存器的值。控制轉移指令包括:
·條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
·復合條件分支:tableswitch、lookupswitch
·無條件分支:goto、goto_w、jsr、jsr_w、ret
6.4.8 方法調用和返回指令
方法調用(分派、執行過程)將在第8章具體講解,這里僅列舉以下五條指令用于方法調用:
·invokevirtual指令:用于調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是Java語言中最常見的方法分派方式。
·invokeinterface指令:用于調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。
·invokespecial指令:用于調用一些需要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。
·invokestatic指令:用于調用類靜態方法(static方法)。
·invokedynamic指令:用于在運行時動態解析出調用點限定符所引用的方法。并執行該方法。前面四條調用指令的分派邏輯都固化在Java虛擬機內部,用戶無法改變,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
方法調用指令與數據類型無關,而方法返回指令是根據返回值的類型區分的,包括ireturn(當返回值是boolean、byte、char、short和int類型時使用)、lreturn、freturn、dreturn和areturn,另外還有一條return指令供聲明為void的方法、實例初始化方法、類和接口的類初始化方法使用。
6.4.9 異常處理指令
在Java程序中顯式拋出異常的操作(throw語句)都由athrow指令來實現,除了用throw語句顯式拋出異常的情況之外,《Java虛擬機規范》還規定了許多運行時異常會在其他Java虛擬機指令檢測到異常狀況時自動拋出。
而在Java虛擬機中,處理異常(catch語句)不是由字節碼指令來實現的,而是采用異常表來完成
6.4.10 同步指令
Java虛擬機可以支持**方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor,更常見的是直接將它稱為“鎖”)**來實現的。
方法級的同步是隱式的,無須通過字節碼指令來控制,它實現在方法調用和返回操作之中。虛擬機可以從方法常量池中的方法表結構中的ACC_SYNCHRONIZED訪問標志得知一個方法是否被聲明為同步方法。當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZED訪問標志是否被設置,如果設置了,執行線程就要求先成功持有管程,然后才能執行方法,最后當方法完成(無論是正常完成還是非正常完成)時釋放管程。
同步一段指令集序列通常是由Java語言中的synchronized語句塊來表示的,Java虛擬機的指令集中有monitorenter和monitorexit兩條指令來支持synchronized關鍵字的語義,正確實現synchronized關鍵字需要Javac編譯器與Java虛擬機兩者共同協作支持.
6.5 公有設計,私有實現
《Java虛擬機規范》描繪了Java虛擬機應有的共同程序存儲格式:Class文件格式以及字節碼指令集。這些內容與硬件、操作系統和具體的Java虛擬機實現之間是完全獨立的,虛擬機實現者可能更愿意把它們看作程序在各種Java平臺實現之間互相安全地交互的手段。理解公有設計與私有實現之間的分界線是非常有必要的,任何一款Java虛擬機實現都必須能夠讀取Class文件并精確實現包含在其中的Java虛擬機代碼的語義。
虛擬機實現的方式主要有以下兩種:
·將輸入的Java虛擬機代碼在加載時或執行時翻譯成另一種虛擬機的指令集;
·將輸入的Java虛擬機代碼在加載時或執行時翻譯成宿主機處理程序的本地指令集(即即時編譯器代碼生成技術)。
第7章 虛擬機類加載機制
代碼編譯:本地機器碼變成字節碼
7.1 概述
Java虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這個過程被稱作虛擬機的類加載機制。
Java中,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略使得提前編譯變得困難,也增加了類加載的性能開銷,但是為java應用提供餓了擴展性和靈活性。
java的動態擴展就是依賴運行期間動態加載和動態連接這個特點實現的。
7.2 類加載的時機
一個類型從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期將會經歷加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接(Linking)。這七個階段的發生順序如圖7-1所示。
圖7-1中,加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,類型的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定特性(也稱為動態綁定或晚期綁定)。
關于在什么情況下需要開始類加載過程的第一個階段“加載”,《Java虛擬機規范》中并沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。
但是對于初始化階段,《Java虛擬機規范》則是嚴格規定了有且只有六種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):
1)遇到new、getstatic、putstatic或invokestatic這四條字節碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:
·使用new關鍵字實例化對象的時候。
·讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候。
·調用一個類型的靜態方法的時候。
2)使用java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。
3)當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
5)當使用JDK 7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。**
6)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
對于這六種會觸發類型進行初始化的場景,《Java虛擬機規范》中使用了一個非常強烈的限定語——“有且只有”,這六種場景中的行為稱為對一個類型進行主動引用。除此之外,所有引用類型的方式都不會觸發初始化,稱為被動引用。
-常量會在編譯階段存入調用類的常量池,不會觸發定義類的初始化
接口不能使用static{}語句塊,一個接口在初始化時,并不需要其父接口都實現初始化,只有真正使用到父接口市,才會初始化父接口
7.3 類加載的過程
接下來我們會詳細了解Java虛擬機中類加載的全過程,即加載、驗證、準備、解析和初始化這五個階段所執行的具體動作。
7.3.1 加載
“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段.
在加載階段,Java虛擬機需要完成以下三件事情:
1)通過一個類的全限定名來獲取定義此類的二進制字節流。
2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
相對于類加載過程的其他階段,非數組類型的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的階段。加載階段既可以使用Java虛擬機里內置的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員通過定義自己的類加載器去控制字節流的獲取方式(重寫一個類加載器的findClass()或loadClass()方法),實現根據自己的想法來賦予應用程序獲取運行代碼的動態性。
對于數組類而言,數組類本身不通過類加載器創建,它是由Java虛擬機直接在內存中動態構造出來的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終還是要靠類加載器來完成加載,一個數組類(下面簡稱為C)創建過程遵循以下規則:
·如果數組的組件類型不是引用類型(例如int[]數組的組件類型為int),Java虛擬機將會把數組C標記為與引導類加載器關聯。
·數組類的可訪問性與它的組件類型的可訪問性一致,如果組件類型不是引用類型,它的數組類的可訪問性將默認為public,可被所有的類和接口訪問到。
加載階段結束后,Java虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區之中了,方法區中的數據存儲格式完全由虛擬機實現自行定義,《Java虛擬機規范》未規定此區域的具體數據結構。
類型數據妥善安置在方法區之后,會在Java堆內存中實例化一個java.lang.Class類的對象,這個對象將作為程序訪問方法區中的類型數據的外部接口。
7.3.2 驗證
驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節流中包含的信息符合《Java虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。
驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。
1.文件格式驗證
第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。一階段可能包括下面這些驗證點:
·是否以魔數0xCAFEBABE開頭。
·主、次版本號是否在當前Java虛擬機接受范圍之內。
……
該驗證階段的主要目的是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求。
2.元數據驗證
第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規范》的要求,這個階段可能包括的驗證點如下:
·這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
·這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
……
第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在與《Java語言規范》定義相悖的元數據信息。
3.字節碼驗證
第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型校驗完畢以后,這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為,例如:
·保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似于“在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中”這樣的情況。
·保證任何跳轉指令都不會跳轉到方法體以外的字節碼指令上。
……
4.符號引用驗證
最后一個階段的校驗行為發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。
本階段通常需要校驗下列內容:
·符號引用中通過字符串描述的全限定名是否能找到對應的類。
·在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
……
符號引用驗證的主要目的是確保解析行為能正常執行,如果無法通過符號引用驗證,Java虛擬機將會拋出一個java.lang.IncompatibleClassChangeError的子類異常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
7.3.3 準備
準備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存并設置類變量初始值的階段,從概念上講,這些變量所使用的內存都應當在方法區中進行分配,但必須注意到方法區本身是一個邏輯上的區域,在JDK 7及之前,HotSpot使用永久代來實現方法區時,實現是完全符合這種邏輯概念的;而在JDK 8及之后,類變量則會隨著Class對象一起存放在Java堆中,這時候“類變量在方法區”就完全是一種對邏輯概念的表述了,關于這部分內容,筆者已在4.3.1節介紹并且驗證過。
關于準備階段,首先這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其次是這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:
那變量value在準備階段過后的初始值為0而不是123,因為這時尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯后,存放于類構造器()方法之中,所以把value賦值為123的動作要到類的初始化階段才會被執行。
上面提到在“通常情況”下初始值是零值,那言外之意是相對的會有某些“特殊情況”:如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化為ConstantValue屬性所指定的初始值,假設上面類變量value的定義修改為:
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據Con-stantValue的設置將value賦值為123。
7.3.4 解析
解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程。
·符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
·直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7類符號引用進行,分別對應于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8種常量類型。
7.3.5 初始化
類的初始化階段是類加載過程的最后一個步驟,之前介紹的幾個類加載的動作里,除了在加載階段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控制。直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序。
進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程序員通過程序編碼制定的主觀計劃去初始化類變量和其他資源。
類的初始化可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器()方法的過程。()并不是程序員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何產生的,以及()方法執行過程中各種可能會影響程序運行行為的細節,這部分比起其他類加載過程更貼近于普通的程序開發人員的實際工作。
·()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的。靜態語句塊只能訪問在靜態句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以復制,但不能訪問。
·()方法與類的構造函數(即在虛擬機視角中的實例構造器()方法)不同,它不需要顯式地調用父類構造器,Java虛擬機會保證在子類的()方法執行前,父類的()方法已經執行完畢。因此在Java虛擬機中第一個被執行的()方法的類型肯定是java.lang.Object。
·由于父類的()方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作。
·()方法對于類或接口來說并不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成()方法。
·接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成()方法。但接口與類不同的是,執行接口的()方法不需要先執行父接口的()方法,因為只有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現類在初始化時也一樣不會執行接口的()方法。
·Java虛擬機必須保證一個類的()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那么只會有其中一個線程去執行這個類的()方法,其他線程都需要阻塞等待,直到活動線程執行完畢()方法。如果在一個類的()方法中有耗時很長的操作,那就可能造成多個進程阻塞[插圖],在實際應用中這種阻塞往往是很隱蔽的。
7.4 類加載器
Java虛擬機設計團隊有意把類加載階段中的“通過一個類的全限定名來獲取描述該類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需的類。實現這個動作的代碼被稱為**“類加載器”(Class Loader)**。
7.4.1 類與類加載器
類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠超類加載階段。對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。即使兩個類來源于同一個class文件,被同一個java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
7.4.2 雙親委派模型
站在Java虛擬機的角度來看,只存在兩種不同的類加載器:
一種是啟動類加載器(BootstrapClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;
另外一種就是其他所有的類加載器,這些類加載器都由Java語言實現,獨立存在于虛擬機外部,并且全都繼承自抽象類java.lang.ClassLoader。
站在Java開發人員的角度來看,類加載器就應當劃分得更細致一些:
自JDK 1.2以來,Java一直保持著三層類加載器、雙親委派的類加載架構,盡管這套架構在Java模塊化系統出現后有了一些調整變動,但依然未改變其主體結構,我們將在7.5節中專門討論模塊化系統下的類加載器。
對于這個時期的Java應用,絕大多數Java程序都會使用到以下3個系統提供的類加載器來進行加載:
·啟動類加載器(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來實現。由于應用程序類加載器是ClassLoader類中的getSystem-ClassLoader()方法的返回值,所以有些場合中也稱它為“系統類加載器”。它負責加載用戶類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
圖7-2中展示的各種類加載器之間的層次關系被稱為類加載器的“雙親委派模型(Parents Delegation Model)”。雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現的,而是通常使用組合(Composition)關系來復用父加載器的代碼。
雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。
使用雙親委派模型來組織類加載器之間的關系,一個顯而易見的好處就是Java中的類隨著它的類加載器一起具備了一種帶有優先級的層次關系。
雙親委派模型對于保證Java程序的穩定運作極為重要,但它的實現卻異常簡單,用以實現雙親委派的代碼只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中,如代碼清單7-10所示。
這段代碼的邏輯清晰易懂:先檢查請求加載的類型是否已經被加載過,若沒有則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。假如父類加載器加載失敗,拋出ClassNotFoundException異常的話,才調用自己的findClass()方法嘗試進行加載。
7.4.3 破壞雙親委派模型
雙親委派模型并不是一個具有強制性約束的模型,而是Java設計者推薦給開發者們的類加載器實現方式。
在Java的世界中大部分的類加載器都遵循這個模型,但也有例外的情況,直到Java模塊化出現為止,雙親委派模型主要出現過3次較大規模“被破壞”的情況:
第一次“被破壞”:
由于雙親委派模型在JDK 1.2之后才被引入,但是類加載器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經存在,面對已經存在的用戶自定義類加載器的代碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一個新的protected方法findClass(),并引導用戶編寫的類加載邏輯時盡可能去重寫這個方法,而不是在loadClass()中編寫代碼。上節我們已經分析過loadClass()方法,雙親委派的具體邏輯就實現在這里面,按照loadClass()方法的邏輯,如果父類加載失敗,會自動調用自己的findClass()方法來完成加載,這樣既不影響用戶按照自己的意愿去加載類,又可以保證新寫出來的類加載器是符合雙親委派規則的。
第二次“被破壞”:
是由這個模型自身的缺陷導致的,雙親委派很好地解決了各個類加載器協作時基礎類型的一致性問題(越基礎的類由越上層的加載器進行加載),基礎類型之所以被稱為“基礎”,是因為它們總是作為被用戶代碼繼承、調用的API存在,但程序設計往往沒有絕對不變的完美規則,如果有基礎類型又要調用回用戶的代碼,那該怎么辦呢?
為了解決這個困境,Java的設計團隊只好引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局范圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。
第三次“被破壞”:
是由于用戶對程序動態性的追求而導致的,這里所說的“動態性”指的是一些非常“熱”門的名詞:代碼熱替換(Hot Swap)、模塊熱部署(HotDeployment)等。
例如OSGi實現模塊化熱部署的關鍵是它自定義的類加載器機制的實現,每一個程序模塊(OSGi中稱為Bundle)都有一個自己的類加載器,當需要更換一個Bundle時,就把Bundle連同類加載器一起換掉以實現代碼的熱替換。在OSGi環境下,類加載器不再雙親委派模型推薦的樹狀結構,而是進一步發展為更加復雜的網狀結構,當收到類加載請求時,OSGi將按照下面的順序進行類搜索:
1)將以java.*開頭的類,委派給父類加載器加載。
2)否則,將委派列表名單內的類,委派給父類加載器加載。
3)否則,將Import列表中的類,委派給Export這個類的Bundle的類加載器加載。
4)否則,查找當前Bundle的ClassPath,使用自己的類加載器加載。
5)否則,查找類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類加載器加載。
6)否則,查找Dynamic Import列表的Bundle,委派給對應Bundle的類加載器加載。
7)否則,類查找失敗。
7.5 Java模塊化系統
在JDK 9中引入的Java模塊化系統(Java Platform Module System,JPMS)是對Java技術的一次重要升級,為了能夠實現模塊化的關鍵目標——可配置的封裝隔離機制,Java虛擬機對類加載架構也做出了相應的變動調整,才使模塊化系統得以順利地運作。
第8章 虛擬機字節碼執行引擎
8.1 概述
在《Java虛擬機規范》中制定了Java虛擬機字節碼執行引擎的概念模型,在不同的虛擬機實現中,執行引擎在執行字節碼的時候,通常會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器產生本地代碼執行)兩種選擇,也可能兩者兼備等。
但從外觀上來看,所有的Java虛擬機的執行引擎輸入、輸出都是一致的:輸入的是字節碼二進制流,處理過程是字節碼解析執行的等效過程,輸出的是執行結果,本章將主要從概念模型的角度來講解虛擬機的方法調用和字節碼執行。
8.2 運行時棧幀結構
Java虛擬機以方法作為最基本的執行單元,**“棧幀”(Stack Frame)**則是用于支持虛擬機進行方法調用和方法執行背后的數據結構,它也是虛擬機運行時數據區中的虛擬機棧(VirtualMachine Stack)的棧元素。
棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。
每一個方法從調用開始至執行結束的過程,都對應著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
8.2.1 局部變量表
局部變量表(Local Variables Table)是一組變量值的存儲空間,用于存放方法參數和方法內部定義的局部變量。在Java程序被編譯為Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需分配的局部變量表的最大容量。
局部變量表的容量以變量槽(Variable Slot)為最小單位,《Java虛擬機規范》中并沒有明確指出一個變量槽應占用的內存空間大小,只是很有導向性地說到每個變量槽都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據。
8.2.2 操作數棧
操作數棧(Operand Stack)也常被稱為操作棧,它是一個后入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks數據項之中。
8.2.3 動態連接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接(Dynamic Linking)。
通過第6章的講解,我們知道Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池里指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜態解析。另外一部分將在每一次運行期間都轉化為直接引用,這部分就稱為動態連接。
8.2.4 方法返回地址
當一個方法開始執行后,只有兩種方式退出這個方法:
第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者或者主調方法),方法是否有返回值以及返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為**“正常調用完成”(Normal Method Invocation Completion)**。
另外一種退出方式是在方法執行的過程中遇到了異常,并且這個異常沒有在方法體內得到妥善處理。無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為**“異常調用完成(Abrupt Method Invocation Completion)”**。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者提供任何返回值的。
一般來說,方法正常退出時,主調方法的PC計數器的值就可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中就一般不會保存這部分信息。
8.2.5 附加信息
《Java虛擬機規范》允許虛擬機實現增加一些規范里沒有描述的信息到棧幀之中,例如與調試、性能收集相關的信息,這部分信息完全取決于具體的虛擬機實現,這里不再詳述。在討論概念時,一般會把動態連接、方法返回地址與其他附加信息全部歸為一類,稱為棧幀信息。
8.3 方法調用
方法調用并不等同于方法中的代碼被執行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法),暫時還未涉及方法內部的具體運行過程。
8.3.1 解析
所有方法調用的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能夠成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯那一刻就已經確定下來。這類方法的調用被稱為解析(Resolution)。
在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要有靜態方法和私有方法兩大類,前者與類型直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫出其他版本,因此它們都適合在類加載階段進行解析。
調用不同類型的方法,字節碼指令集里設計了不同的指令。在Java虛擬機支持以下5條方法調用字節碼指令,分別是:
·invokestatic。用于調用靜態方法。
·invokespecial。用于調用實例構造器()方法、私有方法和父類中的方法。
·invokevirtual。用于調用所有的虛方法。
·invokeinterface。用于調用接口方法,會在運行時再確定一個實現該接口的對象。
·invokedynamic。先在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法。
前面4條調用指令,分派邏輯都固化在Java虛擬機內部,而invokedynamic指令的分派邏輯是由用戶設定的引導方法來決定的。
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中確定唯一的調用版本,Java語言里符合這個條件的方法共有靜態方法、私有方法、實例構造器、父類方法4種,再加上被final修飾的方法(盡管它使用invokevirtual指令調用),這5種方法調用會在類加載的時候就可以把符號引用解析為該方法的直接引用。這些方法統稱為“非虛方法”(Non-VirtualMethod),與之相反,其他方法就被稱為**“虛方法”(Virtual Method)。Java中的非虛方法除了使用invokestatic、invokespecial調用的方法之外還有一種,就是被final**修飾的實例方法。雖然由于歷史設計的原因,final方法是使用invokevirtual指令來調用的。
解析調用一定是個靜態的過程,在編譯期間就完全確定,在類加載的解析階段就會把涉及的符號引用全部轉變為明確的直接引用,不必延遲到運行期再去完成。而另一種主要的方法調用形式:分派(Dispatch)調用則要復雜許多,它可能是靜態的也可能是動態的,按照分派依據的宗量數可分為單分派和多分派。這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況,下面我們來看看虛擬機中的方法分派是如何進行的。
8.3.2 分派
本節講解的分派調用過程將會揭示多態性特征的一些最基本的體現,如“重載”和“重寫”在Java虛擬機之中是如何實現的,這里的實現當然不是語法上該如何寫,我們關心的依然是虛擬機如何確定正確的目標方法。
1.靜態分派
運行結果:
把上面代碼中的“Human”稱為變量的**“靜態類型”(Static Type),或者叫“外觀類型”(Apparent Type),后面的“Man”則被稱為變量的“實際類型”(Actual Type)或者叫“運行時類型”(Runtime Type)。靜態類型和實際類型在程序中都可能會發生變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,并且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序的時候并不知道一個對象的實際類型是什么。**
**虛擬機在重載時是通過參數的靜態類型而不是實際類型作為判定依據的。**由于靜態類型在編譯器可知,所以在編譯階段,編譯器就根據參數的靜態類型決定了會使用哪個重載版本。
所有依賴靜態類型來決定方法執行版本的分派動作,都稱為靜態分派。靜態分派發生在編譯階段,靜態分派的最典型應用表現就是方法重載。
2.動態分派
運行結果:
根據《Java虛擬機規范》,invokevirtual指令的運行時解析過程大致分為以下幾步:
1)找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;不通過則返回java.lang.IllegalAccessError異常。
3)否則,按照繼承關系從下往上依次對C的各個父類進行第二步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
正是因為invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令并不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質。在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
既然這種多態性的根源在于虛方法調用指令invokevirtual的執行邏輯,那自然我們得出的結論就只會對方法有效,對字段是無效的,因為字段不使用這條指令。事實上,在Java里面只有虛方法存在,字段永遠不可能是虛的,換句話說,字段永遠不參與多態,哪個類的方法訪問某個名字的字段時,該名字指的就是這個類能看到的那個字段。當子類聲明了與父類同名的字段時,雖然在子類的內存中兩個字段都會存在,但是子類的字段會遮蔽父類的同名字段。
3.單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量。
根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多于一個宗量對目標方法進行選擇。
總結一句:如今(直至本書編寫的Java 12和預覽版的Java 13)的Java語言是一門靜態多分派、動態單分派的語言。
4.虛擬機動態分派的實現
動態分派是執行非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在接收者類型的方法元數據中搜索合適的目標方法,因此,Java虛擬機實現基于執行性能的考慮,真正運行時一般不會如此頻繁地去反復搜索類型元數據。面對這種情況,一種基礎而且常見的優化手段是為類型在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能。虛方法表中存放著各個方法的實際入口地址。
8.4 動態類型語言支持
JDK 7的發布的字節碼首位新成員——invokedynamic指令。這條新增加的指令是JDK 7的項目目標:實現動態類型語言(Dynamically Typed Language)支持而進行的改進之一,也是為JDK 8里可以順利實現Lambda表達式而做的技術儲備。
8.4.1 動態類型語言
**動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期進行的,**常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl。
那相對地,在編譯期就進行類型檢查過程的語言,譬如C++和Java等就是最常用的靜態類型語言。
8.5 基于棧的字節碼解釋執行引擎
第9章 類加載及執行子系統的案例與實戰
第四部分 程序編譯與代碼優化
第10章 前端編譯與優化
10.1 概述
在Java技術下談“編譯期”而沒有具體上下文語境的話,其實是一句很含糊的表述,因為它可能是指一個前端編譯器(叫“編譯器的前端”更準確一些)把*.java文件轉變成*.class文件的過程;也可能是指Java虛擬機的即時編譯器(常稱JIT編譯器,Just In Time Compiler)運行期把字節碼轉變成本地機器碼的過程;還可能是指使用靜態的提前編譯器(常稱AOT編譯器,Ahead Of Time Compiler)直接把程序編譯成與目標機器指令集相關的二進制代碼的過程。
下面筆者列舉了這3類編譯過程里一些比較有代表性的編譯器產品:
·前端編譯器:JDK的Javac、Eclipse JDT中的增量式編譯器(ECJ)。
·即時編譯器:HotSpot虛擬機的C1、C2編譯器,Graal編譯器。
·提前編譯器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
10.2 Javac編譯器
10.2.1 Javac的源碼與調試
從Javac代碼的總體結構來看,編譯過程大致可以分為1個準備過程和3個處理過程,它們分別如下所示。
1)準備過程:初始化插入式注解處理器。
2)解析與填充符號表過程,包括:
·詞法、語法分析。將源代碼的字符流轉變為標記集合,構造出抽象語法樹。
·填充符號表。產生符號地址和符號信息。
3)插入式注解處理器的注解處理過程:插入式注解處理器的執行階段,本章的實戰部分會設計一個插入式注解處理器來影響Javac的編譯行為。
4)分析與字節碼生成過程,包括:
·標注檢查。對語法的靜態信息進行檢查。
·數據流及控制流分析。對程序動態運行過程進行檢查。
·解語法糖。將簡化代碼編寫的語法糖還原為原有的形式。
·字節碼生成。將前面各個步驟所生成的信息轉化成字節碼。
上述3個處理過程里,執行插入式注解時又可能會產生新的符號,如果有新的符號產生,就必須轉回到之前的解析、填充符號表的過程中重新處理這些新符號,從總體來看,三者之間的關系與交互順序如圖10-4所示。
Javac編譯動作的入口是com.sun.tools.javac.main.JavaCompiler類,上述3個過程的代碼邏輯集中在這個類的compile()和compile2()方法里。
10.2.2 解析與填充符號表
解析過程由圖10-5中的parseFiles()方法(圖10-5中的過程1.1)來完成,解析過程包括了經典程序編譯原理中的詞法分析和語法分析兩個步驟。
1.詞法、語法分析
詞法分析是將源代碼的字符流轉變為標記(Token)集合的過程,單個字符是程序編寫時的最小元素,但標記才是編譯時的最小元素。關鍵字、變量名、字面量、運算符都可以作為標記。
語法分析是根據標記序列構造抽象語法樹的過程,抽象語法樹(Abstract Syntax Tree,AST)是一種用來描述程序代碼語法結構的樹形表示方式,抽象語法樹的每一個節點都代表著程序代碼中的一個語法結構(Syntax Construct),例如包、類型、修飾符、運算符、接口、返回值甚至連代碼注釋等都可以是一種特定的語法結構。
2.填充符號表
符號表(Symbol Table)是由一組符號地址和符號信息構成的數據結構,讀者可以把它類比想象成哈希表中鍵值對的存儲形式(實際上符號表不一定是哈希表實現,可以是有序符號表、樹狀符號表、棧結構符號表等各種形式)。符號表中所登記的信息在編譯的不同階段都要被用到。譬如在語義分析的過程中,符號表所登記的內容將用于語義檢查(如檢查一個名字的使用和原先的聲明是否一致)和產生中間代碼,在目標代碼生成階段,當對符號名進行地址分配時,符號表是地址分配的直接依據。
10.2.3 注解處理器
JDK 5之后,Java語言提供了對注解(Annotations)的支持,注解在設計上原本是與普通的Java代碼一樣,都只會在程序運行期間發揮作用的。但在JDK 6中又提出并通過了JSR-269提案,該提案設計了一組被稱為“插入式注解處理器”的標準API,可以提前至編譯期對代碼中的特定注解進行處理,從而影響到前端編譯器的工作過程。
可以把插入式注解處理器看作是一組編譯器的插件,當這些插件工作時,允許讀取、修改、添加抽象語法樹中的任意元素。如果這些插件在處理注解期間對語法樹進行過修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式注解處理器都沒有再對語法樹進行修改為止,每一次循環過程稱為一個輪次(Round),這也就對應著圖10-4的那個回環過程。
10.2.4 語義分析與字節碼生成
經過語法分析之后,編譯器獲得了程序代碼的抽象語法樹表示,抽象語法樹能夠表示一個結構正確的源程序,但無法保證源程序的語義是符合邏輯的。
語義分析的主要任務則是對結構上正確的源程序進行上下文相關性質的檢查,譬如進行類型檢查、控制流檢查、數據流檢查,等等。
Javac在編譯過程中,語義分析過程可分為標注檢查和數據及控制流分析兩個步驟,分別由圖10-5的attribute()和flow()方法(分別對應圖10-5中的過程3.1和過程3.2)完成。
1.標注檢查
標注檢查步驟要檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配,等等。
2.數據及控制流分析
數據流分析和控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上可以看作是一致的,但校驗范圍會有所區別,有一些校驗項只有在編譯期或運行期才能進行。
3.解語法糖
語法糖(Syntactic Sugar),也稱糖衣語法,指的是在計算機語言中添加的某種語法,這種語法對語言的編譯結果和功能并沒有實際影響,但是卻能更方便程序員使用該語言。通常來說使用語法糖能夠減少代碼量、增加程序的可讀性,從而減少程序代碼出錯的機會。
Java中最常見的語法糖包括了泛型、變長參數、自動裝箱拆箱,等等,Java虛擬機運行時并不直接支持這些語法,它們在編譯階段被還原回原始的基礎語法結構,這個過程就稱為解語法糖。
4.字節碼生成
字節碼生成是Javac編譯過程的最后一個階段,字節碼生成階段不僅僅是把前面各個步驟所生成的信息(語法樹、符號表)轉化成字節碼指令寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。
10.3 Java語法糖的味道
10.3.1 泛型
泛型的本質是參數化類型(Parameterized Type)或者參數化多態(Parametric Poly morphism)的應用,即可以將操作的數據類型指定為方法簽名中的一種特殊參數,這種參數類型能夠在類、接口和方法的創建中,分別構成泛型類、泛型接口和泛型方法。泛型讓程序員能夠針對泛化的數據類型編寫相同的算法,這極大地增強了編程語言的類型系統及抽象能力。
Java選擇的泛型實現方式叫作“類型擦除式泛型”(Type erasure generics),它的唯一優勢是在于實現這種泛型的影響范圍上:擦除式泛型的實現幾乎只需要在javac編譯器上作出改進即可,不需要改變字節碼,不需要改動java虛擬機,也保證了以前沒有使用泛型的庫可以直接運行在Java5.0之上。
泛型擦除帶來的問題:
泛型更多可參考:
泛型知識點
10.3.2 自動裝箱、拆箱與遍歷循環
裝箱、拆箱
1、什么是裝箱?什么是拆箱?
裝箱:基本類型轉變為包裝器類型的過程。
拆箱:包裝器類型轉變為基本類型的過程。
2、裝箱和拆箱的執行過程?
裝箱是通過調用包裝器類的 valueOf 方法實現的
拆箱是通過調用包裝器類的 xxxValue 方法實現的,xxx代表對應的基本數據類型。
如int裝箱的時候自動調用Integer的valueOf(int)方法;Integer拆箱的時候自動調用Integer的intValue方法。
3、常見問題?
整型的包裝類 valueOf 方法返回對象時,在常用的取值范圍內(-128<=x<128),會返回緩存對象。
浮點型的包裝類 valueOf 方法返回新的對象。
布爾型的包裝類 valueOf 方法 Boolean類的靜態常量 TRUE | FALSE。
裝箱操作會創建對象,頻繁的裝箱操作會消耗許多內存,影響性能,所以可以避免裝箱的時候應該盡量避免。
equals(Object o) 因為原equals方法中的參數類型是封裝類型,所傳入的參數類型(a)是原始數據類型,所以會自動對其裝箱,反之,會對其進行拆箱
當兩種不同類型用比較時,包裝器類的需要拆箱, 當同種類型用比較時,會自動拆箱或者裝箱
包含算術運算會觸發自動拆箱。
存在大量自動裝箱的過程,如果裝箱返回的包裝對象不是從緩存中獲取,會創建很多新的對象,比較消耗內存。
10.3.3 條件編譯
10.4 實戰:插入式注解處理器
第11章 后端編譯與優化
11.1 概述
如果我們把字節碼看作是程序語言的一種中間表示形式(Intermediate Representation,IR)的話,那編譯器無論在何時、在何種狀態下把Class文件轉換成與本地基礎設施(硬件指令集、操作系統)相關的二進制機器碼,它都可以視為整個編譯過程的后端。如果讀者閱讀過本書的第2版,可能會發現本章的標題已經從“運行期編譯與優化”悄然改成了“后端編譯與優化”,這是因為在2012年的Java世界里,雖然提前編譯(Ahead Of Time,AOT)早已有所應用,但相對而言,即時編譯(Just In Time,JIT)才是占絕對主流的編譯形式。不過,最近幾年編譯技術發展出現了一些微妙的變化,提前編譯不僅逐漸被主流JDK所支持,而且在Java編譯技術的前沿研究中又重新成了一個熱門的話題,所以再繼續只提“運行期”和“即時編譯”就顯得不夠全面了,在本章中它們兩者都是主角。
11.2 即時編譯器
11.3 提前編譯器
11.4 編譯器優化技術
11.5 實戰:深入理解Graal編譯器
第五部分 高效并發
第12章 Java內存模型與線程
12.2 硬件的效率與一致性
高速緩存(Cache):讀寫速度盡可能接近處理器運算速度的高速緩存作為內存與處理器之間的緩沖,將運算需要使用的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中。
緩存一致性(Cache Coherence):多路處理器系統,每個處理器有自己的高速緩存,而又共享同一主內存(Main Memory),這種系統被稱為共享內存多核系統(Shared Memory Multiprocessors System)。
12.3 Java內存模型 Java Memory Model(JMM)
12.3.1 主內存與工作內存
Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。
此處的變量(Variables)與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,因為后者是線程私有的,不會被共享,自然就不會存在競爭問題。
Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中,每條線程還有自己的工作內存,線程的工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關系如圖12-2所示。
這里所講的主內存、工作內存與第2章所講的Java內存區域中的Java堆、棧、方法區等并不是同一個層次的對內存的劃分,這兩者基本上是沒有任何關系的。如果兩者一定要勉強對應起來,那么從變量、主內存、工作內存的定義來看,主內存主要對應于Java堆中的對象實例數據部分,而工作內存則對應于虛擬機棧中的部分區域。從更基礎的層次上說,主內存直接對應于物理硬件的內存,而為了獲取更好的運行速度,虛擬機(或者是硬件、操作系統本身的優化措施)可能會讓工作內存優先存儲于寄存器和高速緩存中,因為程序運行時主要訪問的是工作內存。
12.3.2 內存間交互操作
關于主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存這一類的實現細節,Java內存模型中定義了以下8種操作來完成。Java虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對于double和long類型的變量來說,load、store、read和write操作在某些平臺上允許有例外,這個問題在12.3.4節會專門討論)。
·lock(鎖定):作用于主內存的變量,它把一個變量標識為一條線程獨占的狀態。
·unlock(解鎖):作用于主內存的變量,它把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
·read(讀取):作用于主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
·load(載入):作用于工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
·use(使用):作用于工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
·assign(賦值):作用于工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
·store(存儲):作用于工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨后的write操作使用。
·write(寫入):作用于主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
**如果要把一個變量從主內存拷貝到工作內存,那就要按順序執行read和load操作,如果要把變量從工作內存同步回主內存,就要按順序執行store和write操作。**注意,Java內存模型只要求上述兩個操作必須按順序執行,但不要求是連續執行。也就是說read與load之間、store與write之間是可插入其他指令的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是reada、read b、load b、load a。
除此之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:
·不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者工作內存發起回寫了但主內存不接受的情況出現。
·不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之后必須把該變化同步回主內存。
·不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
·一個新的變量只能在主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use、store操作之前,必須先執行assign和load操作。
·一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重復執行多次,多次執行lock后,只有執行相同次數的unlock操作,變量才會被解鎖。
·如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作以初始化變量的值。
·如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
·對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
這8種內存訪問操作以及上述規則限定,再加上稍后會介紹的專門針對volatile的一些特殊規定,就已經能準確地描述出Java程序中哪些內存訪問操作在并發下才是安全的。
12.3.3 對于volatile型變量的特殊規則
當一個變量被定義成volatile之后,它將具備兩項特性:
第一項是保證此變量對所有線程的可見性,可見性是指一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的;第二個語義是禁止指令重排序優化。
Volatile不保證原子性,因此在不符合以下兩條規則的運算場景中,仍要通過加鎖來包裝原子性:
· 運算結果并不依賴變量的當前值,或者能確保只有單一的線程修改變量的值。
· 變量不需要與其他的狀態變量共同參與不變約束
內存屏障(Memory Barrier/Memory Fence):重排序時不能把后面的指令重排序到內存屏障之前的位置。
12.3.4 針對long和double型變量的特殊規則
Java內存模型要求lock、unlock、read、load、assign、use、store、write這八種操作都具有原子性,但是對于64位的數據類型(long和double),在模型中特別定義了一條寬松的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機實現自行選擇是否要保證64位數據類型的load、store、read和write這四個操作的原子性,這就是所謂的“long和double的非原子性協定”(Non-Atomic Treatment of doubleand long Variables)。
12.3.5 原子性、可見性與有序性
介紹完Java內存模型的相關操作和規則后,我們再整體回顧一下這個模型的特征。Java內存模型是圍繞著在并發過程中如何處理原子性、可見性和有序性這三個特征來建立的,我們逐個來看一下哪些操作實現了這三個特性。
1.原子性(Atomicity)
一個或某幾個操作只能在一個線程執行完之后,另一個線程才能開始執行該操作,也就是說這些操作是不可分割的,線程不能在這些操作上交替執行。
由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write這六個,我們大致可以認為,基本數據類型的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性協定,讀者只要知道這件事情就可以了,無須太過在意這些幾乎不會發生的例外情況)。
如果應用場景需要一個更大范圍的原子性保證(經常會遇到),Java內存模型還提供了lock和unlock操作來滿足這種需求,盡管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作。這兩個字節碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
2.可見性(Visibility)
**可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。**Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此。普通變量與volatile變量的區別是,volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此我們可以說volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。
除了volatile之外,Java還有兩個關鍵字能實現可見性,它們是synchronized和final。
同步塊的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)”這條規則獲得的。而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,并且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化了一半”的對象),那么在其他線程中就能看見final字段的值。
3.有序性(Ordering)
Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,**volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行lock操作”**這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。
12.3.6 先行發生原則
Java語言中有一個**“先行發生”(Happens-Before)的原則。這個原則非常重要,它是判斷數據是否存在競爭,線程是否安全的非常有用的手段**。依賴這個原則,我們可以通過幾條簡單規則一攬子解決并發環境下兩個操作之間是否可能存在沖突的所有問題,而不需要陷入Java內存模型苦澀難懂的定義之中。
·**程序次序規則(Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操作先行發生于書寫在后面的操作。注意,這里說的是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構。
·***管程鎖定規則(Monitor Lock Rule)***:一個unlock操作先行發生于后面對同一個鎖的lock操作。這里必須強調的是“同一個鎖”,而“后面”是指時間上的先后。
·volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生于后面對這個變量的讀操作,這里的“后面”同樣是指時間上的先后。
·線程啟動原則(Threa Start Rule):Thread對象的start()方法先行發生于此線程的每一個動作
·線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生于對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
·線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷發生。
·對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。
·傳遞性(Transitivity):如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。
12.4 Java與線程
12.4.1 線程的實現
線程是進行處理器資源調度的最基本單位
進程是操作系統資源分配的基本單位,而線程是任務調度和執行的基本單位
12.4.2 Java線程調度
線程調度是指系統為線程分配處理器使用權的過程。主要有兩種調度方式:協同式線程調度(Cooperative Threads-Scheduling)和搶占式線程調度(Preemptive Threads-Scheduling)。
協同式:線程的執行時間由線程本身控制,線程把自己的工作執行完,主動通知系統切換到另一個線程上。實現簡單,切換操作對線程可知;但是如一個線程有問題,會一直阻塞。
搶占式:每個線程由系統來分配執行時間。Java就是搶占式的。Java線程優先級,兩個處于ready狀態的線程,優先級越高的越容易被系統選擇。
12.4.3 狀態轉換
Java中線程的狀態分為6種。
新建(NEW):創建后尚未啟動的線程對象,還沒有調用start()方法。
運行(RUNNABLE):Java線程中將**就緒(ready)和運行中(running)**兩種狀態籠統的稱為“運行”。處于這個狀態的線程可能正在執行或者正在等待操作系統為它分配執行時間。
線程對象創建后,其他線程(比如main線程)調用了該對象的start()方法。該狀態的線程位于可運行線程池中,等待被線程調度選中,獲取CPU的使用權,此時處于就緒狀態(ready)。就緒狀態的線程在獲得CPU時間片后變為運行中狀態(running)。
阻塞(BLOCKED):表示線程阻塞于鎖。
無限期等待(WAITING):處于這個狀態的線程不會被分配處理器執行時間,進入該狀態的線程需要等待其他線程做出一些特定動作(通知或中斷)才能喚醒。會讓線程陷入這種狀態的方法:
·沒有設置Timeout參數的Object::wait()方法
·沒有設置Timeout參數的Thread::join()方法
·LockSupport::park()方法
超時等待(TIMED_WAITING):該狀態也不會被分配處理器執行時間,但是不同于WAITING,它可以在指定的時間后自行返回。以下方法會讓線程進入此狀態:
·Thread::sleep()方法
·設置Timeout參數的Object::wait()方法
·設置Timeout參數的Thread::join()方法
·LockSupport::parkNanos()方法
·LockSupport::parkUtil()方法
終止(TERMINATED):表示該線程已經執行完畢。
12.5 Java與協程
第13章 線程安全與鎖優化
13.1 概述
13.2 線程安全
13.2.1 Java語言中的線程安全
不可變的對象一定是線程安全的
13.2.2 線程安全的實現方法
1.互斥同步(Mutual Exclusion & Synchronization)
互斥同步是最常見也是最主要的并發正確性保障手段。同步是在多個線程并發訪問共享數據時,保證共享數據在同一時刻只被一條(或是一些,當使用信號量的時候)線程使用。互斥是實現同步的一種手段,臨界區(critical section)、互斥量(Mutex)和信號量(Semaphor)都是常見的互斥實現方式。
最基本的互斥同步手段就是synchronized關鍵字,是一種塊結構(Block Structured)的同步語法。Synchronized關鍵字經過Javac編譯之后,會在同步塊的前后分別形成monitorenter和monitorexit兩個字節碼指令。這兩個字節碼指令都需要一個reference類型的參數來指明要鎖定和解鎖的對象。
Synchronized修飾的同步塊對同一條線程來說是可重入的,在持有鎖的線程執行完畢并釋放鎖之前,會無條件地阻塞后面其他線程的進入。
從執行成本的角度看,持有鎖是一個重量級(Heavy-weight)的操作。Java的線程是映射到操作系統的原生內核線程之上,如果要阻塞或喚醒一條線程,則需要操作系統來幫忙完成,不可避免需要用戶態到核心態的轉換。
自JDK5起,Java類庫中新提供了java.util.concurrent(J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一種全新的互斥同步手段。基于Lock接口,用戶能夠以非塊(Non-Block Structured)來實現互斥同步。
**重入鎖(ReentrantLock)**是Lock接口最常見的一種實現,它也是可重入的。比Synchronized多了三個高級功能:
·等待可中斷:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可選擇放棄等待,改為處理其他事情。
·公平鎖:多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖在鎖釋放時,任何一個等待鎖的線程都有機會獲得鎖。Synchronized中的鎖是非公平鎖,ReentrantLock在默認情況下也是非公平的,但可改為公平鎖。
·鎖綁定多個條件:一個ReentrantLock對象可以同時綁定多個Condition對象。在Synchronized中,鎖對象的wait()跟它的notify()或者notifyAll()方法配合可以實現一個隱含的條件,如要和多個條件關聯,需要額外添加一個鎖。而ReentrantLock只需多次調用newCondition()方法。
JDK6針對synchronized優化后,synchronized和reentrantlock的性能差不多。兩個都可滿足時優先使用synchronized:
·synchronized是在java語法層面的同步,足夠清晰簡單
·reentrantlock需要在finally塊中釋放鎖,否則一旦受同步保護的代碼塊拋出異常,則可能永遠不會釋放持有的鎖
2.非阻塞同步
互斥同步面臨的主要問題是進行線程阻塞和喚醒鎖帶來的性能開銷,因此這種同步也被稱為阻塞同步(Blocking Synchronization)。
基于沖突檢測的樂觀并發策略:不管有沒有沖突,先進行操作,如果沒有其他線程爭用共享數據,那操作就直接成功了;如果共享的數據的確被爭用,產生了沖突,那再進行其他的補償措施。被稱為非阻塞同步(Non-Blocking Synchronization),使用這種措施的代碼也常被稱為無鎖(Lock-Free)編程。
**Compare-and-Swap(CAS)比較并交換指令,需要有三個操作數,分別是內存位置(在Java中指變量的內存地址,用V表示),舊的預期值(用A表示)和準備設置的新值(用B表示)。CAS指令執行時,當且僅當V符合A時,處理器才會用B更新V的值,否則就不執行更新。但是,不管是否更新了V的值,都會返回V的舊值。**上述處理過程是一個原子操作,執行期間不會被其他線程中斷。
JDK5之后,Java類庫中才開始使用CAS操作,該操作由sun.misc.Unsafe類里面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供。
CAS操作的“ABA問題”:如果一個變量V初次讀取的時候是A值,在這段期間它的值曾被改成B,后又改回為A,那CAS操作就會誤認為它從來沒有改變過。J.U.C包為了解決這個問題,提供了一個帶有標記的原子引用類AtomicStampedReference,它可以通過控制變量值的版本來保證CAS的正確性。
3.無同步方案
可重入代碼(Reentrant Code)/純代碼(Pure Code):指可以在代碼執行的任何時刻中斷它,轉而去執行另一段代碼(包括遞歸調用它本身),而在控制權返回后,原來的程序不會出現任何錯誤,也不會對結果有所影響。在特指多線程的上下文語境里,可認為可重入代碼是線程安全代碼的一個真子集。
線程本地存儲(Thread Local Storage):如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見范圍控制在同一個線程之內,這樣,無需同步也能保證線程之間不出現數據爭用的問題。
在Java之中,如果一個變量要被多線程訪問,可以使用volatile關鍵字將它聲明為易變的。可用java.lang.ThreadLocal類實現線程本地存儲的功能。每一個線程的Thread對象中都有個ThreadLocalMap對象,這個對象存儲了一組以ThreadLocal.threadLocalHashCode為健,以本地線程變量為值的K-V值對,ThreadLocal對象就是當前線程的ThreadLocalMap的訪問入口,每一個ThreadLocal對象都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以在線程K-V值對中找回對應的本地線程變量。
13.3 鎖優化
從JDK5到JDK6HotSpot虛擬機開發團隊花費了大量的資源實現了各種鎖優化技術,如**適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖膨脹(Lock Coarsening)、輕量級鎖(LightEight Locking)、偏向鎖(Biased Locking)**等,這些技術都是胃了在線程之間更高效地共享數據及解決競爭問題,從而提供程序的執行效率。
13.3.1 自旋鎖與自適應鎖
在Java中鎖起到的作用是互斥同步,而互斥同步對性的影響最大的是阻塞,阻塞是通過掛起線程和恢復線程來實現的,這個操作是很昂貴的,消耗的服務器資源比較大。針對于此虛擬機開發團隊發明了自旋鎖,因為在共享數據的鎖定狀態只會持續很短一段時間,為了這段時間去掛起和恢復線程很不值得。所以在一個線程獲得鎖的同時另一個線程可以先“稍等一會兒”,但并不放棄處理器執行時間,為了讓線程等待,只須讓線程執行一個忙循環(自旋),這就是自旋鎖。
那么這個自旋鎖的自旋時間多久比較合適呢?
如自旋時間太短那就起不到自旋的作用了,太長又會占用過多的處理器資源。所以在JDK1.4.2中引入自旋鎖的時候,就提供了自旋次數為10默認值以及可以自行配置的參數-XXPreBlockSpin。
在JDK1.6中對自旋鎖進行了優化,引入了自適應自旋。它可以根據前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果上一次獲得了鎖,那么下一次就會被認為也會獲得鎖,進而自旋時間會加長;如果這個鎖很少被成功獲得,那么有可能就直接省略掉自旋鎖,避免處理器資源浪費
13.3.2 鎖消除
鎖消除是指:虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。
鎖消除是虛擬機自行判斷的,開發人員,在編寫代碼的時候并不用刻意的去規避這些問題,因為有些同步措施都是Java本身自己實現的。
例如如下代碼:
public String concatString(String str1,String str2,String str3){return str1 + str2 + str3; }因為String是被final修飾的類,所以每次變動都是會產生新的String對象來進行的,因此在編譯時會對String連接做自動優化。在JDK5之前會轉成StringBuffer對象進行append()操作,在JDK5以后會轉為StringBuilder對象進行append()操作。
這樣JDK5之前編譯器就會把代碼變成如下形式:
因為StringBuffer::append()方法就涉及到同步塊,鎖的就是sb對象。所以發現sb的動態作用域在concatString()方法內部,其他線程又無法訪問到它,因此這里的鎖就可以被安全的消除。
13.3.3 鎖粗化
我們在編寫代碼的時候,一般會遵循一個原則,就是盡量將同步塊的作用范圍限制的最小,只在共享數據的實際作用域中才進行同步,這樣同步操作數量會變得更少,即使存鎖競爭,等待鎖的線程也能盡可能快地拿到鎖。
但是實際情況,在一系列連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作時出現在循環體之中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
上面的代碼中concatString()方法就是頻繁的堆sb對象進行加鎖,虛擬機會探測到這種情況,將鎖的范圍擴展到整個系列操作的外部。就是在第一個append()操作之前到最后一個append()操作之后,只需要加一次鎖就可以了。
總結一下鎖粗化:虛擬機探測到有一系列零碎的操作都對同一個對象加鎖,將會加鎖的同步范圍擴展(粗化)到整個系列的操作外部。
13.3.4 輕量級鎖
輕量級鎖是相對于操作系統互斥量來實現的“重量級”鎖而言的,但是輕量級鎖并不用來替代重量級鎖的,它是指在沒有多線程競爭的前提下,減少重量級鎖使用操作系統互斥量產生的性能消耗。
要理解輕量級鎖,必須要對虛擬機對象的內存布局(尤其是對象頭部分)。
HotSpot虛擬機的對象頭分為兩部分:
第一部分用于存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡(Generational GC Age)等。這部分數據的長度咋32位和64位的虛擬機中分別會占用32個或64個比特,官方稱它為“Mark Word”,它是實現輕量級鎖和偏向鎖的關鍵。
第二部分是用于存儲指向方法區對象類型數據的指針,如果是數組對象,還會有一個額外的部分用戶存儲數組長度。
由于對象頭信息是與對象自身定義的數據無關的額外存儲成本,Mark Word被設計成一個非固定的動態數據結構,以便在極小的空間內存儲盡量多的信息。
Mark Word會根據對象的狀態復用自己的存儲空間。下面是對象的狀態對應的對象頭的存儲內容表
輕量級鎖工作過程
輕量級鎖加鎖
在代碼即將進入同步塊的時候,如果此同步對象沒有被鎖定(標志位“01”狀態),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝。
然后,虛擬機將使用CAS操作嘗試把對象的Mark Word 更新為執行Lock Record 的指針。
如果這個更新操作成功了,即代表線程擁有了這個對象的鎖,并且對象Mark Word的鎖標志位(Mark Word的最后兩個比特)將轉變為“00”,表示此對象處于輕量級鎖定狀態。
如果這個更新操作失敗了,那就意味著至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧楨,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說嘛這個鎖對象已經被其他線程搶占了。如果出現兩條意思的線程爭用同一個鎖的情況,輕量級鎖就會膨脹為重量級鎖。鎖標記的狀態值變為“10”,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程必須進入阻塞狀態。
上面說了輕量級鎖的加鎖過程了,它的解鎖過程也同樣是通過CAS操作來進行的。
如果對象的Mark Word 仍然指向線程的鎖記錄,那就用CAS操作把對象當前的Mark Wrod和線程中復制的Displaced Mark Word替換回來。
假如能夠替換,那整個同步過程就順利完成了;
如果替換失敗,則說明有其他線程嘗試過濾獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖總結:
輕量級鎖能提升性能的依據是:**“對于絕大部分的鎖,在整個同步周期內都是不存在競爭的”。
如果沒有競爭,輕量級鎖便通過CAS操作成功避免了使用互斥量的開銷;但如果確實存在鎖競爭,除了互斥量的本身開銷外,還額外發生了CAS操作的開銷。**因此在有競爭的情況下,輕量級鎖反而會比傳統的重量級鎖更慢。
13.3.5 偏向鎖
偏向鎖的意義:
偏向鎖的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。
如果說輕量級鎖是在無競爭的情況下使用CAS操作消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不去做了。
偏向鎖的定義:
這個鎖會偏向于第一個獲得它的線程,如果在接下來的執行過程中,該鎖一直沒有被其他線程獲取,則持有偏向鎖的線程將用于不需要在進行同步。
偏向鎖加鎖過程
當虛擬機啟動了偏向鎖,那么當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標志位設置為“01”、把偏向模式設置為“1”,表示進入偏向模式。
同時使用CAS操作把獲取到這個鎖的線程ID記錄在對象的Mark Word之中。
如果CAS操作成功,持有偏向鎖的線程以后每次進入這個鎖相關的同步塊是,虛擬機都可以不再進行任何同步操作。
偏向鎖解鎖過程
當出現另外一個線程區嘗試獲取這個鎖的情況,偏向模式就馬上宣告結束。根據鎖對象目前是否處于被鎖定的狀態決定是否撤銷偏向(偏向模式設置為“0”),撤銷后標志位恢復到未鎖定(標志位“01”)或輕量級鎖定(標志位為“00”)的狀態,后續的同步操作就按照上面介紹的輕量級鎖那樣去執行。
參考鏈接
總結
以上是生活随笔為你收集整理的《深入理解Java虚拟机:JVM高级特性与最佳实践》 (第3版)周志明 著的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 19 | 散列表(中):如何打造一个工业
- 下一篇: 搭建jenkins+gitlab+tom