一、JVM 基本認識
1、虛擬機 與 JVM
(1)虛擬機(Virtual Machine),可以理解為一台虛擬的計算機,其是一款軟件,用來執行一系列虛擬的計算機指令。
可以分為:系統(硬件)虛擬機、程序(軟件)虛擬機。
(2)系統(硬件)虛擬機
系統虛擬機是一個可以運行完整操作系統的一個平台,其模擬了物理計算機的硬件。即相當於在物理計算機上 模擬出 一台計算機(操作系統)。比如:VMware。
(3)程序(軟件)虛擬機
程序虛擬機是一個可以運行某個計算機程序的一個平台,其模擬了物理計算機某些硬件功能(比如:處理器、堆棧、寄存器等)、具備相應的指令系統(字節碼指令)。即相當於在操作系統上 模擬出 一個軟件運行平台。比如:JVM。
(4)JVM
JVM(Java Virtual Machine),是一台執行字節碼指令並運行程序的虛擬機,其字節碼並不一定由 Java 語言編譯而成,任何一個語言通過 編譯器 生成 具備 JVM 規范的字節碼文件時,均可以被 JVM 解釋並執行(即 JVM 是一個跨語言的平台)。
特點:自動內存管理、自動垃圾回收。字節碼一次編譯,到處運行。
官方文檔地址(自行選擇合適的版本):https://docs.oracle.com/javase/specs/index.html
(5)學習 JVM 的目的
一般進行 Java 開發時,不需要關注太底層的東西,專注於業務邏輯層面。這是因為 JVM 已經對底層技術、硬件、操作系統這些方面做了相應的處理(JVM 已經幫我們完成了 硬件平台的兼容以及內存資源管理 等工作)。
但由於 JVM 跨平台的特性,其會犧牲一些硬件相關的性能以達到 統一虛擬平台 的效果。當 程序使用人數 增大、業務邏輯復雜時,程序的性能、穩定性、可靠性會受到影響,往往提升硬件的性能不能成比例的提高程序的性能。
所以有必要了解 JVM 一些底層運行原理,寫出適合 JVM 運行、優化 的代碼,從而提高程序性能(當然也可以快速定位、解決內存溢出等問題)。
2、JVM 整體結構
(1)Java 運行過程
如下圖,Java 源碼經過 Java 編譯器,將源碼編譯為字節碼,再使用 JVM 解析運行字節碼。
(2)JVM 運行過程(圖片來源於網絡)
如下圖,字節碼文件被類加載器導入,加載、驗證字節碼文件的正確性並分配初始化內存。
通過執行引擎解釋執行字節碼文件,並與 運行時數據區 進行數據交互(當然,其中邏輯實現沒那么簡單,此處略過)。
(3)JVM 架構分類
虛擬機 內部處理指令流可以分為兩種:基於棧的指令集架構、基於寄存器的指令集架構。
基於棧架構特點:
不需要硬件的支持,可移植性好(跨平台方便)、設計與實現簡單、指令集小但指令會變多(可能會影響效率)。
一般 JVM 都是基於棧的,比如:HotSpot 虛擬機。
基於寄存器架構特點:
依賴於硬件,可移植性差、但指令少(使用更少的指令執行更多的操作,執行效率稍高)。
比如: Android 的 Dalvik 虛擬機
【舉例:(執行如下操作時)】 int a = 2; int b = 3; a += b; 【基於棧的指令集架構:(輸出字節碼如下)】 0: iconst_2 常量 2 1: istore_1 常量 2 入棧 2: iconst_3 常量 3 3: istore_2 常量 3 入棧 4: iload_1 5: iload_2 6: iadd 2 + 3 相加 7: istore_1 將相加結果 5 入棧 【基於寄存器的指令集架構:(沒有實際操做、大致指令如下)】 mov a, 2 將 2 賦給 a add a, 3 將 a 加 3 后再將結果 賦給 a 可以看到 使用寄存器時,指令數量明顯少於棧。
注:
直接打開 class 字節碼文件會亂碼,可以通過 javap -c 字節碼文件 來反編譯,得到可讀的字節碼文件。(也可以使用 IDEA 插件 bytecode viewer 或者 jclasslib bytecode viewer 去查看字節碼,此處不做過多介紹)
javap -c XXX.class 對代碼進行反編譯。
javap -v XXX.class 對代碼進行反編譯,並顯示額外信息(比如:常量池等信息)
(4)簡單了解一下 JVM 生命周期
JVM 生命周期 即 JVM 從創建、使用、銷毀的整個過程。
JVM 啟動
通過引導類加載器(bootstrap class loader)創建一個初始類(initial class)來啟動 JVM。這個初始類由虛擬機的具體實現指定(JVM 種類很多)。
JVM 使用(執行)
JVM 用於運行程序,每個程序啟動運行都會存在一個 JVM 進程與之對應。
程序結束后,JVM 也就結束。
JVM 銷毀
可以分為:正常銷毀、異常銷毀。
【正常銷毀:】 程序正常結束。 【異常銷毀:】 程序執行中出現異常,且異常未被處理導致 JVM 終止。 由於操作系統異常,導致 JVM 進程結束。 調用 System.exit() 方法,參數為非 0 時 JVM 退出。
3、簡單了解幾個虛擬機
(1)Sun Classic VM
Sun 公司開發的第一款商用虛擬機(在JDK 1.4 時被淘汰)。
內部只提供解釋器(解釋器、即時編譯器不能配合工作,二選一使用)。
注:
解釋器:根據字節碼文件,一行一行讀取解析並執行(立即執行,響應時間短,效率較低)。
即時編譯器:把整個字節碼文件編譯成 可執行的機器碼(需要響應時間、造成卡頓),機器碼能直接在平台運行,將一些重復出現的代碼(熱點代碼)緩存起來提高執行效率。
(2)Sun Exact VM
為了解決 Classic VM 的問題,Sun 公司提供了此虛擬機(被 HotSpot 替代)。
解釋器、編譯器混合工作模式。且具備熱點探測功能。
使用 Exact Memory Management(准確式內存管理),可以知道內存中某位置的數據的類型。
(3)HotSpot 虛擬機
一家小公司開發,被 Sun 公司收購。
HotSpot 虛擬機采用 解釋器、即時編譯器 並存的架構,是 JVM 高性能代表作之一。
HotSpot 即熱點(熱點探測功能),通過 計數器 找到最具有編譯價值的代碼,觸發即時編譯(方法被頻繁調用)或者棧上替換(方法中循環次數多)。
通過解釋器、即時編譯器協同工作,在響應時間與執行性能中取得平衡。
如下圖:
Java 8 依舊采用 HotSpot 作為 JVM。
(4)BEA JRockit
專注於服務端應用,代碼由 即時編譯器 編譯執行,不包含解釋器(即不關心程序啟動速度)。
是 JVM 高性能代表作之一,執行速度最快(大量行業數據測試后得出)。
BEA 被 Sun 公司收購,Sun 公司被 Oracle 收購。Oracle 以 HotSpot 為基礎,融合了 JRockit 的優秀特性(垃圾回收器、MissionControl)。
(5) IBM J9
市場定位與 HotSpot 接近。廣泛應用於 IBM 各種 Java 產品。也是高性能 JVM 代表作之一。
二、類加載子系統(Class Loader SubSystem)
1、類加載子系統作用、流程
(1)作用:
類加載子系統負責從 文件系統 或者 網絡 中加載 class 文件(class 文件頭部有特殊標識)。
將 class 文件加載到系統內存中,並對數據進行 校驗、解析 以及 初始化操作,最終形成可以被虛擬機使用的 Java 類型。
注:
類加載器只負責 class 文件的加載,由執行引擎決定是否能夠運行。
加載的類信息 存放於名為 方法區 的內存空間中,方法區還會存放 運行時常量池等信息。
(2)流程
類的生命周期指的是 類從被加載到內存開始、到從內存中移除結束。
過程如下圖所示:
而類加載過程,需要關心的就是前幾步(加載 到 初始化)。需要注意的是,解析操作可能會在 初始化之后執行(比如:Java 的運行期綁定)。
流程圖如下:
2、加載(Loading)
(1)目的:
加載 class 二進制字節流文件到內存中。
(2)步驟:
Step1:使用類加載器 通過一個類的全限定名 去獲取此類的 二進制字節流(獲取方式開放)。
Step2:將字節流 對應的靜態存儲結構 轉為 方法區 運行時的數據結構。
Step3:在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區中這個類的各種數據訪問的外部入口(HotSpot 中,該 Class 對象存放於方法區中)。
(3)獲取 class 二進制字節流方式(簡單列舉幾個)
Type1:從本地系統直接加載。
Type2:從網絡中加載(比如: Applet)。
Type3:從 zip 壓縮包中讀取(jar、war 包等)。
Type4:運行時計算生成(動態代理技術)。
Type5:從數據庫中讀取(比較少見)。
Type6:從其他文件中讀取(JSP 文件生成對應的 Class 類)。
3、連接(Linking)-- 驗證(Verification)
(1)目的:
確保 class 文件的二進制字節流中包含的信息符合當前虛擬機的要求,保證數據的正確性 而不會影響虛擬機自身的安全(比如:通過某種方式修改了 class 文件,若不去驗證字節流是否符合格式,則可能導致虛擬機載入錯誤字節流而崩潰)。
注:
驗證階段非常重要但不一定必要,如果代碼是經過反復使用、驗證過后並沒有出現問題,可以考慮將驗證關閉(-Xverify:none),從而縮短類加載時間。
(2)驗證方式:
具體細節自行查閱相關文檔、書籍,此處來源於 “深入理解 JAVA 虛擬機 第二版 周志明 著”。
Step1:文件格式驗證
驗證字節流是否符合 class 文件格式規范,並能夠被當前虛擬機處理。
比如:class 文件要以 CAFEBABE 開頭
Step2:元數據驗證
對類的 元數據 信息進行語義校驗,驗證當前數據是否符合 Java 語言規范。
比如:類是否存在父類、是否繼承了 final 修飾的類等。
Step3:字節碼驗證
對類的方法體進行語義校驗。
比如:方法體中類型的轉換是否有效。
Step4:符號引用驗證。
對常量池中各符號引用進行匹配性校驗(一般發生在 解析階段)。
比如:符號引用中通過字符串描述的全限定名能否找到對應的類。
注:
文件格式驗證 是 基於 二進制字節流 進行的,通過驗證后,會將數據存入 內存的方法區。后續三種驗證均是對方法區數據進行操作。
4、連接(Linking)-- 准備(Preparation)
(1)目的:
為類變量分配內存並設置類變量的默認初始值為 零值(比如:int 為 0, boolean 為 false)。
注:
此處的類變量是 static 修飾的變量,但不包含 final static 修飾的變量。
final static 修飾的即為常量,在編譯時就已經設置好了,在 准備階段(preparation)會賦值。
static 修飾的變量在 准備階段賦零值,在 初始化階段(Initialization)執行真正賦值操作。
非 static 修飾的變量為 實例變量,隨着對象分配到 堆中,並非存在於方法區中。
【舉例:】 public static int value = 123; 此時 value 屬於類變量,准備階段 value = 0,初始化階段 value = 123. public static final int value = 123; 此時 value 屬於常量,准備階段 value = 123.
(2)零值
數據類型 零值 int 0 long 0L short (short)0 byte (byte)0 char '\u0000' float 0.0f double 0.0d boolean false reference null
5、連接(Linking)-- 解析(Resolution)
(1)目的:
將常量池中的 符號引用 轉換為 直接引用。
注:
符號引用(Symbolic References):指用一組符號(字面量)來描述所引用的目標,但引用目標並不一定加載到了內存中。字面量形式明確定義在 Java 虛擬機規范的 Class 文件格式中。
直接引用(Direct References):指直接指向目標的指針 或 能間接定位到目標的句柄,引用目標一定存在於內存中。
(2)解析動作
解析動作主要針對 類或接口、字段、類方法、接口方法、方法類型、方法句柄、調用點限定符 這 7 類符號引用(具體解析過程此處略過,自行查閱文檔、書籍)。
6、初始化(Initialization)
(1)目的:
在准備階段是為 類變量賦零值,而初始化階段就是真正執行類中相關代碼操作,是執行類構造器 <clinit>() 方法的過程。
(2)值得注意的點
<clinit>() 方法是 編譯器自動收集類中所有 類變量的賦值操作、靜態語句塊(static{}) 等語句合並而成的。且其順序是由 語句在源文件中出現的順序而定的。
<clinit>() 方法不同於 類的構造函數(實例構造器 <init>()),若當前類具有父類,則當前類執行 <clinit>() 之前 父類的 <clinit>() 方法就已經執行完畢了。對於父接口,當前類執行時不會執行父類接口的 <clinit>() 方法,只有使用到類變量時才會去實例化(接口中不能定義 靜態語句塊,可以存在類變量,即常量)。
若一個類中沒有類變量以及靜態語句塊,則不會生成 <clinit>()。
在多線程下,虛擬機會保證一個類的 <clinit>() 方法被加鎖、同步,即一個線程執行 <clinit>() 后,其余執行到此處的線程均會阻塞,直至當前線程執行完畢。其他線程不會再次執行 <clinit>()。
(3)初始化的方式
當類被主動使用時,會導致類的初始化。而被動使用時,不會導致類的初始化。
主動使用:
使用 new 關鍵字實例化對象時。
讀取、設置某個類、接口的靜態變量時(非 final static 修飾的常量)。
調用某個類的靜態方法時。
初始化一個類的子類時(先觸發父類初始化)。
JVM 啟動時被標明為啟動類的類(main 方法所在的類)。
反射調用某類時。
java.lang.invoke.MethodHandle 實例(JDK 7 之后提供的動態語言支持)的解析結果REF_static、REF_putStatic、REF_invokeStatic 句柄對應的類沒有初始化,則初始化。
被動使用:
除了上面 7 種情況之外的情況都是被動使用,不會導致類的初始化。
7、類加載器
(1)目的:
前面加載過程的第一步:使用類加載器 通過一個類的全限定名 去獲取此類的 二進制字節流。這個類加載器可以由用戶自定義實現(在 JVM 外部去實現),使程序可以自定義以何種方式去獲取需要的類(當然一般使用 JVM 提供的即可)。
注:
每一個類加載器,都有一個獨立的類名稱空間,對於任何一個類,該類與加載它的類加載器共同確定它在 JVM 中的唯一性(即判斷兩個類是否相同,需要保證兩個類由同一個 JVM 且同一個類加載器加載時才有可能相等)。
(2)類加載器分類
從 JVM 角度,可以分為兩種:
引導類加載器(Bootstrap ClassLoader)、其他所有類的類加載器。
注:
引導類加載器,由 C/C++ 語言編寫,是 JVM 的一部分,其實例對象無法被獲取。
其他所有類的類加載器,由 Java 語言開發,獨立於 JVM,且派生於 java.lang.ClassLoader。
從 開發人員 角度,可以細分為四種:
引導類加載器(Bootstrap ClassLoader)、擴展類加載器(Extension ClassLoader)、應用程序類加載器(Application ClassLoader)、用戶自定義類加載器(User-Defined ClassLoader)
注:
引導類加載器,用來加載 Java 的核心類庫(JAVA_HOME/jre/lib 或者 sun.boot.class.path 下的內容),且出於安全考慮,其只加載包名為 java、javax、sun 等開頭的類。
擴展類加載器,由 Java 語言編寫,派生於 ClassLoader(sun.misc.Launcher$ExtClassLoader),其父類為引導類加載器(但是代碼中獲取不到),用來加載 Java 的擴展類(加載系統屬性 java.ext.dirs 或者 jre/lib/ext 下的內容)。
應用程序類加載器,由 Java 語言編寫,派生於 ClassLoader(sun.misc.Launcher$AppClassLoader),其父類為擴展類加載器。是程序中默認的類加載器(一般類均由其完成加載),負責加載環境變量(classpath) 或者系統屬性 java.class.path 指定的路徑下的內容。
用戶自定義類加載器,自定義類的加載方式,可以用於拓展加載源、修改類的加載方式。
(3)ClassLoader
ClassLoader 是一個抽象類,除了引導類加載器,其余所有類加載器均由其派生而來。
常見獲取 ClassLoader 的方式:
【獲取 ClassLoader 的方式:】 【方式一:獲取當前類的 ClassLoader(調用當前類的 getClassLoader() 方法))】 ClassLoader classLoader = String.class.getClassLoader(); 【方式二:獲取當前系統的 ClassLoader(即 sun.misc.Launcher$AppClassLoader)】 ClassLoader classLoader = ClassLoader.getSystemClassLoader(); 【方式三:獲取當前線程上下文的 ClassLoader】 ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); 【舉例:】 public class JVMDemo { public static void main(String[] args) { // 自定義類(JVMDemo),使用默認類加載器加載(系統類加載器) ClassLoader jvmDemoClassLoader = JVMDemo.class.getClassLoader(); // 獲取自定義類 的類加載器 System.out.println(jvmDemoClassLoader); // 為默認類加載器 sun.misc.Launcher$AppClassLoader@18b4aac2 // 獲取自定義類 的父類加載器(拓展類加載器) System.out.println(jvmDemoClassLoader.getParent()); // 為拓展類加載器 sun.misc.Launcher$ExtClassLoader@4554617c // 獲取拓展 類加載器 的父類加載器(引導類加載器) System.out.println(jvmDemoClassLoader.getParent().getParent()); // 為引導類加載器,獲取不到,為 null // 核心類(String),使用引導類加載器加載 ClassLoader stringClassLoader = String.class.getClassLoader(); // 獲取核心類 的類加載器 System.out.println(stringClassLoader); // 為引導類加載器,獲取不到,為 null // 獲取系統類加載器 System.out.println(jvmDemoClassLoader.getSystemClassLoader()); // 為 sun.misc.Launcher$AppClassLoader@18b4aac2 System.out.println(stringClassLoader.getSystemClassLoader()); // 為 sun.misc.Launcher$AppClassLoader@18b4aac2 // 獲取當前線程的 類加載器 System.out.println(Thread.currentThread().getContextClassLoader()); // 為 sun.misc.Launcher$AppClassLoader@18b4aac2 } }
常見 ClassLoader 方法:
【常見 ClassLoader 方法:】 ClassLoader getParent(); 返回該類加載器的 父類加載器 Class<?> loadClass(String name); 加載名為 name 的類,返回 Class 對象。 Class<?> findClass(String name); 查找名為 name 的類,返回 Class 對象。 Class<?> findLoadedClass(String name); 查找名為 name 被加載過的類,返回 Class 對象。 void resolveClass(Class<?> c); 連接指定的 Java 類。 【自定義類加載器步驟:(一般格式)】 Step1:繼承 java.lang.ClassLoader,實現自定義類加載器。 Step2:重寫 fingClass() 邏輯。 【自定義類加載器步驟:(簡單版)】 Step1:繼承 java.net.URLClassLoader,該類已編寫 findClass() 方法以及獲取字節碼流的方式。
8、雙親委派機制(Parents Delegation Model)
(1)目的:
使類加載器間具備層級結構。
防止類被重復加載。
保護程序安全,防止核心 API 被篡改。
(2)雙親委派機制原理
JVM 按需加載 class 文件,即使用到該類時,才會去加載其 class 文件到內存生成 class 對象。且采用雙親委派機制去加載。
雙親委派機制原理:
除了頂層的 引導類加載器外,其余的類加載器應該存在其 父類加載器。
如果一個類加載器 收到了 類加載 請求,其並不會立即去加載,而是把這個請求委托給 父類加載器 進行加載,若父類加載器 仍有 父類加載器,則繼續向上委托,直至到達 引導類加載器。
如果父類加載器可以完成 類加載 請求,則成功返回,否則子類加載器才會去嘗試加載。
如下為 ClassLoader 中的雙親委派實現:
先檢查類是否被加載過,若該類沒有被加載過,則調用父類加載器的 loadClass() 方法去加載。
若父類加載器不存在,則默認使用 引導類加載器為 父類加載器。如果父類加載失敗后,則拋出異常,並執行子類的 findClass() 方法進行加載。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
(3)沙箱安全機制
沙箱即限制一個程序的運行環境。
而 JVM 中沙箱安全機制 指將 Java代碼限定在 JVM 運行范圍內,限制代碼對本地資源的訪問,從而對代碼隔離,防止核心 API 被修改。
如下圖所示:
自定義一個 java.lang.String.class,由於 JVM 的機制會使用 引導類加載器 對其加載,而 JVM 會先去加載 /jre/lib/rt.jar 下的 java.lang.String.class,但是其並沒有 main 方法,所以報錯。
再如下圖所示:
自定義一個 java.lang.StringTe.class,同樣 JVM 會使用 引導類加載器 加載,但是並沒有加載到此類,所以會報錯(SecurityException)。
三、運行時數據區
1、了解下 JVM 內存布局
(1)內存布局
內存是非常重要的系統資源,為了保證 JVM 高效穩定的運行,JVM 內存布局規定了 Java 在運行過程中內存 申請、分配、管理 的策略。不同 JVM 對於內存的划分方式以及管理機制存在部分差異。
(2)基本 JVM 內存布局
JVM 在執行 Java 程序過程中,會將其管理的內存 分為 若干個不同的數據區域,每個區域均有各自的用途(堆、方法區等)。有些區域隨着 JVM 啟動、退出 而創建、銷毀,有的區域隨着 用戶線程的開始、結束 而創建、銷毀。
如下圖所示:
多個線程共享 堆、以及方法區(永久代、元空間)。
每個線程獨有 程序計數器、虛擬機棧、本地方法棧。
2、運行時數據區各內存空間區別
(1)按線程是否共享划分
堆、方法區(元空間 或 永久代)線程共享。
虛擬機棧、本地方法棧、 程序計數器 線程私有。
(2)按拋出異常划分
堆、方法區 會發生 GC 以及 拋出 OOM(OutOfMemoryError)。
虛擬機棧、本地方法棧 會拋出 OOM 或者 StackOverflowError,不會發生 GC。
程序計數器 不會發生 GC 以及拋出 OOM 異常。
四、運行時數據區 -- 程序計數器(Program Counter Register)
1、什么是程序計數器?
程序計數器是一塊很小的內存空間,用於存放 下一條字節碼指令 所在地址(即 即將執行的指令,由執行引擎讀取下一條指令)。
是線程私有的(每個線程創建時均會創建)。
是 JVM 中唯一一個不會出現 OOM(OutOfMemory,內存溢出) 的區域。也不會存在 GC(Garbage Collection,垃圾回收)。
注:
字節碼解釋器工作時,通過改變程序計數器的值來獲取下一條需要執行的字節碼指令(比如:分支、循環、跳轉、異常處理、線程恢復等操作)。
2、每個線程獨有程序計數器。
JVM 多線程通過線程輪流切換並分配處理器執行時間的方式實現的。在任意一個時間點,一個處理器只會處理一個線程的指令,而為了使線程切換后能回到正確的位置(執行正確的指令),每個線程均會有個獨立的程序計數器,各個線程間互不影響,通過各自的程序計數器執行正確的指令。
注:
若線程執行的是 Java 方法,程序計數器保存的是 即將執行的字節碼指令的地址。
若線程執行的是 Native 方法,程序計數器保存的是 Undefined。
五、運行時數據區 -- 虛擬機棧(Virtual Machine Stacks)
1、棧與堆?虛擬機棧?
(1)棧與堆?
可以理解 棧是運行時的單位、堆時存儲時的單位。
棧解決的是程序運行問題,即 程序怎么執行、處理數據。
堆解決的是數據存儲問題,即 數據怎么存儲、放在何處。
(2)什么是虛擬機棧?
每個線程創建時均會創建一個虛擬機棧(線程私有),其內部保存着一個一個棧幀(Stack Frame),用於存儲局部變量、操作結果,參與方法調用和返回。
注:
一個棧幀對應一個方法調用。即 一個棧幀從入棧 到 出棧 的過程,即為 一個方法從調用到完成的過程。
棧幀是一個內存區塊,內部維護着方法執行過程中的各種數據信息(局部變量表、操作數棧、動態鏈接、方法出口、以及附加信息)。
2、虛擬機棧的常見異常?基本運行原理?基本內部結構?
(1)虛擬機棧常見異常?
JVM 規范中允許 虛擬機棧 的大小 是動態的 或者 是固定不變的。
如果采用固定大小的 Java 虛擬機棧,那每一個線程的虛擬機棧大小可以在 線程創建時指定,若線程請求分配的棧容量(深度)超過了虛擬機棧的最大容量(深度),將會導致 JVM 拋出 StackOverflowError 異常。
如果采用動態擴展容量的 虛擬機棧,若在嘗試拓展的過程中無法申請到足夠的內存(或者創建線程時沒有足夠的內存去創建對應的虛擬機棧),將會導致 JVM 拋出 OutOfMemoryError 異常。
如下圖:
main 方法內存遞歸調用 main 方法,形成一個死循環(導致棧空間耗盡),最終導致 StackOverflowError。可以通過 -Xss 參數去設置 棧的大小。
(2)基本運行原理
虛擬機棧的操作只有兩個:每個方法執行觸發入棧操作,方法執行結束觸發出棧操作。即棧幀的入棧、出棧操作(遵循 先進后出 FILO、后進先出 原則 LIFO)。
一個線程運行時,在一個時間點上只會存在一個活動的棧幀(方法),即當前棧頂棧幀 是有效的,如果當前方法中調用了其他方法,則會創建新的棧幀並入棧成為 新的棧頂棧幀。當新的棧幀執行結束后,會將執行結果返回給上一個棧幀,丟棄當前棧幀並將上一個棧幀重新作為新的棧頂棧幀。
(3)棧幀的內部結構分類
局部變量表(Local Variables)或者 局部變量數組。
操作數棧(Operand Stack)或者 表達式棧。
動態鏈接(Dynamic Linking)或者 指向運行時常量池(Constant pool)的方法引用。
方法返回地址(Return Address)。
附加信息。
3、棧幀結構 -- 局部變量表(Local Variables)
(1)什么是局部變量表?
一組變量值存儲空間(可以理解為 數組),用於存儲方法參數以及定義在方法體內部的局部變量。其包括的數據類型為:基本數據類型(int、long、double 等)對象引用(reference)以及 方法返回地址(returnAddress)。
局部變量表建立在 虛擬機棧 上,屬於線程獨有數據,即不會出現線程安全問題。
被局部變量表直接或間接引用的對象不會被 GC(垃圾回收)。
局部變量表所需容量大小是在編譯期就確定下來的,方法運行期間不會改變其大小(即編譯期就可以知道該方法需要幾個局部變量 以及 其所占用的 slot 空間)。
注:
32 位以內長度類型只會占用一個 局部變量表空間(slot),比如:short、byte、boolean 等。
64 位類型會占用兩個 局部變量表空間,比如:long、double。
(2)舉例
如下圖:
靜態方法沒有 this 變量,若為 構造器方法或者 實例方法,會存在一個 this 變量。
此處 main() 方法中存在 4 個變量,其中 b 為 double 型,占用兩個 slot 空間,args 為引用類型,占用 1 個空間,也即總空間為 5。
start 表示變量開始生效的 字節碼指令 行數。
如下圖:
slot 可以被重用,當某個局部變量作用域結束后,其后續定義的新的局部變量可以占用 過期的 slot,可用於節省資源(但可能會影響 垃圾回收)。
4、棧幀結構 -- 操作數棧(Operand Stack)
(1)什么是操作數棧?
每一個棧幀中包含一個 后進先出的 操作數棧,在方法執行過程中,根據字節碼指令,往棧中寫入數據(入棧, push)或者提取數據(出棧,pop)。操作數棧主要用於保存計算過程中的中間結果、並作為計算過程中 變量 臨時的存儲空間。
如果被調用的方法(棧幀)存在返回值,則將其返回值壓入操作數棧中,並更新程序計數器中下一條需要執行的字節碼指令。
注:
JVM 基於棧的架構,其中棧 指的即為 操作數棧。基於棧時 為了完成一項操作,會存在更多的入棧、出棧操作,而操作數存儲在內存中,頻繁入棧、出棧操作(內存寫、讀操作)必然會影響執行速度。HotSpot 設計者提出 棧頂緩存技術(TOS,Top-of-Stack Cashing) 解決這個問題,將棧頂元素全部緩存到 物理 CPU 的寄存器中,以降低內存的 讀、寫次數,提高執行引擎的執行效率。
5、棧幀結構 -- 方法返回地址(return address)
(1)什么是方法返回地址?
方法結束的方式有兩種:正常執行完成(Normal Method Invocation Completion)、出現異常退出(Abort Method Invocation Completion)。無論哪種方式退出,均需要回到方法被調用的位置。
方法正常退出時,即當前棧幀出棧,並恢復上一次棧幀(可能涉及操作:恢復上一次棧幀的局部變量表以及操作數棧、存在返回值時會將返回值壓入操作數棧、調整 程序計數器 使其指向下一條指令)。
方法異常退出時,通過異常表(保存返回地址)查找是否有匹配的異常處理器,若沒有對應的異常處理器,則會導致方法退出(棧幀一般不會保存返回地址,且一般不會產生返回值給 上一個棧幀)。
注:
方法正常退出時,使用哪個返回指令由 方法返回值 的實際數據類型決定。
ireturn 返回值為 boolean、byte、char、short、int 時的返回指令 lreturn 返回值為 long 時的返回指令 freturn 返回值為 float 時的返回指令 dreturn 返回值為 double 時的返回指令 areturn 返回值為 引用類型 時的返回指令 return 返回值為 void、構造器方法等 無返回值時的返回指令
6、棧幀結構 -- 動態鏈接(Dynamic Linking)
Java 源文件編譯成字節碼文件時,所有的 變量 以及 方法 都作為符號引用保存在 class 文件的運行時常量池中。每一個棧幀內部都包含一個指向 運行時常量池中 該棧幀對應的 方法的引用(即符號引用),使用符號引用的目的 是為了使 當前方法的代碼 支持 動態鏈接(詳見下面的方法調用)。
7、方法調用(方法重載、方法重寫)
Java 常用方法操作 有方法重載、方法重寫。那編譯器如何去識別 真實調用的方法呢?
(1)先熟悉基本概念
靜態鏈接:
類加載字節碼文件時,若被調用的目標方法 在編譯期可知且運行期不變時,此時將調用方法的符號引用轉為直接引用的過程叫 靜態鏈接(發生在 類加載的 連接 的 解析階段)。
注:
類加載的連接的解析(Resolution)階段,會將常量池中一部分符號引用 轉為 直接引用。而解析的前提就是:方法在程序運行之前(編譯時就已確定)能夠確定下來,不會發生改變。
動態鏈接:
若被調用的目標方法在 編譯期無法被確定下來,即需要在程序運行時將 符號引用轉為 直接引用 的過程 叫做動態鏈接。
方法綁定:
綁定是一個字段、方法或者 類 的符號引用 被替換到 直接引用的過程,僅發生一次。可以分為早期綁定、晚期綁定。早期綁定是 方法編譯期可知且運行期不變時進行綁定,也即通過靜態鏈接的方式綁定。晚期綁定是 方法運行期根據實際類型綁定,即通過動態鏈接的方式綁定。
非虛方法:
非虛方法指的是 編譯期就確定且 運行期不可變的方法。在類加載階段就會將 符號引用 解析為 直接引用。
常見類型為:靜態方法、私有方法、final 方法、實例構造器、父類方法(即不可被重寫的方法)。
虛方法:
非虛方法之外的方法(即需要運行期確定的方法)。
(2)方法調用相關虛擬機指令:
【普通指令:】 invokestatic 調用靜態方法 invokespecial 調用實例構造器 <init> 方法、私有方法、父類方法 invokevirtual 調用虛方法(final 方法除外) invokeinterface 調用接口方法(運行期確定實現此接口的對象) 注: 這四條指令固化在虛擬機內部,方法調用執行不可被人為干預。 invokestatic、invokespecial 指令調用的方法為 非虛方法, invokevirtual(除 final 方法)、invokeinterface 指令調用的方法為 虛方法。 final 修飾的方法也由 invokevirtual 指令調用,但其為 非虛方法。 【動態調用指令:】 invokedynamic 動態解析出需要調用的方法並執行 注: 支持人為干預。 Java 為了支持 動態類型語言,在 JDK 7 中增加了 invokedynamic 指令, 但 JDK 7 中並沒有直接提供該指令,需要借助 ASM 等底層字節碼工具實現。 直至 JDK 8 中 Lambda 表達式出現才有直接生成 invokedynamic 指令的方式。 【動態類型語言、靜態類型語言:】 二者區別在於 類型檢查 發生的時期。 動態類型語言 對類型檢查 是在運行期,即變量沒有類型信息、變量值才有類型信息(比如: JavaScript)。 靜態類型語言 對類型檢查 是在編譯期,即變量有類型信息(比如:Java)。 比如: Java: String hello = "hello"; hello = 10; // 編譯報錯 JS: var hello = "hello"; hello = 10; // 可以運行成功
(3)方法重載
接下來再看看 方法重載 與 方法重寫。涉及到多個方法(多態),虛擬機如何去確定真實調用的是哪個方法呢(分派)?
如下代碼(方法重載),最終輸出結果是什么?
【代碼:】 public class JVMDemo { static abstract class Human {} static class Man extends Human {} static class Woman extends Human {} public void sayHello(Human human) { System.out.println("Human"); } public void sayHello(Man man) { System.out.println("Man"); } public void sayHello(Woman woman) { System.out.println("Woman"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); JVMDemo jvmDemo = new JVMDemo(); jvmDemo.sayHello(man); jvmDemo.sayHello(woman); } } 【輸出結果:】 Human Human
對於上述代碼中:
Human man = new Man();
Human 為父類,Man 為子類,將 Human 稱為變量 man 的靜態類型(Static Type)或者 外觀類型(Apparent Type),將 Man 稱為變量的實際類型(Actual Type)。
靜態類型 在編譯期是可知的,而實際類型只有在 運行期才可以確定。
在編譯期根據 靜態類型 去定位方法執行 的(分派)動作稱為 靜態分派,而靜態分派的典型代表就是 方法重載。靜態分派發生在編譯階段,其動作不需要 JVM 去執行。
在運行期根據 實際類型 去定位方法執行 的(分派)動作稱為 動態分派,而動態分派的典型代表就是方法重寫。動態分派發生在運行階段,其動作需要 JVM 去執行。
上述代碼中,man 與 woman 的靜態類型實際都是 Human,方法重載時,編譯器根據靜態類型去決定重載方法,也即在編譯期就能確定到是 sayHello(Human human) 最終執行,故輸出結果均為 Human。
(4)方法重寫
如下代碼(方法重寫),最終輸出結果是什么?
【代碼:】 public class JVMDemo2 { static class Human { public void sayHello() { System.out.println("Human"); } } static class Man extends Human { public void sayHello() { System.out.println("Man"); } } static class Woman extends Human { public void sayHello() { System.out.println("Woman"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); } } 【輸出結果:】 Man Woman
方法重寫的過程:
Step1:找到操作數棧頂的 第一個元素 所指向對象的實際類型,記為 C。
Step2:如果在類型 C 中查找到與常量池中 符號引用 所代表的描述符、簡單名稱都相符的方法,則進行訪問權限校驗,如果通過校驗則返回該方法的直接引用,結束查找。若校驗失敗,則拋出異常 java.lang.IllegalAccessError。
Step3:若在類型 C 中未查找到相關方法,則根據繼承關系從下到上 以及對 C 的父類執行 Step2 的查找與驗證過程。
Step4:如果始終沒有合適的方法,則拋出異常 java.lang.AbstractMethodError。
注:
invokevirtual 指令執行第一步就是在運行期 確定 參數的實際類型,所以盡管兩次執行的是 Human 的 sayHello() 方法,但最終執行的是 man 與 woman 的 sayHello() 方法。
8、虛方法表
平常開發中,方法重寫是非常常見的,也即 動態分派 會頻繁操作,如果每次動態分配都去 執行一遍 查找邏輯(在類的方法元數據中查找合適的目標方法),那么將有可能影響執行效率。為了提高性能, JVM 在類的方法區中 建立了一個 虛方法表(virtual method table,vtable)實現,使用虛方法表的索引來替代元數據 以提高性能。類似的,在 invokeinterface 指令執行時會用到接口方法表(interface method table,itable)。
虛方法表會在 類加載的鏈接階段被創建並初始化,准備階段 給類變量 賦初始值后,JVM 會把該類的方法表也進行初始化。
虛方法表中存放着每個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那么子類的虛方法表里面的地址 與 父類方法的地址一致(均指向父類的 方法入口)。如果子類重寫了某方法,則子類的虛方法表的地址將 為指向子類的 方法入口。
如下圖(圖片來源於網絡):
Father、Son 均沒有重寫 Object 的方法,所以虛方法表中均指向 Object。
而 Son 重寫了 Father 的兩個方法,所以 Son 的兩個方法均指向自己,沒有指向其父類 Father。
六、運行時數據區 -- 本地方法棧(Native Method Stack)
1、本地方法接口(Native Method Interface)
(1)什么是本地方法?
本地方法(Native Method)是非 Java 語言編寫的方法,比如 C、C++ 語言編寫,而 Java 可以通過調用 本地方法接口 去使用 本地方法。
(2)為什么使用本地方法?
可以與 Java 外面的環境進行交互,簡化邏輯。比如涉及一些底層操作時,使用 Java 較難實現,但使用 C 或者 C++ 可以很方便的實現,而本地方法采用 C 或者 C++ 很好的實現了功能,我們只需要調用這個本地方法接口 就可以很方便的使用其功能(不需要關心其實現邏輯)。
與操作系統交互。JVM 與底層操作系統還是有區別的,若想實現與 操作系統的交互,還是需要通過本地方法實現的。
2、本地方法棧(Native Method Stack)
本地方法棧與 Java 虛擬機棧類似。但是 Java 虛擬機棧用來管理 Java 方法的調用。而 本地方法棧用來管理 本地方法的調用。
本地方法棧是線程私有的,在異常方面與 Java 虛擬機棧相同。
當線程調用 Java 方法時,JVM 會創建一個棧幀 並壓入 虛擬機棧,但是調用 native 方法時,JVM 直接動態連接並指向 native 方法。
本地方法棧可以由 JVM 自由實現,比如:在 HotSpot 中,本地方法棧 與 虛擬機棧 合二為一。
七、運行時數據區 -- 堆(Heap)
1、什么是堆?
Java 堆是 JVM 所管理內存中最大的一塊區域,被所有線程共享(存在線程私有的分配緩沖區 Thread Local Allocation Buffer,TLAB)。
在 JVM 啟動時創建(空間大小確定,可通過 -Xms、-Xmx調節)。
其目的是用於 存放 實例對象(對象實例、數組等)。
注:
-Xms 用於設置堆的初始內存,等價於 -XX:InitialHeapSize。默認初始內存 = 物理內存 / 64。
-Xmx 用於設置堆的最大內存,等價於 -XX:MaxHeapSize。默認最大內存 = 物理內存 / 4。
如果 堆 中內存大小超過 Xmx 所指定的最大內存時,將會拋出 OutOfMemoryError 異常。
一般將 -Xms 與 -Xmx 兩個參數設置成相同的值,防止 GC 垃圾回收完 堆區 對象后重新計算堆區的大小,從而提高性能。
2、堆內存細分
現代垃圾收集器 大部分 基於分代收集理論,可以將堆空間 細分為如下幾個區:
(1)JDK7 及 以前對 堆內存 划分:
新生區(年輕代、新生代、Young Generation Space)
養老區(老年代、老年區、Old Generation Space)
永久區(Permanent Space)
(2)JDK8 及 之后對 堆內存 划分:
新生區(年輕代、新生代、Young Generation Space)
養老區(老年代、老年區、Old Generation Space)
元空間(Meta Space)
一般講堆空間,講的是 新生代 與 老年代。永久區、元空間 屬於方法區的實現。
JVM 規范中指出 方法區 邏輯上屬於堆,但並沒有規定方法區具體實現方式,由 JVM 自行實現。
使用 -XX:+PrintGCDetails 可以打印 GC 詳細信息(可以看到堆相關信息)。
使用 JDK 自帶的 jvisualvm 工具,可以分析 JVM 運行時的 JVM 參數、堆棧、CPU 等信息。
3、年輕代、老年代
無論年輕代 還是 老年代 都是用來存儲 對象的,其不同的是 存儲對象的 生命周期。
(1)什么是 年輕代、老年代?
堆中 對象按照生命周期 可以划分為兩類:
生命周期較短的對象,這類對象的創建、消亡很快。
生命周期較長的對象,某些極端情況下 可能與 JVM 生命周期保持一致。
年輕代一般用於存儲生命周期較短的對象,老年代一般用於存儲生命周期較長的對象。
默認 年輕代 與 老年代 的比例為 1:2,即 年輕代 占堆空間 的 1/3。可以通過 -XX:NewRatio 來設置。比如: -XX:NewRatio=4,此時年輕代 : 老年代 = 1/4,即 年輕代占堆空間 1/5(但一般不會修改)。
(2)年輕代內部結構
年輕代內部又可以分為 Eden Space、Survivor0 Space、Survivor1 Space。其中 Survivor 又可以稱為 from、to。from、to 大小相同,用於保存經過垃圾回收 幸存下來的 對象,且總有一個為空。
在 HotSpot 中,默認 Eden : Survivor0 : Survivor1 = 8:1:1(但是經過自適應后,顯示出來的是 6:1:1,可以通過 -XX:SurvivorRatio=8 設置)。
幾乎所有的對象 均創建在 Eden(80%,大於 Eden 內存的對象可直接進入 老年代),可以通過 -Xmn 設置新生代最大內存。
(3)為什么給堆分代?不分代就不能正常工作嗎?
分代的唯一理由是 優化 GC 性能。
堆中存儲對象的生命周期不同,且大部分生命周期非常短暫,如果不加管理(不分代)全部放在一起,則每次 GC 都需要全局掃描一次才可以知道哪些是需要被 回收的對象,每次都會掃描到很多不需要被回收的對象(生命周期長的對象),這樣會極大影響效率。
而使用分代后(年輕代、老年代),將生命周期短的對象保存在年輕代,GC 多回收此處的對象,這樣可以減少掃描數據,從而提高效率。
4、Minor GC、Major GC、Full GC
JVM 進行 GC 時,根據不同的內存空間 會有不同的 GC 算法與之對應。
(1)HotSpot 根據回收區域划分:
部分收集(Partial GC):
Minor GC 針對 年輕代 進行 GC
Major GC 針對 老年代 進行 GC
Mixed GC 針對 整個新生代以及部分老年代 進行 GC
整堆收集(Full GC):
Full GC 針對 整個堆以及方法區 進行 GC
(2)Minor GC 觸發時機:
年輕代空間(Eden)不足時,會觸發 Minor GC。而 Java 對象生命周期一般較短,所以 Minor GC 非常頻繁且回收速度也較快。Minor GC 執行會引發 STW(Stop The World),會暫停其他線程直至 GC 結束(可能造成 程序卡頓)。
(3)Major GC 觸發時機:
老年代空間不足時,會觸發 Major GC。Major GC 速度一般比 Minor GC 慢 10 倍以上(STW 時間更長),若經過一次 Major GC 后內存仍不足,則會拋出 OOM 異常。
(4)Full GC 觸發時機:
調用 System.gc() 時,系統會建議執行 Full GC,但是不一定執行(應盡量避免此操作)。
大對象(占用大量連續內存空間的 java 對象)直接進入老年代,但老年代沒有連續的空間存儲,此時會觸發 Full GC。
通過 Minor GC 進入老年代的平均大小 大於老年代的 可用內存,會觸發 Full GC。
方法區空間不足時,會觸發 Full GC。
5、內存分配策略(對象提升 Promotion 規則)、對象分配過程
(1)內存分配策略
對象在內存中 存在不同的生命周期,而對於不同生命周期的對象在內存中分配規則如下:
Rule1:
對象優先分配到年輕代。
對象優先分配到年輕代中的 Eden 區。
Rule2:
大對象直接存入老年代。
大對象(占用大量連續空間的對象)直接分配到老年代(應盡量避免出現過多的大對象)。
Rule3:
長期存活對象存入老年代
在年輕代經過多次 GC 后仍存活的對象(對象年齡足夠),將其移入老年代。
Rule4:
對象動態年齡判斷
如果 Survivor 區中相同年齡的所有對象大小的總和大於 Survivor 空間的一半,則這些對象直接進入老年代(無需考慮閾值)。
Rule5:
空間分配擔保
在發生 Minor GC 之前,虛擬機會檢查 老年代最大可用的連續空間是否大於新生代所有對象的總空間。如果大於,則此次 Minor GC 是安全的,如果小於,則會繼續檢查 老年代最大可用的連續空間是否大於 歷次晉升到老年代的對象的平均大小。若大於,則嘗試進行一次 Minor GC,若小於,則會進行一次 Full GC。
注:
對象存入 Eden,經過 Minor GC 后,存活的對象存入 Survivor 並將其對象年齡設為 1,每經過一次 Minor GC,對象年齡加 1,當增加到一定年齡(默認 15,不同 JVM 不同),該對象將移入 老年代。可以通過 -XX:MaxTenuringThreshold 設置年齡閾值。
(2)對象分配過程
給對象分配內存 是一件非常嚴謹、復雜的任務,需要考慮內存分配、回收、回收是否產生內存碎片等一系列問題,涉及到 內存分配算法、內存回收算法。
對象分配簡單流程如下:
Step1:
對象申請內存,先經過 Eden 區,若 Eden 內存足夠,則給對象分配內存。若 Eden 已滿 或者 對象超過 Eden 內存,則會觸發 Minor GC 進行垃圾回收。
Step2:
進行 Minor GC 回收,會將 Eden 區不再被其他對象引用的對象銷毀,並將幸存下來的對象存入 Survivor 區。此時 Eden 又可存入對象。
Step3:
當再次觸發 Minor GC 時,會將幸存下來的對象存入另一個 Survivor 中,兩個 Survivor 總有一個為空(多次 GC,幸存的對象會在 兩個 Survivor 中相互交換)。
Step4:
當 Survivor 中的對象交換次數到達某一個值(對象年齡達到閾值),該對象進入老年代。
Step5:
老年代內存不足時,會觸發 Major GC 進行內存清理,若清理后仍無法保存對象,則會拋出 OutOfMemoryError 異常。
第一次 Minor GC:
對象從 Eden 區進入 Survivor 區。
第二次 Minor GC:
對象從 Eden 區、Survivor 區進入另一個 Survivor 區。
第 N 次 Minor GC:
對象從 Survivor 進入 老年代。
詳細流程如下:
6、TLAB(Thread Local Allocation Buffer)
(1)並發問題:
堆區是線程共享區域,任何線程均可以訪問到堆區中的共享數據。但是由於對象實例的創建在 JVM 中頻繁,並發環境下從堆區中划分 內存空間 是線程不安全的。為了避免多個線程操作同一個地址,需要給對象加鎖或者 CAS 等機制實現線程安全,進而影響分配速度。
(2)為什么使用 TLAB?
TLAB 指的是Thread Local Allocation Buffer(線程本地分配緩存),屬於線程私有的堆空間,TLAB 作為內存分配的首選(即對象分配首先經過此處),但是其空間較小,所以不是所有的對象實例都能在 TLAB 中成功分配內存。
默認情況下 TLAB 是開啟的(通過 -XX:UseTLAB 可以設置是否開啟 TLAB)。
7、逃逸分析(Escape Analysis)-- 棧上分配、標量替換、同步消除
JVM 中,對象一般都存儲在 堆中,但是隨着 逃逸技術的 發展,棧上分配、標量替換等優化技術的產生使 對象分配在堆中不那么絕對了(棧上分配會將 對象直接存入 棧中,無需存入堆)。
(1)逃逸分析(Escape Analysis):
逃逸分析 是分析對象動態作用域,當一個對象在方法中定義后,若該對象在方法外被引用(比如:作為參數傳遞到其他方法中),則稱為方法逃逸。若未被引用,則沒有發生逃逸。
其並不是直接優化代碼的手段,而是為其他優化手段提供依據的分析技術。
JDK 8 中,HotSpot 默認已開啟了逃逸分析,可以通過 -XX:DoEscapeAnalysis 手動開啟或者關閉逃逸分析。使用逃逸分析后,編譯器可以對代碼做一些優化:棧上分配、同步消除、標量替換。
注:
由於無法保證逃逸分析的性能消耗 與 其效果成正比(可能存在經過逃逸分析后發現沒有一個不逃逸的對象,那么這分析的過程就白白浪費了),所以不同的 JVM 可能對逃逸技術相關優化有不同的實現,比如 HotSpot 就沒有進行 棧上分配 這個優化。
【不會發生逃逸:(對象只存在於當前方法內部)】 public static void test(){ StringBuilder sb = new StringBuilder(); } 【會發生逃逸:(對象可能被其他方法調用)】 public static StringBuilder test(){ return new StringBuilder(); }
(2)棧上分配(Stack Allocation):
GC 回收堆內存不再使用的對象時,無論篩選對象還是整理對象都是對內存操作,會消耗時間。而使用棧分配后,當一個對象不會逃逸出方法時,對象可能被優化成棧上分配,即對象隨着棧幀出棧而銷毀(不存於堆),那對 GC 的壓力會小很多(HotSpot 默認不支持)。
(3)同步消除(Synchronization Elimination):
線程同步是一個耗時的操作,如果一個對象不會逃逸出線程(無法被其他線程訪問),那么對這個對象的同步操作可以省略(消除),從而提高並發性能。可以通過 -XX:EliminateLocks 開啟或關閉(HotSpot 默認開啟)。
【未進行同步消除:】 public static void test(){ StringBuilder sb = new StringBuilder("hello"); synchronized (sb) { System.out.println(sb); } } 【進行同步消除后:】 public static void test(){ StringBuilder sb = new StringBuilder("hello"); System.out.println(sb); }
(4)標量替換(Scalar Replacement):
經過逃逸分析,發現一個對象不能被外界訪問時,經過優化,可以將這個對象拆解成若干個成員變量來替換。可以通過 -XX:-EliminateAllocations 開啟或關閉(HotSpot 默認開啟)。
注:
標量(Scalar)指的是不能再被分解的數據。比如:基本數據類型(int、long 等)。
聚合量(Aggregate)指的是還可以被分解的數據。比如:自定義對象(其成員變量可以分解為其他聚合量 或者 標量)。
【未進行標量替換:】 public static void test(){ People people = new People(); } class People { String name; int age; } 【進行標量替換:】 public static void test(){ String name; int age; }
8、常用 堆 相關參數設置
【命令參考:】 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html 【常用命令:】 -XX:+PrintFlagsInitial 查看所有參數的默認初始值 -XX:+PrintFlagsFinal 查看所有參數的最終值(某些值可能被修改) -XX:+PrintGCDetails 查看 GC 詳細處理信息 -XX:-UseTLAB 關閉 TLAB -Xms20m 設置初始堆內存空間,等價於 -XX:InitialHeapSize=20m -Xmx20m 設置最大堆內存空間,等價於 -XX:MaxHeapSize=20m -Xmn10m 設置年輕代大小 -XX:NewRatio=4 配置年輕代與老年代的占比(默認 1:2) -XX:SurvivorRatio=4 配置年輕代中 Eden 與 Survivor 的占比(默認 8:1:1)。 -XX:MaxTenuringThreshold=10 設置年輕代 GC 處理對象最大年齡 -XX:-DoEscapeAnalysis 關閉逃逸分析 -XX:-EliminateLocks 關閉同步消除 -XX:-EliminateAllocations 關閉標量替換
八、運行時數據區 -- 方法區(Method Area)
1、什么是方法區?
方法區隨 JVM 啟動、關閉而創建、銷毀,屬於 各線程 共享的內存區域,大小可以固定或拓展。
JVM 規范中指出 方法區邏輯上屬於 堆的一部分,但是不要求具體的實現方式。
比如:
HotSpot 中方法區可以看成一個獨立於 堆 的空間。
2、設置方法區內存大小
方法區大小可以根據應用進行動態調整。
【JDK 7 及以前:】 -XX:PermSize 設置永久代初始分配內存,默認為 20.75M -XX:MaxPerSize 設置永久代最大分配內存,默認 82M(64 位機器)或者 64M(32 位機器) 注: 當 JVM 加載的類信息 超過 MaxPerSize 時,會拋出 OOM:PermGenSpace 【JDK 8 及之后:】 -XX:MetaspaceSize 設置元空間初始分配內存,默認為 20.75M -XX:MaxMetaspaceSize 設置元空間最大分配內存,默認為本地內存(系統可用內存) 注: 當 JVM 耗盡系統可用內存后,會拋出 OOM:MetaspaceSize 當超過初始內存后,會觸發 Full GC,為了避免頻繁 GC,MetaspaceSize 值應該設置為一個相對較高的值。
3、方法區內部結構
【經典版內部結構:】 虛擬機加載的 類信息(類型、屬性、方法) 靜態變量 運行時常量池 即時編譯器編譯后的代碼緩存(JIT 代碼緩存) 注: JDK 8 后 靜態變量以及運行時常量池 存於堆中。 【類信息 -- 類型】 包含了加載的 類 的類型信息(全類名(包名.類名)、修飾符、繼承的父類、實現的接口等信息)。 【類信息 -- 屬性(域 Field)】 包含了加載的 類 的變量信息(變量名稱、變量類型、變量修飾符等)。 注: 靜態變量(static 修飾的變量)隨類加載而加載,被類的所有實例共享(即使沒有類實例也可訪問)。 全局常量(static final 修飾的變量)在編譯時就被分配了。 【類信息 -- 方法】 包含了加載的 類 的方法信息(方法名稱、方法返回類型、方法參數與類型、方法修飾符、操作數棧、局部變量表、異常表等)。 【常量池 與 運行時常量池】 常量池中存放 Java 源碼編譯期生成的數據, 包含 各種字面量、類型、屬性、方法等 的符號引用(可以理解成一個常量表,虛擬機指令通過 符號引用 在常量表中找到需要執行的類、方法、屬性等)。 運行時常量池 為常量池中被 類加載后 存放到方法區中的數據,包含編譯期就確定的常量 以及 運行期解析后才能獲得的方法、字段引用。
4、HotSpot 方法區演變
JDK 7 及之前,方法區稱為 永久代(Permanent Generation,屬於虛擬機內存)。
JDK 8 及之后,方法區稱為 元空間(MetaSpace,屬於本地內存)。
注:
不同虛擬機 實現方法區的方式不同,永久代 僅針對於 HotSpot,JRockit 以及 J9 不存在永久代概念。
HotSpot 方法區演進細節:
JDK 1.6 及之前,使用永久代實現方法區,靜態變量 存放於永久代上。
JDK 1.7,使用永久代實現方法區,字符串常量池、靜態變量 存放於堆。
JDK 1.8 及之后,使用元空間實現方法區,類信息、屬性、方法、常量等存放於本地內存,但字符串常量池、靜態變量 仍存放於 堆中。
5、為何使用 元空間 替代 永久代?
為永久代設置空間大小 是不好確定的,如果動態加載類過多,則容易導致 OOM。永久代使用的是虛擬機內存,而元空間使用的是 本地內存(與系統可用內存有關)。
永久代的調優 也是挺困難的。
6、字符串常量池 為什么移到堆中?
字符串常量池 存放於 永久代時,由於 永久代 垃圾回收(Full GC)效率很低,而開發過程中容易創建大量字符串,若一直存放於 永久代(內存空間小),則永久代內存不足導致 OOM,而存於堆中,可以即時回收內存。
注:
不同 JVM 對方法區垃圾回收的實現可能不同(有的甚至都不去實現方法區垃圾回收)。
方法區垃圾回收主要回收:常量池中廢棄的常量、不再使用的類。
回收廢棄常量:
常量未被引用即可被回收。
回收不使用的類:
首先得判斷該類的所有實例是否 已經被回收(即 堆中不存在該類以及其子類的實例對象)。
其次得判斷該類的類加載器是否 已經被回收(一般都不會回收)。
最后得判斷該類是否 沒有在任意地方引用。
滿足上述三個條件,則該類允許被回收。
通過 -XX:+TraceClassLoading、-XX:+TraceClassUnloading 可以看到類加載、卸載信息。
九、執行引擎(Execution Engine)
1、基本概念了解一下
(1)機器碼:
使用二進制編碼方式表示指令,即機器碼,比如: 1001、0001 等。CPU 可以直接讀取並運行,但是機器碼不易記憶,且容易出錯。不同硬件的機器碼可能不同。
(2)匯編指令:
匯編指令指的是 將機器碼中特定的 0、1 組合的序列,簡化成 對應的指令,比如:mov,inc 等。統一指令在不同的硬件中可能對應不同的機器碼。可讀性比機器碼 稍好。
(3)匯編指令集:
不同的平台,支持不同的指令,每個平台所支持的指令,即指令集,比如: x86 指令集。
(4)高級語言:
為了使編程更輕松、可讀,出現了高級語言。計算機執行高級語言編寫的程序時,需要先把程序解釋、編譯成機器指令(高級語言 -》匯編指令 -》機器碼),然后執行。
(5)字節碼:
是一種中間狀態的二進制文件,比機器碼抽象,需直譯器轉譯后才能成為機器碼。
源代碼 編譯成 字節碼,字節碼再通過 指令平台的 JVM 轉譯為可執行的 指令。
2、執行引擎
(1)執行引擎概述
執行引擎是 JVM 核心之一。其將 字節碼指令 解釋/編譯 為對應平台的本地機器指令並執行。
執行代碼時常分為兩種:解釋執行(解釋器)、編譯執行(即時編譯器)。
注:
解釋器(Interpreter):JVM 啟動時根據預定義的規范對 字節碼 采用逐行解釋的方式執行(逐行翻譯 字節碼文件 為對應的 本地機器指令執行)。直接解釋並執行。
即使編譯器(Just in time compiler):JVM 直接將 字節碼 編譯成 對應的 本地機器指令(一般用於編譯 熱點代碼,再次訪問熱點代碼時 直接返回 機器指令,從而提高代碼執行效率)。需要消耗程序運行時間 進行編譯操作。
(2)HotSpot 采用 解釋器、即時編譯器 並存架構
早期 Java 由解釋器進行 解釋運行,為了提高熱點代碼的執行效率,引入即時編譯器,將熱點代碼直接編譯成 機器指令,提高執行效率。
當程序啟動、執行時,解釋器首先發揮作用,省去編譯時間,立即執行。隨着程序運行時間增長,即時編譯器開始發揮作用,將代碼編譯成 本地機器指令后,提高代碼的執行效率。
注:
熱點代碼 指的是 某個方法、代碼塊 執行頻率高,將其標記為 熱點代碼。
JVM 規范中並未規定 JVM 必須使用即時編譯器,但是即時編譯器的 性能是 衡量一款 JVM 是否優秀的標准。比如 J9 中只存在 即時編譯器(沒有解釋器)。
(3)HotSpot 設置程序執行方式
默認 HotSpot 采用解釋器、即時編譯器 並存的架構,但可以根據實際情況執行 JVM 運行時是完全采用解釋器執行,還是完全采用即時編譯器執行。
【命令:】 -Xint 完全采用解釋器模式運行。 -Xcomp 完全采用即時編譯器模式運行。 -Xmixed 采用解釋器 + 即時編譯器混合模式運行。 注: 可以通過 java -version 查看當前模式。
(4)HotSpot 中 即時編譯器分類
HotSpot 中內嵌兩個 JIT 編譯器,分別為 Client Compiler(C1)、Server Compiler(C2)。
C1 編譯器會對字節碼進行簡單、可靠的優化,耗時較短,編譯速度較快。
C2 編譯器會對字節碼進行耗時較長的優化、激進優化,但優化后的代碼執行效率更高。
分層編譯(Tiered Compilation)策略:
為了使程序在啟動響應速度以及運行效率上達到平衡狀態,在 JDK7 的 Server 模式下,采用 分層編譯策略 作為默認編譯策略。
分層編譯根據編譯器編譯、優化規模與耗時划分出不同的編譯層次。
其中:
第 0 層,程序解釋執行,僅使用解釋器,不開啟性能監控,可觸發第 1 層編譯。
第 1 層,C1 編譯,將字節碼編譯為本地代碼,進行簡答、可靠優化(可以加上性能監控)。
第 2 層,C2 編譯,將字節碼編譯為本地代碼,啟動耗時較長的優化(可以根據性能監控信息進行一些不可靠的激進優化)。
注:
分層編譯開啟后,C1、C2 可能會同時工作,一些代碼可能被多次編譯。
C1 可以提高編譯速度,C2 可以提高執行效率。
C1 優化策略:
方法內聯:將引用的函數代碼編譯到引用點處,減少棧幀生成、參數傳遞以及跳轉過程。
去虛擬化:對唯一的實現類進行內聯。
冗余消除:將運行期間一些不會執行的代碼消除掉。
C2 優化策略(基於逃逸分析):
標量替換:用標量值替換聚合對象的屬性值。
棧上分配:對於未逃逸的對象 將其分配在棧 而非堆。
同步消除:消除未逃逸的對象的同步操作。
3、解釋器、即時編譯器、熱點探測
(1)解釋器
由於不同平台底層的 機器碼 不同,而為了實現 Java 程序跨平台的特性,不能直接將源碼編譯成 本地機器指令(可以直接編譯成機器指令,不同平台對應不同的 機器指令,可移植性差),所以出現字節碼文件,不同 JVM 逐行解釋字節碼文件 來執行程序。
解釋器 逐行讀取 字節碼文件並解釋成 相應的機器指令,當一條字節碼解釋完成后,從 PC 計數器中 獲取下一條 被執行的字節碼 指令進行解釋執行操作。
解釋器分類:
字節碼解釋器:純軟件代碼模擬字節碼執行,效率低下。
模板解釋器:將每一條字節碼和一個模板函數關聯,模板函數直接產生字節碼執行的機器碼,從而提高解釋器性能。
(2)編譯期相關概念
前端編譯器:
將 .java 文件轉為 .class 文件的過程。比如:javac。
即使編譯器(JIT 編譯器、Just In Time Compiler):
將 .class 文件轉為 機器碼 的過程。比如:HotSpot 的 C1、C2。
靜態提前編譯器(AOT 編譯器、Ahead Of Time Compiler):
直接將 .java 文件轉為 機器碼 的過程。
(3)即時編譯器
即時編譯器 將熱點代碼 直接編譯成 機器指令,從而提高執行性能。通過熱點探測 判斷 某個方法、代碼塊 是否為熱點代碼。
熱點代碼常指:
多次被調用的方法。將整個方法 作為編譯對象(標准 JIT 編譯方式)。
方法中多次被調用的循環體。將整個方法 作為編譯對象(棧上替換,發生在方法執行過程中,即方法棧幀還存在於 棧中,但是方法被替換了)。
熱點探測分類:
基於采樣的熱點檢測(Sample Based Hot Spot Detection):使用該方法的虛擬機會周期性的檢查各個線程的棧頂,若某個方法經常出現在棧頂達到閾值,則為熱點方法。
基於計數器的熱點探測(Counter Based Hot Spot Detection):使用該方法的虛擬機會為每個方法建立計數器,統計方法執行次數,若執行次數達到某個閾值,則為熱點方法。
(4)HotSpot 采用基於熱點計數器的熱點探測
HotSpot 采用基於熱點計數器的熱點探測,其內部維護了兩個計數器。
計數器:
方法調用計數器(Invocation Counter),用於統計方法的執行次數。
回邊計數器(Back Edge Counter),用於統計循環體執行的次數。
方法調用計數器:
用於統計方法的執行次數。默認閾值在 client 模式下為 1500 次,在 Server 模式下為 10000 次,超過閾值觸發 JIT 編譯。這個閾值可以通過 -XX:CompileThreshold 來設置。一般 HotSpot 會根據自身版本以及機器硬件性能自動選擇運行模式,可以使用 -client 或者 -server 手動指定模式。
當一個方法被調用時,首先會檢查該方法是否被 JIT 編譯過,如果存在則使用編譯后的代碼執行,如果不存在,則將該方法調用計數器值加 1,然后判斷方法調用計數器 以及 回邊計數器之和 是否超過方法調用的閾值。若超過閾值則觸發 JIT 編譯(只是提交請求,執行還是解釋執行,當編譯完成后,下一次調用才會是已編譯的版本)。
注:
方法調用計數器記錄的並非方法被調用的絕對次數,而是一個相對的執行頻率(某段時間內方法被調用的次數)。當超過一定時間且方法調用次數 並未達到閾值,則次數減少一半,此過程稱為 方法計數器熱度的衰減,此時間稱為 方法統計的半衰周期。熱度衰減是執行垃圾回收順便執行的,可以使用 -XX:-UseCounterDecay 關閉熱度衰減(此時方法調用計數器記錄的為方法調用的絕對次數,只要系統運行時間夠長,大部分方法均會執行 JIT 編譯)。
回邊計數器:
用於統計循環體執行次數。回邊指的是 字節碼中 控制流向后跳轉的指令。建立回邊計數器目的是觸發 OSR(On Stack Replacement,棧上替換),執行 JIT 編譯。
當一個方法被調用時,首先會檢查該方法是否被 JIT 編譯過,如果存在則使用編譯后的代碼執行,如果不存在,則將回邊計數器值加 1,然后判斷方法調用計數器 以及 回邊計數器之和 是否超過回邊調用的閾值。若超過閾值則觸發 JIT 編譯(提交 OSR 編譯請求,並降低回邊計數器的值,並解釋執行,等待編譯完成)。
注:
回邊計數器沒有熱度衰減的過程,其統計的是循環體執行的絕對次數。
未完待續。。。