玩命學JVM(一)—認識JVM和位元組碼檔案

CleverZiv發表於2020-09-29

本篇文章的思維導圖
Alt

一、JVM的簡單介紹

1.1 JVM是什麼?

JVM (java virtual machine),java虛擬機器,是一個虛構出來的計算機,但是有自己完善的硬體結構:處理器、堆疊、暫存器等。java虛擬機器是用於執行位元組碼檔案的。

1.2 JAVA為什麼能跨平臺?

首先我們可以問一個這樣的問題,為什麼 C 語言不能跨平臺?如下圖:
Alt

C語言在不同平臺上的對應的編譯器會將其編譯為不同的機器碼檔案,不同的機器碼檔案只能在本平臺中執行。

而java檔案的執行過程如圖:
Alt
java通過javac將原始檔編譯為.class檔案(位元組碼檔案),該位元組碼檔案遵循了JVM的規範,使其可以在不同系統的JVM下執行。

小結

  • java 程式碼不是直接在計算機上執行的,而是在JVM中執行的,不同作業系統下的 JVM 不同,但是會提供相同的介面。
  • javac 會先將 .java 檔案編譯成二進位制位元組碼檔案,位元組碼檔案與作業系統平臺無關,只面向 JVM, 注意同一段程式碼的位元組碼檔案是相同的。
  • 接著JVM執行位元組碼檔案,不同作業系統下的JVM會將同樣的位元組碼檔案對映為不同系統的API呼叫。
  • JVM不是跨平臺的,java是跨平臺的。

1.3 JVM為什麼跨語言

前面提到".class檔案是一種遵循了JVM規範的位元組碼檔案",那麼不難想到,只要另一種語言也同樣了遵循了JVM規範,可將其原始檔編譯為.class檔案,就也能在 JVM 上執行。如下圖:
Alt

1.4 JDK、JRE、JVM的關係

我們看一下官方給的圖:
Alt

三者定義

  • JDK:JDK(Java SE Development Kit),Java標準開發包,它提供了編譯、執行Java程式所需的各種工具和資源,包括Java編譯器(javac)、Java執行時環境(JRE),以及常用的Java類庫等。
  • JRE:JRE( Java Runtime Environment) 、Java執行環境,用於解釋執行Java的位元組碼檔案。普通使用者而只需要安裝 JRE 來執行 Java 程式。而程式開發者必須安裝JDK來編譯、除錯程式。
  • JVM:JVM(Java Virtual Mechinal),是JRE的一部分。負責解釋執行位元組碼檔案,是可執行java位元組碼檔案的虛擬計算機。

區別和聯絡

  1. JDK 用於開發,JRE 用於執行java程式 ;如果只是執行Java程式,可以只安裝JRE,無需安裝JDK。
  2. JDk包含JRE,JDK 和 JRE 中都包含 JVM。
  3. JVM 是 java 程式語言的核心並且具有平臺獨立性。

二、位元組碼檔案詳解

官方文件地址:https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-4.html#jvms-4.1

2.1 位元組碼檔案的結構

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}
  • "ClassFile"中的“u4、u2”等指的是每項資料的所佔的長度,u4表示佔4個位元組,u2表示佔2個位元組,以此類推。
  • .class檔案是以16進位制組織的,一個16進位制位可以用4個2進位制位表示,一個2進位制位是一個bit,所以一個16進位制位是4個bit,兩個16進位制位就是8bit = 1 byte。以Main.class檔案的開頭cafe為例分析:
    Alt
    因此 u4 對應4個位元組,就是 cafe babe

接下來先分析 ClassFile的結構:

  1. magic
    在 class 檔案開頭的四個位元組, 存放著 class 檔案的魔數, 這個魔數是 class 檔案的標誌,是一個固定的值: 0xcafebabe 。 也就是說他是判斷一個檔案是不是 class 格式的檔案的標準, 如果開頭四個位元組不是 0xcafebabe , 那麼就說明它不是 class 檔案, 不能被 JVM 識別。
  2. minor_version 和 major_version
    次版本號和主版本號決定了該class file檔案的版本,如果 major_version 記作 M,minor_version 記作 m ,則該檔案的版本號為:M.m。因此,可以按字典順序對類檔案格式的版本進行排序,例如1.5 <2.0 <2.1。當且僅當v處於 Mi.0≤v≤Mj.m 的某個連續範圍內時,Java 虛擬機器實現才能支援版本 v 的類檔案格式。範圍列表如下:
    Alt
  3. constant_pool_count
    constant_pool_count 項的值等於 constant_pool 表中的條目數加1。如果 constant_pool 索引大於零且小於 constant_pool_count,則該索引被視為有效,但 CONSTANT_Long_info 和CONSTANT_Double_info 型別的常量除外。
  4. constant_pool
    constant_pool 是一個結構表,表示各種字串常量,類和介面名稱,欄位名稱以及在ClassFile 結構及其子結構中引用的其他常量。 每個 constant_pool 表條目的格式由其第一個“標籤”位元組指示。constant_pool 表的索引從1到 constant_pool_count-1。
    Java虛擬機器指令不依賴於類,介面,類例項或陣列的執行時佈局。 相反,指令引用了constant_pool 表中的符號資訊。
    所有 constant_pool 表條目均具有以下常規格式:
    cp_info {
        u1 tag;
        u1 info[];
    }
    

constant_pool 表中的每個條目都必須以一個1位元組的標籤開頭,該標籤指示該條目表示的常量的種類。 常量有17種,在下表中列出,並帶有相應的標記。每個標籤位元組後必須跟兩個或多個位元組,以提供有關特定常數的資訊。 附加資訊的格式取決於標籤位元組,即info陣列的內容隨標籤的值而變化。
Alt

  1. access_flags
    access_flags 項的值是標誌的掩碼,用於表示對該類或介面的訪問許可權和屬性。設定後,每個標誌的解釋在下表中指定。
    Alt

  2. this_class
    this_class 專案的值必須是指向 constant_pool 表的有效索引。該索引處的 constant_pool 條目必須是代表此類檔案定義的類或介面的 CONSTANT_Class_info 結構。

    CONSTANT_Class_info {
          u1 tag;
          u2 name_index;
    }
    
  3. super_class
    對於一個類,父類索引的值必須為零或必須是 constant_pool 表中的有效索引。 如果super_class 項的值非零,則該索引處的 constant_pool 條目必須是 CONSTANT_Class_info 結構,該結構表示此類檔案定義的類的直接超類。 直接超類或其任何超類都不能在其 ClassFile結構的 access_flags 項中設定 ACC_FINAL 標誌。如果 super_class 項的值為零,則該類只可能是 java.lang.Object ,這是沒有直接超類的唯一類或介面。對於介面,父類索引的值必須始終是 constant_pool 表中的有效索引。該索引處的 constant_pool 條目必須是 java.lang.Object 的CONSTANT_Class_info 結構。

  4. interfaces_count
    interfaces_count 專案的值給出了此類或介面型別的直接超介面的數量。

  5. interfaces[]
    介面表的每個值都必須是 constant_pool 表中的有效索引。interfaces [i]的每個值(其中0≤i <interfaces_count)上的 constant_pool 條目必須是 CONSTANT_Class_info 結構,該結構描述當前類或介面型別的直接超介面。

  6. fields_count
    欄位計數器的值給出了 fields 表中 field_info 結構的數量。 field_info 結構代表此類或介面型別宣告的所有欄位,包括類變數和例項變數。

  7. fields[]
    欄位表中的每個值都必須是field_info結構,以提供對該類或介面中欄位的完整描述。 欄位表僅包含此類或介面宣告的欄位,不包含從超類或超介面繼承的欄位。
    欄位結構如下:

          field_info {
              u2             access_flags;
              u2             name_index;
              u2             descriptor_index;
              u2             attributes_count;
              attribute_info attributes[attributes_count];
          }
    
  8. methods_count
    方法計數器的值表示方法表中 method_info 結構的數量。

  9. methods[]
    方法表中的每個值都必須是 method_info 結構,以提供對該類或介面中方法的完整描述。 如果在 method_info 結構的 access_flags 項中均未設定 ACC_NATIVE 和 ACC_ABSTRACT 標誌,則還將提供實現該方法的Java虛擬機器指令;
    method_info 結構表示此類或介面型別宣告的所有方法,包括例項方法,類方法,例項初始化方法以及任何類或介面初始化的方法。 方法表不包含表示從超類或超介面繼承的方法。
    方法具有如下結構:

        method_info {
            u2             access_flags;
            u2             name_index;
            u2             descriptor_index;
            u2             attributes_count;
            attribute_info attributes[attributes_count];
        }
    
  10. attributes_count
    屬性計數器的值表示當前類的屬性表中的屬性數量。

  11. attributes[]
    注意,這裡的屬性並不是Java程式碼裡面的類屬性(類欄位),而是Java原始檔便已有特有的一些屬性(不要與 fields 混淆),屬性的結構:
    xml attribute_info { u2 attribute_name_index; u4 attribute_length; u1 info[attribute_length]; }
    屬性列表:
    Alt

2.2 例項分析

首先寫一段Java程式,我們熟悉的“Hello World”

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

使用javac Main.java編譯生成Main.class檔案:

cafe babe 0000 0034 001d 0a00 0600 0f09
0010 0011 0800 120a 0013 0014 0700 1507
0016 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 046d 6169
6e01 0016 285b 4c6a 6176 612f 6c61 6e67
2f53 7472 696e 673b 2956 0100 0a53 6f75
7263 6546 696c 6501 0009 4d61 696e 2e6a
6176 610c 0007 0008 0700 170c 0018 0019
0100 0b48 656c 6c6f 2057 6f72 6c64 0700
1a0c 001b 001c 0100 044d 6169 6e01 0010
6a61 7661 2f6c 616e 672f 4f62 6a65 6374
0100 106a 6176 612f 6c61 6e67 2f53 7973
7465 6d01 0003 6f75 7401 0015 4c6a 6176
612f 696f 2f50 7269 6e74 5374 7265 616d
3b01 0013 6a61 7661 2f69 6f2f 5072 696e
7453 7472 6561 6d01 0007 7072 696e 746c
6e01 0015 284c 6a61 7661 2f6c 616e 672f
5374 7269 6e67 3b29 5600 2100 0500 0600
0000 0000 0200 0100 0700 0800 0100 0900
0000 1d00 0100 0100 0000 052a b700 01b1
0000 0001 000a 0000 0006 0001 0000 0001
0009 000b 000c 0001 0009 0000 0025 0002
0001 0000 0009 b200 0212 03b6 0004 b100
0000 0100 0a00 0000 0a00 0200 0000 0400
0800 0500 0100 0d00 0000 0200 0e

開始按照以上知識破譯上面的Main.class檔案
按順序解析,首先是前10個位元組:

cafe babe // 魔法數,標識為.class位元組碼檔案
0000 0034 //版本號 52.0
001d //常量池長度 constant_pool_count 29-1=28

接著開始解析常量,先檢視往後的第一個位元組:0a,對應的常量型別CONSTANT_Methodref,對應的結構為:

CONSTANT_Methodref_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}

tag佔一個位元組,class_index 佔2個位元組,name_and_type_index 佔2個自己,依次往後數,注意0a就是tag,所以往後數2個位元組是 class_index

00 06 // class_index 指向常量池中第6個常量所代表的類
00 0f // name_and_type_index 指向常量池中第15個常量所代表的方法

通過以上方法逐個解析,最終可得到常量池為:

0a // 10 CONSTANT_Methodref
00 06 // 指向常量池中第6個常量所代表的類
00 0f // 指向常量池中第15個常量所代表的方法

09 CONSTANT_Fieldref
0010 // 指向常量池中第16個常量所代表的類
0011 // 指向常量池中第17個常量所代表的變數

08 // CONSTANT_String
00 12 // 指向常量池中第18個常量所代表的變數

0a // CONSTANT_Methodref
0013 // 指向常量池中第19個常量所代表的類
0014 // 指向常量池中第20個常量所代表的方法

07 // CONSTANT_Class
00 15 // 指向常量池中第21個常量所代表的變數

07 // CONSTANT_Class
0016 // 指向常量池中第22個常量所代表的變數

01 // CONSTANT_Utf8 標識字串
00 // 下標為0
06 // 6個位元組
3c 696e 6974 3e //<init>

01 //CONSTANT_Utf8 表示字串
00 // 下標為0
03 // 3個位元組
2829 56 // ()v

01 //CONSTANT_Utf8 表示字串
00 // 下標為0
04 // 4個位元組
436f 6465 // code

01 //CONSTANT_Utf8 表示字串
00 // 下標為0
0f // 15個位元組
4c 696e 654e 756d 6265 7254 6162 6c65 //lineNumberTable

01 //CONSTANT_Utf8 表示字串
00 // 下標為0
04 // 4個位元組
6d 6169 6e //main

01 
00
16 
285b 4c6a 6176 612f 6c61 6e67 2f53 7472 696e 673b 2956 //([Ljava/lang/String;)V

0100
0a //10
53 6f75 7263 6546 696c 65 //sourceFile

01 00
09 
4d61 696e 2e6a 6176 61 //Main.java

0c // CONSTANT_NameAndType
0007 //nameIndex:7
0008 //descriptor_index:8

07 //CONSTANT_Class
00 17 // 第21個變數

0c 
0018 
0019

0100
0b
48 656c 6c6f 2057 6f72 6c64 // Hello World

07
00 1a

0c 001b 001c 

0100 
04
4d 6169 6e //main

01 00
10
6a61 7661 2f6c 616e 672f 4f62 6a65 6374 //java/lang/Object

0100 
10
6a 6176 612f 6c61 6e67 2f53 7973 7465 6d // java/lang/System

01 00
03 
6f75 74 // out

01 00
15 
4c6a 6176 612f 696f 2f50 7269 6e74 5374 7265 616d 3b //Ljava/io/PrintStream;

01 00
13 
6a61 7661 2f69 6f2f 5072 696e 7453 7472 6561 6d // java/io/PrintStrea

01 00
07 
7072 696e 746c 6e //println

01 00
15 
284c 6a61 7661 2f6c 616e 672f 5374 7269 6e67 3b29 56 // (ljava/lang/String/String;)V

常量池往後的結構可繼續按照這種方式進行解析。現在我們採用java自帶的方法來將.class檔案反編譯,並驗證我們以上的解析是正確的。
使用javap -v Main.class可得到:

  Last modified 2020-9-29; size 413 bytes
  MD5 checksum 8b2b7cdf6c4121be8e242746b4dea946
  Compiled from "Main.java"
public class Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // Hello World
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // Main
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Main.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               Hello World
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               Main
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  public Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 4: 0
        line 5: 8
}
SourceFile: "Main.java"

對比下可以發現與我們人工解析的結果是一致的。

小結

本文第一部分圍繞JVM的幾個常見的問題做了一些簡單介紹。第二部分詳細介紹了ClassFile的結構及 JVM 對 ClassFile 指定的規範(更多詳細的規範有興趣的讀者可檢視官方文件),接著按照規範進行了部分位元組碼的手動解析,並與 JVM 的解析結果進行了對比。個人認為作為偏應用層的programer沒必要去記憶這些“規範”,而是要跳出這些繁雜的規範掌握到以下幾點:

  1. 會藉助官方文件對位元組碼檔案做簡單閱讀。
  2. 理解位元組碼檔案在整個執行過程的角色和作用,其實就是一個“編解碼”的過程。javac將.java檔案按照JVM的規則生成位元組碼檔案,JVM按照規範解析位元組碼檔案為機器可執行的指令。

參考文獻
https://blog.csdn.net/peng_zhanxuan/article/details/104329859
https://docs.oracle.com/javase/specs/jvms/se11/html/index.html
https://blog.csdn.net/weelyy/article/details/78969412

相關文章