深入理解 Java 虚拟机(一)~ class 字节码文件剖析
本文分析了字节码文件的组成,如魔数、字节码版本、常量池、字段、方法、属性等,还介绍了 invokeDynamic 指令,并分析了其实现原理;接着分析了字节码指令集, 并通过一个案例分析了其对应的指令,每执行完一个指令,展示其对应的操作数栈和局部变量表的情况。最后通过分析字节码的方式知道实际开发工作,加深对 Java 语言的理解深度,帮助我们编写更好的 Java 代码。
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
字段和它的 getter
和 setter
方法,字段也有一个 @Deprecated
注解,里面一个 main
方法
通过 javac
命令将该类编译下,通过 Sublime
查看 Client.class 文件:
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):
紧接着版本号后面的数据项是 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_info
和 CONSTANT_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,如下图所示:
从上图可以看出 CONSTANT_Long_info
的编号是 2
,它的下一个数据项的编号变成了 4
,因为它占用了 2 个 entry
8 字节的常量使用 high_bytes
和 low_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 ~ 0x7fffffffffffffffL
或0xfff0000000000001L ~ 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_info
,CONSTANT_Utf8_info
里是字段或者方法的名字
descriptor_index
指向常量池中的 CONSTANT_Utf8_info
,CONSTANT_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
中的数据项如下所示:
接下来我们基于程序来分析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):
bootstrap_method_attr_index
指向的是 bootstrap_methods
第0个元素(#0):
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_flag
,access_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
,它继承了 Serializable
,Serializable
在 interfaces
数组中
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
注解
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
注解:
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 属性如下所示:
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[] 里面存放着方法代码的字节码,里面是一系列的字节码指令
:
至此,我们就把 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
为了方便描述,我在每一行的指令后面都添加了操作数栈和局部变量表的内部细节,如下图所示:
学习字节码对开发的指导意义
-
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
实现 lambda
和 Kotlin
实现 lambda
表达式的异同。
接着分析了字节码指令集, 并通过一个案例分析了其对应的指令,每执行完一个指令,展示其对应的 操作数栈
和 局部变量表
的情况。
最后通过分析字节码的方式知道实际开发工作,加深对 Java 语言的理解深度,帮助我们编写更好的 Java 代码,对我们编写的每一行 Java 代码更加自信。
至此,我们就将 class 字节码文件分析完毕了,那么 JVM 虚拟机是如何将 class 字节码文件加载到内存中来的呢?它在这个过程做了什么事情呢?有兴趣的可以查看我的文章: 《深入理解 Java 虚拟机 ~ 类的加载过程剖析》
Reference
- 《深入理解Java虚拟机 - JVM 高级特性与最佳时间(第二版)》
- https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
- https://www.zhihu.com/question/40427344
- https://www.jianshu.com/p/bb0d8e2fa8f5
- https://blog.csdn.net/a18922723593/article/details/49516145
- https://blog.csdn.net/a18922723593/article/details/49516145
如果你觉得本文帮助到你,给我个关注和赞呗!
如果你觉得本文帮助到你,给我个关注和赞呗!
另外本文涉及到的代码都在我的 AndroidAll GitHub 仓库中。该仓库除了 Java虚拟机
技术,还有 Android 程序员需要掌握的技术栈,如:程序架构、设计模式、性能优化、数据结构算法、Kotlin、Flutter、NDK,以及常用开源框架 Router、RxJava、Glide、LeakCanary、Dagger2、Retrofit、OkHttp、ButterKnife、Router 的原理分析 等,持续更新,欢迎 star。
更多推荐
所有评论(0)