【從 1 開始學 JVM 系列】
JVM 對於每位 Java 語言編程者來說無疑是“重中之重”,盡管我們每天都在與它打交道,卻很少來審視它、了解它,慢慢地,它成為了我們“熟悉的陌生人”。
因此,我計划寫一個「從 1 開始學 JVM 系列」 ,主要面向有一定 Java 基礎的同學。同時,梳理總結一下自己過去積累的 JVM 體系知識和技能。
從 JVM 基礎知識聊起
常見的編程語言是如何分類的?
眾多周知,Java 是一門面向對象的編程語言。
對於編程語言,使用不同的標准有不同的分類,我們不妨一起來看看常見的分類。
第一種常見的分類為面向過程、面向對象、面向函數的編程語言。
- 面向過程,如 C
- 面向對象,如 Java、C++
- 面向函數,如 Scala
第二種可以將編程語言分為靜態類型、動態類型。
- 靜態類型,如 Java
- 動態類型,如 python、javascript
第三種可以將編程語言分為有虛擬機、無虛擬機。
- 有虛擬機,如 Java
- 無虛擬機,如 C、C++
第四種可以將編程語言分為有 GC、無 GC。
-
有 GC,如 Java、Go
-
無 GC,如 C、C++
對於沒有 GC 的編程語言人工管理容易出現內存泄漏和野指針,例如 C++,這就要求編程者要足夠細心。
通過對前面分類的小結,我們知道,Java 是一種面向對象、靜態類型、有虛擬機、有 GC 的高級語言。
此外,Java 同時支持編譯執行和解釋執行、有運行時、能夠跨平台(Write once, run anywhere,即“一次編寫,到處執行”)。
- 即時編譯執行,將一個方法中包含的所有字節碼編譯成機器碼后再執行
- 解釋執行,即逐條將字節碼翻譯成機器碼並執行。
Java 代碼解釋執行,到達一定的次數后,如果被判定為是熱點代碼,則會被編譯成機器碼執行(一般執行效率會更高)。
編程語言如何跨平台?
一般而言,有兩種跨平台的方式。
第一種方式是「源代碼跨平台」。
這種方式通過在不同的平台上(例如分別在 Linux、Window)編譯源碼,生成不同的二進制文件,從而獲得跨平台運行的能力。
但缺點也很明顯,特定平台上編譯出來的二進制無法跨平台運行。
如 Linux 編譯出來的二進制文件無法在 Windows 上運行。
第二種方式是「二進制跨平台」。
例如 Java 語言,通過講源代碼編譯成字節碼,從而就能夠實現跨平台運行。
為什么二進制能夠跨平台?
一個非常重要的原因是虛擬機的誕生,使得在不同的平台上都能執行相同的字節碼文件。
Java、C++、Rust 有哪些區別?
我們以幾種常見的編程語言為例,對比一下不同類型的編程語言,看看它們之間的區別。
語言 | 對程序員態度 | 優勢 | 劣勢 |
---|---|---|---|
C/C++ | 完全相信、慣着程序員 | 自行管理內存,代碼編寫很自由 | 不小心會造成內存泄漏等問題,導致程序崩潰 |
Java/Golang | 完全不相信、但慣着程序員 | 內存生命周期都由 JVM 運行時統一管理。絕大部分場景,非常自由的寫代碼,不用關心內存情況;內存使用有問題時,可以通過 JVM 信息進行分析診斷和調整 | 存在 STW,無法靈活管理內存 |
Rust | 既不相信程序員,也不慣着程序員 | 寫代碼時,必須清楚用 Rust 的規則管理好變量,好讓機器能明白高效地分析和管理內存 | 代碼不利於人的理解,寫代碼很不自由,學習成本也很高 |
字節碼、類加載器、虛擬機之間是什么關系?
我們通過對照一張圖來說明它們之間的關系。
Java 源代碼被編譯成「字節碼文件」(即 xxx.class 文件),然后通過「類加載器(ClassLoader)」將字節碼文件加載到 JVM 內存中,然后再實例化為對象,最終被程序使用。
上面,我們簡單聊了一下 JVM 的基礎知識,為你學習 Java 虛擬機也算是熱了個身,接下來我們正式的來聊聊 Java 的字節碼技術。
什么是字節碼?
Java bytecode 由「單字節(byte)」的指令組成,理論上最多支持 256 個「操作碼(opcode)」。
實際上 Java 只使用了200左右的操作碼, 還有一些操作碼則保留給調試操作。
一般來說,根據指令的性質,主要分為四類:
-
棧操作指令,包括與局部變量交互的指令
JVM 是基於棧的,比如 Java 虛擬機棧、局部變量表的操作。
-
程序流程控制指令
例如 if、for、while
-
對象操作指令,包括方法調用指令
例如 invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic
-
算術運算以及類型轉換指令
如何生成字節碼?
我們來寫一個簡單的類,練習一下生成字節碼的操作。
/**
* @author: Alan Yin
* @date: 2021/9/2
*/
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode helloByteCode = new HelloByteCode();
}
}
編譯命令:
javac HelloByteCode.java
查看字節碼命令:
javap - c HelloByteCode
如下圖所示:
查看更詳細的字節碼命令:
javap -verbose HelloByteCode
如下圖所示:
分析一下字節碼
我們先來分析一個簡單一點的代碼字節碼。
上圖中的 aload 是一個助記符,實際上對應一個操作碼(如 76),因為助記符可讀性更好,你想啊,如果全部換成數字,看這一段字節碼不得查上半天?
其中 a 代表了引用。
另外值得一提的是 load 和 store 之間的關系,如下圖。
load 指令會將字節碼從本地變量表加載到操作數棧,store 則將字節碼由操作數棧上存儲到本地變量表中。
棧楨由本地變量表、操作數棧、動態鏈接、方法返回值組成,參考下圖。
接下來,我們來分析一個復雜一點的字節碼。
javap -c -verbose HelloByteCode
效果如下:
從上圖可以看出,版本號為 52.0(java8),stack=2, locals=2
代表了需要深度為 2 的棧和本地變量表。
其他指令的含義可以查閱 Java 虛擬機規范,網上資料很多,這里不再贅述。
字節碼的運行時結構是什么樣的?
我們現在已經知道, JVM 是一台基於棧的計算器。
每一個線程都有一個獨屬自己的「線程棧(Stack)」,用於存儲「棧楨(Frame)」,如下圖所示。
每一次方法調用, JVM 會自動創建一個棧楨,位於頂部的即為當前棧楨。
從上圖中可以看出,棧楨由局部變量表、操作數棧、動態鏈接(Class 引用)、返回地址(返回值)組成。
動態鏈接(Class 引用)指定當前方法在運行時常量池中對應的 Class。
具體一個棧楨的構成見下圖。
助記符到二進制的對應關系
從前面我們知道,通過 javap 命令可以將二進制轉換為助記符文件,它們之間的對應關系可以見下圖。
演示:四則運算的例子
public class MovingAverage {
private int count = 0;
private double sum = 0.0D;
public void submit(double value) {
this.count++;
this.sum += value;
}
public double getAvg() {
if (0 == this.count) {
return sum;
}
return this.sum / this.count;
}
}
/**
* 棧楨的局部變量表字節碼分析測試
*
* @author: Alan Yin
* @date: 2021/9/3
*/
public class LocalVariableTest {
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
int num1 = 1;
int num2 = 2;
ma.submit(num1);
ma.submit(num2);
double avg = ma.getAvg();
}
}
數值處理與本地變量表
我們結合下面的字節碼,分析一下如何處理數值。
結合上圖和代碼,我們可以看出 iconst_1
對應了代碼中的常量1,aload_1
是把本地變量表中的 int 變量值加載到棧上,istore_1
是把棧上的值保存到本地變量表中。
其中 a 代表了引用類型,i 代表了 int 類型,d 代表了 double 類型。
一個循環控制例子
/**
* 循環控制示例演示
*
* @author: Alan Yin
* @date: 2021/9/7
*/
public class ForLoopTest {
private static int[] numbers = {1, 6, 8};
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
for (int number : numbers) {
ma.submit(number);
}
double avg = ma.getAvg();
}
}
字節碼如上,iinc 代表 int 類型的自增加一。
算數操作與類型轉換
目前 JVM 有 5 種數據類型,即下面表格4種 + 引用類型(如 aload)
⚠️特別提示
- byte、boolean 在字節碼都用int 表示,int 是 jvm 中的最小單位
- long 由 2 個 32 位組成,因此 long 操作不是原子性的(在 32 位機器上存在這種可能,比如賦值出錯)
方法調用的指令
為了方便查看,我將常見的方法調用指令放在了下面的表格中。
指令 | 含義 | 備注 |
---|---|---|
invokestatic | 用於調用某個類的靜態方法,這是方法調用指令中最快的一個。 | |
invokespecial | 用來調用構造函數、同一個類中的 private 方法, 以及可見的超類方法。 | |
invokevirtual | 如果是具體類型的目標對象,invokevirtual 用於調用公共、受保護和 package 級的私有方法 | |
invokeinterface | 當通過接口引用來調用方法時,將會編譯為 invokeinterface 指令。 | |
invokedynamic | JDK7 新增指令,是實現“動態類型語言”(Dynamically Typed Language)支持而進行的升級改進,同時也是 JDK8 以后支持 lambda 表達式的實現基礎。 |
【小知識】invokevirtual 指令為什么叫 virtual ?
因為子類可以覆蓋父類的方法。
todo 查找資料,補充 5 種指令的說明和含義。
演示:動態的例子
/**
* 動態例子演示
*
* @author: Alan Yin
* @date: 2021/9/8
*/
public class Demo {
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
}
從上圖可以看到,構造函數調用為 invokespecial,對應 <init>
方法。
END
小尹說:
文章的每個字,都是我用心書寫的。希望為每一位關注我的朋友帶來價值。
如果你覺得有用,歡迎關注 「小尹探世界」 微信公眾號,希望我們一起打造一個有知識、有溫度、有趣點、有價值的頻道,探索技術之外的廣袤世界。