深入理解 Java 虚拟机
文章太长了,拆成两部分,这是第一部分。
每一个使用 Java 的开发者都知道 Java 字节码在 JRE(Java 运行时环境)中运行。JRE 中最重要的部件就是分析并执行 Java 字节码的 Java 虚拟机(JVM)。Java 开发者不需要知道 JVM 怎么工作。有很多优秀的应用和第三方库都是在开发者没有深入理解 JVM 的情况下开发出来的。然而,如果你理解 JVM,你会更好的理解 Java,并且能够解决一些看起来很简单但是却无法解决的问题。
所以,在本文中,我将介绍 JVM 怎么工作,它的结构,它如何执行 Java 字节码,执行顺序,通过示例介绍一些常见问题和它们的解决办法,以及 Java SE 7版本的新功能。
虚拟机
JRE由 Java API 和 JVM 组成。JVM 的职责就是通过类加载器读取 Java 应用并通过 Java API 执行。
虚拟机(VM)机器(即电脑)的软件实现,它像真实的计算机一样执行程序。为了实现 WORA(编写一次,任何地方都可以运行--Write Once Run Anywhere,尽管这一目标基本已经被忘记)这一目标,Java 最初的设计是基于运行在与物理机器隔离的虚拟机中。所以,运行在各种硬件上的 JVM 可以执行 Java 字节码,而不需要修改 Java 执行代码。
JVM 的特征包括:
- JVM 是基于栈的虚拟机:最流行的计算机架构比如 Intel x86 架构和 ARM 架构是基于 寄存器 运行。然而 JVM 是基于栈运行。
- 符号引用:除了原始数据类型以外的所有类型(类和接口)都是通过符号引用,而不是通过直接的内存地址引用。
- 垃圾回收:类的实例是通过用户代码手动创建,但是通过垃圾回收自动销毁。
- 通过清晰的定义原始数据类型来保证平台独立性:传统的像 C/C++ 这样的语言在不同的平台上有不同的类型大小。JVM 通过清晰的定义原始数据类型来维持其兼容性,并且保证平台独立。
- 网络字节序:Java 类文件使用网络字节序。为了在Intel x86 架构的小端和 RISC 架构使用的大端之间维持平台独立,必须要使用一种混合的字节序。所以,JVM 使用网络字节序,网络字节序用于网络传输。网络字节序使用的是大端。
Sun Microsystems 开发了 Java。然而,任何厂商只要遵循 JVM 规范都可以开发并提供 JVM。由于这一原因,现在存在各种 JVM,包括 Oracle Hotspot JVM 和 IBM JVM。Google 的 Android 操作系统中运行的 Dalvik 虚拟机也是一种 JVM,尽管它没有遵循 JVM 规范。和其他基于栈的虚拟机不一样的是,Dalvik 虚拟机采用的是基于寄存器架构。Java 字节码也会转换成基于寄存器的指令集供 Dalvik 虚拟机使用。
Java 字节码
为了实现 WORA,JVM 使用 java 字节码,这是一种介于 Java(用户语言)和机器语言的中间语言。Java 字节码是部署 Java 代码的最小单元。
在了解 Java 字节码之前,我们一起来看一个问题,这个案例是发生在开发过程中的真实例子的总结。
问题表现
一个曾正常运行的应用不再正常运行。此外,在库文件更新以后会返回如下错误:
Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
at com.nhn.service.UserService.add(UserService.java:14)
at com.nhn.service.UserService.main(UserService.java:19)
应用的代码如下,并且不曾做任何修改:
// UserService.java
…
public void add(String userName) {
admin.addUser(userName);
}
库源码更新后的版本和与更新前的版本对比如下:
// UserAdmin.java - Updated library source code
…
public User addUser(String userName) {
User user = new User(userName);
User prevUser = userMap.put(userName, user);
return prevUser;
}
// UserAdmin.java - Original library source code
…
public void addUser(String userName) {
User user = new User(userName);
userMap.put(userName, user);
}
简而言之,之前没有返回值的 addUser() 方法别修改成了返回 User 实例的方法。然而,应用程序没有做修改,因为它没有使用 addUser() 方法的返回值。
乍一看,com.nhn.user.UserAdmin.addUser() 方法仍存在,但是如果是这样, 为什么会出现 NoSuchMethodError?
原因
原因是应用代码还没有使用新的库进行编译。换句话说,应用代码似乎调用了一个方法而不管它的返回类型。然而已经编译好的类认为这个方法有一个返回值。
通过以下错误信息你会发现这一点。
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
发生 * NoSuchMethodError* 是因为找不到 “com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V” 这个方法。来看一下 “Ljava/lang/String;” 和最后的 “V”。在 Java 字节码表达式中,“L<classname>” 表示一个类实例。这意味着 addUser() 方法获得一个 java/lang/String 对象作为一个参数。在本例的库中,参数并没有被修改,所以这点没有问题。错误信息最后的 “V” 代表这个方法的返回值。在 Java 字节码表达式中, “V” 代表没有返回值。简而言之,这条错误信息表明:以一个 java.lang.String 对象为参数,不返回任何值的 com.nhn.user.UserAdmin.addUser 方法没有找到。
因为应用程序是和上一个库一起编译的,在那个版本中类文件定义了返回 “V” 的方法可以调用。然而,在修改后的库中,返回 “V” 的方法不复存在,但是返回 “Lcom/nhn/user/User” 的方法被添加了。所以,发生了 NoSuchMethodError。
注释:
错误的发生时因为开发者没有使用新的库重新编译。然而,在本例中,库的提供者应该负主要责任。没有返回值的方法属性是 public,但是随后被修改为返回一个类实例。这明显修改了方法的签名。这意味着库的向下兼容性被破坏。所以,库的提供者应该告知用户方法已经被修改。
让我们回到字节码的讨论。字节码是 JVM 的基础组件。JVM 是一个仿真 Java 字节码的仿真器。Java 编译器不会像 C/C++ 的编译器那样直接将高级语言直接转换成机器语言( CPU 指令);它将开发者能够理解的 Java 语言转换成 JVM 能够理解的字节码。由于字节码与平台无关,只要安装了 JVM (准确的说,是 JRE)的硬件就可以执行,即使 CPU 或者 操作系统都不相同(在 Windows PC 上开发编译的类文件不用做任何修改就可以在一台 Linux 机器上运行)。编译后代码的大小和源码文件的大小是一致的,这使得通过网络传输执行编译后的代码变得很简单。
类文件本身是人类无法理解的二进制文件。为了管理类文件,JVM 厂商提供了反编译器 javap。使用 javap 产生的结果称为 Java 汇编。在上面的案例中,下面的 Java 汇编是通过使用 javap -c 选项反编译应用代码的 UserService.add()方法获得的。
public void add(java.lang.String);
Code:
0: aload_0
1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin;
4: aload_1
5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)V
8: return
在这段 Java 汇编中,addUser() 方法在第4行被调用,“5: invokevirtual #23;”。这段代码的含义是编号为 #23 的方法将被调用。编号为 #23 的方法是通过 javap 程序注明的。invokevirtual 是 Java 字节码中最基本命令(调用一个函数)的操作码。在 Java 字节码中调用一个函数一共有4个操作码:invokeinterface, invokespecial, invokestatic, and invokevirtual。每一个操作码的含义如下。
- invokeinterface:调用一个接口方法
- invokespecial:调用一个初始化方法、私有方法或者是父类的方法
- invokestatic:调用静态方法
- invokevirtual:调用实例方法
Java 字节码的指令集包含操作码和操作数。例如 invokevirtual 操作码需要两个字节的操作数。
通过使用更新后的库编译应用源码,然后再反编译,会得到以下结果。
public void add(java.lang.String);
Code:
0: aload_0
1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin;
4: aload_1
5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
8: pop
9: return
你会发现编号为 #23的方法已经被修改为一个返回 “Lcom/nhn/user/User;” 的方法。
在上边的反编译代码中,代码前面的数字是什么含义?
这代表字节数。也许这就是为什么 JVM 执行的代码成为 Java “字节”码。简而言之,字节码命令操作码(比如 aload_0,getfield 和 invokevirtual)用一个字节表示(aload_0 = 0x2a,getfield=0xb4, invokevirtual = 0xb6)。所以,Java 字节码命令操作码的最大编号是256.
像 aload_0 和 aload_1 这样的操作码不需要任何操作数。所以 aload_0 的下一个字节是下一条命令的操作码。然而, getfield 和 invokevirtual 需要两个字节的操作数,所以,处于第一个字节的 getfield 的下一条命令位于第4个字节,跳过了两个字节。这一段字节码使用十六进制编辑器打开就是这样的。
2a b4 00 0f 2b b6 00 17 57 b1
在 Java 字节码中,类实例通过 “L;” 表示, void 以 “V” 表示。同样,其他的类型也有各自的表达式。详细的信息参考下表。
表1:Java 字节码中的类型表达式
Java 字节码 | 类型 | 描述 |
---|---|---|
B | byte | 字节 |
C | char | Unicode 字符 |
D | double | 双精度浮点数 |
F | float | 单精度浮点数 |
I | int | 整数 |
J | long | 长整数 |
L<classname> | 引用 | <classname>类的一个实例 |
S | short | 短整型 |
Z | boolean | true or false |
[ | reference | one array dimension |
下表展示了一组 Java 字节码表达式的样例。
表2:Java 字节码表达式样例
Java 代码 | Java 字节码表达式 |
---|---|
double d[][][]; | [[[D |
Object mymethod(int I, double d, Thread t) | (IDLjava/lang/Thread;)Ljava/lang/Object; |
更多详细的信息,可以参考 "Java 虚拟机规范,第二版"的 “4.3节”。关于各种 Java 字节码的指令集,可以参考“Java 虚拟机规范,第二版”的第6章“Java 虚拟机指令集”。
类文件格式
在了解 Java 类文件格式之前,我们一起来看一个在 java web 应用中经常发生的一个问题。
表现
当在 Tomcat 上编写、执行 JSP时, JSP 没有执行,并发生如下错误。
Servlet.service() for servlet jsp threw exception org.apache.jasper.JasperException: Unable to compile class for JSP Generated servlet error:
The code of method _jspService(HttpServletRequest, HttpServletResponse) is exceeding the 65535 bytes limit"
原因
上面的错误信息在不同的 Web 应用服务器上可能会略有不同,但是有一件事情是相同的,那就是因为65535字节限制。65535字节限制是 JVM 的限制之一,并且规定一个方法的大侠不能超过65535个字节。
我将详细的说明65535字节限制的含义以及为什么会设定这样一个限制。
Java 字节码使用的分支/跳转指令是 “goto” 和 “jsr”。
goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]
两者都接收两个字节的有符号的跳转偏移量作为它们的操作数,所以它们最大的偏移量是65535。然而,为了支持更多的跳转, Java 字节码又提供了 “goto_
w” 和 “jsr_w”,分别接收4个字节的偏移量。
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
有了这两条指令,分支的偏移量就可以远远超出65535。所以,Java 方法的 65535 字节限制是可以解决的。然而,由于 Java 类文件格式的各种其他限制,Java 方法仍然不能超过 65535。为了说明其他的限制,我简单介绍一下类文件格式。
Java 类文件的主要框架如下:
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];}
以上内容在“Java 虚拟机规范,第二版”的 4.1节“类文件结构”。
我们前面反编译的 UserServices.class 的前16个字节内容如下:
ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b
以此为例,我们来看看类文件的格式。
- magic:类文件的头4个字节是魔法数字。这是一个用于区分类文件的预定义值。如你所见,这个值总是 0XCAFEBABE。简而言之,如果一个文件的前四个字节是 0XCAFEBABE,它就会被认为是 Java 类文件。这个有趣的魔法数和 “Java” 这个名字有关。
- minor_version,major_version:接下来的4个字节用于标明类的版本。UserServices.class 文件的这4个字节是 0x00000032,即类的版本是50。由 JDK 1.6编译的类文件的版本就是50,由JDK 1.5编译出来的类文件的版本是 49.0。Java 虚拟机需要向下兼容版本比自己低(使用低于自己的JDK 编译)的类文件。反过来,如果一个高版本的类文件在一个低版本的 JVM 上运行时,会发生 java.lang.UnsupportedClassVersionError 错误。
- constant_pool_count, constant_pool[]:紧挨着版本号数据描述的信息是,类的常量池信息。这一部分信息包含运行时常量池信息,这个我们稍后再说明。当加载这个类文件时,JVM 将这些常量池信息存放在方法区的运行时常量池区域。由于 UserServices.class 文件的 constant_pool_count 是 0x0028,你会发现 constant_pool 有(40-1)个索引,即39个索引。
- access_flags:这个标志展示了类的修饰信息,即:public,final,abstract 或者是否是一个 interface。
- this_class,super_class:常量池中(constant_pool)的这两个索引位置分别代表 this 和 父类。
- interfaces_count, interfaces[]:常量池 interfaces_count 索引位置存储了当前类实现的接口数目,interfaces[]中存储每一个接口。
- fields_count, fields[]:分别表示当前类 field 的数目和field 信息。field 信息包括名称,类型,修饰符,以及在常量池中的索引。
- methods_count, methods[]:当前类的方法数以及所有的方法信息。方法信息包含:方法名称,参数类型和数目,返回类型,修饰符,常量池中的索引,执行代码以及异常信息。
- attributes_count, attributes[]:attribute_info 的结构中包含各种属性。供 field_info 和 method_info 使用。
javap 程序简洁的将类文件格式以用户能够阅读的形式展示。当使用 “javap -verbose” 选项来分析 UserServices.class 时,会打印出以下内容:
Compiled from "UserService.java"
public class com.nhn.service.UserService extends java.lang.Object
SourceFile: "UserService.java"
minor version: 0
major version: 50
Constant pool:const #1 = class #2; // com/nhn/service/UserService
const #2 = Asciz com/nhn/service/UserService;
const #3 = class #4; // java/lang/Object
const #4 = Asciz java/lang/Object;
const #5 = Asciz admin;
const #6 = Asciz Lcom/nhn/user/UserAdmin;;// … omitted - constant pool continued …
{
// … omitted - method information …
public void add(java.lang.String);
Code:
Stack=2, Locals=2, Args_size=2
0: aload_0
1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin;
4: aload_1
5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User;
8: pop
9: return LineNumberTable:
line 14: 0
line 15: 9 LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/nhn/service/UserService;
0 10 1 userName Ljava/lang/String; // … Omitted - Other method information …
}
由于篇幅限制,我从完整的打印中提取一部分。完整的打印中会显示常量池中的各种信息和每一个方法的内容。
65535字节限制和method_info struct的内容相关。如你所见,method_info 结构体中包含 Code,行号表以及本地变量表属性。所有行号表、本地变量表和代码表里的异常表中的值的长度都是固定的个字节。所以方法的大小不能超过方法表、本地变量表以及异常表的长度,也就是65535字节限制。
很多人都对这一方法限制抱有怨言,JVM 规格中也说明后续会扩展,然而到目前为止没有看到有什么改进的迹象。考虑到 JVM 明确说明一次几乎要将类的全部内容加载到方法区的特点,如果要扩展方法大小还要保持向下兼容将是一个极大的挑战。
如果因为编译器的问题生成了一个错误的类文件会发生什么事情?或者说因为网络传输或者文件复制的过程中类文件被损坏会怎样?
为了应对这种情况,Java 类加载器会进行非常严谨的验证。JVM 规范中明确的说明了这一过程。
注释
我们怎么确认 JVM 成功的执行了类文件的验证过程?我们怎么确认不同的 JVM 厂商提供的各种 JVM 符合 JVM 规范?对于验证,Oracle 提供了一个测试工具,TCK(Technology Compatibility Kit)、TCK 会通过数以万计的测试来验证 JVM 规范,包括以各种错误的方式出错的错误类文件。只有通过了 TCK 测试,JVM 才能被称为 JVM。
像TCK一样,还存在一个 JCP(Java Community Process; http://jcp.org),JCP 提出新的技术规范(和已有的 Java 规范一样)。对于JCP,为了完成 JSR(Java Specification Request--Java 规范申请)必须提供完整的规范文档,参考实现以及为 JSR 准备的 TCK。用户如果想使用 JSR 提出的新技术,需要先从 RI 提供者那里获得许可,或者直接实现并使用 TCK 测试实现。