初識 JVM#
什麼是 JVM#
JVM 全稱為 Java Virtual Machine,本質上是一個運行在計算機上的程序,它的職責是運行 Java 字節碼文件。
先用 javac 將源代碼 .java 編譯成 .class 字節碼文件,然後利用 java 命令啟動 JVM 運行字節碼,JVM 會混合使用解釋執行和 JIT 編譯執行的方式在計算機上最終執行。
JVM 功能#
JVM 功能
- 解釋和運行:
- 對字節碼文件中的指令,實時的解釋成機器碼,讓計算機執行。
- 內存管理:
- 自動為對象、方法等分配內存。
- 自動的垃圾回收機制,回收不再使用的對象。
- 即時編譯 Just-In-time JIT:
- 對熱點代碼進行優化,提升執行效率。
字節碼機器無法識別,需要 JVM 實時解釋為機器碼去執行。
觀察 C 語言,將 .c 源代碼直接編譯成機器碼,明顯差了很多。
所以添加了即時編譯功能,即將熱點字節碼指令解釋優化後保存在內存中,後續可以直接調用,省去再次重複解釋的過程,優化性能。
Java 虛擬機規範#
規範了當前版本二次開發的虛擬機需要滿足的規範:包含 class 字節碼文件的定義、類和接口的加載和初始化、指令集等內容。
《Java 虛擬機規範》是對虛擬機設計的要求,而不是對 Java 設計的要求,也就是說虛擬機可以運行在其他的語言比如 Groovy、Scala 生成的 class 字節碼文件之上。
名稱 | 作者 | 支持版本 | 社區活躍度(github star) | 特性 | 適用場景 |
---|---|---|---|---|---|
HotSpot (Oracle JDK 版) | Oracle | 所有版本 | 高 (閉源) | 使用最廣泛,穩定可靠,社區活躍 JIT 支持 Oracle JDK 默認虛擬機 | 默認 |
HotSpot (Open JDK 版) | Oracle | 所有版本 | 中 (16.1k) | 同上開源,Open JDK 默認虛擬機 | 默認對 JDK 有二次開發需求 |
GraalVM | Oracle | 11, 17, 19 企業版支持 8 | 高(18.7k) | 多語言支持高性能、JIT、AOT 支持 | 微服務、雲原生架構需要多語言混合編程 |
Dragonwell JDK 龍井 | Alibaba | 標準版 8, 11, 17 擴展版 11, 17 | 低 (3.9k) | 基於 OpenJDK 的增強高性能、bug 修復、安全性提升 JWarmup、ElasticHeap、Wisp 特性支持 | 電商、物流、金融領域對性能要求比較高 |
Eclipse OpenJ9 (原 IBM J9) | IBM | 8, 11, 17, 19, 20 | 低 (3.1k) | 高性能、可擴展 JIT、AOT 特性支持 | 微服務、雲原生架構 |
HotSpot 最為廣泛。
字節碼文件詳解#
Java 虛擬機的組成#
類加載器 ClassLoader:核心組件類加載器,負責將字節碼文件中的內容加載到內存中。
運行時數據區域:管理 JVM 使用到的內存,創建出來的對象、類的信息等內容都會放在這塊區域中。
執行引擎:包含了即時編譯器、解釋器、垃圾回收器;執行引擎使用解釋器將字節碼指令解釋成機器碼,使用即時編譯器優化性能,使用垃圾回收器回收不再使用的對象。
本地接口:調用本地使用 C/C++ 編譯好的方法,本地方法會在 Java 中帶上 native 關鍵字聲明,如 public static native void sleep(long millis) throws InterruptedException;
字節碼文件的組成#
查看字節碼#
字節碼文件查看器 jclasslib。
字節碼文件組成#
字節碼文件組成部分
- 基本信息:魔數、字節碼文件對應的 Java 版本號、訪問標識 (public final 等)、父類和接口信息。
- 常量池:保存了字符串常量、類或接口名、字段名,主要在字節碼指令中使用。
- 字段:當前類或接口聲明的字段信息。
- 方法:當前類或接口聲明的方法信息,核心內容為方法的字節碼指令。
- 屬性:類的屬性,比如源碼的文件名、內部類的列表等。
基本信息#
文件是無法通過文件擴展名來確定文件類型的,文件擴展名可以隨意修改,不影響文件的內容。
軟件通過使用文件的頭幾個字節(文件頭)去校驗文件的類型,如果軟件不支持該種類型就會出錯。
Java 字節碼文件中,將文件頭稱為 magic 魔數。
Java 虛擬機會校驗字節碼文件的前四個字節是不是 0xcafebabe,如果不是,該字節碼文件就無法正常使用,Java 虛擬機會拋出對應的錯誤。
主版本號用於判斷當前字節碼版本與 JVM 是否兼容。
主副版本號指的是編譯字節碼文件時使用的 JDK 版本號,主版本號用來標識大版本號,副版本號用於區別不同住版本號的標識。
JDK 1.0 - 1.1 使用了 45.0 - 45.3。
JDK 1.2 後大版本號計算方法為主版本號 - 44,如 52 主版本號為 JDK 8。
如果出現不兼容情況,比如字節碼文件版本 52,但 JVM 版本是 50。
- 升級 JDK。
- 降低字節碼文件需求版本,降低依賴的版本或者更換依賴。
一般選擇 2,調整依賴,因為升級 JDK 是一個比較大的動作,可能引發兼容問題。
常量池#
可以節省字符串字面量占用,只存一份,字符串存儲 String 類的常量並指向一個 UTF-8 的字面量常量。
情況:a="abc"; abc="abc"
這時候只有一個 UTF-8 的字面量常量,被 String 類常量的引用,作為變量 abc
的 name
。
方法#
引入:int i=0;i=i++;
最終 i 的值是多少?
局部變量表根據聲明的順序作為下標,這裡傳參的 args 為 0,i 和 j 為 1 2,操作數棧用於操作數。
int i=0; int j=i+1;
字節碼指令解析。
-
iconst_0,將常量 0 放入操作數棧,此時棧上只有 0。
-
istore_1 彈出操作數棧,並將之存放到局部變量表 1 號位置。
-
iload_1 將局部變量表 1 號位置的數放入操作數棧中,即放入 0。
-
iconst_1 給操作數棧壓入常量 1。
-
iadd 將操作數棧中頂部兩個數相加,並放回棧中,即操作數棧只剩下常數 1。
-
istore_2 彈出操作數棧中頂部元素 1,並將之存放到局部變量表 2 號位置,完成變量 j 的賦值。
-
return 語句執行,方法結束並返回。
int i=0; i=i++;
方法字節碼。
int i=0; i=++i;
方法字節碼。
一般來說字節碼指令數越多性能越差,對於下面三種 +1 的性能?
int i=0,j=0,k=0;
i++;
j = j + 1;
k += 1;
三種典型字節碼生成,但實際上 JIT 編譯器可能會將這幾種都優化成 iinc
。
i++;
(iinc
)j = j + 1;
(iload
,iconst_1
,iadd
,istore
)k += 1;
(iload
,iconst_1
,iadd
,istore
)
字段#
字段中存放的是當前類或接口聲明的字段信息。
如下圖中,定義了兩個字段 a1 和 a2,這兩個字段就會出現在字段這部分內容中,同時還包含字段的名字、描述符(字段的類型)、訪問標識(public/private static final 等)。
屬性#
屬性主要指的是類的屬性,比如源碼的文件名、內部類的列表等。
字節碼常用工具#
javap 是 JDK 自帶的反編譯工具,可以通過控制台查看字節碼文件的內容。
直接輸入 javap 查看所有參數,輸入 javap -v xxx.class
查看具體的字節碼信息,如果是 jar 包需要先使用 jar –xvf
命令解壓。
jclasslib 也有 IDEA 插件版本,可以查看代碼編譯後的字節碼文件的內容。
新工具:阿里 Arthas。
Arthas 是一款線上監控診斷產品,通過全局視角實時查看應用 load、內存、gc、線程的狀態信息,並能在不修改應用代碼的情況下,對業務問題進行診斷,利用反射。
下載 Arthas 文檔 jar 包並運行即可。
相關命令:
dump -d /tmp/output java.lang.String
將字節碼文件保存到本地。jad --source-only demo.MathGame
將類的字節碼反編譯成源代碼,用於確認代碼。
類的生命週期#
生命週期概述#
加載 連接 初始化 使用 卸載。
加載階段#
-
加載 Loading 階段的第一步是類加載器根據類的全限定名通過不同的渠道以二進制流的方式獲取字節碼信息,程序員可以使用 Java 代碼拓展的不同的渠道。
- 從本地磁碟上獲取文件。
- 運行時通過動態代理生成,比如 Spring 框架。
- Applet 技術通過網絡獲取字節碼文件。
-
類加載器在加載完類之後,JVM 會將字節碼中的信息保存到方法區中,方法區中生成一個 InstanceKlass 對象,保存類的所有信息,裡邊還包含實現特定功能比如多態的信息!image.png|500
-
JVM 同時會在堆上生成與方法區中數據類似的 java.lang.Class 對象,作用是在 Java 代碼中去獲取類的信息以及存儲靜態字段的數據 JDK8 及之後!image.png|500
連接階段#
連接階段分為三個子階段:
- 驗證,驗證內容是否滿足《Java 虛擬機規範》。
- 準備,給靜態變量賦初值。
- 解析,將常量池中的符號引用替換成指向內存的直接引用。
連接階段 - 驗證#
驗證的主要目的是檢測 Java 字節碼文件是否遵守了《Java 虛擬機規範》中的約束,這個階段一般不需要程序員參與。
- 文件格式驗證,比如文件是否以 0xCAFEBABE 開頭,主次版本號是否滿足當前 Java 虛擬機版本要求,JDK 版本不可小於文件版本。
- 元信息驗證,例如類必須有父類(super 不能為空)。
- 驗證程序執行指令的語義,比如方法內的指令執行中跳轉到不正確的位置。
- 符號引用驗證,例如是否訪問了其他類中 private 的方法等。
連接階段 - 準備#
準備階段為靜態變量 static 分配內存並設置初值,每一種基本數據類型和引用數據類型都有其初值,注意:此處的初值為每種類型的默認值,不是代碼中設置的初始值。
數據類型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用數據類型 | null |
如下示例,在連接階段 - 準備子階段中會給 value
分配內存並賦初值 0,在初始化階段才會將值修改為 1。
public class Student {
public static int value = 1;
}
例外是,final 修飾的變量,因為 final 修飾的變量以後不會發生值的變更,所以會在準備階段將代碼中的值賦值。
連接階段 - 解析#
解析階段主要是將常量池中的符號引用替換為直接引用,符號引用就是在字節碼文件中使用編號來訪問常量池中的內容。
直接引用即使用內存中的地址訪問具體數據。
初始化階段#
初始化階段會執行字節碼文件中 clinit (class init 類的初始化) 方法的字節碼指令,包含了為靜態變量賦值、執行靜態代碼塊中的代碼(按照代碼順序)。
clinit 方法的執行順序與代碼順序一致。
putstatic 指令會將操作數棧上的數彈出來,並放入堆中靜態變量的位置,字節碼指令中 #2 指向了常量池中的靜態變量 value,在解析階段會被替換成變量的地址。
以下幾種方式會導致類的初始化:
- 訪問一個類的靜態變量或者靜態方法,注意變量是 final 修飾的並且等號右邊是常量不會觸發類初始化,因為這個變量已經在連接階段的準備階段賦值。
- 調用 Class.forName (String className) 可用入參控制是否初始化。
- new 一個該類的對象時。
- 執行 Main 方法的當前類。
在 Java 啟動參數中添加 -XX:+TraceClassLoading 參數可以打印出加載並初始化的類。
示例題。
clinit 指令在特定情況下不會出現,比如:
- 無靜態代碼塊且無靜態變量賦值語句。
- 有靜態變量的聲明,但是沒有賦值語句。
- 靜態變量的定義使用 final 關鍵字,這類變量會在連接階段的準備階段時直接賦值。
繼承情況:
- 直接訪問父類的靜態變量,不會觸發子類的初始化。
- 子類的初始化 clinit 調用之前,會先調用父類的 clinit 初始化方法。
示例題。
初始化子類前先初始化父類。
直接訪問父類靜態變量不會觸發子類初始化。
數組的創建不會導致數組中元素的類進行初始化。
public class Test2 {
public static void main(String[] args) {
Test2_A[] arr = new Test2_A[10];
}
}
class Test2_A {
static {
System.out.println("Test2 A的靜態代碼塊運行");
}
}
final 修飾的變量如果賦值的內容需要執行指令才能得出結果,會執行 clinit 方法進行初始化。
public class Test4 {
public static void main(String[] args) {
System.out.println(Test4_A.a);
}
}
class Test4_A {
public static final int a = Integer.valueOf(1);
static {
System.out.println("Test4_A的靜態代碼塊運行");
}
}
類加載器#
類加載器 ClassLoader 是 JVM 提供給應用程序去實現獲取類和接口字節碼數據的技術,類加載器只參與加載過程中的字節碼獲取並加載到內存這一部分。
類加載器會通過二進制流的方式獲取到字節碼文件的內容,接下來將獲取到的數據交給 JVM,虛擬機會在方法區和堆上生成對應的對象保存字節碼信息。
類加載器分類#
類加載器分為兩類,一類是 Java 代碼中實現的,一類是 JVM 底層源碼實現。
JDK 8 及之前的版本#
類加載器 BootStrap,加載 JRE 內核心 jar 包,在 Java 代碼中無法獲取到這個底層的 ClassLoader。
擴展類加載器 Extension 應用程序類加載器 Application,都位於 sun.misc.Launcher 中,是一個靜態內部類,繼承自 URLClassLoader,具備通過目錄或指定 jar 包方式加載字節碼文件到內存中。
使用 -Djava.ext.dirs=jar包目錄
參數可以拓展使用的擴展 jar 包目錄,利用;(windows) :(macos/linux) 進行目錄路徑分割。
應用程序類加載器會加載 classpath 下的類文件,默認加載的是項目中的類以及通過 maven 引入的第三方 jar 包中的類。
類加載器的雙親委派機制#
由於 Java 虛擬機中有多個類加載器,雙親委派機制的核心是解決一個類到底由誰加載的問題。
機制作用:
- 避免惡意代碼替換 JDK 中的核心類庫,如 java.lang.String 確保核心類庫的完整性和安全性。
- 避免重複加載,保證一個類只被一個類加載器加載。
雙親委派機制指的是:當一個類加載器接收到加載類的任務時,會自底向上查找是否加載過,再由頂向下嘗試加載。
向下委派加載起到了加載優先級的作用,從啟動類加載器向下嘗試加載,如果在其加載目錄下就成功加載。
示例:dev.chanler.my.C 在 classpath 中;從 Application 向上查找,都沒有加載過;從 Bootstrap 向下嘗試加載,都不在加載目錄中,只有 Application 可以加載成功,因為 C 在 classpath 中。
題目:
- 如果一個類重複出現在三個類加載器的加載位置,應該由誰來加載?
- 啟動類加載器加載,根據雙親委派機制,它的優先級是最高的。
- String 類能覆蓋嗎,在自己的項目中去創建一個 java.lang.String 類,會被加載嗎?
- 不能,會返回啟動類加載器加載在 rt.jar 包中的 String 類。
- 類的雙親委派機制是什麼?
- 當一個類加載器去加載某個類的時候,會自底向上查找是否加載過,如果加載過就直接返回,如果一直到最頂層的類加載器都沒有加載,再由頂向下進行加載。
- 應用程序類加載器的父類加載器是擴展類加載器,擴展類加載器的父類加載器是啟動類加載器,但在代碼中是 null,因為 Bootstrap 無法被獲取。
- 雙親委派機制的好處有兩點:第一是避免惡意代碼替換 JDK 中的核心類庫,比如 java.lang.String,確保核心類庫的完整性和安全性;第二是避免一個類被重複加載。
打破雙親委派機制#
打破雙親委派機制有三種方式,但本質上只有第一種算是真正的打破了雙親委派機制:
- 自定義類加載器:自定義類加載器並且重寫 loadClass 方法,Tomcat 通過這種方式實現應用之間類隔離。
- 线程上下文類加載器:利用上下文類加載器加載類,比如 JDBC 和 JNDI 等。
- Osgi 框架的類加載器:歷史上 Osgi 框架實現了一套新的類加載器機制,允許同級之間委托進行類的加載,目前很少使用。
打破雙親委派機制 - 自定義類加載器#
一個 Tomcat 程序可以運行多個 Web 應用,如果這兩個應用出現了相同的限定名如 Servlet 類,Tomcat 就要保證這兩個類都能加載並且他們應該是不同的類,所以不打破雙親委派機制就無法加載第二個 Servlet 類。
Tomcat 使用了自定義類加載器來實現應用之間類的隔離,每一個應用會有一個獨立的類加載器加載對應的類。
ClassLoader 四個核心方法。
public Class<?> loadClass(String name)
類加載的入口,提供了雙親委派機制。內部會調用 findClass 重要。
protected Class<?> findClass(String name)
由類加載器子類實現, 獲取二進制數據調用 defineClass,比如 URLClassLoader 會根據文件路徑去獲取類文件中的二進制數據 重要。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
做一些類名的校驗,然後調用虛擬機底層的方法將字節碼信息加載到虛擬機內存中。
protected final void resolveClass(Class<?> c)
執行類生命週期中的連接階段, loadClass 默認 false。
loadClass 方法默認 resolve 為 false,不會進行連接階段和初始化階段。
Class.forName 會進行加載、連接、初始化。
要打破雙親委派機制也就是對 loadClass 內的核心邏輯重新實現。
自定義類加載器 parent 默認為 AppClassLoader。
/**
* 打破雙親委派機制 - 自定義類加載器
*/
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
//設置加載目錄
public void setBasePath(String basePath) {
this.basePath = basePath;
}
//使用 commons io 從指定目錄下加載文件
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定義類加載器加載失敗,錯誤原因:" + e.getMessage());
return null;
}
}
//重寫 loadClass 方法
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
//如果是 java 包下,還是走雙親委派機制
if(name.startsWith("java.")){
return super.loadClass(name);
}
//從磁碟中指定目錄下加載
byte[] data = loadClassData(name);
//調用虛擬機底層方法,方法區和堆區創建對象
return defineClass(name, data, 0, data.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
//第一個自定義類加載器對象
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
//第二個自定義類加載器對象
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2);
Thread.currentThread().setContextClassLoader(classLoader1);
System.out.println(Thread.currentThread().getContextClassLoader());
System.in.read();
}
}
問題:兩個自定義類加載器加載相同限定名的類,不會衝突嗎?
- 不會衝突,在同一個 JVM 中,只有相同類加載器+相同的類限定名才會被認為是同一個類。
- 在 Arthas 中使用 sc-d 類名 的方式查看具體的情況。
雙親委派機制在 loadClass 中,而 loadClass 調用了 findClass,而重寫 findClass 才是真正實現多種渠道加載字節碼文件的合理方式,如加載數據庫中的類,轉成二進制數組調用 defineClass 存入內存。
打破雙親委派機制 - 线程上下文類加載器 JDBC 案例#
JDBC 的 DriverManager 管理不同驅動。
DriverManager 類位於 rt.jar 包,那麼就是由啟動類加載器 Bootstrap 加載,但 DriverManager 又委派 Application 加載驅動 jar 包。
問題:DriverManager 怎麼知道 jar 包中要加載的驅動在哪兒?
Service Provider Interface SPI 是 JDK 內置的一種服務發現機制!image.png|500
线程上下文加載器其實默認就是應用程序加載器。
觀點:JDBC 案例中真的打破了雙親委派機制嗎?
- 由 Bootstrap 加載的 DriverManager 委派 Application 加載驅動類,打破了雙親委派。
- JDBC 只是在 DriverManager 加載完後,通過初始化階段觸發了驅動類的加載,類的加載依然遵循雙親委派機制,因為通過 Application 加載依舊是走 loadClass 方法,而這個方法含有雙親委派機制。
只能說從宏觀上看,是父層級委派給子層級,在微觀上,執行層面,子層級的類加載器內部函數邏輯依舊走了雙親委派,只不過父層級都拒絕執行罷了。
打破雙親委派機制 - OSGi 模塊化框架#
利用 Arthas 熱部署解決線上 bug#
注意事項:
- 程序重啟之後,字節碼文件會恢復,因為只是替換到了內存中,除非將 class 文件放入 jar 包中進行更新。
- 使用 retransform 不能添加方法或者字段,也不能更新正在執行中的方法。
JDK 9 之後的類加載器#
JDK 8 及之前,Extension 和 Application 繼承自 rt.jar 包中的 sun.misc.Launcher.java 中的 URLClassLoader。
JDK 9 之後,引入了 module 的概念,類加載器的設計變化了很多。
啟動類加載器使用 Java 編寫,位於 jdk.internal.loader.ClassLoaders 類中。
Java 中的 BootClassLoader 繼承自 BuiltinClassLoader 實現從模塊中找到要加載的字節碼資源文件。
平台類加載器遵循模塊化方式加載字節碼文件,所以繼承關係從 URLClassLoader 變成了 BuiltinClassLoader,BuiltinClassLoader 實現了從模塊中加載字節碼文件,主要是用於兼容老版本,並沒有特殊邏輯。
JVM 內存區域 運行時數據區#
運行時數據區負責管理 JVM 使用到的內存,比如創建對象和銷毀對象。
《Java 虛擬機規範》規定了每一部分的作用,分為兩大塊:線程不共享和線程共享。
線程不共享:程序計數器、Java 虛擬機栈、本地方法栈。
線程共享:方法區、堆。
程序計數器#
程序計數器 Program Counter Register 也叫 PC 寄存器,每個線程會通過程序計數器記錄當前要執行的字節碼指令的地址。
案例:
在加載階段,虛擬機將字節碼文件中的指令讀取到內存之後,會將原文件中的偏移量轉換成內存地址,每一條字節碼指令都會擁有一個內存地址。
在代碼執行過程中,程序計數器會記錄下一行字節碼指令的地址,執行完當前指令之後,虛擬機的執行引擎根據程序計數器執行下一行指令,這裡為了簡單起見,使用偏移量代替,真實內存中執行時保存的應該是地址。
一路向下執行到最後一行,return 語句,當前方法執行結束,程序計數器中會放入方法出口的地址,也就是回到調用這個方法的方法。
所以,程序計數器可以控制程序指令的進行,實現分支、跳轉、異常等邏輯,只需要在程序計數器中放入下一行要執行的指令地址即可。
多線程情況下,程序計數器還可以記錄 CPU 切換前接下來要解釋執行的指令地址,方便切換回後繼續解釋執行。
問題:程序計數器在運行中會出現內存溢出嗎?
- 內存溢出指的是程序在使用某一塊內存區域時,存放的數據需要占用的內存大小超過了虛擬機能提供的內存上限。
- 由於每個線程只存儲一個固定長度的內存地址,程序計數器是無法發生內存溢出的。
- 程序員無需對程序計數器做任何處理。
JVM 栈#
Java 虛擬機栈 Java Virtual Machine Stack 采用栈的數據結構來管理方法調用中的基本數據,先進後出 First In Last Out,每一個方法的調用使用一個栈幀 Stack Frame 來保存。
Java 虛擬機栈的栈幀中主要包含三方面的內容:
- 局部變量表,局部變量表的作用是在運行過程中存放所有的局部變量。
- 操作數栈,操作數栈是栈幀中虛擬機在執行指令過程中用來存放臨時數據的一塊區域。
- 幀數據,幀數據主要包含動態鏈接、方法出口、異常表的引用。
局部變量表#
局部變量表的作用是在方法執行過程中存放所有的局部變量。
局部變量表分為兩種:
- 一種是字節碼文件中的。
- 另外一種是栈幀中的,會保存在內存中,栈幀中的局部變量表是根據字節碼文件中的內容生成的。
生效範圍:這個局部變量在字節碼中可被訪問的有效範圍。
起始 PC 指從什麼偏移量開始,可以訪問這個變量,確保變量已經初始化。
長度指從起始 PC 開始,這個局部變量生效範圍的長度,如 j 可生效範圍為第 4 行字節碼 return。
栈幀中的局部變量表為數組,一個位置為一個 slot,long 和 double 占用兩個 slot。
實例對象的 this 和方法參數也會在局部變量表的開頭,按照定義順序保存。
問題:以下代碼占用幾個槽 slot?
public void test4(int k,int m){
{
int a = 1;
int b = 2;
}
{
int c = 1;
}
int i = 0;
long j = 1;
}
this、k、m、a、b、c、i、j、j 是 9 個 slot 嗎?並非。
為了節省空間,局部變量表中的槽是可以復用的,一旦某個局部變量不再生效,當前槽就可以再次被使用;此處 a、b、c 後續都不在使用會被 i、j 复用;而實例對象 this 引用和方法參數貫穿整個方法生命週期,它們所占用的槽位不會被復用。
所以局部變量表的槽位數應該是運行時最小需要的槽位數,這一點在編譯時就可以確定,運行過程中只需要在栈幀中創建相應長度的局部變量表數組即可。
操作數栈#
操作數栈是栈幀中虛擬機在執行指令過程中用來存放中間數據的一塊區域,栈式結構。
在編譯期就可以確定操作數栈的最大深度,從而在執行時正確的分配內存大小。
案例,操作數栈最大深度為 2。
幀數據#
幀數據主要包含動態鏈接、方法出口、異常表的引用。
動態鏈接#
當前類的字節碼指令引用了其他類的屬性或者方法時,需要將符號引用編號轉換成對應的運行時常量池中的內存地址。
動態鏈接就保存了編號到運行時常量池的內存地址的映射關係。
方法出口#
方法出口指的是方法在正確或者異常結束時,當前栈幀會被彈出,同時程序計數器應該指向上個栈幀中的下一條指令的地址,也就是調用者接下來一行的指令地址。
異常表#
異常表存放的是代碼中異常的處理信息,包含了異常捕獲的生效範圍以及異常發生後跳轉到的字節碼指令位置。
示例:此異常表中,異常捕獲的起始偏移量是 2,結束偏移量是 4,在 2 - 4 執行過程中拋出了 java.lang.Exception
對象或者子類對象,就會將其捕獲,然後跳轉到偏移量為 7 的指令。
栈內存溢出#
JVM 栈如果栈幀過多,占用內存超過栈內存可以分配的最大大小就會出現內存溢出,即錯誤 StackOverflowError。
可以設置虛擬機參數 -Xss1m
-Xss1024K
。
1M 的虛擬機栈內存可以容納 10676 個栈幀。
每個版本的 JVM 也會對栈大小有要求,HotSpot JVM 在 Windows 64 位下 JDK 8 要求最小 180K 最大 1024M。
本地方法栈#
HotSpot JVM 中,Java 虛擬機栈和本地方法栈實現上使用了同一個栈空間,本地方法栈保存了本地方法的參數、局部變量、返回值等信息。
本地方法指 native 方法,使用 C 語言編寫在 JVM 內部,在 JAVA 代碼中公開聲明允許調用。
堆內存#
一般 Java 程序中堆內存是空間最大的一塊內存區域,線程共享。
創建出來的對象都存在於堆上,栈上的局部變量表中,可以存放堆上對象的引用,靜態變量也可以存放堆對象的引用,通過靜態變量就可以實現對象在線程之間共享。
堆內存溢出#
堆內存大小是有上限的,當一直向堆中放入對象達到上限之後,就會拋出 OutOfMemory OOM 錯誤,在這段代碼中,不停創建 100M 大小的字節數組並放入 ArrayList 集合中,最終超過了堆內存的上限,拋出 OOM 錯誤。
/**
* 堆內存的使用和回收
*/
public class Demo1 {
public static void main(String[] args) throws InterruptedException, IOException {
ArrayList<Object> objects = new ArrayList<Object>();
System.in.read();
while (true){
objects.add(new byte[1024 * 1024 * 100]);
Thread.sleep(1000);
}
}
}
三個重要的值#
堆空間有三個需要關注的值,used、total、max。
used 指的是當前已使用的堆內存,total 是 JVM 已經分配的可用堆內存,max 是 JVM 允許分配的最大堆內存,也就是 total 可以最大拓展到 max 的大小。
在 Arthas 中可以通過 dashboard -i 刷新頻率(5000ms)
命令看到堆內存的這三個值 used、total、max。
如果不設置任何的虛擬機參數,max 默認是系統內存的 1/4,total 默認是系統內存的 1/64。
隨著堆中對象不斷增多 used 越大,total 中可以使用的內存不足,就會繼續申請內存,上限為 max。
問題:那麼是不是當 used = max = total 時堆內存就溢出了呢?
不是,堆內存溢出的判斷條件比較複雜,在 GC 的講解中會詳細介紹。
設置堆大小#
要修改堆的大小,可以使用虛擬機參數 –Xmx
(max 最大值)和 -Xms
(初始的 total)。
語法:-Xmx值 -Xms值
。
單位:字節(默認,必須是 1024 的倍數)、k 或者 K (KB)、m 或者 M (MB)、g 或者 G (GB)。
限制:-Xmx
max 必須大於 2 MB,-Xms
total 必須大於 1MB。
建議:-Xmx
max 和 -Xms
total 設置為相同的值,這樣減少了申請內存和分配內存上的開銷,以及內存過剩之後堆收縮的情況。
方法區#
方法區是存放基礎信息的位置,線程共享,包含:
- 類的元信息,保存了所有類的基本信息。
- 運行時常量池,保存了字節碼文件中的常量池內容。
- 字符串常量池,保存了字符串常量。
類的元信息#
方法區存儲每個類的基本信息也稱元信息 InstanceKlass 對象。
在類的加載階段完成,其中包含類的字段、方法等字節碼文件中的內容,同時還保存了運行過程中需要使用的虛方法表(實現多態的基礎)等信息。
運行時常量池#
方法區除了存儲類的元信息之外,還存放了運行時常量池,常量池中存放的是字節碼中的常量池內容。
字節碼文件中通過編號查表的方式找到常量,這種常量池稱為靜態常量池,當常量池加載到內存中之後,可以通過內存地址快速的定位到常量池中的內容,這種常量池稱為運行時常量池。
方法區的實現#
方法區是《Java 虛擬機規範》中設計的虛擬概念,每款 Java 虛擬機在實現上都各不相同,Hotspot 設計如下:
- JDK 7 及之前的版本將方法區存放在堆區域中的永久代空間,堆的大小由虛擬機參數來控制。
- JDK 8 及之後的版本將方法區存放在元空間中,元空間位於操作系統維護的直接內存中,默認情況下只要不超過操作系統承受的上限,可以一直分配。
方法區的溢出#
通過 ByteBuddy 工具動態生成字節碼數據,加載到內存中,死循環不斷加載到方法區,模擬方法區的溢出。
字符串常量池#
方法區中除了類的元信息、運行時常量池之外,還有一塊區域叫字符串常量池 StringTable。
字符串常量池存儲代碼中定義的常量字符串內容,如 “123” 的 123 就會被放入字符串常量池。
new 出來的對象存放在堆內存中。
早期設計時,字符串常量池是屬於運行時常量池的一部分,他們存儲的位置也是一致的,字符串常量池和運行時常量池做了拆分;JDK 7 之後,字符串常量池就在堆內存中了。
問題:地址是否相等?
/**
* 字符串常量池案例
*/
public class Demo2 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = a + b;
System.out.println(c == d);
}
}
指向的並非同一個地址。
問題:指向地址是否相同?
package chapter03.stringtable;
/**
* 字符串常量池案例
*/
public class Demo3 {
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = "1" + "2";
System.out.println(c == d);
}
}
查看字節碼文件可以得知在編譯階段時就已經將 1 2 連接,所以指向的都是字符串常量池中的對象。
兩個問題的總結。
字符串的變量連接使用 StringBuilder 存放到堆內存中;而用常量連接在編譯階段直接連接。
JDK 7 後 string.intern () 會返回字符串常量池中的字符串,沒有則會將字符串的引用放入字符串常量池中。
這裡字符串常量池的 java 是 JVM 自動放的。
問題:靜態變量存儲在哪。
- JDK 6 及之前的版本中,靜態變量是存放在方法區中的,也就是永久代。
- JDK 7 及之後的版本中,靜態變量是存放在堆中的 Class 對象中,脫離了永久代。
直接內存#
直接內存 Direct Memory 不在《Java 虛擬機規範》中存在,不屬於 Java 運行時的內存區域。
在 JDK 1.4 中引入了 NIO 機制,使用了直接內存,主要為了解決以下兩個問題:
- Java 堆中的對象如果不再使用要回收,回收時會影響對象的創建和使用。
- IO 操作比如讀文件,需要先把文件讀入直接內存(緩衝區)再把數據複製到 Java 堆中。
可以將文件放入直接內存中,並在堆上維護直接內存的引用,避免數據的拷貝開銷以及對文件對象的創建回收開銷。
可以通過參數 XX:MaxDirectMemorySize=大小
分配大小。
JVM 垃圾回收#
在 C/C++ 這類沒有自動垃圾回收機制的語言中,一個對象如果不再使用,需要手動釋放,否則就會出現內存泄漏,內存泄漏指的是不再使用的對象在系統中未被回收,內存泄漏的積累可能會導致內存溢出。
釋放對象的過程稱為垃圾回收,Java 為了簡化對象的釋放,引入了自動的垃圾回收 GC 機制,垃圾回收器主要負責堆上內存的回收。
問題:垃圾回收器要負責哪些部分的內存呢?
對於線程不共享部分,都伴隨線程創建而創建,線程的銷毀而銷毀;方法的栈幀在執行完方法後就會自動彈出栈而釋放內存,所以不需要;所以需要垃圾回收的就是線程共享的方法區、堆。
方法區的回收#
方法區中能回收的內容主要就是不再使用的類。
判定一個類可以被卸載,要同時滿足:
- 此類所有實例對象都已經被回收,在堆中不存在任何該類的實例對象以及子類對象。
Class<?> clazz = loader.loadClass (name: "com.itheima.my.A");
Object o = clazz.newInstance ();
◎ = nu11;
- 加載該類的類加載器已經被回收。
- 該類對應的 java.lang.Class 對象沒有在任何地方被引用。
兩個虛擬機參數 -XX:+TraceClassLoading
-XX:+TraceClassUnloading
可以看到類加載和卸載也就是回收的日誌。
如果需要手動觸發垃圾回收,可以調用 System.gc () 方法,但是不一定會立即回收垃圾,僅僅是向 Java 虛擬機發送一個垃圾回收的請求,具體是否需要執行垃圾回收 Java 虛擬機會自行判斷。
堆回收#
引用計數法和可達性分析法#
GC 判斷對象是否能被回收,要根據這個對象是否被引用決定,如果對象被引用了,說明該對象還在使用,不允許被回收。
問題:A B 之間的相互引用需要去除嗎?
不需要,因為發昂發中已經沒有辦法使用引用去訪問 A B 對象。
常見的對象能否回收的判斷方法:引用計數法和可達性分析法。
引用計數法#
引用計數法會為每個對象維護一個引用計數器,當對象被引用時加 1,取消引用時減 1。
這個情況取消兩條引用就可以讓引用計數器歸 0,使之可以被回收。
但是在下面的情況中,A B 對象循環引用,計數器均為 1,但是沒有局部變量引用這兩個對象,代碼中無法訪問到這兩個對象,理應可以被回收,但根據引用計數器歸 0 才回收來說,不對。
可達性分析法#
Java 使用的是可達性分析算法來判斷對象是否可以被回收。
可達性分析將對象分為兩類:垃圾回收的根對象 GC Root 和普通對象;對象與對象之間有引用關係。
如果從根對象 GC Root 可達某個對象,那麼就是不可回收的;GC Root 不可回收。
GC Root 根對象:
- 線程 Thread 對象,引用線程栈幀中的方法參數、局部變量等。
- 系統類加載器加載的 java.lang.Class 對象。
- 監視器對象,用來保存同步鎖 synchronized 關鍵字持有的對象。
- 本地方法調用時使用的全局對象。
案例:線程 Thread 對象。
五種對象引用#
可達性算法中描述的對象引用,一般指的是強引用,即 GC Root 對象對普通對象有引用關係,這時普通對象就無法被回收。
Java 設計了 5 種引用方式:
- 強引用。
- 软引用。
- 弱引用。
- 虚引用。
- 終結器引用。
软引用#
軟引用比強引用弱,如果一個對象只有軟引用關聯到它,當程序內存不足時,就會回收軟引用中的數據。
軟引用的執行過程如下:
- 將對象使用軟引用包裝起來,
new SoftReference<對象類型>(對象)
。 - 內存不足時,虛擬機嘗試進行垃圾回收。
- 如果垃圾回收仍不能解決內存不足的問題,回收軟引用中的對象。
- 如果依然內存不足,拋出 OutOfMemory 異常。
放入 100M 數據的軟引用,其中 bytes = null;
解除了數據的強引用,只剩下了 SoftReference
包裹的軟引用,如果設置 -Xmx=200M
虛擬機 max 內存為 200M,第二次獲取軟引用內的數據就失敗,因為在第二次創建 100M 數據時,即使 GC 也內存不足,會嘗試回收軟引用內的對象,此時回收成功得到足以容納 100M 新數據的內存空間。
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
bytes = null;
System.out.println(softReference.get());
byte[] bytes2 = new byte[1024 * 1024 * 100];
System.out.println(softReference.get());
軟引用內的對象都因為內存不足而回收,那 SoftReference 本身也要被回收,SoftReference 提供了一套隊列機制:
- 軟引用創建時,通過構造器傳參引用隊列。
- 在軟引用中包含的對象被回收時,該軟引用對象會被放入引用隊列。
- 通過代碼遍歷引用隊列,將 SoftReference 的強引用刪除。
利用 ReferenceQueue
強引用保存 SoftReference
對象,當軟引用包裹的對象被回收時,SoftReference
本身會被放入構造時傳入的引用隊列當中,可以彈出遍歷一遍,這樣 SoftReference
就失去強引用,能被回收。
ArrayList<SoftReference> softReferences = new ArrayList<>();
ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();
for (int i = 0; i < 10; i++) {
byte[] bytes = new byte[1024 * 1024 * 100];
SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);
softReferences.add(studentRef);
}
SoftReference<byte[]> ref = null;
int count = 0;
while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
count++;
}
System.out.println(count);
可以繼承 SoftReference
,在構造時用 super(data, queue)
。
案例:使用軟引用實現學生數據緩存,value 使用軟引用對象,注意實際對象被回收時,要把 key 和 value 即軟引用對象回收;用 StudentRef 繼承 SoftReference<student> ,裡面存儲 _key 即可在回收軟引用對象時同時清理 HashMap 中的 key。
private void cleanCache() {
StudentRef ref = null;
while ((ref = (StudentRef) q.poll()) != null) {
StudentRefs.remove(ref._key);
}
}
弱引用#
弱引用和軟引用相似,區別在於,弱引用不管內存夠不夠都會被回收,實現類為 WeakReference,主要用在 ThreadLocal,弱引用同樣提供引用隊列,會將包裹的數據被回收後的弱引用放入隊列當中。
手動 GC 導致弱引用包裹的數據直接被回收,第二次結果即為 null。
byte[] bytes = new byte[1024 * 1024 * 100];
WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
bytes = null;
System.out.println(weakReference.get());
System.gc();
System.out.println(weakReference.get());
虛引用和終結器引用#
這兩種引用在常規開發中都不會用到。
虛引用也叫幽靈引用 / 幻影引用,不能通過虛引用對象獲取到包含的對象,虛引用唯一的用途是當對象被垃圾回收器回收時可以接收到對應的通知。Java 中使用 PhantomReference 實現了虛引用,直接內存中為了及時知道直接內存對象不再使用,從而回收內存,使用了虛引用來實現。
終結器引用指的是在對象需要被回收時,對象將會被放置在 Finalizer 類中的引用隊列中,並在稍後由一條 finalizerThread 線程從隊列中獲取對象,然後執行對象的 finalize 方法,在這個過程中可以在 finalize 方法中再自身對象使用強引用關聯上,但是不建議這樣做,如果耗時過長會影響其他對象的回收。
垃圾回收算法#
介紹#
對於垃圾回收來說,只有兩步。
- 找到內存中存活的對象。
- 釋放不再存活對象的內存,使得程序能再次利用這部分空間。
1960 年 John McCarthy 發布了第一個 GC 算法:標記 - 清除算法。
1963 年 Marvin L. Minsky 發布了複製算法。
後續所有的垃圾回收算法,如標記 - 整理算法、分代 GC,都是在上述兩種算法的基礎上優化而來。
標準評價#
Java 垃圾回收過程會通過單獨的 GC 線程來完成,但是不管使用哪一種 GC 算法,總會有部分階段需要停止所有的用戶線程,這個過程被稱之為 Stop The World 簡稱 STW,如果 STW 時間過長則會影響用戶的使用。
用戶代碼執行和垃圾回收執行讓用戶線程停止執行 STW 是交替執行的,判斷一個 GC 算法是否優秀,有三個方面。
- 吞吐量:吞吐量指的是 CPU 用於執行用戶代碼的時間與 CPU 總執行時間的比值,即 吞吐量 = 執行用戶代碼時間 /(執行用戶代碼時間 + GC 時間),吞吐量數值越高,垃圾回收的效率就越高!image.png|500|500
- 最大暫停時間:最大暫停時間指的是所有在垃圾回收過程中的 STW 時間最大值!image.png|500
- 堆使用效率:不同垃圾回收算法,對堆內存的使用方式是不同的,如標記清除算法,可以使用完整的堆內存。而複製算法會將堆內存一分為二,每次只能使用一半內存,從堆使用效率上來說,標記清除算法要優於複製算法!image.png|500
一般來說,堆內存越大,最大暫停時間就越長;想要減少最大暫停時間,就會降低吞吐量。
且堆使用效率、吞吐量、最大暫停時間無法兼得。
標記清除算法#
標記清除算法核心分兩個階段:
- 標記階段,將所有存活的對象進行標記,Java 中使用可達性分析算法,從 GC Root 開始通過引用鏈遍歷出所有存活對象。
- 清除階段,從內存中刪除沒有被標記也就是非存活對象。
如對象 D 未被標記,則清除之。
優點:實現簡單,只需要在第一階段給每個對象維護標志位,第二階段刪除對象。
缺點:
1.