原文连接:http://www.cubrid.org/blog/dev-platform/understanding-jvm-internalsjava
每一个使用Java的开发者都知道Java字节码是在JRE中运行(JRE: Java 运行时环境)。JVM则是JRE中的核心组成部分,承担分析和执行Java字节码的工做,而Java程序员一般并不须要深刻了解JVM运行状况就能够开发出大型应用和类库。尽管如此,若是你对JVM有足够了解,就会对Java有更好的掌握,而且能解决一些看起来简单但又还没有解决的问题。程序员
因此,在本篇文章中,我将会介绍JVM工做原理,内部结构,Java字节码的执行及指令的执行顺序,并会介绍一些常见的JVM错误及其解决方案。最后会简单介绍下Java SE7带来的新特性。apache
JRE由Java API和JVM组成,JVM经过类加载器(Class Loader)加类Java应用,并经过Java API进行执行。编程
虚拟机(VM: Virtual Machine)是经过软件模拟物理机器执行程序的执行器。最初Java语言被设计为基于虚拟机器在而非物理机器,重而实现WORA(一次编写,处处运行)的目的,尽管这个目标几乎被世人所遗忘。因此,JVM能够在全部的硬件环境上执行Java字节码而无须调整Java的执行模式。数组
JVM的基本特性:缓存
Sun 公司开发了Java语言,但任何人均可以在遵循JVM规范的前提下开发和提供JVM实现。因此目前业界有多种不一样的JVM实现,包括Oracle Hostpot JVM和IBM JVM。Google公司使用的Dalvik VM也是一种JVM实现,尽管其并未彻底遵循JVM规范。与基于栈机制的Java 虚拟机不一样的是Dalvik VM是基于寄存器的,Java 字节码也被转换为Dalvik VM使用的寄存器指令集。安全
JVM使用Java字节码—一种运行于Java(用户语言)和机器语言的中间语言,以达到WORA的目的。Java字节码是部署Java程序的最小单元。性能优化
在介绍Java 字节码以前,咱们先来看一下什么是字节码。下面涉及的案例是曾在一个真实的开发场景中遇到过的情境。服务器
一个曾运行无缺的程序在更新了类库后却不能再次运行,并抛出了以下异常:网络
1 Exception in thread "main" java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V 2 at com.nhn.service.UserService.add(UserService.java:14) 3 at com.nhn.service.UserService.main(UserService.java:19)
程序代码以下,并在更新类库以前不曾对这段代码作过变动:
1 // UserService.java 2 … 3 public void add(String userName) { 4 admin.addUser(userName); 5 }
类库中更新过的代码先后对好比下:
1 // UserAdmin.java - Updated library source code 2 … 3 public User addUser(String userName) { 4 User user = new User(userName); 5 User prevUser = userMap.put(userName, user); 6 return prevUser; 7 } 8 // UserAdmin.java - Original library source code 9 … 10 public void addUser(String userName) { 11 User user = new User(userName); 12 userMap.put(userName, user); 13 }
简单来讲就是addUser()方法在更新以后返回void而在更新以后返回了User类型实例。而程序代码由于不关心addUser的返回值,因此在使用的过程当中并未作过改变。
初看起来,com.mhn.user.UserAdmin.addUser()依然存在,但为何会出现NoSuchMethodError?
主要缘由是程序代码在更新类库时并未从新编译代码,也就是说,虽然程序代码看起来依然是在调用addUser方法而不关心其返回值,而对编译的类文件来讲,他是要明确知道调用方法的返回值类型的。
能够经过下面的异常信息说明这一点:
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/langString;)V
NoSuchMethodError 是由于"com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V"方法找不到引发的。看一下"Ljava/lang/String;"和后面的"V"。在Java字节码表示中,"L;"表示类的实例。因此上面的addUser方法须要一个java/lang/String对象做为参数。就这个案例中,类库中的addUser()方法的参数未发生变化,因此参数是正常的。再看一下异常信息中最后面的"V",它表示方法的返回值类型。在Java字节码表示中,"V"意味着该方法没有返回值。因此上面的异常信息就是说须要一个java.lang.String参数且没有任何返回值的com.nhn.user.UserAdmin.addUser方法找不到。
由于程序代码是使用以前版本的类库进编译的,class文件中定义的是应该调用返回"V"类型的方法。然而,在改变类库后,返回"V"类型的方法已不存在,取而代之的是返回类型为"Lcom/nhn/user/User;"的方法。因此便发生了上面看到的NoSuchMethodError。
注释
由于开发者未针对新类库从新编译程序代码,因此发生了错误。尽管如此,类库提供者却也要为此负责。由于以前没有返回值的addUser()方法既然是public方法,但后面却改为了会返回user实现,这意味着方法签名发生了明显的变化。这意味了该类库不能对以前的版本进行兼容,因此类库提供者必须事前对此进行通知。
咱们从新回到Java 字节码,Java 字节码是JVM的基本元素,JVM自己就是一个用于执行Java字节码的执行器。Java编译器并不会把像C/C++那样把高级语言转为机器语言(CPU执行指令),而是把开发者能理解的Java语言转为JVM理解的Java字节码。由于Java字节码是平台无关的,因此它能够在安装了JVM(准确的说,是JRE环境)的任何硬件环境执行,即便它们的CPU和操做系统各不相同(因此在Windows PC机上开发和编译的class文件在不作任何调整的状况下就能够在Linux机器上执行)。编译后文件的大小与源文件大小基本一致,因此比较容易经过网络传输和执行Java字节码。
Java class文件自己是基于二进制的文件,因此咱们很难直观的理解其中的指令。为了管理这些class 文件, JVM提供了javap命令来对二进制文件进行反编译。执行javap获得的是直观的java指令序列。在上面的案例中,经过对程序代码执行javap -c就可获得应用中的UserService.add()方法的指令序列,以下:
1 public void add(java.lang.String); 2 Code: 3 0: aload_0 4 1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin; 5 4: aload_1 6 5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)V 7 8: return
在上面的Java指令中,addUser()方法是在第五行被调用,即"5: invokevirtual #23"。这句的意思是索引位置为23的方法会被调用,方法的索引位置是由javap程序标注的。invokevirtual是Java 字节码中最经常使用到的一个操做码,用于调用一个方法。另外,在Java字节码中有4个表示调用方法的操做码: invokeinterface, invokespecial, invokestatic, invokevirtual 。他们每一个的含义以下:
Java 字节码的指令集包含操做码(OpCode)和操做数(Operand)。像invokevirtual这样的操做码须要一个2字节长度的操做数。
对上面案例中的程序代码,若是在更新类库后从新编译程序代码,而后咱们再反编译字节码将看到以下结果:
1 public void add(java.lang.String); 2 Code: 3 0: aload_0 4 1: getfield #15; //Field admin:Lcom/nhn/user/UserAdmin; 5 4: aload_1 6 5: invokevirtual #23; //Method com/nhn/user/UserAdmin.addUser:(Ljava/lang/String;)Lcom/nhn/user/User; 7 8: pop 8 9: return
如上咱们看到#23对应的方法变成了具备返回值类型"Lcom/nhn/user/User;"的方法。
在上面的反编译结果中,代码前面的数字是具备什么含义?
它是一个一字节数字,也许正所以JVM执行的代码被称为“字节码”。像 aload0, getfield_ 和 invokevirtual 都被表示为一个单字节数字。(aload_0 = 0x2a, getfiled = 0xb4, invokevirtual = 0xb6)。所以Java字节码表示的最大指令码为256。
像aload0和aload1这样的操做码不须要任何操做数,所以aload_0的下一个字节就是下一个指令的操做码。而像getfield和invokevirtual这样的操做码却须要一个2字节的操做数,所以第一个字节里的第二个指令getfield指令的一下指令是在第4个字节,其中跳过了2个字节。经过16进制编辑器查看字节码以下:
2a b4 00 0f 2b b6 00 17 57 b1
在Java字节码中,类实例表示为"L;",而void表示为"V",相似的其余类型也有各自的表示。下表列出了Java字节码中类型表示。
表1: Java字节码里的类型表示
Java 字节码 | 类型 | 描述 |
---|---|---|
B | byte | 单字节 |
C | char | Unicode字符 |
D | double | 双精度浮点数 |
F | float | 单精度浮点数 |
I | int | 整型 |
J | long | 长整型 |
L | 引用 | classname类型的实例 |
S | short | 短整型 |
Z | boolean | 布尔类型 |
[ | 引用 | 一维数组 |
表2: Java代码的字节码示例
java 代码 | Java 字节码表示 |
---|---|
double d[][][] | [[[D |
Object mymethod(int i, double d, Thread t) | mymethod(I,D,Ljava/lang/Thread;)Ljava/lang/Object; |
在《Java虚拟机技术规范第二版》的4.3 描述符(Descriptors)章节中有关于此的详细描述,在第6章"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字节的限制。这个限制是JVM定义的,用于规定方法的定义不能大于65535个字节。
下面我将先介绍65535个的字节限制,而后详细说明为何要有这个限制。
Java字节码中,"goto"和"jsr"指令分别表示分支和跳转。
goto [branchbyte1] [branchbyte2] jsr [branchbyte1] [branchbyte2]
这两个操做指令都跟着一个2字节的操做数,而2个字节能表示的最大偏移量只能是65535。然而为了支持更大范围的分支,Java字节码又分别定义了"gotow" 和 "jsrw" 用于接收4个字节的分支偏移量。
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4] jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
受这两个指令所赐,分支能表示的最大偏移远远超过了65535,这么说来java 方法就不会再有65535个字节的限制了。然而,因为Java 类文件的各类其余限制,java方法的定义仍然不可以超过65535个字节的限制。下面咱们经过对类文件的解释来看看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节"类文件结构"。
以前讲过的UserService.class文件的前16个字节的16进制表示以下:
ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b
咱们经过对这一段符号的分析来了解一个类文件的具体格式。
javap程序把class文件格式以可阅读的方式输出来。在对UserService.class文件使用"javap -verbose"命令分析时,输出内容以下:
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的影响。如上面"javap -verbose"的输出所示,结构体methodinfo包括代码(Code)、行号表(LineNumberTable)以及本地变量表(LocalVariableTable)。其中行号表、本地变量表以及代码里的异常表(exceptiontable)的总长度为一个固定2字节的值。所以方法的大小不能超过行号表、本地变量表、异常表的长度,即不能超过65535个字节。
尽管不少人抱怨方法的大小限制,JVM规范也声称将会对此大小进行扩充,然而到目前为止并无明确的进展。由于JVM技术规范里定义要把几乎整个类文件的内容都加载到方法区,所以若是方法长度将会对程序的向后兼容带来极大的挑战。
对于一个由Java编译器错误而致使的错误的类文件将发生怎样的状况?若是是在网络传输或文件复制过程当中,类文件被损坏又将发生什么?
为了应对这些场景,Java类加载器的加载过程被设计为一个很是严谨的处理过程。JVM规范详细描述了这个过程。
注释
咱们如何验证JVM成功执行了类文件的验证过程?如何验证不一样的JVM实现是否符合JVM规范?为此,Oracle提供了专门的测试工具:TCK(Technology Compatibility Kit)。TCK经过执行大量的测试用例(包括大量经过不一样方式生成的错误类文件)来验证JVM规范。只有经过TCK测试的JVM才能被称做是JVM。
相似TCK,还有一个JCP(Java Community Process; http://jcp.org),用于验证新的Java技术规范。对于一个JCP,必须具备详细的文档,相关的实现以及提交给JSR(Java Specification Request)的TCK测试。若是用户想像JSR同样使用新的Java技术,那他必须先从RI提供者那里获得许可,或者本身直接实现它并对之进行TCK测试。
Java程序的执行过程以下图所示:
图1: Java代码执行过程
类加载器把Java字节码载入到运行时数据区,执行引擎负责Java字节码的执行。
Java提供了动态加载的特性,只有在运行时第一次遇到类时才会去加载和连接,而非在编译时加载它。JVM的类加载器负责类的动态加载过程。Java类加载器的特色以下:
每一个类加载器都有本身的空间,用于存储其加载的类信息。当类加载器须要加载一个类时,它经过FQCN)(Fully Quanlified Class Name: 全限定类名)的方式先在本身的存储空间中检测此类是否已存在。在JVM中,即使具备相同FQCN的类,若是出如今了两个不一样的类加载器空间中,它们也会被认为是不一样的。存在于不一样的空间意味着类是由不一样的加载器加载的。
下图解释了类加载器的代理模型:
图2: 类加载器的代理模型
当JVM请示类加载器加载一个类时,加载器老是按照从类加载器缓存、父类加载器以及本身加载器的顺序查找和加载类。也就是说加载器会先从缓存中判断此类是否已存在,若是不存在就请示父类加载器判断是否存在,若是直到Bootstrap类加载器都不存在该类,那么当前类加载器就会从文件系统中找到类文件进行加载。
像Web应用服务器(WAS: Web Application Server)等框架经过使用用户自定义加载器使Web应用和企业级应用能够隔离开在各自的类加载空间独自运行。也就是说能够经过类加载器的代理模型来保证应用的独立性。不一样的WAS在自定义类加载器时会有略微不一样,但都不外乎使用加载器的层次结构原理。
若是一个类加载器发现了一个未加载的类,则该类的加载和连接过程以下图:
图3: 类加载步骤
每一步的具体描述以下:
JVM规范定义了规则,但也容许在运行时灵活处理。
图4: 运行时数据区结构
运行时数据区是JVM程序运行时在操做系统上分配的内存区域。运行时数据区又可细分为6个部分,即:为每一个线程分别建立的PC寄存器、JVM栈、本地方法栈和被全部线程共用的数据堆、方法区和运行时常量池。
JVM 栈:每一个线程都有一个JVM栈,并跟随线程的启动而建立。其中存储的数据无素称为栈帧(Stack Frame)。JVM会每把栈桢压入JVM栈或从中弹出一个栈帧。若是有任何异常抛出,像printStackTrace()方法输出的栈跟踪信息的每一行表示一个栈帧。
图5: JVM栈结构
本地方法栈:为非Java编写的本地代程定义的栈空间。也就是说它基本上是用于经过JNI(Java Native Interface)方式调用和执行的C/C++代码。根据具体状况,C栈或C++栈将会被建立。
方法区:方法区是被全部线程共用的内存空间,在JVM启动时建立。它存储了运行时常量池、字段和方法信息、静态变量以及被JVM载入的全部类和接口的方法的字节码。不一样的JVM提供者在实现方法区时会一般有不一样的形式。在Oracle的Hotspot JVM里方法区被称为Permanent Area(永久区)或Permanent Generation(PermGen, 永久代)。JVM规范并对方法区的垃圾回收未作强制限定,所以对于JVM实现者来讲,方法区的垃圾回收是可选操做。
运行时常量池:一个存储了类文件格式中的常量池表的内存空间。这部分空间虽然存在于方法区内,但却在JVM操做中扮演着举足轻重的角色,所以JVM规范单独把这一部分拿出来描述。除了每一个类或接口中定义的常量,它还包含了全部对方法和字段的引用。所以当须要一个方法或字段时,JVM经过运行时常量池中的信息从内存空间中来查找其相应的实际地址。
数据堆:堆中存储着全部的类实例或对象,而且也是垃圾回收的目标场所。当涉及到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
比较一下上面反编译过的字节码和咱们常见的基于x86架构的机器码的区别,虽然它们着类似的格式、操做码,但有一个明显的区别:Java字节码中没有寄存器名称、内存地址或者操做数的偏移位置。正如前所述,JVM使用的是栈模型,所以它并不须要x86架构中使用的寄存器。由于JVM本身管理内存,因此Java字节码中使用像1五、23这样的索引值而非直接的内存地址。上面的15和23指向的是当前类的常量池中的位置(即UserService类)。也就是JVM为每一个类建立一个常量池,并在常量池中存储真实对象的引用。
上面每行代码的解释以下:
下图将帮忙容易理解上面的文字解释:
图6: 从运行时数据区加载Java字节码示例
做为示例,上面的方法中本地变量数组中的值不曾有任何改变,因此上图中咱们只看到操做数栈的变化。实际上,在大多数场景中本地变量数组也是被发生变化的。数据经过加载指令(aload, iload)和存储指令(astore, istore)在本地变量数组和操做数栈之间发生变化和移动。
在本章节咱们对运行时常量池和JVM栈做了清晰的介绍。在JVM运行时,每一个类的实例被分配到数据堆上,类信息(包括User, UserAdmin, UserService, String)等被存储在方法区。
JVM经过类加载器把字节码载入运行时数据区是由执行引擎执行的。执行引擎以指令为单位读入Java字节码,就像CPU一个接一个的执行机器命令同样。每一个字节码命令包含一字节的操做码和可选的操做数。执行引擎读取一个指令并执行相应的操做数,而后去读取并执行下一条指令。
尽管如此,Java字节码仍是以一种能够理解的语言编写的,而不像那些机器直接执行的没法读懂的语言。因此JVM的执行引擎必需要把字节码转换为能被机器执行的语言指令。执行引擎有两种经常使用的方法来完成这一工做:
然而,即时编译器在编译代码时比逐一解释和执行每条指令更耗时,因此若是代码只会被执行一次,解释执行可能会具备更好的性能。因此JVM经过检查方法的执行频率,而后只对达到必定频率的方法才会作即时编译。
图7: Java编译器和即时编译器
JVM规范中并未强行约束执行引擎如何运行。因此不一样的JVM在实现各类的执行引擎时经过各类技术手段并引入多种即时编译器来提高性能。
大部分的即时编译器运行流程以下图:
图8: 即时编译器
即时编译器先把字节码转为一种中间形式的表达式(IR: Itermediate Representation),并对之进行优化,而后再把这种表达式转为本地代码。
Oracel Hotspot VM使用的即时编译器称为Hotspot编译器。之因此称为Hotspot是由于Hotspot Compiler会根据分析找到具备更高编译优先级的热点代码,而后所这些热点代码转为本地代码。若是一个被编译过的方法再也不被频繁调用,也即再也不是热点代码,Hotspot VM会把这些本地代码从缓存中删除并对其再次使用解释器模式执行。Hotspot VM有Server VM和Client VM以后,它们所使用的即时编译器也有所不一样。
图9: Hotspot ClientVM 和Server VM
Client VM和Server VM使用相同的运行时环境,如上图所示,它们的区别在于使用了不一样的即时编译器。Server VM经过使用多种更为复杂的性能优化技术从而具备更好的表现。
IBM VM在他的IBM JDK6中引入了AOT(Ahead-Of-Time) 编译器技术。经过此种技术使得多个JVM之间能经过共享缓存分享已编译的本地代码。也就是说经过AOT编译器编译的代码能被其余JVM直接使用而无须再次编译。另外IBM JVM经过使用AOT编译器把代码预编译为JXE(Java Executable)文件格式从而提供了一种快速执行代码的方式。
大多数的Java性能提高都是经过优化执行引擎的性能实现的。像即时编译等各类优化技术被不断的引入,从而使得JVM性能获得了持续的优化和提高。老旧的JVM与最新的JVM之间最大的差别其实就来自于执行引擎的提高。
Hotspot编译器从Java 1.3开始便引入到了Oracle Hotspot VM中,而即时编译器从Android 2.2开始便被引入到了Android Dalvik VM中。
注释
像其余使用了像字节码同样的中间层语言的编译语言,VM在执行中间层字节码时也像JVM执行字节码同样,引入了即时编译等技术来提升VM的执行效率。像Microsoft的.Net语言,其运行时的VM叫作CLR(Common Language Runtime)。CLR执行一种相似字节码的语言CIL(Common Intermediate Language)。CLR同时提供了AOT编译器和即时编译器。由于若是使用C#或VB.NET编写程序,编译器会把源码编译成CIL,CLR经过使用即时编译器来执行CIL。CLR也有垃圾回收,而且和JVM同样也是以基于栈的方式运行。
虽然使用Java并不须要了解Java是如何被创造出来的,而且不少程序员在并无深刻研究JVM的状况下依然开发出了不少伟大的应用和类库。可是若是可以了解JVM,就能对Java 有更多深刻的提升,并在解决文中案例问题场景时有所帮助。
除了上文所述,JVM还有不少特性和技术细节。JVM技术规范为JVM开发者提供了灵活的规范空间,以帮忙开发者能使用多种技术手段创造出具备更好性能的JVM实现。另外虽然垃圾回收手术已被不少具备相似VM能力的编程语言做为经常使用的性能提高的新手段,但因有不少对其详细介绍的资料,因此这里没有深刻讲解。
原文做者:Se Hoon Park,消息平台开发团队,NHN公司。