原文: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的新特性。
JRE由Java API和JVM组成,JVM的作用是通过Class Loader加载Java程序并通过Java API来执行加载的程序
虚拟机可以像物理机一样运行程序,它是通过软件的方式来模拟实现的机器(例如计算机)。Java被设计成基于虚拟机运行的初衷是希望通过和物理机分离以达到 WORA(Write One Run Anywhere)的目标,尽管这个目标早已被淡忘。正因如此,JVM才可以既不改变Java代码却又能运行在各种硬件上。
JVM的特性如下:
虽然Sun公司开发了Java,但是所有JVM提供商都可以基于JVM规范开发自己的JVM。正因如此,市面上有许多不同的虚拟机,包含Oracle的HotSpot JVM和IBM JVM。Google 安卓操作系统中的Dalvik虚拟机也是一种JVM,尽管它没有基于JVM规范,不像基于Stack的Java虚拟机,Dalvik虚拟机是基于寄存器的架构,Dalvik虚拟机会将Java字节码转换成基于寄存器的指令集。
为了实现 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.,其含义如下:
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; |
在解释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有一个非常严格的校验过程。
Java中编写的代码通过以下流程来执行。
一个Class Loader装载编译后的字节码到运行时数据区,然后执行引擎用于执行Java字节码。
Java提供了一种动态装载特性,它可以在运行时首次引用某个class时对它进行装载和链接,而不是在编译时进行。JVM的class loader用于进行动态装载,下面是class loader的几个特性:
每一个class loader都一个自己的命名空间来保存装载的类,当一个class loader装载一个类时,它会使用类的全限定名(FQCN: Fully Qualified Class Name)去命名空间中查找类是否被装载。需要注意的是即使类的全限定名相同,但如果命名空间不同,也会被认为是不同的类,命名空间不同意味着类已经被其他class loader装载了。
下图演示了class loader的委派模型:
当一个class loader请求加载一个class时,它首先按顺序在上层装载器、父装载器以及自身的装载器缓存中检查类是否已存在。简单来说,首先会检查自己是否装载了该类,如果没有将继续检查父装载器,最后如果在Bootstrap装载器中都没有找到的话,将会从文件系统装载这个类。
Web应用服务器(WAS)等框架会使用这种结构让Web应用和企业级应用保持独立运行,换句话说,通过class loader的委派模型来保证应用的独立性,不同WAS服务商的class loader在层级结构上可能稍有不同。
如果一个class loader找到了一个未装载的类,这个类装载和链接的流程如下图:
JVM规范中定义了上面几个任务,但是在执行时可以进行灵活的变动。
运行时数据区是JVM程序运行在操作系统上时的内存分配区域,它可以分称6个部分:程序计数器,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。
让我们回到前面讨论的反汇编的字节码。
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汇编代码和x86架构下的汇编代码我们可以发现二者的格式有点相似:都有操作码;然而,有一点不同是在Java字节码中不会在操作数中写入寄存器名称、内存地址或偏移量。上面说过,JVM使用stack,因此,它不像x86架构直接使用寄存器,由于JVM自己管理内存,所以使用像15、23这样的索引数来代替内存地址。15和23都是当前类(UserService class)常量池中的索引。简而言之,JVM为每个类创建了一个常量池用于存储实际的引用。
上面每一行反汇编代码的含义如下:
add()
方法时,会将字符串参数userName的引用添加到操作数栈。getfield
添加的引用和aload_1
添加的参数都将用于方法执行。当方法执行完后,返回值将会添加到操作数栈。invokevirtual
指令将返回值从操作数栈中pop出来,最上面的例子中,通过最开始的Library编译的方法是没有返回值的,由于方法没有返回值,因此没有必要将返回值从stack中Pop出来。下图可以辅助你理解上面的信息:
另外,在这个方法中,局部变量表并没有发生变化,所以上图只展示了操作数栈的变化。然而,在大多数场景中,局部变量表会发生变更,数据会通过一些load指令(如aload,iload)和store指令(如astore,istore)在局部变量表和操作数栈中进行传输。
在图中,我们简单演示验证了运行时常量池和JVM Stack。在JVM运行时,类实例将在堆中进行分配,而像User、UserAdmin、UserService这些类信息和字符串将被存储到方法区。
字节码通过class loader加载到JVM的运行时数据区,然后由执行引擎执行。执行引擎像CPU一条一条执行机器码一样以指令为单位读取Java字节码,每个字节码指令由1个字节的操作码和附加的操作数组成。执行引擎获取一个操作码再结合操作数来执行任务,执行完后再执行下一条操作码。
与可以被机器直接执行的语言相比,字节码是以一种人类可读的语言来编写。因此,字节码必须在JVM中转换成一种可以被机器直接执行的语言。字节码可以通过以下两种方式转换成合适的语言:
然而,与解释器逐条解释字节码相比,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规范以及结语部分。