你的 JVM 基礎“大廈”穩健嗎?


【從 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 代碼解釋執行,到達一定的次數后,如果被判定為是熱點代碼,則會被編譯成機器碼執行(一般執行效率會更高)。

image

編程語言如何跨平台?

一般而言,有兩種跨平台的方式。

第一種方式是「源代碼跨平台」

這種方式通過在不同的平台上(例如分別在 Linux、Window)編譯源碼,生成不同的二進制文件,從而獲得跨平台運行的能力。

但缺點也很明顯,特定平台上編譯出來的二進制無法跨平台運行

如 Linux 編譯出來的二進制文件無法在 Windows 上運行。

image

源代碼跨平台

第二種方式是「二進制跨平台」

例如 Java 語言,通過講源代碼編譯成字節碼,從而就能夠實現跨平台運行。

image

二進制跨平台

為什么二進制能夠跨平台?

一個非常重要的原因是虛擬機的誕生,使得在不同的平台上都能執行相同的字節碼文件。

Java、C++、Rust 有哪些區別?

我們以幾種常見的編程語言為例,對比一下不同類型的編程語言,看看它們之間的區別。

語言 對程序員態度 優勢 劣勢
C/C++ 完全相信、慣着程序員 自行管理內存,代碼編寫很自由 不小心會造成內存泄漏等問題,導致程序崩潰
Java/Golang 完全不相信、但慣着程序員 內存生命周期都由 JVM 運行時統一管理。絕大部分場景,非常自由的寫代碼,不用關心內存情況;內存使用有問題時,可以通過 JVM 信息進行分析診斷和調整 存在 STW,無法靈活管理內存
Rust 既不相信程序員,也不慣着程序員 寫代碼時,必須清楚用 Rust 的規則管理好變量,好讓機器能明白高效地分析和管理內存 代碼不利於人的理解,寫代碼很不自由,學習成本也很高

字節碼、類加載器、虛擬機之間是什么關系?

我們通過對照一張圖來說明它們之間的關系。

image

Java 源代碼被編譯成「字節碼文件」(即 xxx.class 文件),然后通過「類加載器(ClassLoader)」將字節碼文件加載到 JVM 內存中,然后再實例化為對象,最終被程序使用。

上面,我們簡單聊了一下 JVM 的基礎知識,為你學習 Java 虛擬機也算是熱了個身,接下來我們正式的來聊聊 Java 的字節碼技術。

什么是字節碼?

Java bytecode 由「單字節(byte)」的指令組成,理論上最多支持 256 個「操作碼(opcode)」

實際上 Java 只使用了200左右的操作碼, 還有一些操作碼則保留給調試操作。

一般來說,根據指令的性質,主要分為四類:

  1. 棧操作指令,包括與局部變量交互的指令

    JVM 是基於棧的,比如 Java 虛擬機棧、局部變量表的操作。

  2. 程序流程控制指令

    例如 if、for、while

  3. 對象操作指令,包括方法調用指令

    例如 invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic

  4. 算術運算以及類型轉換指令

如何生成字節碼?

我們來寫一個簡單的類,練習一下生成字節碼的操作。

/**
 * @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

如下圖所示:

image

查看更詳細的字節碼命令:

javap -verbose HelloByteCode

如下圖所示:

image

分析一下字節碼

我們先來分析一個簡單一點的代碼字節碼。

image

上圖中的 aload 是一個助記符,實際上對應一個操作碼(如 76),因為助記符可讀性更好,你想啊,如果全部換成數字,看這一段字節碼不得查上半天?

其中 a 代表了引用。

另外值得一提的是 load 和 store 之間的關系,如下圖。

image

load 指令會將字節碼從本地變量表加載到操作數棧,store 則將字節碼由操作數棧上存儲到本地變量表中。

棧楨由本地變量表、操作數棧、動態鏈接、方法返回值組成,參考下圖。

image

接下來,我們來分析一個復雜一點的字節碼。

javap -c -verbose HelloByteCode

效果如下:

image

從上圖可以看出,版本號為 52.0(java8),stack=2, locals=2代表了需要深度為 2 的棧和本地變量表。

其他指令的含義可以查閱 Java 虛擬機規范,網上資料很多,這里不再贅述。

字節碼的運行時結構是什么樣的?

我們現在已經知道, JVM 是一台基於棧的計算器。

每一個線程都有一個獨屬自己的「線程棧(Stack)」,用於存儲「棧楨(Frame)」,如下圖所示。

image

每一次方法調用, JVM 會自動創建一個棧楨,位於頂部的即為當前棧楨。

從上圖中可以看出,棧楨由局部變量表、操作數棧、動態鏈接(Class 引用)、返回地址(返回值)組成

動態鏈接(Class 引用)指定當前方法在運行時常量池中對應的 Class。

具體一個棧楨的構成見下圖。

image

助記符到二進制的對應關系

從前面我們知道,通過 javap 命令可以將二進制轉換為助記符文件,它們之間的對應關系可以見下圖。

image

演示:四則運算的例子

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();
    }
}

數值處理與本地變量表

我們結合下面的字節碼,分析一下如何處理數值。

image

結合上圖和代碼,我們可以看出 iconst_1 對應了代碼中的常量1,aload_1 是把本地變量表中的 int 變量值加載到棧上,istore_1 是把棧上的值保存到本地變量表中。

其中 a 代表了引用類型,i 代表了 int 類型,d 代表了 double 類型。

image

一個循環控制例子

/**
 * 循環控制示例演示
 *
 * @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();
    }

}

image

字節碼如上,iinc 代表 int 類型的自增加一。

算數操作與類型轉換

目前 JVM 有 5 種數據類型,即下面表格4種 + 引用類型(如 aload)

image

⚠️特別提示

  • 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;
    }
}

image

image

從上圖可以看到,構造函數調用為 invokespecial,對應 <init>方法。

END

小尹說:
文章的每個字,都是我用心書寫的。希望為每一位關注我的朋友帶來價值。

如果你覺得有用,歡迎關注 「小尹探世界」 微信公眾號,希望我們一起打造一個有知識、有溫度、有趣點、有價值的頻道,探索技術之外的廣袤世界。
image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM