天天看點

Jvm與位元組碼——類的方法區模型

從一個類開始

我們從一個簡單類開始說起:

package example.classLifecicle;
public class SimpleClass {
	public static void main(String[] args) {
		SimpleClass ins = new SimpleClass();
	}
}           

這是一段平凡得不能再平凡的Java代碼,稍微有點程式設計語言入門知識的人都能了解它表達的意思:

  1. 建立一個名為SimpleClass的類;
  2. 定義一個入口main方法;
  3. 在main方法中建立一個SimpleClass類執行個體;
  4. 退出。

什麼是Java bytecode

那麼這一段代碼是怎麼在機器(JVM)裡運作的呢?在向下介紹之前先說清幾個概念。

首先,Java語言和JVM完全可以看成2個完全不相幹的體系。雖然JVM全稱叫Java Virtual Machine,最開始也是為了能夠實作Java的設計思想而制定開發的。但是時至今日他完全獨立于Java語言成為一套生命力更為強悍的體系工具。他有整套規範,根據這個規範它有上百個應用實作,其中包括我們最熟悉的hotspot、jrockit等。還有一些知名的變種版本——harmony和android dalvik,嚴格意義上變種版本并不能叫java虛拟機,因為其并未按照jvm規範開發,但是從設計思想、API上看又有大量的相似之處。

其次,JVM并不能了解Java語言,他所了解的是稱之為Java bytecode的"語言"。Java bytecode從形式上來說是面向過程的,目前包含130多個指令,他更像可以直接用于CPU計算的一組指令集。是以無論什麼語言,最後隻要按照規範編譯成java bytecode(以下簡稱為"位元組碼")都可以在JVM上運作。這也是scala、groovy、kotlin等各具特色的語言雖然在文法規則上不一緻,但是最終都可以在JVM上平穩運作的原因。

Java bytecode的規範和存儲形式

前面代碼儲存成 .java 檔案然後用下面的指令編譯過後就可以生成.class位元組碼了:

$ javac SimpleClass.java #SimpleClass.class           

位元組碼是直接使用2進制的方式存儲的,每一段資料都定義了具體的作用。下面是SimpleClass.class 的16進制資料(使用vim + xxd打開):

一個 .class 檔案的位元組碼分為10個部分:

0~4位元組:檔案頭,用于表示這是一個Java bytecode檔案,值固定為0xCAFEBABE。

2+2位元組:編譯器的版本資訊。

2+n位元組:常量池資訊。

2位元組:入口權限标記。

2位元組:類符号名稱。

2位元組:父類符号名稱。

2+n位元組:接口。

2+n位元組:域(成員變量)。

2+n位元組:方法。

2+n位元組:屬性。

每個部分的前2個位元組都是該部分的辨別位。

本篇的目的是說明位元組碼的作用以及JVM如何使用位元組碼運轉的,想要詳細了解2進制意義的請看這裡:http://www.jianshu.com/p/252f381a6bc4。

反彙編及位元組碼解析

我們可以使用 javap 指令将位元組碼反彙編成我們容易閱讀的格式化了的指令集編碼:

$ javap -p SimpleClass.class #檢視類和成員
$ javap -s SimpleClass.class #檢視方法簽名
$ javap -c SimpleClass.class #反彙編位元組碼
$ javap -v SimpleClass.class #返彙編檢視所有資訊           

javap 還有很多的參數,可以使用 javap --help 來了解。下面是使用javap -v 指令輸出的内容,輸出了常量池資訊、方法簽名、方法描述、堆棧數量、本地記憶體等資訊:

public class example.classLifecicle.SimpleClass
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#13         // java/lang/Object."<init>":()V
   #2 = Class              #14            // example/classLifecicle/SimpleClass
   #3 = Methodref          #2.#13         // example/classLifecicle/SimpleClass."<init>":()V
   #4 = Class              #15            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               main
  #10 = Utf8               ([Ljava/lang/String;)V
  #11 = Utf8               SourceFile
  #12 = Utf8               SimpleClass.java
  #13 = NameAndType        #5:#6          // "<init>":()V
  #14 = Utf8               example/classLifecicle/SimpleClass
  #15 = Utf8               java/lang/Object
{
  public example.classLifecicle.SimpleClass();
    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 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class example/classLifecicle/SimpleClass
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
           

下面是關于位元組碼格式的描述:

public class example.classLifecicle.SimpleClass

這一段表示這個類的符号。

flags: ACC_PUBLIC, ACC_SUPER

該類的标記。例如是否是public類等等,實際上就是将一些Java關鍵字轉譯成對應的Java bytecode。

Constant pool:

constant pool: 之後的内容一直到 { 符号,都是我們所說的"常量池"。在對java類進行編譯之後就會産生這個常量池。通常我們所說的類加載,就是加載器将位元組碼描述的常量資訊轉換成實際存儲在運作時常量池中的一些記憶體資料(當然每個方法中的指令集也會随之加載到方法指向的某個記憶體空間中)。

"#1"可以了解為常量的ID。可以把常量池看作一個Table,每一個ID都指向一個常量,而在使用時都直接用"#1"這樣的ID來引用常量。

常量池中的包含了運作這個類中方法所有需要用到的所有常量資訊,Methodref、Class、Utf8、NameAndType等表示常量的類型,後面跟随的參數表示這個常量的引用位置或者數值。

{}:

常量池之後的{}之間是方法。每一個方法分為符号(名稱)、标記、描述以及指令集。descriptor:描述。flags:入口權限标記。Code:指令集。

Code中,stack表示這一段指令集堆棧的最大深度, locals表示本地存儲的最大個數, args_size表述傳入參數的個數。

位元組碼如何驅動機器運作

在往下說之前,先說下JVM方法區的内容。方法區顧名思義就是存儲各種方法的地方。但是從實際應用來看,以Hotspot為例——方法區在實作時通常分為class常量池、運作常量池。在大部分書籍中,運作時常量池被描述為包括類、方法的所有描述資訊以及常量資料,(

詳情請看這篇文章

對于機器來說并不存在什麼類的感念的。到了硬體層面,他所能了解的内容就是:1)我要計算什麼(cpu),2)我要存儲什麼(緩存、主存、磁盤等,我們統稱記憶體)?

按照分層模型來說JVM隻是一個應用程序,是不可能直接和機器打交道的(這話也不是絕對的,有些虛拟機還真直接當作作業系統在特有硬體裝置上用)。在JVM到硬體之間還隔着一層作業系統,在本地運作時是直接調用作業系統接口的(windows和linux都是C/C++)。不過為了JVM虛拟機更高效,位元組碼設計為更接近機器邏輯行為的方式來運作。不然也沒必要弄一個位元組碼來轉譯Java語言,像nodejs用的V8引擎那樣實時編譯Javascript不是更直接?這也是過去C/C++唾棄Java效率低下,到了如今Java反而去吐槽其他解釋型編譯環境跑得慢的原因(不過這也不見得100%正确。比如某些情況下Java在JVM上處理JSON不見得比JavaScript在nodejs上快,而且寫起代碼來也挺費勁的)。

我們回到硬體計算和存儲的問題。CPU的計算過程實質上就是作業系統的線程不斷給CPU傳遞指令集。線程就像傳送帶一樣,把一系列指令排好隊然後一個一個交給CPU去處理。每一個指令告訴CPU幹一件事,而幹事的前後總得有個依據(輸入)和結果(輸出),這就是各種緩存、記憶體、磁盤的作用——提供依據、儲存結果。JVM線程和作業系統線程是映射關系(mapping),而JVM的堆(heap)和非堆(Non-heap)就是一個記憶體管理的模型。是以我們跳出分層的概念,将位元組碼了解為直接在驅動cpu和記憶體運作的彙編碼更容易了解。

最後,我們回到方法區(Method Area)這個規範概念。CPU隻關心一堆指令,而JVM中所有的指令都是放置在方法區中的。JVM的首要任務是把這些指令有序的組織起來,按照程式設計好的邏輯将指令一個一個交給CPU去運作。而CPU都是靠線程來組織指令運算的,是以JVM中每個線程都有一個線程棧,通過他将指令組織起來一個一個的交給CPU去運算——這就是計數器(Counter Register,用以訓示目前應該執行什麼位元組碼指令)、線程棧(Stacks,線程的運算模型——先進後出) 和 棧幀(Stacks Frame,方法執行的本地變量) 的概念。是以無論多複雜的設計,方法區可以簡單的了解為:有序的将指令集組織起來,并在使用的時候可以通過某些方法找到對應的指令集合。

解析常量池

先看 SimpleClass 位元組碼中常量池中的一些資料,上圖中每一個方框表示一個常量。方框中第一行的 #1 表示目前常量的ID,第二行 Methodref 表示這個這個常量的類型,第三行 #4,#13 表示常量的值。

我們從 #1 開始跟着每個常量的值向下延伸可以展開一根以 Utf8 類型作為葉節點的樹,每一個葉節點都是一個值。所有的方法我們都可以通過樹的方式展開得到下面的查詢字段:

class = java/lang/Object //屬于哪個類
method = "<init>" //方法名稱
params = NaN //參數
return = V //傳回類型           

所有的方法都會以 package.class.name:(params)return 的形式存儲在方法區中,通過上面的參數很快可以定位到方法,例如  java.lang.Object."<init>":()V,這裡"<init>"是構造方法專用的名稱。

解析方法中的指令集

方法除了用于定位的辨別符外就是指令集,下面解析main方法的指令集:

0: new           #2                  // class example/classLifecicle/SimpleClass
3: dup
4: invokespecial #3                  // Method "<init>":()V
7: astore_1
8: return           

1))new 表示建立一個ID為#2的對象即SimpleClass(#2->#15="example/classLifecicle/SimpleClass")。此時JVM會在堆上建立一個能放置SimpleClass類的空間并将引用位址傳回寫到棧頂。這裡僅僅完成在堆中配置設定空間,沒執行初始化。

2)dup表示複制棧頂資料。此時棧中有2個指向同一記憶體區域的SimpleClass引用。

3)invokespecial #3表示執行#3的方法。通過解析常量池#3就是SimpleClass的構造方法。此後會将SimpleClass構造方法中的指令壓入棧中執行。

4)接下來來是SimpleClass的構造方法部分: a)aload_0 表示将本地記憶體的第一個資料壓入棧頂,本地記憶體的第一個資料就是this。b)invokespecial #1 表示執行 Object 的構造方法。c)退出方法。這樣就完成了執行個體的構造過程。

5)完成上述步驟後,線程棧上還剩下一個指向SimpleClass執行個體的引用,astore_1 表示将引用存入本地緩存第二個位置。

6)return -> 退出 main 方法。

方法區結構

那麼在方法區中所有的類是如何組織存放的呢?

我們用一個關系型資料庫常的結構就可以解釋他。在資料庫中我們常用的對象有3個——表、字段、資料。每一個類對應的位元組碼我們都可以看成會生成2張資料庫表——常量池表、方法表。通過位元組碼的解析,在記憶體中産生了如下結構的表:

常量池表:example.classLifecicle.SimpleClass_Constant

id type value
#1 Methodref #4,#13
……
#4 Class #15
Utf8 java/lang/Object

方法表:example.classLifecicle.SimpleClass_Method

name params return flag code
<init>     NaN V static,public
… 

然後在運作過程中當計數器遇到 invokespecial #3 這樣的指令時就會根據指令後面的ID去本類的常量表中查詢并組裝資料。當組裝出 class = java/lang/Object、method = "<init>"、params = NaN、return = V這樣的資料後,就會去名為java.lang.Object的表中根據 method、params、return 字段的資料查詢對應的code,找到後為該code建立一個本地記憶體,随後線程計數器逐個執行code中的指令。

這裡僅僅用關系型資料庫表的概念來解釋方法區中如何将指令執行和位元組碼對應起來,真正的JVM運作方式比這複雜得多。不過這樣很容易了解方法區到底是怎麼一回事。