1. 程式人生 > >Java位元組碼簡介(Introduction to Java Bytecode)

Java位元組碼簡介(Introduction to Java Bytecode)

跟隨本篇文章深入研究JVM內部結構和java位元組碼,你將會知道如何分解你的檔案進行深入檢查。

        對於一個經驗豐富的開發人員閱讀java位元組碼也是非常枯燥的事情。首先我們要弄清楚我們為什麼需要知道如此底層的東西?上週有一個能應用簡單的場景:很早以前我作了程式碼修改,編譯至jar包中並部署到服務測試一個潛在的效能問題。不幸的是修改的程式碼從未儲存在版本控制系統,無論什麼原因,本地修改的程式碼已消失的無影無蹤。在幾個月後,我再次需要原格式的修改的程式碼,但我已找不到了!

        幸好編譯後的程式碼還儲存在伺服器上,長舒一口氣,我獲取伺服器的JAR並使用反編譯工具開啟。。唯一的問題:反編譯工具GUI不是完美的,JAR中的許多類,只有我想反編譯的那個特定類,UI開啟時引起了bug,反編譯器直接崩潰了!

        絕望時孤注一擲,還好我屬性原位元組碼,我寧願再花些時間手動反編譯一些程式碼而不去完成程式碼,再去測試它們。至少我還記得去哪兒檢視程式碼,讀位元組碼幫助我精確的找出修改的地方,把它們轉換成原始碼格式。

        一旦你學會了位元組碼的語法,它可以應用到所有支援java的平臺上——因為它是程式碼的中間的表現,不是最終通過底層cpu執行的程式碼。而且由於JVM結構相當的簡單,位元組碼也比機器碼簡單,因此也是簡化的指令集合,另外集合中所有的指令Oracle提供了完善的文件說明

        在學習位元組碼指令集合之前,我們得先熟悉一些關於JVM的知識點。

JVM 資料型別

java是靜態型別的,影響了其設計位元組碼指令,一個指令需要操作一個特定型別的數值。舉個例子,現在有幾個操作兩個數字相加操作:iadd,ladd,fadd,dadd,它們期望的運算元型別,依次為,int,long,float和double。相同的功能由於不用的運算元型別會有不一樣的展現形式。

JMV定義的資料型別:

  1. 原始型別:
    • 數字型別: byte (8-bit 2's complement)八進位制二位補碼, short (16-bit 2's complement), int (32-bit 2's complement), long (64-bit 2's complement), char (16-bit unsigned Unicode), float (32-bit IEEE 754 single precision FP)單精度, double (64-bit IEEE 754 double precision FP)雙精度
    • boolean type
    • returnAddress
      : pointer to instruction
  2. 引用型別:
    • 陣列
    • 介面

boolean 型別在位元組碼中是有限支援的。例如,沒有直接操作boolean值得指令,Boolean值將通過編譯器轉換為int型別進而通過int的指令進行使用。

Java開發者應熟悉以上所有的型別,除了returnAddress, 在java程式語言中沒有相對應的型別。

基於棧的結構

位元組碼的簡單很大程度得益於Sun設計了基於棧的VM(虛擬機器)結構,而不是通過暫存器。JVM程序使用了很多記憶體元件,我只需要詳細瞭解JVM的棧就可以基本上理解位元組碼指令:

PC register: 執行在java程式上的每個執行緒,PC暫存器儲存當前指令的地址。

JVM stack: 每個執行緒,一個棧用於儲存區域性變數,方法引數,返回值。下圖展示3個執行緒的棧。

jvm_stacks

Heap: 所有執行緒共享該記憶體,儲存物件(類的實體和集合物件)。物件的儲存分配通過垃圾回收器進行管理。

heap.png

Method area: 每個載入的類,儲存它方法的程式碼和一個符號表(欄位的引用或方法的引用)和常量池。

method_area.png

一個JVM棧是由幀組成,在每一幀中方法執行時將值放置在棧上,方法執行完成時棧將值彈出(不管是正常返回還是丟擲異常)。每一幀還包含:

  1. 區域性變數集合,從0至其長度-1。長度是編譯器計算得出:一個區域性變數能夠儲存任何型別的值,除了long和double,因為他們是由兩個區域性變數組成。
  2. 一個用於儲存中間值的運算元棧,這些中間值將充當指令的運算元或將引數推送到方法呼叫中.

stack_frame_zoom.png

位元組碼探索

帶著JVM內部結構認識,我們往下看一些從樣例程式碼生成的基礎位元組碼。每個方法都有一個程式碼塊,包含一系列指令,每個指令有以下格式:

opcode (1 byte)      operand1 (optional)      operand2 (optional)      ...

一個指令包含一個位元組的操作碼和處理0個或多個包含資料的運算元。

當前執行方法內部的棧幀,一個指令會對運算元棧推送或彈出值,它也可能在區域性變數陣列上載入或儲存值,看以下簡單的例子:

public static void main(String[] args) {
    int a = 1;
    int b = 2;
    int c = a + b;
}

為了打印出編譯成class檔案的位元組碼(假設已編譯成Test.class檔案),我們可以執行如下javap命令:

javap -v Test.class

然後我們會得到:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
...

方法標識著為main,descriptor描述資訊宣告這個方法接收String陣列的引數  ([Ljava/lang/String; ), 和一個空返回值型別 (V ). flags 描述了方法為public (ACC_PUBLIC) and static (ACC_STATIC).

最重要的部分為 Code 屬性, 包含方法指令和資訊,例如運算元棧的最大容量(此處為2),為該方法分配的區域性變數數量(此處為4)。以上所有的區域性變數都使用了除了第一個(索引0),它對應的是args 引數。 在原始碼中剩下3個變數對應的是 ab and c 。

從0至8的指令將會實現以下操作:

iconst_1: 推送int常量1至運算元棧。

iconst_1.png

istore_1: 彈出頂部運算元(一個int值)然後儲存至索引為1的區域性變數,對應著變數a.

istore_1.png

iconst_2: 推送int常量2至運算元棧。

iconst_2.png

istore_2: 彈出頂部int運算元值,儲存至索引為2的區域性變數b上。

istore_2.png

iload_1: 從索引為1的區域性變數載入int值並推送至運算元棧。

iload_1.png

iload_2: 從索引為2的區域性變數載入int值並推送至運算元棧。

iload_2.png

iadd: 從運算元棧頂部彈出兩個int值,把它們相加,把結果再推送至運算元棧。

iadd

istore_3: 彈出頂部的int運算元值並存儲至索引為3的區域性變數,對應變數c.

istore_3.png

return: Return from the void method.

上面每個指令僅包含一個操作碼,精確決定JVM執行的操作。

Method Invocations(方法呼叫)

上面的例子只有一個main方法. 假設我們需要對變數c進行更精細的計算,我們決定用一個叫calc的新方法返回它:

public static void main(String[] args) {
    int a = 1;
    int b = 2;
    int c = calc(a, b);
}
static int calc(int a, int b) {
    return (int) Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

Let's see the resulting bytecode:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=2, locals=4, args_size=1
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: invokestatic  #2         // Method calc:(II)I
       9: istore_3
      10: return
static int calc(int, int);
  descriptor: (II)I
  flags: (0x0008) ACC_STATIC
  Code:
    stack=6, locals=2, args_size=2
       0: iload_0
       1: i2d
       2: ldc2_w        #3         // double 2.0d
       5: invokestatic  #5         // Method java/lang/Math.pow:(DD)D
       8: iload_1
       9: i2d
      10: ldc2_w        #3         // double 2.0d
      13: invokestatic  #5         // Method java/lang/Math.pow:(DD)D
      16: dadd
      17: invokestatic  #6         // Method java/lang/Math.sqrt:(D)D
      20: d2i
      21: ireturn

該main方法與前一個main方法的唯一區別就是使用invokestatic替換了iadd,invokestatic只是簡單呼叫了static方法calc。關鍵事項是運算元棧需要包含兩個引數傳遞給方法calc。換句話說,呼叫方法通過將他們按正確順序推送至運算元棧來準備給被呼叫的方法的所有引數。invokestatic(或一個相似的呼叫指令,接下來將會看到)將隨後彈出這些引數,方法呼叫時將建立一個新幀,這些引數將會賦值給該新幀中的區域性變數陣列中。

觀察code程式碼的地址,我們也注意到invokestatic 指令佔用了 3 個位元組,地址從6直接到9。與之前看的指令不同,因為invokestatic包含兩個額外的位元組去構造被呼叫方法的引用(操作碼除外)。引用展示在javap生成結果的#2處,象徵著指向calc方法, 通過更早初始化的常量池解決的。

另外的新資訊就是calc方法的程式碼。一開始載入第一個int引數至運算元棧 (iload_0). 下一個指令, i2d, 通過加寬將其轉換為double。double結果放到操作棧的頂部。

下一個指令推送一個double的常量2.0d(從常量池獲取)至運算元棧。帶著目前兩個已經準備好的引數呼叫靜態方法Math.pow (calc的一個引數 和 常量 2.0d). 當Math.pow方法返回, 它的結果將返回至其呼叫者的運算元棧。圖解如下。

math_pow.png

對於Math.pow(b, 2)是相同的處理過程:

math_pow2.png

下一個指令, dadd, 彈出頂部兩個中間狀態的結果,相加,將總和推送回運算元頂部。最終invokestatic 對結果總和執行 Math.sqrt 方法, 結果通過縮小轉換從double轉換為int (d2i)。結果int返回至main方法,並存儲值c變數 (istore_3).

Instance Creations(例項建立)

修改例項,建立一個Point類去封裝XY座標。

public class Test {
    public static void main(String[] args) {
        Point a = new Point(1, 1);
        Point b = new Point(5, 3);
        int c = a.area(b);
    }
}
class Point {
    int x, y;
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public int area(Point b) {
        int length = Math.abs(b.y - this.y);
        int width = Math.abs(b.x - this.x);
        return length * width;
    }
}

main方法編譯後的位元組碼如下:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: (0x0009) ACC_PUBLIC, ACC_STATIC
  Code:
    stack=4, locals=4, args_size=1
       0: new           #2       // class test/Point
       3: dup
       4: iconst_1
       5: iconst_1
       6: invokespecial #3       // Method test/Point."<init>":(II)V
       9: astore_1
      10: new           #2       // class test/Point
      13: dup
      14: iconst_5
      15: iconst_3
      16: invokespecial #3       // Method test/Point."<init>":(II)V
      19: astore_2
      20: aload_1
      21: aload_2
      22: invokevirtual #4       // Method test/Point.area:(Ltest/Point;)I
      25: istore_3
      26: return

產生的新的指令有new , dup, 和 invokespecial。與程式語言的new操作相似,new指令建立運算元指向的特定的型別物件(象徵class Point)在堆上分配物件的記憶體,物件的引用推送至運算元棧。

dup 指令複製一份頂部的運算元值,意味著在運算元棧頂部我們有兩個指向Point物件的引用。下面三個指令將構造方法(用於初始化物件)的引數推送至運算元棧,然後呼叫一個與建構函式對應的特定初始化方法:下一個方法將會初始化欄位x和欄位y。當方法完成後,運算元棧頂部的三個值都已被消費,剩下的引用指向已建立的物件 (目前為止,已成功初始化).

init.png

下一步, astore_1 彈出Point 的引用 並賦值給索引為1的區域性變數 (the a in astore_1indicates this is a reference value).

init_store.png

重複以上過程初始化第二個Point物件,並賦值給變數b。

init2.png

init_store2.png

最後一步從區域性變數陣列的索引1和2分別載入兩個Point的引用 (分別使用 aload_1 和 aload_2),使用invokevirtual呼叫area 方法,根據物件的實際型別將呼叫排程到適當的方法。例如,如果變數a是SpecialPoint物件,Special繼承自Point並重寫了area方法,那麼將呼叫重寫的方法。本例子中,沒有子類,因此只有一個area方法可用。

area.png

注意到即使area方法接收一個引數,棧的頂部仍有兩個Point引用。第一個(pointA, 來自於變數 a) 實際上呼叫該方法的物件(另外在程式語言中被稱為this),它將作為area方法的新幀中第一個區域性變數傳遞過去 ,另一個運算元值 (pointB)將作為area方法的引數。

The Other Way Around(另一種使用場景)

你不需要精通每個指令和精確的執行流程,根據手邊的位元組碼來了解程式所作的工作。舉個例子,我想知道通過stream讀取檔案的程式碼是否有適當的關閉。提供以下位元組碼,它相對簡單去決定使用try-with-resources語句時是否stream作為一部分進行關閉。

public static void main(java.lang.String[]) throws java.lang.Exception;
 descriptor: ([Ljava/lang/String;)V
 flags: (0x0009) ACC_PUBLIC, ACC_STATIC
 Code:
   stack=2, locals=8, args_size=1
      0: ldc           #2                  // class test/Test
      2: ldc           #3                  // String input.txt
      4: invokevirtual #4                  // Method java/lang/Class.getResource:(Ljava/lang/String;)Ljava/net/URL;
      7: invokevirtual #5                  // Method java/net/URL.toURI:()Ljava/net/URI;
     10: invokestatic  #6                  // Method java/nio/file/Paths.get:(Ljava/net/URI;)Ljava/nio/file/Path;
     13: astore_1
     14: new           #7                  // class java/lang/StringBuilder
     17: dup
     18: invokespecial #8                  // Method java/lang/StringBuilder."<init>":()V
     21: astore_2
     22: aload_1
     23: invokestatic  #9                  // Method java/nio/file/Files.lines:(Ljava/nio/file/Path;)Ljava/util/stream/Stream;
     26: astore_3
            
           

相關推薦

Java位元組簡介(Introduction to Java Bytecode)

跟隨本篇文章深入研究JVM內部結構和java位元組碼,你將會知道如何分解你的檔案進行深入檢查。        對於一個經驗豐富的開發人員閱讀java位元組碼也是非常枯燥的事情。首先我們要弄清楚我們為什麼需要知道如此底層的東西?上週有一個能應用簡單的場景:很早以前我作了程式碼修

java位元組理解——Java bytecode:翻譯和解讀

本篇部落格是對Java bytecode:這篇文章的翻譯和解讀,原文連結在這 http://www.ibm.com/developerworks/library/it-haggar_bytecode/index.html 如有不正之處還請各位指教,不喜勿噴,相互交流才能進步。 下面正片開始 生成java位

使用JBE(Java Bytecode Editor)修改Java位元組

JBE JBE(Java Bytecode Editor)是一個Java位元組碼編輯工具,而且是開源的,該專案是基於jclasslib ej-technologies( https://github.com/ingokegel/jclasslib)位元組碼檢視工具和Apa

Brief introduction to Java String Split 【簡單介紹下Java String Split】

a-z include cte eve class some sim string arr Split is a common function in Java. It split a full string to an array based on delimeter.

一文讓你明白 Java 位元組

前言 也許你寫了無數行的程式碼,也許你能非常溜的使用高階語言,但是你未必瞭解那些高階語言的執行過程。例如大行其道的Java。 Java號稱是一門“一次編譯到處執行”的語言,但是我們對這句話的理解深度又有多少呢?從我們寫的java檔案到通過編譯器編譯成java位元組碼檔案(也就是.class檔案),這個過程

大話+圖說:Java位元組指令——只為讓你懂

前言 隨著Java開發技術不斷被推到新的高度,對於Java程式設計師來講越來越需要具備對更深入的基礎性技術的理解,比如Java位元組碼指令。不然,可能很難深入理解一些時下的新框架、新技術,盲目一味追新也會越來越感乏力。 本文既不求照本宣科,亦不求炫技或著文立說,僅力圖以最簡明、最形象生動的方式,結合例子與

java位元組-this分析

1.this我們用的非常多,但是沒有搞清楚為啥我們可以在例項方法中使用this。這裡我從java位元組碼的角度來分析this。 2.程式碼: public class Test { private static String hello(String hello){

深入理解java位元組

Javap 反編譯class檔案 –verbose 顯示冗餘資訊 (1)魔數:所有的class位元組碼檔案的4個位元組都是魔數,魔數固定值:0xCAFEBABE (2)版本:魔數之後4個位元組是版本資訊,前兩個位元組minor version次版本號例如0,後兩個位元組是主機板號majo

例項分析理解Java位元組

Java語言最廣為人知的口號就是“一次編譯到處執行”,這裡的“編譯”指的是編譯器將Java原始碼編譯為Java位元組碼檔案(也就是.class檔案,本文中不做區分),“執行”則指的是Java虛擬機器執行位元組碼檔案。Java的跨平臺得益於不同平臺上不同的JVM的實現,只要提供規範的位元組碼檔案,無論是什麼平臺

Java位元組結構剖析二:欄位表

access_flags 訪問標誌資訊包括該class檔案是類還是介面,是否定義成public,是否是abstract,如果是類,是否被申明為final。access_flags 的取值範圍和相應含義見下表。 我們的位元組碼裡該位置的16進製表示是0×0021。0×0021=0×0001 ^ 0×00

Java 位元組到 ASM 實踐

1. 概述 AOP(面向切面程式設計)的概念現在已經應用的非常廣泛了,下面是從百度百科上摘抄的一段解釋,比較淺顯易懂 在軟體業,AOP為Aspect Oriented Programming的縮寫,意為:面向切面程式設計,通過預編譯方式和執行期動態代理實現程式功能的統一維護的一種技術。AOP是OOP

Java位元組結構剖析三:方法表

這裡給大家介紹一款位元組碼分析小工具——jclasslib bytecode viewer。它可以將位元組碼檔案結構化的展現給我們看。 緊接著上篇『欄位表』的分析。後面的分析輪到了『方法表』。 方法表結構 u2 method_count:方法計數器,metho

Java位元組結構剖析一:常量池

這篇部落格開始,我打算帶大家去解讀一下JVM平臺下的位元組碼檔案(熟悉而又陌生的感覺)。眾所周知,Class檔案包含了我們定義的類或介面的資訊。然後位元組碼又會被JVM載入到記憶體中,供JVM使用。那麼,類資訊到了位元組碼檔案裡,它們如何表示的,以及在位元組碼裡是怎麼分佈的呢?帶著這些問題,讓我們

Java位元組指令收集大全

Java位元組碼指令大全 常量入棧指令 指令碼 操作碼(助記符) 運算元 描述(棧指運算元棧) 0x01 aconst_null  

Java位元組詳解(三)位元組指令(轉)

一、概述 Java虛擬機器採用基於棧的架構,其指令由操作碼和運算元組成。 操作碼:一個位元組長度(0~255),意味著指令集的操作碼個數不能操作256條。 運算元:一條指令可以有零或者多個運算元,且運算元可以是1個或者多個位元組。編譯後的程式碼沒有采用運算元長

一文讓你明白Java位元組

也許你寫了無數行的程式碼,也許你能非常溜的使用高階語言,但是你未必瞭解那些高階語言的執行過程。例如大行其道的Java。 Java號稱是一門“一次編譯到處執行”的語言,但是我們對這句話的理解深度又有多少呢?從我們寫的java檔案到通過編譯器編譯成java位元組碼檔案(也就是.

Java位元組淺析(—)

英文原文連結,譯文連結,原文作者:James Bloom,譯者:有孚 明白Java程式碼是如何編譯成位元組碼並在JVM上執行的非常重要,這有助於理解程式執行的時候究竟發生了些什麼。理解這點不僅能搞清語言特性是如何實現的,並且在做方案討論的時候能清楚相應的副作用及權衡利弊。 本文介紹了Java程

Java位元組淺析(二)

英文原文連結,譯文連結,原文作者:James Bloom,譯者:有孚 條件語句 像if-else, switch這樣的流程控制的條件語句,是通過用一條指令來進行兩個值的比較,然後根據結果跳轉到另一條位元組碼來實現的。 迴圈語句包括for迴圈,while迴圈,它們的實現方式也很類似,但有一點不同

Java位元組淺析(三)

英文原文連結,譯文連結,原文作者:James Bloom,譯者:有孚 從Java7開始,switch語句增加了對String型別的支援。不過位元組碼中的switch指令還是隻支援int型別,並沒有增加對其它型別的支援。事實上switch語句對String的支援是分成兩個步驟來完成的。首先,將每

java位元組指令列表

Mnemonic Opcode(in hex) Other bytes Stack [before]→[after] Description aaload 32