深入理解 Java 虛擬機(一)~ class 字節碼文件剖析

Java 虛擬機系列文章目錄導讀:

深入理解 Java 虛擬機(一)~ class 字節碼文件剖析
深入理解 Java 虛擬機(二)~ 類的加載過程剖析
深入理解 Java 虛擬機(三)~ class 字節碼的執行過程剖析
深入理解 Java 虛擬機(四)~ 各種容易混淆的常量池
深入理解 Java 虛擬機(五)~ 對象的創建過程
深入理解 Java 虛擬機(六)~ Garbage Collection 剖析

前言

我們知道 Java 代碼會先編譯成 class 字節碼,然後 Java 虛擬機加載執行這個字節碼。
實際上除了 Java 語言編譯成 class 字節碼,其他的諸如 Clojure、Scala、Kotlin、Groovy 等語言都是編譯成 class 字節碼運行在 Java 虛擬機上的。所以可以看出 class 字節碼不是專屬於 Java 語言的。任何編譯成符合 Java 虛擬機規範的 class 字節碼的語言都可以在 Java 虛擬機上運行,這正是 Java 虛擬機語言無關性的體現。
所以學習 class 字節碼不僅有助於理解 Java 語言,也是有助於將來學習和理解其他基於 Java 虛擬機之上運行的語言。本文主要介紹包括 class 字節碼的構成、字節碼的指令,學習的目的是爲了在實際工作中應用,本文除了介紹字節碼的構成和指令集,還介紹了在實際工作通過查看 class 字節碼來 提高代碼質量 和 對Java特性的理解。

class字節碼的構成

class 字節碼文件是由一組以 8 位字節爲基礎單位的二進制流,各個數據項嚴格按照順序緊湊地排列在字節碼文件中,中間沒有任何分隔符,這使得整個 class 文件存儲的內容幾乎全部是程序運行的必要數據。

數據項主要有兩種數據類型:無符號數和表。無符號數是一個基本數據類型,以 u1、u2、u4、u8 來分別代表 1、2、4、8 個字節的無符號數。無符號數可以用來描述數字、索引引用、數量值。 是一個複合數據類型,它由一個或多個無符號數或者其他表作爲數據項構成。

那麼 class 字節碼文件中的數據項有哪些呢?如下所示:

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

上面摘自 oracle官網, 可見 class 字節碼文件主要由以上 16 項組成。

我們來寫一個簡單的 Java 代碼:

@Deprecated
public class Client implements Serializable {

    @Deprecated
    private String username;
    
    public String getUsername() {
        return username;
    }
    public void setUsername(String username) {
        this.username = username;
    }
    public static void main(String[] args) {
        Client client = new Client();
        client.setUsername("Chiclaim");
        System.out.println(client.getUsername());
    }
}

上面的代碼很簡單,定義了一個名爲 Client 的類,它實現了 Serializable 接口,類有個 @Deprecated 註解,類裏有一個 username 字段和它的 gettersetter 方法,字段也有一個 @Deprecated 註解,裏面一個 main 方法

通過 javac 命令將該類編譯下,通過 Sublime 查看 Client.class 文件:

class byte code

Sublime 將 class 字節碼文件轉成 16 進制,然後展示出來。下面我們通過這個字節碼文件分析下class字節碼的組成部分:

magic

前面講到 class 字節碼組成的時候提到,字節碼文件開頭是一個魔數(Magic Number),它佔 4 個字節。上圖的 cafe babe 就是魔數,它的作用是確定這個文件是否能夠被虛擬機執行。因爲文件的後綴可以被隨意修改,所以僅僅通過文件的後綴是不能判斷某個文件是否是 class 字節碼文件。

minor_version & major_version

緊接着魔數後的是副版本號(minor_version)和主版本號(major_version),他們分別佔用 2 個字節。

minor_version 和 major_version 組合到一起,決定了class文件格式的版本。假設 class 文件的 major_version 爲 M,minor_version 爲 m,那麼我們將 class 文件格式的版本記爲 M.m

class 字節碼文件的版本號是從 45 開始的,JDK 1.1 對應的版本號爲 45,JDK 1.1 之後的每個 JDK 大版本發佈主版本號向上加 1。例如 JDK 1.1.* 的 JVM 支持版本號爲 45.0 ~ 45.65535 的 class 文件;JDK 1.k(k>=2) 的 JVM 支持的版本號爲 45 ~ 44+k.0

高版本的 JDK 能向下兼容之前的版本的 class 文件 ,但不能運行以後版本的 class 文件。

上面的十六進制的 class 文件可以看出,魔數後面的副版本號是 0000 ,主版本號是 0034,十六進制的 34 對應十進制的 52,因爲我的本機環境的 JDK 版本是 1.8 所以對應主版本就是 52(45+7)

constant_pool_count & constant_pool

上面我們分析了字節碼文件中的魔數、副版本號、主版本號,但是使用上面的方式來分析比較累,每個數據項佔用的字節數還不一樣,我們需要對着那個十六進制文件一個一個地數,非常不方便。我們通過 classpy 來分析字節文件中的數據項,classpy 會列出字節碼文件中的所有數據項,選中某個數據項會自動選中數據項對應的內容,例如選中 magic 數據項,classpy 會幫我們選中它對應的數據內容(CAFEBABE):

classpy

緊接着版本號後面的數據項是 constant_pool_count (常量池中的常量個數),constant_pool_count 後面是常量池(constant_pool),由於 constant_pool 的個數是不確定的所以前面需要放置一個 constant_pool_count 來描述常量的個數。但是 constant_pool_count 是從 1 開始數的,比如 constant_pool 有 45 個常量,那麼 constant_pool_count 就等於 46。從 classpy 中可以直觀的看出 Client.class 總共有 45(46-1) 個常量。例如上面的 Client.class 的常量池:

在這裏插入圖片描述

constant_pool(常量池)是 class 字節碼文件中佔用最大數據項之一。常量池主要存放兩大類的常量:

  • 字面量(Literal)

    字面量類似於 Java 中的常量,如字符串,數字等

  • 符號引用(Symbolic)

    主要包括 3 類常量:

    • 類和接口的全限定名
    • 字段的名稱和描述符
    • 方法的名稱和描述符

常量池中的數據項都是表(table),也就是說都是複雜類型的。爲了區分不同的數據項的類型,每個數據項都有一個 tag 屬性,用於區分不同的數據項,常量池中主要有如下不同的數據項:

Constant Type Tag
CONSTANT_Class 7
CONSTANT_Fieldref 9
CONSTANT_Methodref 10
CONSTANT_InterfaceMethodref 11
CONSTANT_String 8
CONSTANT_Integer 3
CONSTANT_Float 4
CONSTANT_Long 5
CONSTANT_Double 6
CONSTANT_NameAndType 12
CONSTANT_Utf8 1
CONSTANT_MethodHandle 15
CONSTANT_MethodType 16
CONSTANT_InvokeDynamic 18

下面我們介紹下常量池中的這些數據項

CONSTANT_Class_info

CONSTANT_Class_info 結構的數據項主要用來描述一個 或者 接口 的,數據結構如下所示:

CONSTANT_Class_info {
    u1 tag;
    u2 name_index;
}

因爲它是 CONSTANT_Class 數據項,所以 tag 值是 7 ,name_index 是索引到常量池中的其他數據項,因爲類或者接口是有名字的,所以 name_index 指向的是常量池中的 Utf8 數據項:

在這裏插入圖片描述

CONSTANT_XXXref_info

主要有 字段引用、方法引用、接口方法引用 3 個數據結構:

CONSTANT_Fieldref_info {
    u1 tag;  // 9
    // 指向常量池中的 CONSTANT_Class_info
    u2 class_index;
    // 指向常量池中的 CONSTANT_NameAndType
    u2 name_and_type_index; 
}

CONSTANT_Methodref_info {
    u1 tag;  // 10
    // 指向常量池中的 CONSTANT_Class_info
    u2 class_index;
    // 指向常量池中的 CONSTANT_NameAndType
    u2 name_and_type_index; 
}

CONSTANT_InterfaceMethodref_info {
    u1 tag;  // 11
    // 指向常量池中的 CONSTANT_Class_info
    u2 class_index;
    // 指向常量池中的 CONSTANT_NameAndType
    u2 name_and_type_index; 
}

CONSTANT_String_info

CONSTANT_String_info 用於描述常量池中的 String 對象,數據結構爲:

CONSTANT_String_info {
    u1 tag; // 8
    // 指向常量池中的 CONSTANT_Utf8_info
    u2 string_index;
}

CONSTANT_Integer_info & CONSTANT_Float_info

CONSTANT_Integer_info 和 CONSTANT_Float_info 用於描述 4 字節的數字(int、float),數據結構爲:

CONSTANT_Integer_info {
    u1 tag;   // 3
    u4 bytes;
}

CONSTANT_Float_info {
    u1 tag;  // 4
    u4 bytes;
}

CONSTANT_Float_info 裏的 bytes 用於描述 float 常量,首先將 bytes 轉成 int 常量的 bit,然後經過下面的流程,最終形成它要表示的值:

  • 如果 bits 是 0x7f800000, 那麼浮點數的值是正無窮

  • 如果 bits 是 0xff800000, 那麼浮點數的值是負無窮

  • 如果 bits 在 0x7f800001 ~ 0x7fffffff 或者在 0xff800001 ~ 0xffffffff 之間,那麼浮點數的值是 NaN.

  • 其他情況通過下面的方式計算出來:

    int s = ((bits >> 31) == 0) ? 1 : -1;
    int e = ((bits >> 23) & 0xff);
    int m = (e == 0) ?
      (bits & 0x7fffff) << 1 :
      (bits & 0x7fffff) | 0x800000;
    

    浮點數的值 = s · m · 2^(e-150),下面以計算浮點數是 2.5 的情況:

    // 2.5 轉成 int bits
    int bits = Float.floatToIntBits(2.5f);
    int s = ((bits >> 31) == 0) ? 1 : -1;
    int e = ((bits >> 23) & 0xff);
    int m = (e == 0) ?
            (bits & 0x7fffff) << 1 :
            (bits & 0x7fffff) | 0x800000;
            
    // s = 1, e = 128, m = 10485760
    // 10485760 * 2^(128-150) = 2.5
    

CONSTANT_Long_info & CONSTANT_Double_info

CONSTANT_Long_infoCONSTANT_Double_info 用於描述 8 字節的數字(long/double)

CONSTANT_Long_info {
    u1 tag;  // 5
    u4 high_bytes;
    u4 low_bytes;
}

CONSTANT_Double_info {
    u1 tag;  // 6
    u4 high_bytes;
    u4 low_bytes;
}

所有佔據 8 字節的常量,在常量池中都佔用 2 個 entry,如下圖所示:

8字節常量

從上圖可以看出 CONSTANT_Long_info 的編號是 2 ,它的下一個數據項的編號變成了 4,因爲它佔用了 2 個 entry

8 字節的常量使用 high_byteslow_bytes 來描述常量值。

CONSTANT_Long_info 描述的值通過下面算法來描述:

((long) high_bytes << 32) + low_bytes

已上圖的 123456789000000000 爲例,它的 hight_bytes = 0x01B69B4B,low_bytes = A5749200

轉成十進制,然後計算: (28744523L << 32) + 2775880192L = 123456789000000000

CONSTANT_Double_info 描述的值通過以下流程來表示:

先將 high_bytes 和 low_bytes 通過下面算法轉成 long

((long) high_bytes << 32) + low_bytes

然後獲取將該值的 long 常量 bits:

  • 如果 bits 是 0x7ff0000000000000L, 那麼浮點型的值爲正無窮

  • 如果 bits 是 0xfff0000000000000L, 那麼浮點型的值是負無窮

  • 如果 bits 在 0x7ff0000000000001L ~ 0x7fffffffffffffffL0xfff0000000000001L ~ 0xffffffffffffffffL 區間, 那麼浮點數的值爲 NaN.

  • 其他情況通過下面的方式計算出來:s · m · 2^(e-1075)

    int s = ((bits >> 63) == 0) ? 1 : -1;
    int e = (int)((bits >> 52) & 0x7ffL);
    long m = (e == 0) ?
           (bits & 0xfffffffffffffL) << 1 :
           (bits & 0xfffffffffffffL) | 0x10000000000000L
    

    以浮點數爲 3.1415926 爲例:

    double d = 3.1415926;
    long bits = Double.doubleToLongBits(d);
    int s = ((bits >> 63) == 0) ? 1 : -1;
    int e = (int) ((bits >> 52) & 0x7ffL);
    long m = (e == 0) ?
            (bits & 0xfffffffffffffL) << 1 :
            (bits & 0xfffffffffffffL) | 0x10000000000000L;
            
    // s = 1, e = 1024, m = 7074237631354954
    // 7074237631354954*2^(1024-1075) = 3.1415926
    

CONSTANT_NameAndType_info

CONSTANT_NameAndType_info 用於描述字段、方法,數據結構爲:

CONSTANT_NameAndType_info {
    u1 tag;  // 12
    u2 name_index;
    u2 descriptor_index;
}

name_index 指向常量池中的 CONSTANT_Utf8_infoCONSTANT_Utf8_info 裏是字段或者方法的名字
descriptor_index 指向常量池中的 CONSTANT_Utf8_infoCONSTANT_Utf8_info 裏是字段或者方法的描述符

CONSTANT_Utf8_info

CONSTANT_Utf8_info 用於描述字符串常量,數據結構爲:

CONSTANT_Utf8_info {
    u1 tag;
    u2 length;
    u1 bytes[length];
}

CONSTANT_MethodHandle_info

CONSTANT_MethodHandle_info 用於描述方法句柄(method handle)

MethodHandle 是 Java1.7 新特性,它提供了一種新的確定動態目標方法的機制,Method Handle 使得 Java 擁有了類似函數指針或委託的方法別名的工具。調用一個方法一般有直接調用或者通過反射來調用。反射更像是針對 Java 語言的,而 MethodHandle 是針對 class 字節碼的。MethodHandle 要比反射效率要高。例如下面通過方法句柄的方式來調用 sum 方法:


public class MethodHandleTest {
    public int sum(int a, int b) {
        return a + b;
    }
}

public class Client {
    public static void main(String[] args) {
    
        // 方法描述符(sum)
        MethodType methodType = MethodType.methodType(int.class, new Class[]{int.class, int.class});

        try {
            // 通過方法名和方法描述符找到方法句柄(method handle)
            MethodHandle methodHandle = MethodHandles.lookup()
                    .findVirtual(MethodHandleTest.class, "sum", methodType);

            // 通過方法句柄調用方法
            int result = (int) methodHandle.invoke(new MethodHandleTest(), 1, 2);

            System.out.println(result);

        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
    }
}

更多關於 MethodHandle 的細節有興趣的可以查看相關資料。

CONSTANT_MethodHandle_info 的數據結構爲:

CONSTANT_MethodHandle_info {
    u1 tag; // 15
    u1 reference_kind;
    u2 reference_index;
}

reference_kind 的值必須要在 [1 ~ 9] 區間. 用來區分不同的函數句柄類型:

Kind Description Interpretation
1 REF_getField getfield C.f:T
2 REF_getStatic getstatic C.f:T
3 REF_putField putfield C.f:T
4 REF_putStatic putstatic C.f:T
5 REF_invokeVirtual invokevirtual C.m:(A*)T
6 REF_invokeStatic invokestatic C.m:(A*)T
7 REF_invokeSpecial invokespecial C.m:(A*)T
8 REF_newInvokeSpecial new C; dup; invokespecial C.:(A*)void
9 REF_invokeInterface invokeinterface C.m:(A*)T

例如上面 MethodHandles.lookup().findVirtual 對應的就是 REF_invokeVirtual

reference_index 指向常量池中的 Methodref

CONSTANT_MethodType_info

CONSTANT_MethodType_info 用於描述方法的描述符,數據結構如下:

CONSTANT_MethodType_info {
    u1 tag;  // 16
    u2 descriptor_index;
}

descriptor_index 指向常量池中的 CONSTANT_Utf8_info,表示方法的描述符

CONSTANT_InvokeDynamic_info

CONSTANT_InvokeDynamic_info 被用於 invokedynamic 指令

所以爲了講解 CONSTANT_InvokeDynamic_info 需要先理解 invokedynamic 指令,該指令是所有指令當中最複雜的一個。

invokedynamic 指令是在 JDK1.7 加入的,用於更好的支持運行在 JVM 上的動態語言

我們看下 CONSTANT_InvokeDynamic_info 的數據結構:

CONSTANT_InvokeDynamic_info {
    u1 tag; // 18
    u2 bootstrap_method_attr_index;
    u2 name_and_type_index;
}

bootstrap_method_attr_index 不是指向常量池中的數據項,而是指向 attributes/BootstrapMethods/bootstrap_methods 中的數據。

JDK1.8 中該指令也用於實現 lambda 表達式。我們寫一個最簡單的使用了 lambda 的 Java 文件:

public class InvokeDynamic {
    public static void main(String[] args) {
        Runnable x = () -> System.out.println(args.length);
    }
}

attributes/BootstrapMethods/bootstrap_methods 中的數據項如下所示:

bootstrap_methods

接下來我們基於程序來分析invokedynamic 指令,通過 javap 命令看下它的字節碼:

//javap -c -p -v InvokeDynamic.class

public class class_bytecode.InvokeDynamic {

  // 爲了減少篇幅,省略一些常量池...
  
  public class_bytecode.InvokeDynamic();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1    
         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=1, locals=2, args_size=1
         0: aload_0
         1: invokedynamic #2,  0
         6: astore_1
         7: return
      LineNumberTable:
        line 7: 0
        line 8: 7

  private static void lambda$main$0(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #3
         3: aload_0
         4: arraylength
         5: invokevirtual #4 
         8: return
      LineNumberTable:
        line 7: 0
}

由於我們這個 Java 程序非常簡單,所以只需要看 main 方法的關鍵部分即可,發現裏面是用來 invokedynamic 指令:

1: invokedynamic #2,  0

它對應的常量池的數據項爲(#02):

invokedynamic

bootstrap_method_attr_index 指向的是 bootstrap_methods 第0個元素(#0):

bootstrap_methods0

bootstrap_method_ref 指向常量池中的 CONSTANT_MethodHandle 數據項,經查看它調用了 LambdaMetafactory.metafactory 方法,我們來看下該方法:

public static CallSite metafactory(MethodHandles.Lookup caller,
                                   String invokedName,
                                   MethodType invokedType,
                                   MethodType samMethodType,
                                   MethodHandle implMethod,
                                   MethodType instantiatedMethodType)
        throws LambdaConversionException {
    AbstractValidatingLambdaMetafactory mf;
    mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                         invokedName, samMethodType,
                                         implMethod, instantiatedMethodType,
                                         false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
    mf.validateMetafactoryArgs();
    return mf.buildCallSite();
}

關鍵的方法是 buildCallSite:

CallSite buildCallSite() throws LambdaConversionException {

	// 通過 ASM 生成內部類的class
	final Class<?> innerClass = spinInnerClass();
	if (invokedType.parameterCount() == 0) {
		// 生成的內部類構造函數是私有的,通過反射setAccessible(true)
		final Constructor<?>[] ctrs = AccessController.doPrivileged(
				new PrivilegedAction<Constructor<?>[]>() {
			@Override
			public Constructor<?>[] run() {
				Constructor<?>[] ctrs = innerClass.getDeclaredConstructors();
				if (ctrs.length == 1) {
					// The lambda implementing inner class constructor is private, set
					// it accessible (by us) before creating the constant sole instance
					ctrs[0].setAccessible(true);
				}
				return ctrs;
			}
				});
		
		try {
			// 通過反射獲取內類的對象
			Object inst = ctrs[0].newInstance();
			// 組裝一個 MethodHandle 傳遞給 CallSite
			return new ConstantCallSite(MethodHandles.constant(samBase, inst));
		}
		catch (ReflectiveOperationException e) {
			throw new LambdaConversionException("Exception instantiating lambda object", e);
		}
	} else {
		try {
			UNSAFE.ensureClassInitialized(innerClass);
			return new ConstantCallSite(
					MethodHandles.Lookup.IMPL_LOOKUP
						 .findStatic(innerClass, NAME_FACTORY, invokedType));
		}
		catch (ReflectiveOperationException e) {
			throw new LambdaConversionException("Exception finding constructor", e);
		}
	}
}

可以看出 CallSite 裏封裝了方法句柄 HandleMethod ,HandleMethod 裏有內部類對象,便於將來調用內部類對象的方法。

最後的關鍵在於 JVM 生成了一個什麼樣的 Class,我們可以通過下面的配置,告訴虛擬機保存產生的 Class

System.setProperty("jdk.internal.lambda.dumpProxyClasses", ".");

生成的類如下所示:

// $FF: synthetic class
final class InvokeDynamic$$Lambda$1 implements Runnable {
    private final String[] arg$1;

    private InvokeDynamic$$Lambda$1(String[] var1) {
        this.arg$1 = var1;
    }

    private static Runnable get$Lambda(String[] var0) {
        return new InvokeDynamic$$Lambda$1(var0);
    }

    @Hidden
    public void run() {
        InvokeDynamic.lambda$main$0(this.arg$1);
    }
}

InvokeDynamic$$Lambda$1 就是運行時產生的代理類,它是 InvokeDynamic 的內部類。這個內部類實現了 Runnable 接口,run 方法裏調用的是外部類的靜態方法 lambda$main$0,該靜態方法體就是 lambda 的代碼體。

可以看出 Java1.8 的 lambda 底層使用了動態代理技術,然後通過 MethodHandle 來調用這個動態代理類。學習過 Kotlin lambda 的讀者知道,爲了兼容 Java6,Kotlin 實現 lambda 也是通過內部類來實現的,但是是在編譯期生成內部類,所以會對代碼體積有影響,而 Java1.8 是通過動態生成代理類的方式來實現的,然後通過 invokeDynamic 指令來調用該動態代理類,所以對代碼體積不會產生影響。這是 Java 和 Kotlin 在實現 Lambda 上的不同。

access_flags

access_flags 用於描述類或接口的訪問權限和屬性:

Flag Name Value Interpretation
ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
ACC_FINAL 0x0010 Declared final; no subclasses allowed.
ACC_SUPER 0x0020 Treat superclass methods specially when invoked by the invokespecial instruction.
ACC_INTERFACE 0x0200 Is an interface, not a class.
ACC_ABSTRACT 0x0400 Declared abstract; must not be instantiated.
ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.
ACC_ANNOTATION 0x2000 Declared as an annotation type.
ACC_ENUM 0x4000 Declared as an enum type.

類或者接口可能會有多個 access_flagaccess_flags 的計算公式爲:access_flags = flag1 | flag2 | flag3 ...

例如 Client.class 的 access_flags 爲 ACC_PUBLIC、ACC_SUPER,所以 access_flags = 0x0001 | 0x0020 = 33, 33 的 十六進制爲 21,經 classpy 查看 access_flags 正是 21

this_class & super_class & interfaces

this_class 用於描述當前類的 class 文件,它指向常量池中的 Class 數據項

super_class 用於描述類或接口的父類,它指向常量池中的 Class 數據項。需要注意的是如果一個接口繼承了另一個接口,該接口的父類是 Object,而不是繼承的這個類,例如:

// super_class 爲 Object
public interface MyInterface extends Serializable {
}

interfaces 用於描述類實現了哪些接口,因爲可以實現多個接口,所以 interfaces 是個不定長的數組,所以 interfaces 前面需要放置 interfaces_count

需要注意的時候,接口可以繼承多個接口,例如上面的 MyInterface,它繼承了 SerializableSerializableinterfaces 數組中

fields_count & fields

fields 用來描述類中的字段的的,因爲 field 的數量也是不確定的,所以 fields 前面需要放置 fields_count

字段的數據結構爲:

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}
  • access_flags

    access_flags 用於描述字段的訪問權限及其他屬性:

    Flag Name Value Interpretation
    ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
    ACC_PRIVATE 0x0002 Declared private; usable only within the defining class.
    ACC_PROTECTED 0x0004 Declared protected; may be accessed within subclasses.
    ACC_STATIC 0x0008 Declared static.
    ACC_FINAL 0x0010 Declared final; never directly assigned to after object construction.
    ACC_VOLATILE 0x0040 Declared volatile; cannot be cached.
    ACC_TRANSIENT 0x0080 Declared transient; not written or read by a persistent object manager.
    ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.
    ACC_ENUM 0x4000 Declared as an element of an enum.
  • name_index

    描述字段的名稱,指向常量池中的 uft8 數據項

  • descriptor_index

    指向常量池中的 uft8 數據項,表示字段的描述符

  • attributes_count

    字段屬性的數量

  • attributes

    字段的屬性列表,例如上面的 username 字段上加了 Deprecated 註解

    field-attributes

methods_count & methods

method_info 用於描述類中的方法,一個類的方法數不確定的,所有需要在前面放置 methods_count

method_info 的數據結構爲:

method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}
  • access_flags

    Flag Name Value Interpretation
    ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
    ACC_PRIVATE 0x0002 Declared private; accessible only within the defining class.
    ACC_PROTECTED 0x0004 Declared protected; may be accessed within subclasses.
    ACC_STATIC 0x0008 Declared static.
    ACC_FINAL 0x0010 Declared final; must not be overridden (§5.4.5).
    ACC_SYNCHRONIZED 0x0020 Declared synchronized; invocation is wrapped by a monitor use.
    ACC_BRIDGE 0x0040 A bridge method, generated by the compiler.
    ACC_VARARGS 0x0080 Declared with variable number of arguments.
    ACC_NATIVE 0x0100 Declared native; implemented in a language other than Java.
    ACC_ABSTRACT 0x0400 Declared abstract; no implementation is provided.
    ACC_STRICT 0x0800 Declared strictfp; floating-point mode is FP-strict.
    ACC_SYNTHETIC 0x1000 Declared synthetic; not present in the source code.
  • name_index

    指向常量池中的 uft8 數據項,用於描述方法的名稱

  • descriptor_index

    指向常量池中的 uft8 數據項,用於描述方法的描述符

  • attributes_count

    屬性的常量

  • attributes

    可用於方法的屬性有:

    • Code
    • Exceptions
    • Signature
    • Deprecated
    • RuntimeVisibleAnnotations
    • RuntimeVisibleParameterAnnotations
    • RuntimeInvisibleParameterAnnotations
    • AnnotationDefault

    例如 getUsername() 方法上加上 Deprecated 註解:

    method-attribute

attributes_count & attributes

attribute_info 可用於 ClassFile, field_info, method_info 和 Code_attribute 等數據結構

attribute_info 的數量也是不確定的,所以需要它之前放置 attributes_count

attribute_info數據結構爲:

attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

預定義好的 class 字節碼文件屬性有(每個屬性可以查看官方文檔):

Attribute Java SE 版本 class file 版本
ConstantValue 1.0.2 45.3
Code 1.0.2 45.3
StackMapTable 6 50.0
Exceptions 1.0.2 45.3
InnerClasses 1.1 45.3
EnclosingMethod 5.0 49.0
Synthetic 1.1 45.3
Signature 5.0 49.0
SourceFile 1.0.2 45.3
SourceDebugExtension 5.0 49.0
LineNumberTable 1.0.2 45.3
LocalVariableTable 1.0.2 45.3
LocalVariableTypeTable 5.0 49.0
Deprecated 1.1 45.3
RuntimeVisibleAnnotations 5.0 49.0
RuntimeInvisibleAnnotations 5.0 49.0
RuntimeVisibleParameterAnnotations 5.0 49.0
RuntimeInvisibleParameterAnnotations 5.0 49.0
AnnotationDefault 5.0 49.0
BootstrapMethods 7 51.0

在這裏我們只介紹下比較重要的 Code 屬性,上面介紹的 method_info 裏的也有 Code 屬性。Code 的數據結構爲:

Code_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 max_stack;
    u2 max_locals;
    u4 code_length;
    u1 code[code_length];
    u2 exception_table_length;
    {   u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}

例如上面的 Client.java 中的 getUsername() 方法:

@Deprecated
public String getUsername() {
    return username;
}

它對應的 Code 屬性如下所示:

Code屬性

max_stack操作數棧 深度的最大值,在方法執行的時候, 操作數棧都不會超過這個深度,虛擬機根據這個值來分配 棧幀 中的操作數棧深度

max_locals局部變量表 所需要的存儲空間。max_locals 的單位是 Slot,Slot 是虛擬機爲局部變量分配內存所用的最小單位。

對於 4 字節類型的數據,每個局部變量只佔用 1 個 Slot,佔用 8 字節數據類型佔用兩個 Slot。

例如我們上面介紹的常量池數據項的 long 和 double 就在常量池中分別佔用 2 個 entry

方法的參數(包括實例方法的隱含參數 this)、顯示處理異常的參數(如 catch(Exception e))、方法體中定義的局部變量都需要用局部變量表來存放。

需要注意的是,並不是方法中有多少個局部變量 ,max_locals 就設置多少,因爲當方法裏的代碼執行超出局部變量的作用域,那麼這個局部變量所佔用的 Slot 可以被其他變量所使用

Javac 編譯器會根據變量的作用域來分配 Slot 給各個變量使用,然後計算出 max_locals 的大小。舉個例子:

public void test() {
    int count = 0;
    for (int i = 0; i < 10; i++) {
        int num = i;
        count += num;
    }
    System.out.println(count);
}

// 通過 javap 反編譯查看局部變量表(locals)大小:
stack=2, locals=4, args_size=1

在循環中定義 num 變量,循環 10 次,num 變量始終都會共用一個 Slot,局部變量表佔用 Slot 情況如下所示:

Slot1 -> this // 每個實例方法都會隱含 this 參數
Slot2 -> conut
Slot3 -> i
Slot4 -> num

code_length 表示 code[] 字節數組大小,需要注意是 官方文檔:Static Constraints 規定 code_length 不能超過 65536

code[] 裏面存放着方法代碼的字節碼,裏面是一系列的字節碼指令

method code

至此,我們就把 class 字節碼的結構介紹完。下面開始介紹字節碼指令。

class字節碼指令

JVM 中的指令非常多,大概 200 多個,由於篇幅的原因,我將這些指令集整理放在 Github 上。

因爲指令集非常多,這裏僅舉一個例子來分析字節碼指令集,作爲拋磚引玉:

public static void main(String[] args) {
    int a = 10;
    int b = 15;
    int sum = a + b;

    float f1 = 1.1f;
    float f2 = 1.2f;
    float f3 = 1.3f;
    float fSum = f1 + f2 + f3;

    double d1 = 3.14;
}

通過 javap 反編譯其字節碼文件:

stack=2, locals=10, args_size=1
      0: bipush        10                    
      2: istore_1                            
      3: bipush        15                    
      5: istore_2                            
      6: iload_1                             
      7: iload_2                             
      8: iadd                                
      9: istore_3                            
     10: ldc           #2    // float 1.1f   
     12: fstore        4                     
     14: ldc           #3    // float 1.2f   
     16: fstore        5                     
     18: ldc           #4    // float 1.3f   
     20: fstore        6                     
     22: fload         4                     
     24: fload         5                     
     26: fadd                                
     27: fload         6                     
     29: fadd                                
     30: fstore        7                     
     32: ldc2_w        #5    // double 3.14d 
     35: dstore        8                     
     37: return

可以看出操作數棧爲最大深度爲2,局部變量表大小爲 10

爲什麼局部變量表大小爲 10?靜態方法 main 有 1 個參數 args,3 個 int 變量,4 個 float 變量,1 個 double 變量,因爲 double 佔 8 個字節,所有佔用 2 個 Slot,所以局部變量表大小爲:1 + 3 + 4 + 2 = 10

爲了方便描述,我在每一行的指令後面都添加了操作數棧和局部變量表的內部細節,如下圖所示:

class-instruction

學習字節碼對開發的指導意義

  • 1. 內存泄漏

    Android 開發的同學都知道,在 Activity 中定義 Handler 內部類,會導致內存泄漏。說內部類會對外部類有一個引用。通過這句話可能比較抽象,我們並不好理解。
    這個時候就可以分析其class字節碼的方式一看究竟。下面我們寫一個簡單的 Activity 裏面包含了一個 Handler:

    public class MyActivity extends AppCompatActivity {
    
        private Handler handler = new Handler(){
            @Override
            public void handleMessage(Message msg) {
                super.handleMessage(msg);
            }
        };
    
    }
    

    然後通過 javap 查看其字節碼:

    class com.chiclaim.MyActivity$1 extends android.os.Handler {
    
      final com.chiclaim.MyActivity this$0;
    
      com.chiclaim.MyActivity$1(com.chiclaim.MyActivity);
        Code:
           0: aload_0
           1: aload_1
           2: putfield      #1                  // Field this$0:Lcom/zmsoft/ccd/module/cateringorder/detail/viewholder/MyActivity;
           5: aload_0
           6: invokespecial #2                  // Method android/os/Handler."<init>":()V
           9: return
    
      public void handleMessage(android.os.Message);
        Code:
           0: aload_0
           1: aload_1
           2: invokespecial #3                  // Method android/os/Handler.handleMessage:(Landroid/os/Message;)V
           5: return
    }
    

    通過字節碼我們發現,原來內部類會將外部類當做自己的一個成員變量,通過內部類的構造方法參數將外部類實例傳遞進來,翻譯爲 Java 代碼就是:

    class com.chiclaim.MyActivity$1 extends android.os.Handler {
      final com.chiclaim.MyActivity out;
      
      com.chiclaim.MyActivity$1(com.chiclaim.MyActivity out){
    	this.out = out;
      }
      
      public void handleMessage(android.os.Message){
    	super.handleMessage(msg);
      }
    }
    

    需要注意的是,並不是有了內部類就一定會導致內存泄漏。當內部類的實例生命週期比外部類實例要長,纔會導致內存泄漏。

  • 2. 字符串拼接

    在內存優化的時候,我們也經常看到很多文章說字符串拼接的時候不要使用 + 加號,要是用 StringBuilder,減少對象的創建。
    其實 Java 編譯器已經爲我們做了優化,如:

    public static void main(String[] args) {
        String str = "before" + System.currentTimeMillis() + "after";
    }
    

    通過 javap 查看字節碼:

    public static void main(java.lang.String[]);
        Code:
           0: new           #2                  // class java/lang/StringBuilder
           3: dup
           4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
           7: ldc           #4                  // String before
           9: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
          12: invokestatic  #6                  // Method java/lang/System.currentTimeMillis:()J
          15: invokevirtual #7                  // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
          18: ldc           #8                  // String after
          20: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
          23: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
          26: astore_1
          27: return
    }
    

    我們發現編譯器會自動幫我們創建 StringBuilder 來拼接字符串。

    需要注意是:並不是字符串使用 + 加號拼接,編譯器就一定會爲我們創建StringBuilder 對象,如果是字符串字面量之間的拼接,或字符串字面量和數字字面量拼接,編譯器都不會使用 StringBuidler,如:

    public class Client {
    
    public static final int AGE = 17;
    
    public static void main(String[] args) {
        String str = new String("Chiclaim" + "Hello");
        String str2 = "Chiclaim" + AGE;
        }
    }
    

    編譯後,並不會使用 StringBuilder,上面的代碼相當於:

    String str = new String("ChiclaimHello");
    String str2 = "Chiclaim17";
    

    所以總結來說,如果字符串拼接的過程中有變量,編譯器會爲我們創建 StringBuilder。

    現在的編譯器這麼智能,是否代表我們就可以隨意使用 + 加號呢?不是的,例如:

    public static void main(String[] args) {
        int age = 18;
        String str = age + "";
    }
    

    上面通過 + 加號將 int 整型變成 string,編譯期也會爲我們新建一個 StringBuilder 對象,這個時候就有點浪費了,我們可以通過 String.valueOf() 方法來代替加號:

    String str = String.valueOf(age);
    

    所以,對於基本類型轉 String,不要使用加號拼接,使用 valueOf 方法.

  • 3. 深入理解裝箱與拆箱

    我們經常在技術文章上或者面試中碰到這樣的問題:

    public static void main(String[] args) {
        Integer j = 10000;
        Integer k = 10000;
    
        System.out.println(j==k);
    }
    

    看過面試寶典的都知道,上面的代碼輸出 false。爲什麼呢?一般答案是:因爲 Java 的整型 Integer 會通過數組來緩存 [-128~ 127] 之間的整型,只要在這個區間,都會複用裏面的對象。但是上面的 10000 肯定是超過了這個區間所以會創建新的對象。
    其實看完這樣的解釋,依然有點怪怪的:我們只是將 10000 賦值給一個 Integer,怎麼就創建了一個對象呢?我們也沒有使用 new 關鍵字啊。這個時候我們就可以看看這段代碼的字節碼了:

    public static void main(java.lang.String[]);
    Code:
       0: sipush        10000
       3: invokestatic  #2      // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       6: astore_1
       7: sipush        10000
      10: invokestatic  #2      // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      13: astore_2
      14: getstatic     #3      // Field java/lang/System.out:Ljava/io/PrintStream;
      17: aload_1
      18: aload_2
      19: if_acmpne     26
      22: iconst_1
      23: goto          27
      26: iconst_0
      27: invokevirtual #4     // Method java/io/PrintStream.println:(Z)V
      30: return
    

    由此可見,上面的 Integer j = 10000; 編譯之後相當於:

    Integer j = Integer.valueOf(10000);
    

    再來看下 Integer.valueOf 方法:

    // 如果 i 在 [-128 ~ 127] 之間則返回緩存裏的對象,否則創建新的Integer
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
    

    這樣我們解釋通了,整個原理就有跡可循了,也不用死記硬背:當我們將一個 int 值賦值給 Integer,因爲基本類型 int 和 Integer 類型不一樣,一個是基本類型,一個是複雜類型,無法賦值,編譯器會將 int 基本類型通過 valueOf 方法將其裝箱成 Integer,然後再賦值。

    如果能使用基本類型儘量使用基本類型,但是有的時候可能一定要使用複雜類型而不是基本類型,這個時候進行比較的時候要注意使用 equals 方法而不是 ==,這是很容易掉入的陷阱。

    這是開發中關於基本類型 自動裝箱(Autoboxing) 容易出現的問題。

    下面我們再來看下 自動拆箱(Unboxing) 可能導致的問題。

    我們將上面的例子稍作調整:

    public static void main(String[] args) {
        int j = 10000;
        Integer k = 10000;
        System.out.println(j==k);
    }
    

    我們將 Integer j,改成 int j 了。然後輸出結果就成了 true。我們來看下它的字節碼:

     public static void main(java.lang.String[]);
    Code:
       0: sipush        10000
       3: istore_1
       4: sipush        10000
       7: invokestatic  #2       // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      10: astore_2
      11: getstatic     #3       // Field java/lang/System.out:Ljava/io/PrintStream;
      14: iload_1
      15: aload_2
      16: invokevirtual #4       // Method java/lang/Integer.intValue:()I
      19: if_icmpne     26
      22: iconst_1
      23: goto          27
      26: iconst_0
      27: invokevirtual #5       // Method java/io/PrintStream.println:(Z)V
      30: return
    

    由於 int 和 Integer 是不同的類型,所以需要將它們裝成的相同的類型才能進行比較。我們發現,編譯器會將 Integer 自動拆箱成 int 然後進行比較, j==k 相當於 j == k.intValue();

    所以輸出結果是 true。 貌似是沒有什麼問題,但是如果這裏的 Integer k 是從外面傳遞進來的,可能爲 null 的話,那麼就可能出現 NullPointerException 異常:

    private void test(Integer k) {
    	// k.intValue() 可能會導致 NullPointerException
        if (k == 10000) {
            // do something...
        }
    }
    

    除了上面的 if 判斷可能會導致空指針異常,switch 語句也有可能導致空指針,因爲都會對其進行自動拆箱操作,開發的時候也要加以注意。

  • 4. 掌握字節碼技術可以讓我們具備修改字節碼的能力

    我們可以使用一些修改字節碼的開源工程,如利用 ASM、AspectJ、Javassist 實現:
    1、統一修復 bug 的目的。比如,在 Android 開發中有的代碼在高 Android 系統版本中會出現閃退,我們寫的程序可以修改,但是開源庫也有可能有這樣的危險代碼,這樣我們只能等待這個庫升級了。其實,我們還可以通過修改字節碼技術將程序(包括第三方庫)中所有出現該危險代碼的地方進行修改。
    2、實現 AOP 編程。
    3、解耦。

  • 5. 掌握字節碼技術可以幫助我們更加深入理解 Java 語言,明白我們每行代碼,背後代表的意義

小結

本文分析了字節碼文件的組成,如魔數、字節碼版本、常量池、字段、方法、屬性等,還介紹了 invokeDynamic 指令,並分析了其實現原理,從而知道了 Java1.8 實現 lambdaKotlin 實現 lambda 表達式的異同。

接着分析了字節碼指令集, 並通過一個案例分析了其對應的指令,每執行完一個指令,展示其對應的 操作數棧局部變量表 的情況。

最後通過分析字節碼的方式知道實際開發工作,加深對 Java 語言的理解深度,幫助我們編寫更好的 Java 代碼,對我們編寫的每一行 Java 代碼更加自信。

至此,我們就將 class 字節碼文件分析完畢了,那麼 JVM 虛擬機是如何將 class 字節碼文件加載到內存中來的呢?它在這個過程做了什麼事情呢?有興趣的可以查看我的文章: 《深入理解 Java 虛擬機 ~ 類的加載過程剖析》

Reference


如果你覺得本文幫助到你,給我個關注和讚唄!

如果你覺得本文幫助到你,給我個關注和讚唄!

另外本文涉及到的代碼都在我的 AndroidAll GitHub 倉庫中。該倉庫除了 Java虛擬機 技術,還有 Android 程序員需要掌握的技術棧,如:程序架構、設計模式、性能優化、數據結構算法、Kotlin、Flutter、NDK,以及常用開源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持續更新,歡迎 star。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章