原文:Understanding JVM Internals by Se Hoon Park On 05/30/2017
翻译:码代码的陈同学
翻译参考:java字节序、主机字节序和网络字节序扫盲贴
众所周知,Java字节码运行在JRE(Java Runtime Environment)中,JVM又是JRE中最重要的部分,主要用于分析和执行字节码。虽然不深入了解JVM,开发人员也已经开发出许多优秀的应用和Library,但如果了解JVM,你可以更好的理解Java语言,同时也可以解决一些看上去很简单却不好解决的问题。
因此,本文我将阐述JVM如何工作、JVM的结构、JVM如何执行字节码以及执行的顺序,常见的错误及其解决方案,也会介绍下Java SE 7的新特性。
虚拟机(Virtual Machine)
JRE由Java API和JVM组成,JVM的作用是通过Class Loader加载Java程序并通过Java API来执行加载的程序
虚拟机可以像物理机一样运行程序,它是通过软件的方式来模拟实现的机器(例如计算机)。Java被设计成基于虚拟机运行的初衷是希望通过和物理机分离以达到 WORA(Write One Run Anywhere)的目标,尽管这个目标早已被淡忘。正因如此,JVM才可以既不改变Java代码却又能运行在各种硬件上。
JVM的特性如下:
- 基于栈的虚拟机(Stack-Based VM):Intel x86 和ARM这两种最为流行的架构都是基于寄存器运行的,然而JVM却是基于Stack运行。
- 符号引用(Symbolic reference):除基本数据类型外,所有的数据类型(类和接口)都是通过符号引用来引用,而不是通过具体的内存地址来引用。
- 垃圾回收(Garbage Collection):对象由用户编写的代码创建,由垃圾回收机制自动销毁。
- 通过对基本数据类型的明确定义来保证平台独立性:像C/C++这种传统语言,int类型的长度取决于平台。JVM明确定义了基本数据类型来确保它的兼容性和独立性。
- 网络字节序(Network byte order):Java class文件使用了网络字节序,为了在小端字节序(如Intel x68体系)和大端字节序(如RISC系列体系)之间维持平台独立性,必须保证固定的字节顺序。因此,JVM使用了用于网络传输的网络字节序,网络字节序属于大端。
虽然Sun公司开发了Java,但是所有JVM提供商都可以基于JVM规范开发自己的JVM。正因如此,市面上有许多不同的虚拟机,包含Oracle的HotSpot JVM和IBM JVM。Google 安卓操作系统中的Dalvik虚拟机也是一种JVM,尽管它没有基于JVM规范,不像基于Stack的Java虚拟机,Dalvik虚拟机是基于寄存器的架构,Dalvik虚拟机会将Java字节码转换成基于寄存器的指令集。
字节码(Java bytecode)
为了实现 WORA 目标,JVM使用字节码这种介于Java(用户语言)和机器语言之间的中间语言,字节码是部署Java代码的最小单位。
在解释Java字节码之前,让我们先看看它的样子。下面是一个开发过程中遇到的真实案例总结:
现象
一个一直运行的程序在某个依赖的Library被更新后发生了如下错误.
译者注:为了便于理解,译者举个例子。例如:一个应用的war包没做任何变更,但是替换了某个依赖的jar包。
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);
}
Library中被更新的部分的前后代码如下:
// 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()
方法也存在,那为什么还要报 NoSuchMethodError 呢?
原因
原因在于应用程序的代码没有基于新的Library重新编译,换句话说,程序中还是执行了正确的方法,只是没有返回值而已。然而,编译后的class文件却表明这个方法是有返回值的。可以通过下面的错误消息来了解:
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
由于找不到方法报了NoSuchMethodError
,看一下 Ljava/lang/String;和后面的 V,在Java字节码表达式中,L; 表示类实例, Ljava/lang/String;表示方法有一个String类型的参数。在上面的例子中,参数没有变化,所以是正常的。最后的V表示方法的返回值,只有一个V表示没有返回值。上述异常消息表示没有找到这个方法。
由于程序代码是根据以前的Library编译的,class文件中并没有定义有返回值的addUser()方法。然而,在Library变更后,addUser()更新成了返回一个User的方法。因此,发生了 NoSuchMethodError。
注:这个错误的发生是由于开发人员没有使用新的Library重新编译应用,但是,这种场景下,Library的提供者更应该为此负责。一个公共的没有返回值的方法变更成了一个返回一个对象的方法,这显然是变更类的签名信息,这也意味着打破了这个Library的向后兼容性。因此,Library的提供者必须告知使用者Library发生了变更。
让我们回到Java字节码,Java字节码是JVM的基本元素。JVM是一个模拟执行字节码的模拟器,Java编辑器不会将高级语言(如C/C++)转换成机器语言(CPU指令),它会将开发人员可以理解的Java语言转换成JVM可以理解的Java字节码。由于Java字节码是平台无关的代码,因此即使CPU或操作系统不同,它也可以运行在所有安装了JVM(准确的说,是与硬件匹配的JRE)的硬件上(一个class文件在 Windows PC上编译后不做任何改变就可以运行在Linux上)。编译后的字节码和源代码的大小基本一致,这样可以更容易的在网络上传输和执行编译后的代码。
class文件本身是一个开发人员无法理解的二进制文件,为了管理这些文件,JVM提供商提供了反汇编器javap,javap产生的结果是Java汇编语言。下面的代码是通过javap命令产生的:
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
在这段汇编代码中,addUser()方法是在第4行的5: invokevirtual #23;
执行的,这表示对应的索引为23的方法会被执行,#23是由javap进行标记的。invokevirtual是Java字节码中最基本的操作码,一共有4种执行方法的操作码:invokeinterface, invokespecial, invokestatic, 和 invokevirtual.,其含义如下:
- invokeinterface: 执行一个接口的方法
- invokespecial: 执行一个初始化方法,私有方法或父类中的方法
- invokestatic: 执行静态方法
- invokevirtual:执行对象实例中的方法
Java字节码的指令集由操作码(OpCode)和操作数(Operand)组成,像 invokevirtual 这样的操作码需要2字节的操作数。
通过编译上面例子中变更后的代码再反汇编得到的结果如下:
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;。
那上面的汇编代码中,最前面的数字又是什么意思呢?
这是字节数(byte number),也许这就是为什么JVM中执行的代码叫字节码的原因。简而言之,字节码指令中的像 aload_0, getfield, 和 invokevirtual 这些操作码都是用1个字节来表示(aload_0 = 0x2a, getfield = 0xb4, invokevirtual = 0xb6)。因此,Java字节码指令的操作码最多有256个。
像aload_0、aload_1这种操作码不需要任何操作数,因此,aload_0后面的下一个字节会是下一条指令的操作码。然后,getfield和invokevirtual需要2个字节的操作数,因此,getfield之后的下一条指令会跳过2字节,是写在第4个字节上。下面是通过16进制编辑器看到的字节码:
2a b4 00 0f 2b b6 00 17 57 b1
在Java字节码中,”L”表示类实例,”V”表示void,其他类型也有它们自己的表达式,下面是字节码中的其他表达式:
Java Bytecode | Type | Description |
---|---|---|
B | byte | signed byte |
C | char | Unicode character |
D | double | double-precision floating-point value |
F | float | single-precision floating-point value |
I | int | integer |
J | long | long integer |
L | reference | an instance of class |
S | short | signed short |
Z | boolean | true or false |
[ | reference | one array dimension |
下面是Java字节码表达式的简单例子:
Java Code | Java Bytecode Expression |
---|---|
double d[][][]; | [[[D |
Object mymethod(int I, double d, Thread t) | (IDLjava/lang/Thread;)Ljava/lang/Object; |
class文件格式(Class File Format)
在解释Java class文件格式之前,我们先回顾下在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的一个限制,用来保证一个方法的 size不能超过65535字节。
我将详细的说明65535字节限制的意义以及为什么设置了这个限制。
Java字节码中的分支和跳转指令分别是 goto 和 jsr:
goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]
这两个指令都接收一个2字节的分支偏移量(有符号数)作为它们的操作数,因此偏移量最大为65535(2字节为16位)。然而,为了支持更多的分支,Java字节码准备了 goto_w 和 jsr_w 这两个可以接收4字节有符号数分支偏移量的指令。
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
通过这两个指令,索引超过65535的分支也是OK的,这样Java方法65535字节的限制也许就搞定了。然而,由于class文件格式的其他限制,Java方法还是不能超过65535字节。为了了解其他限制,我先简单介绍下class文件的格式。
下面是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];}
译者注:各部分的含义暂不翻译,有兴趣的可以参考《深入理解Java虚拟机》这本书。
javap以用户可读的格式简要的展示class文件的信息,使用javap -verbose
命令分析UserService.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,LineNumberTable和LocalVariableTable,如上述 javap -verbose
命令输出的信息。Code属性里的LineNumberTable、LocalVariableTable、Exception_table的长度都是用一个固定的2字节来表示,因此,方法的大小不能超过它们长度65535的限制。
许多人抱怨方法长度的限制,尽管JVM规范里表明后续会对此进行拓展,但到目前为止也没有任实质性的进展。考虑到JVM把class文件中的很多内容放到了方法区,为了保证向后兼容性,拓展方法的长度将愈加艰难。
如果由于Java编译错误创建了错误的class文件会发生什么呢?或者,因为网络传输或文件拷贝时class文件被损坏呢?
为了预防这种情况,JVM class loader有一个非常严格的校验过程。
JVM结构(JVM Structure)
Java中编写的代码通过以下流程来执行。
一个Class Loader装载编译后的字节码到运行时数据区,然后执行引擎用于执行Java字节码。
类装载器(Class Loader)
Java提供了一种动态装载特性,它可以在运行时首次引用某个class时对它进行装载和链接,而不是在编译时进行。JVM的class loader用于进行动态装载,下面是class loader的几个特性:
- 层级结构(Hierarchical Structure): Java里的class loader被组织成了有父子关系的层级结构。Bootstrap class loader是所有class loader的父类。
- 委派模型(Delegation mode): 基于层级结构,类的装载可以在class loaders之间进行委派。当一个class装载之时,会首先检查它是否在父装载器中进行了装载。如果上层装载器已经装载了这个类,将会直接使用这个类;如果没有,当前类装载器将会请求装载这个类。
- 可见性限制(Visibility limit): 一个子装载器可以查找父装载器中的类,但是父装载器不能查找子装载器中的类。
- 不允许卸载(Unload is not allowed): 类装载器可以装载一个类但是不能卸载它,不过可以删除当前类装载器,然后创建一个新的类装载器。
每一个class loader都一个自己的命名空间来保存装载的类,当一个class loader装载一个类时,它会使用类的全限定名(FQCN: Fully Qualified Class Name)去命名空间中查找类是否被装载。需要注意的是即使类的全限定名相同,但如果命名空间不同,也会被认为是不同的类,命名空间不同意味着类已经被其他class loader装载了。
下图演示了class loader的委派模型:
当一个class loader请求加载一个class时,它首先按顺序在上层装载器、父装载器以及自身的装载器缓存中检查类是否已存在。简单来说,首先会检查自己是否装载了该类,如果没有将继续检查父装载器,最后如果在Bootstrap装载器中都没有找到的话,将会从文件系统装载这个类。
- 启动类装载器(Bootstrap class loader): 它在JVM启用时创建,它负责装载Java APIs,包含相关对象的class。不像其他class loader,这个类装载器由native代码实现,而不是Java代码。
- 拓展类装载器(Extension class loader): 它负责装载除了Java API外的拓展类,也负责装载其他安全拓展功能。
- 系统类装载器(System class loader): 如果说bootstrap class loader和 extension class loader是用来装载JVM组件的,那system class loader就是用来装载应用程序的类。它会装载用户在$CLASSPATH中指定的类。
- 用户自定义类装载器(User-defined class loader): 这是用户在程序中直接创建的类装载器。
Web应用服务器(WAS)等框架会使用这种结构让Web应用和企业级应用保持独立运行,换句话说,通过class loader的委派模型来保证应用的独立性,不同WAS服务商的class loader在层级结构上可能稍有不同。
如果一个class loader找到了一个未装载的类,这个类装载和链接的流程如下图:
- 加载(Loading): 从class文件获取类信息并加载到JVM内存
- 验证(Verifying): 检查class是否符合Java语言规范和JVM规范,这是类装载中最为复杂的流程,会花费很长时间。大多数JVM TCK测试case就是用来测试在装载类的时候是否会出现错误
- 准备(Preparing): 准备一个数据结构用来存储类信息,结构中包含:类的成员变量、方法和接口信息。
- 解析(Resolving): 将这个类的常量池中所有的符号引用换成直接引用。
- 初始化(Initializing): 将类的成员变量初始化成合适的值,执行静态初始化程序,把静态变量初始化成合适的值。
JVM规范中定义了上面几个任务,但是在执行时可以进行灵活的变动。
运行时数据区(Runtime Data Area)
运行时数据区是JVM程序运行在操作系统上时的内存分配区域,它可以分称6个部分:程序计数器,JVM栈、本地方法栈都是为每个线程创建的,堆、方法区、运行时常量池是所有线程共享的。
- 程序计数器(PC register): 每个线程有自己的程序计数器 (Program Counter) , 它在线程start的时候创建。.程序计数器保存了当前正在执行的JVM指令的地址。
JVM stack: 每个线程拥有一个JVM栈 , 它在线程start的时候创建。主要用来保存栈帧,JVM只会在Stack上进行栈帧的push和pop操作。如果发生任何异常,stack中的每一行都代表一个栈帧信息,这些信息可以通过像printStackTrace()这样的方法展示出来。
本地方法栈(Native method stack): 提供给非Java语言写的本地方法使用的stack。换句话说,它是一个用于通过JNI(Java Native Interface)执行C/C++代码的stack,根据具体的语言,会创建一个C stack或C++ stack。
- 方法区(Method area): 方法区被所有线程共享,在JVM启动时创建,它存储了运行时常量池、变量和方法信息,静态变量,class中每个方法的字节码以及接口信息。不同JVM提供商对于方法区有不同的实现,Oracle HotSpot JVM把它称为永久区(Permanent Area)或永久代(Permanent Generation (PermGen)),是否对方法区进行垃圾回收对于JVM的实现来说也是可选的。
- 运行时常量池(Runtime constant pool): 这个区域和class文件中的常量池表(contant_pool table)对应,它属于方法区。由于在JVM操作中它却扮演着核心角色,因此JVM规范中单独提到了它的重要性。除了每个类和接口中的常量,它也包含了方法和变量中的所有引用。简而言之,当一个方法或变量被引用时,JVM会从运行时常量区检索方法或者变量的实际地址。
- 堆(Heap): 是用于保存实例和对象的空间,也是垃圾回收的主要区域。当讨论JVM性能问题时,这个区域会频繁提及。JVM提供商可以决定怎么配置堆或者不对它进行垃圾回收。
让我们回到前面讨论的反汇编的字节码。
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
Java汇编代码
对比Java汇编代码和x86架构下的汇编代码我们可以发现二者的格式有点相似:都有操作码;然而,有一点不同是在Java字节码中不会在操作数中写入寄存器名称、内存地址或偏移量。上面说过,JVM使用stack,因此,它不像x86架构直接使用寄存器,由于JVM自己管理内存,所以使用像15、23这样的索引数来代替内存地址。15和23都是当前类(UserService class)常量池中的索引。简而言之,JVM为每个类创建了一个常量池用于存储实际的引用。
上面每一行反汇编代码的含义如下:
- aload_0: 将局部变量表中索引为#0的变量添加到操作数栈,#0永远表示的是this,即当前实例的引用。
- getfield #15:将当前类常量池中索引为#15的变量添加到操作数栈,添加了UserAdmin的admin属性,由于admin属性是一个类的实例,因此添加的是一个引用。
- aload_1: 将局部变量表中索引为#1的变量添加到操作数栈,局部变量表中#1位置的变量是一个方法的参数,因此,在执行
add()
方法时,会将字符串参数userName的引用添加到操作数栈。 - invokevirtual #23: 执行当前类常量池中索引为#23的方法,此时,通过
getfield
添加的引用和aload_1
添加的参数都将用于方法执行。当方法执行完后,返回值将会添加到操作数栈。 - pop: 使用
invokevirtual
指令将返回值从操作数栈中pop出来,最上面的例子中,通过最开始的Library编译的方法是没有返回值的,由于方法没有返回值,因此没有必要将返回值从stack中Pop出来。 - return: 结束方法。
下图可以辅助你理解上面的信息:
另外,在这个方法中,局部变量表并没有发生变化,所以上图只展示了操作数栈的变化。然而,在大多数场景中,局部变量表会发生变更,数据会通过一些load指令(如aload,iload)和store指令(如astore,istore)在局部变量表和操作数栈中进行传输。
在图中,我们简单演示验证了运行时常量池和JVM Stack。在JVM运行时,类实例将在堆中进行分配,而像User、UserAdmin、UserService这些类信息和字符串将被存储到方法区。
执行引擎(Execution Engine)
字节码通过class loader加载到JVM的运行时数据区,然后由执行引擎执行。执行引擎像CPU一条一条执行机器码一样以指令为单位读取Java字节码,每个字节码指令由1个字节的操作码和附加的操作数组成。执行引擎获取一个操作码再结合操作数来执行任务,执行完后再执行下一条操作码。
与可以被机器直接执行的语言相比,字节码是以一种人类可读的语言来编写。因此,字节码必须在JVM中转换成一种可以被机器直接执行的语言。字节码可以通过以下两种方式转换成合适的语言:
- 解释器(Interpreter):逐条读取、解释、执行字节码指令。由于是逐条解释、执行指令,所以可以很快的解释字节码,但是执行起来却比较慢,这也是解释型语言的缺陷,字节码这种”语言”基本上就是解释执行的。
- 即时编译器(JIT(Just-In-Time) Compiler):JIT编译器被引入用来弥补解释器的不足。执行引擎首先以解释执行的方式来运行,然后在合适的时间,会把所有字节码编译成本地代码(Native Code),自此之后,执行引擎可以直接执行本地代码,不再需要解释执行。本地代码的执行比逐条执行字节码快很多,尤其是本地代码存储到缓存之后,编译后的代码可以执行的更快。
然而,与解释器逐条解释字节码相比,JIT编译器会消耗更多的时间用于编译代码。因此,如果代码只会被执行一次,最好用解释执行而不是编译代码。正因如此,JVM内部会使用JIT编译器检查方法的执行频率,而且只会编译执行频率超过一定水平的代码。
图7: Java编译器和即时编译器
JVM规范中并未定义执行引擎如何运行,因此,不同JVM提供商会使用不同的技术来优化他们的执行引擎,也会引入不同种类的JIT编译器。
大多数的JIT编译器以下图的方式执行:
图8: 即时编译器
JIT编译器将字节码转换成中间层表达式,使用中间层表达式来进行优化,再把这种中间层表达式转换成本地代码。
Oracle的HotSpot虚拟机使用了一种叫做 热点编译器(HotSpot Compiler)的JIT编译器,之所以这么取名是因为是热点编译器会通过分析找到需要编译的 “热点” 代码,然后将代码编译成本地代码。如果方法编译后的字节码不再被频繁执行,换句话说,如果这个方法不再是热点,HotSpot虚拟机会将这个方法对应的本地代码从缓存中移除,并且会用解释器模式来执行。HotSpot虚拟机分成了Server VM 和 Client VM,这两部分使用不同的JIT编译器。
图9: HotSpot VM的Client VM和Server VM
Client VM和Server VM使用相同的运行时,不过如上图所示,它们的JIT编译器是不同的。Server VM使用了更高级的动态优化编译器,这个编译器使用很多复杂的性能优化技术。
IBM JVM自IBM JDK 6起引入了一种叫AOT(Ahead-Of-Time)的编译器作为JIT编译器,这意味着许多JVM通过共享缓存来共享编译过的本地代码,简单来说,就是其他JVM可以直接使用AOT编译器编译过的代码,而不用重新编译。另外,IBM JVM通过使用AOT编译器将代码预编译成一种JXE(Java EXecutable)文件格式来提供一种更快速的执行方式。
大多数Java性能的提高是通过优化执行引擎来完成的,正如JIT编译器中,很多JVM性能的持续提高都是通过就、引各种优化技术来完成。早期JVM和最近的JVM之间最大的不同就是执行引擎的差异。
Oracle HotSpot JVM自1.3版本开始引入了热点编译器,Davlvik VM自android 2.2开始也引入了JIT编译器。
译者注:已省略翻译最后的 JVM7规范以及结语部分。