JVM(Java Virtual Machine),即 Java 虛擬機,是操作系統上的一個程序,用於編譯、運行Java程序,使得 Java程序可以跨平台。關於 JVM 我們著重在內存區域、類的加載、對象的創建和內存管理四個部分。
內存區域分爲堆、方法區、程序計數器、虛擬機棧和本地方法棧。其中堆和方法區是線程共享的,程序計數器、虛擬機棧和本地方法棧則是線程私有的。堆是一大塊內存,幾乎所有的對象實例都在這裡分配;方法區在JVM 規範中是堆的一部分,不同的 JVM 可以有不同的實現,就 HotSpot VM 而言,在 JDK1.8 之前使用永久代實現方法區,在JDK1.8及之後使用直接內存上的元空間實現;程序計數器存放下一條指令的地址;虛擬機棧的每一個棧幀保存著方法的局部變量表、操作數棧、動態連接和方法返回地址;本地方法棧類似虛擬機棧,不過是調用 native 方法,在 HotSpot VM中虛擬機棧和本地方法棧合而爲一。
類的生命週期分爲加載、連接、初始化、使用和卸載四個過程,其中:
- 加載:將 .class 文件以二進制字節流方式讀入虛擬機,並在方法區給靜態變量分配空間,在堆中生成 Class 對象作爲訪問靜態變量的入口。
- 連接:分爲驗證、準備和解析三個階段,驗證階段驗證字節碼文件的合規性,準備階段將類變量賦予初始零值,解析階段將常量池中的符號引用轉爲直接引用。
- 初始化:執行
<clinit>
方法。
對象的創建過程依次是類加載檢查、分配內存、初始化零值、設置對象頭和執行<init>
方法。
- 類加載檢查:檢查類是否加載完畢。
- 分配內存:對象實例一般會分配在堆中,根據採用的垃圾回收器會選擇指針碰撞或空閒類表方式分配。JDK1.7之後啟用逃逸分析可以將未逃逸的對象分配到棧中。
- 初始化零值:給對象的成員變量設置初始的零值。
- 設置對象頭:對象頭包括所屬的類、對象哈希碼、GC分代年齡和鎖信息。
- 執行
<init>
方法。
Java 是自動內存管理的,內存的分配和回收由JVM進行控制。通常使用分代內存管理,分新生代、老年代和永久代(JDK1.8及之後無永久代),新對象優先在新生代 Eden 區分配,大對象直接分配到老年代,持續存活的對象也會被轉移到老年代。
內存回收涉及垃圾的判定、垃圾收集算法和垃圾收集器。
判定垃圾通常有引用計數法和可達性分析算法。
垃圾收集(GC)通常分部分收集(Partial GC)和整堆收集(Full GC),部分收集分新生代收集(Minor GC / Young GC)、老年代收集(Major GC / Old GC)和混合收集(Mixed GC)
垃圾收集算法通常有標記-清除算法、複製算法、標記-整理算法和分代收集算法。
垃圾收集器中 ParNew 最早採用並行收集,CMS 最早採用並發收集。JDK1.8 中默認使用 Parallel Scavenge(新生代) + Parallel Old(老年代) 收集器,JDK9之後默認使用 G1 收集器。
內存區域
內存區域可以分爲:
- 線程共享:堆、方法區(邏輯上屬於堆)、直接內存(非運行時內存)
- 線程私有:程序計數器、虛擬機棧和本地方法棧
堆(Heap)
堆是進程中最大的一塊內存,用於存放對象實例,幾乎所有的對象實例都在堆中分配。
JDK 1.7 開始默認開啟了逃逸分析,如果方法中的對象引用沒有逃逸出去(對象沒有 return 或被外面使用),那麼對象可以直接在棧上分配內存,而不是堆。
一般來說,Java 中的堆根據對象實例的存活時長分爲新生代、老年代和永久代,以便於更好地回收和分配內存。
- Young Generation(新生代)
- Eden(伊甸園):一般情況下,新創建的對象實例默認分配到此區域。
- Survivor(幸存者):包含 from 和 to 兩個區,Survivor區的對象實例來自 Eden和另一個 Survivor區,默認情況下對象至多在新生代中來回複製15次(可通過參數
-XX:MaxTenuringThreshold
設置)後才會進入 Old Generation。
- Old Generation(老年代)
- Permanent Generation(永久代):在 JDK1.8之前,HotSpot 虛擬機中使用永久代實現JVM規範中的方法區;JDK1.7 時字符串常量池從方法區(HotSpot永久代)移到了堆中;在 JDK1.8時永久代被移除,採用元空間(Metaspace)在直接內存中實現了JVM規範中的方法區。
方法區(Method Area)
根據 JVM 規範,方法區邏輯上是堆的一部分。這是一塊存放已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據的區域,類似於 UNIX 中的進程對內存的劃分的文本區/代碼段(text segmemt/code segment),通常只讀。
在 JDK1.8之前,HotSpot 虛擬機中使用永久代實現JVM規範中的方法區;在 JDK1.8時永久代被移除,採用元空間(Metaspace)在直接內存中實現了JVM規範中的方法區。
UNIX 中的進程將內存劃分成三個部分:
- text segment,文本區,例如代碼
- data segment,數據區,例如變量
- stack segment,棧區域
直接內存(Direct Memory)
直接內存不是JVM 規範中定義的內存區域,不受Java堆的限制。
- JDK1.4 加入的 NIO,引入一種基於通道(Channel)和緩存區(Buffer)的 I/O 方式,可以直接使用 native 函數分配堆外內存。
- JDK1.8時永久代被移除,採用元空間(Metaspace)在直接內存中實現了JVM規範中的方法區。
程序計數器(Program Counter)
程序計數器(Program Counter,PC)用於存放下一條指令所在單元的地址(注意:執行native方法時該地址爲 undefined)。字節碼解釋器通過改變 PC 來依次讀取指令,實現代碼的流程控制。在多線程的環境下,可以可以當前線程執行的位置,這樣當線程被切換回來的時候就可以從上次的位置繼續。通俗的譬喻就是遊戲中存檔。
因此,PC 必須是線程私有的,否則線程切換後無法恢復到正確的位置。
棧(Stack)
棧有兩種:
虛擬機棧:每個 Java 方法在執行的同時會創建一個棧幀用於存儲方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。從方法調用直到執行完成的過程,對應著一個棧幀在虛擬機棧入棧和出棧的過程。
所謂操作數棧就是一個供常量或變量寫入寫出的棧,出棧的方向可以是局部變量表或者直接返回給調用者。
所謂動態連接就是在運行時將方法的符號引用轉爲直接引用的過程。本地方法棧:類似虛擬機棧,只不過是爲 native 方法服務。在 HotSpot 虛擬機中本地方法棧和虛擬機棧合而爲一。
爲了保證線程中的方法執行所需的數據(包括局部變量等)不被其他線程訪問,所以虛擬機棧和本地方法棧是線程私有的。
類的加載
類的生命週期
(1)加載
- 將 .class 文件轉爲二進制字節流裝載進類加載器(ClassLoader);
- 將其中代表的靜態存儲結構轉換爲方法區中的運行時數據結構;
- 在內存中生成一個 Class 對象,作爲方法區中數據的訪問入口。
(2)連接
連接階段分爲驗證、準備和解析三個步驟。該階段和加載階段不是順序執行的,存在交叉,即加載未完成時連接就開始了。
- 驗證:進行文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。
- 準備:爲類變量(靜態變量)分配內存並設置初始值(默認的零值,除非有 final 字段才會設置爲最終的初始值)。
- 解析:將常量池內的符號引用替換爲直接引用。
符號引用:描述目標的一組符號(字面量),包括類、接口、字段、類方法、接口方法、方法類型、方法句柄和調用限定符。
直接引用:直接指向目標的指針、相對偏移量或一個間接定位到目標的句柄。
(3)初始化
初始化階段是執行初始化方法 <clinit>
方法的過程。初始化的時機有:
- 直接 new 一個類會觸發類的初始化。
- 對類進行反射調用會自動初始化。
- 父類會在子類初始化時自動初始化。
- 主類(包含main方法)會隨虛擬機啟動自動初始化。
- Java 8 中的默認接口方法會在其實現類初始化時自動初始化。
- …
(4)卸載
卸載就是該類的 Class 對象被 GC(垃圾回收)。卸載類需要滿足:
- 該類的所有實例對象都被 GC。
- 該類 Class 對象沒有其他任何地方被引用。
- 該類的類加載器的實例被 GC(自帶的類加載器在JVM生命週期中不會被卸載,因此由之加載的類也不會被卸載,除非是自定義的類加載器,由之加載的類才有可能被卸載)。
類加載器(ClassLoader)
自帶的類加載器有以下三種,分別加載不同路徑下的類:
- BootstrapClassLoader(啟動類加載器):最頂層的加載類,負責加載
%JAVA_HOME%/lib
目錄下的類或者或被-Xbootclasspath
參數指定的路徑中的所有類。 - ExtensionClassLoader(擴展類加載器):繼承自
java.lang.ClassLoader
,負責加載%JRE_HOME%/lib/ext
目錄下的類,或系統變量java.ext.dirs
所指定的路徑下的類。 - AppClassLoader(應用程序類加載器):繼承自
java.lang.ClassLoader
,記載當前應用 classpath 下的類。
JVM 採用雙親委派模型(parent-delegation model)協調類加載器加載類。所謂雙親委派模型就是當一個類要加載時會委託其父類加載器進行加載,如果父類加載器已經加載過會直接返回;如果一直沒有父類加載器處理,會最終委託到頂層類加載器 BootstrapClassLoader;當所有父系類加載器都無法處理時才會自行加載。
爲何要這樣麻煩,不一開始就自己加載類呢?避免類被重複加載,避免 Java 核心類被修改。注意:同一個類文件被不同的類加載器加載之後會生成不同的類。
對象的創建
對象的創建過程
在 HotSpot 虛擬機中,一個 Java 對象的創建經歷以下幾個步驟:
- 類加載檢查。請看「類的加載」部分。
- 分配內存。在堆中分配一塊內存空間供對象使用。有兩種分配方式:
- 指針碰撞:當內存規整時,使用和未使用的內存中間會有一個分界指針,只要指針朝未使用內存方法移動即可分配內存。
- 空閒列表:當內存不規整時,虛擬機會維護一個內存的可用列表,分配時會確定一塊大小合適的內存並更新可用列表。
- 初始化零值。保證對象實例字段可以不用賦予初始值就可以使用。
- 設置對象頭。對象的元數據,包括所屬的類、對象哈希碼、GC分代年齡以及鎖信息。
- 執行
<init>
方法。代碼視角的對象初始化。
比較<clinit>
和 <init>
方法:
<clinit>
方法:class init 類構造器,按順序執行父類靜態變量初始化、父類靜態語句塊、子類靜態變量初始化、子類靜態語句塊。<init>
方法:實例構造器,按順序執行父類變量初始化、父類語句塊、父類構造函數、子類變量初始化、子類語句塊、子類構造函數。
對象的內存佈局
在 Hotspot 虛擬機中,Java對象在內存中由以下部分組成:
- 對象頭(object header):由 mark word 和 class pointer 組成。mark word 存儲了對象的 hashcode、GC信息和鎖信息;class pointer 存儲了指向類對象的指針。32 位的 JVM 上對象頭佔 8 個字節,mark word 和 class pointer 各佔一半。64 位的 JVM 默認開啟了壓縮指針選項(-XX+UseCompressedOops)後上對象頭佔用 12 個字節,mark word 佔用 8 個字節,class pointer 佔用 4 個字節。
- 實例數據(instance data):對象的有效信息。
- 對齊填充(padding):Hotspot 虛擬機的自動內存管理系統要求對象起始地址必須是 8 字節的整數倍,當實例數據不足時需要進行對齊填充。
對象的訪問
Java 程序通過棧上的 reference 類型數據來操作訪問堆上的實例對象。具體來說有以下兩種實現方式:
句柄(handle):操作對象的中間媒介,通過句柄獲取具體對象實例的指針,進而從實例池中獲取想要訪問的對象。
直接指針(direct pointer):reference 存儲對象地址,直接指向對象實例。HotSpot 虛擬機採用這種方式訪問對象,相對與句柄的方式,節約了一次定位對象的時間。
內存管理
Java 是自動進行內存管理的,不同於 C/C++ 需要手動進行內存分配和回收。
內存分配
- 對象優先在新生代 Eden 區分配。
- 大對象直接進入老年代。
避免大對象分配擔保機制帶來的複製而降低效率。
分配擔保機制:當進行 Minor GC 時,如果 Survivor 區空間不夠用,會直接將新生代的對象提前轉移到老年代中。 - 長期存活的對象將進入老年代。
每個對象都有一個對象年齡計數器,超齡的對象一般會進入老年代。 - 根據採用的垃圾回收器會選擇指針碰撞或空閒類表方式分配。
垃圾判定
引用計數法
給對象添加一個引用計數器,當有一個地方引用了該對象,計數器就加 1,引用失效則減 1。若計數爲 0 則判定該對象是垃圾。這種算法有個大缺陷就是無法解決循環引用問題:當對象 A 和對象B除了互相引用外別無引用時,A 和B的計數都不爲0不會判定爲垃圾,但顯然 A、B是垃圾啊。可達性分析算法
以根集合 GC Roots 爲起點向下搜索形成引用鏈,當一個對象到 GC Roots沒有引用鏈時判定其爲垃圾。可作爲 GC Roots 的對象有:
- 虛擬機棧(棧幀中的本地變量表)中引用的對象
- 本地方法棧(Native 方法)中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
廢棄常量的判定
沒有對象引用的常量就是廢棄常量。廢棄類的判定
廢棄類需要滿足:
- 該類的所有實例對象都被 GC。
- 該類 Class 對象沒有其他任何地方被引用。
- 該類的類加載器的實例被 GC(自帶的類加載器在JVM生命週期中不會被卸載,因此由之加載的類也不會被卸載,除非是自定義的類加載器,由之加載的類才有可能被卸載)。
垃圾收集算法
垃圾收集(Garbage Collection, GC),指對判定爲垃圾的內存區域進行收集的過程。針對 HotSpot VM,GC 可分爲:
- 部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只對新生代進行垃圾收集,Eden 區和 From 區的數據會進入 To 區,然後 From 和To會互換角色。
- 老年代收集(Major GC / Old GC):只對老年代進行垃圾收集。需要注意的是 Major GC 在有的語境中也用於指代整堆收集。
- 混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。
- 整堆收集 (Full GC):收集整個 Java 堆和方法區。
常見的垃圾收集算法有:
標記-清除算法
分兩個階段進行,第一個階段標記要回收的垃圾;第二個階段統一清除垃圾。缺點是造成內存碎片化。複製算法
將內存分爲大小相同的兩塊,每次只使用其中的一塊。當一塊使用完後,將存活的對象複製到另一塊去,再把使用完的空間一次清理掉,避免了標記-清除算法的內存碎片化問題。標記-整理算法(Mark-Compact)
分兩個階段進行,第一個階段標記要回收的垃圾;第二個階段統一將存活對象向一端移動,然後清理掉端邊界之外的內存。分代收集算法
便於根據各個年代的特點選擇合適的垃圾收集算法:
- 新生代(From Survivor 和 To Survivor):複製算法
- 老年代:標記-清除 或 標記-整理算法
垃圾收集器
新生代收集器
- Serial
單線程,採用複製算法,串行,收集垃圾時會暫停其他所有的工作線程。 - ParNew (Parallel New)
Serial 的多線程版本,採用複製算法,並行—多條垃圾收集線程並行工作,仍然會暫停其他所有的工作線程(Stop the World)。 - Parallel Scavenge
直譯過來就是「並行清理垃圾」,也採用複製算法,這是 JDK1.8 默認的收集器。該算法關注吞吐量,即CPU中運行用戶代碼的時間與總消耗時間的比值。
- Serial
老年代收集器
- Serial Old
Serial 的老年代版本,採用標記-整理算法。CMS 的後備方案。 - Parallel Old
Parallel Scavenge 的老年代版本,採用標記-整理算法。 - CMS (Concurrent Mark Sweep)
直譯過來就是「並發標記清除」,HotSpot 虛擬機第一款真正意義上的並發收集器,JDK1.5時發佈,採用標記-清除算法,並發—用戶線程與垃圾收集線程同時執行(但不一定是並行的,可能會交替執行),用戶程序在繼續運行。該算法關注用戶線程的停頓時間,綽號并发低停顿收集器(Concurrent Low Pause Collector)。缺點有三:- 對 ****CPU 資源敏感;
- 無法處理浮動垃圾;
- 使用的「標記-清除」算法會導致內存碎片化。
- Serial Old
G1 (Garbage-First)
面向服務端應用的垃圾收集器,JDK1.7時發佈。特點是:並發與並行、分代收集、空間整合(標記-整理)和可預測的停頓。G1 收集器在後台維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的區域。
ZGC (The Z Garbage Collector)
JDK 11時發佈,適用於大內存低延遲服務的內存管理和回收。
使用以下命令可以查看JDK使用的垃圾收集器:
java -XX:+PrintCommandLineFlags |
不同 JDK 版本默認使用的垃圾收集器:
1.8,Parallel Scavenge(新生代) + Parallel Old(老年代)
注意:PS 是「Parallel Scavenge」的簡寫,ParOld 是「Parallel Old」的簡寫。
$ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
$ java -XX:+PrintCommandLineFlags
-XX:InitialHeapSize=266390080 -XX:MaxHeapSize=4262241280 -XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers -XX:+UseCompressedOops
-XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
$ java -XX:+PrintGCDetails -version
Heap
PSYoungGen total 76288K, used 2621K [0x000000076b500000, 0x0000000770a00000, 0x00000007c0000000)
eden space 65536K, 4% used [0x000000076b500000,0x000000076b78f748,0x000000076f500000)
from space 10752K, 0% used [0x000000076ff80000,0x000000076ff80000,0x0000000770a00000)
to space 10752K, 0% used [0x000000076f500000,0x000000076f500000,0x000000076ff80000)
ParOldGen total 175104K, used 0K [0x00000006c1e00000, 0x00000006cc900000, 0x000000076b500000)
object space 175104K, 0% used [0x00000006c1e00000,0x00000006c1e00000,0x00000006cc900000)
Metaspace used 2291K, capacity 4480K, committed 4480K, reserved 1056768K
class space used 254K, capacity 384K, committed 384K, reserved 1048576K9、11、15,G1
$ java -version
java version "15.0.1" 2020-10-20
Java(TM) SE Runtime Environment (build 15.0.1+9-18)
Java HotSpot(TM) 64-Bit Server VM (build 15.0.1+9-18, mixed mode, sharing)
$ java -XX:+PrintCommandLineFlags
-XX:ConcGCThreads=1 -XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
$ java -Xlog:gc
[0.006s][info][gc] Using G1