深刻理解JVM内幕:从基本结构到Java 7新特性[转]

英文原文:cubrid,编译:ImportNew - 朱伟杰html

译文连接:http://www.importnew.com/1486.htmljava

【如需转载,请在正文中标注并保留原文连接、译文连接和译者等信息,谢谢合做!】web

 

 

每一个Java开发者都知道Java字节码是执行在JRE((Java Runtime Environment Java运行时环境)上的。JRE中最重要的部分是Java虚拟机(JVM),JVM负责分析和执行Java字节码。Java开发人员并不须要去关心JVM是如何运行的。在没有深刻理解JVM的状况下,许多开发者已经开发出了很是多的优秀的应用以及Java类库。不过,若是你了解JVM的话,你会更加了解Java的,而且你会轻松解决那些看似简单可是无从下手的问题。apache

所以,在这篇文件里,我会阐述JVM是如何运行的,包括它的结构,它如何去执行字节码,以及按照怎样的顺序去执行,同时我还会给出一些常见错误的示例以及对应的解决办法。最后,我还会讲解Java 7中的一些新特性。编程

虚拟机(Virtual Machine)

JRE是由Java API和JVM组成的。JVM的主要做用是经过Class Loader来加载Java程序,而且按照Java API来执行加载的程序。bootstrap

虚拟机是经过软件的方式来模拟实现的机器(好比说计算机),它能够像物理机同样运行程序。设计虚拟机的初衷是让Java可以经过它来实现WORA(Write Once Run Anywhere 一次编译,处处运行),尽管这个目标如今已经被大多数人忽略了。所以,JVM能够在不修改Java代码的状况下,在全部的硬件环境上运行Java字节码数组

Java虚拟机的特色以下:缓存

  • 基于栈的虚拟机:Intel x86和ARM这两种最多见的计算机体系的机构都是基于寄存器的。不一样的是,JVM是基于栈的。
  • 符号引用:除了基本类型之外的数据(类和接口)都是经过符号来引用,而不是经过显式地使用内存地址来引用。
  • 垃圾回收机制:类的实例都是经过用户代码进行建立,而且自动被垃圾回收机制进行回收。
  • 经过对基本类型的清晰定义来保证平台独立性:传统的编程语言,例如C/C++,int类型的大小取决于不一样的平台。JVM经过对基本类型的清晰定义来保证它的兼容性以及平台独立性。
  • 网络字节码顺序:Java class文件用网络字节码顺序来进行存储:为了保证和小端的Intel x86架构以及大端的RISC系列的架构保持无关性,JVM使用用于网络传输的网络字节顺序,也就是大端。

虽然是Sun公司开发了Java,可是全部的开发商均可以开发而且提供遵循Java虚拟机规范的JVM。正是因为这个缘由,使得Oracle HotSpot和IBM JVM等不一样的JVM可以并存。Google的Android系统里的Dalvik VM也是一种JVM,虽然它并不遵循Java虚拟机规范。和基于栈的Java虚拟机不一样,Dalvik VM是基于寄存器的架构,所以它的Java字节码也被转化成基于寄存器的指令集。安全

 

Java字节码(Java bytecode)

为了保证WORA,JVM使用Java字节码这种介于Java和机器语言之间的中间语言。字节码是部署Java代码的最小单位。性能优化

在解释Java字节码以前,咱们先经过实例来简单了解它。这个案例是一个在开发环境出现的真实案例的总结。

 

现象

一个一直运行正常的应用忽然没法运行了。在类库被更新以后,返回下面的错误。

1
2
3
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 )

应用的代码以下,并且它没有被改动过。

1
2
3
4
5
// UserService.java
public void add(String userName) {
     admin.addUser(userName);
}

更新后的类库的源代码和原始的代码以下。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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的错误呢?

 

缘由

上面问题的缘由是在于应用的代码没有用新的类库来进行编译。换句话来讲,应用代码彷佛是调了正确的方法,只是没有使用它的返回值而已。无论怎样,编译后的class文件代表了这个方法是有返回值的。你能够从下面的错误信息里看到答案。

1
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”表示没有返回值(Void)。综上所述,上面的错误信息是表示有一个java.lang.String类型的参数,而且没有返回值的com.nhn.user.UserAdmin.addUser方法没有找到。

由于应用是用以前的类库编译的,因此返回值为空的方法被调用了。可是在修改后的类库里,返回值为空的方法不存在,而且添加了一个返回值为“Lcom/nhn/user/User”的方法。所以,就出现了NoSuchMethodError。

注:

这个错误出现的缘由是由于开发者没有用新的类库来从新编译应用。不过,出现这种问题的大部分责任在于类库的提供者。这个public的方法原本没有返回值的,可是后来却被修改为返回User类的实例。很明显,方法的签名被修改了,这也代表了这个类库的后向兼容性被破坏了。所以,这个类库的提供者应该告知使用者这个方法已经被改变了。

 

咱们再回到Java字节码上来。Java字节码是JVM很重要的部分。JVM是模拟执行Java字节码的一个模拟器。Java编译器不会直接把高级语言(例如C/C++)编写的代码直接转换成机器语言(CPU指令);它会把开发者能够理解的Java语言转换成JVM可以理解的Java字节码。由于Java字节码自己是平台无关的,因此它能够在任何安装了JVM(确切地说,是相匹配的JRE)的硬件上执行,即便是在CPU和OS都不相同的平台上(在Windows PC上开发和编译的字节码能够不作任何修改就直接运行在Linux机器上)。编译后的代码的大小和源代码大小基本一致,这样就能够很容易地经过网络来传输和执行编译后的代码。

Java class文件是一种人很难去理解的二进文件。为了便于理解它,JVM提供者提供了javap,反汇编器。使用javap产生的结果是Java汇编语言。在上面的例子中,下面的Java汇编代码是经过javap -c对UserServiceadd()方法进行反汇编获得的。

1
2
3
4
5
6
7
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

invokeinterface:调用一个接口方法在这段Java汇编代码中,addUser()方法是在第四行的“5:invokevitual#23″进行调用的。这表示对应索引为23的方法会被调用。索引为23的方法的名称已经被javap给注解在旁边了。invokevirtual是Java字节码里调用方法的最基本的操做码。在Java字节码里,有四种操做码能够用来调用一个方法,分别是:invokeinterface,invokespecial,invokestatic以及invokevirtual。操做码的做用分别以下:

  • invokespecial: 调用一个初始化方法,私有方法或者父类的方法
  • invokestatic:调用静态方法
  • invokevirtual:调用实例方法

Java字节码的指令集由操做码和操做数组成。相似invokevirtual这样的操做数须要2个字节的操做数。

用更新的类库来编译上面的应用代码,而后反编译它,将会获得下面的结果。

1
2
3
4
5
6
7
8
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“字节”码的缘由。简而言之,Java字节码指令的操做码,例如aload_0,getfield和invokevirtual等,都是用一个字节的数字来表示的(aload_0=0x2a,getfield=0xb4,invokevirtual=0xb6)。由此可知Java字节码指令的操做码最多有256个。

aload_0和aload_1这样的指令不须要任何操做数。所以,aload_0指令的下一个字节是下一个指令的操做码。不过,getfield和invokevirtual指令须要2字节的操做数。所以,getfiled的下一条指令是跳过两个字节,写在第四个字节的位置上的。十六进制编译器里查看字节码的结果以下所示。

1
2a b4 00 0f 2b b6 00 17 57 b1

表一:Java字节码中的类型表达式在Java字节码里,类的实例用字母“L;”表示,void 用字母“V”表示。经过这种方式,其余的类型也有对应的表达式。下面的表格对此做了总结。

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<classname> reference an instance of class <classname>
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;

想了解更多细节的话,参考《The java Virtual Machine Specification,第二版》中的“4.3 Descriptors"。想了解更多的Java字节码的指令的话,参考《The Java Virtual Machined Instruction Set》的“6.The Java Virtual Machine Instruction Set"

 

Class文件格式

在讲解Java class文件格式以前,咱们先看看一个在Java Web应用中常常出现的问题。

现象

当咱们编写完jsp代码,而且在Tomcat运行时,Jsp代码没有正常运行,而是出现了下面的错误。

1
2
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"。

1
2
goto [branchbyte1] [branchbyte2]
jsr [branchbyte1] [branchbyte2]

这两个指令都接收一个2字节的有符号的分支跳转偏移量作为操做数,所以偏移量最大只能达到65535。不过,为了支持更多的跳转,Java字节码提供了"goto_w"和"jsr_w"这两个能够接收4字节分支偏移的指令。

1
2
goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]

有了这两个指令,索引超过65535的分支也是可用的。所以,Java方法的65535字节的限制就能够解除了。不过,因为Java class文件的更多的其余的限制,使得Java方法仍是不能超过65535字节。

为了展现其余的限制,我会简单讲解一下class 文件的格式。

 

Java class文件的大体结构以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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];}

上面的内容是来自《The Java Virtual Machine Specification,Second Edition》的4.1节“The ClassFile Structure"。

以前反汇编的UserService.class文件反汇编的结果的前16个字节在十六进制编辑器中以下所示:

ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b

 

经过这些数值,咱们能够来看看class文件的格式。

  •  magic:class文件最开始的四个字节是魔数。它的值是用来标识Java class文件的。从上面的内容里能够看出,魔数 的值是0xCAFEBABE。简而言之,只有一个文件的起始4字节是0xCAFEBABE的时候,它才会被看成Java class文件来处理。
  •   minor_version,major_version:接下来的四个字节表示的是class文件的版本。UserService.class文件里的是0x00000032,因此这个class文件的版本是50.0。JDK 1.6编译的class文件的版本是50.0,JDK 1.5编译出来的class文件的版本是49.0。JVM必须对低版本的class文件保持后向兼容性,也就是低版本的class文件能够运行在高版本的JVM上。不过,反过来就不行了,当一个高版本的class文件运行在低版本的JVM上时,会出现java.lang.UnsupportedClassVersionError的错误。
  • constant_pool_count,constant_pool[]:在版本号以后,存放的是类的常量池。这里保存的信息将会放入运行时常量池(Runtime Constant Pool)中去,这个后面会讲解的。在加载一个class文件的时候,JVM会把常量池里的信息存放在方法区的运行时常量区里。UserService.class文件里的constant_pool_count的值是0x0028,这表示常量池里有39(40-1)个常量。
  •   access_flags:这是表示一个类的描述符的标志;换句话说,它表示一个类是public,final仍是abstract以及是否是接口的标志。
  •  fields_count,fields[]:当前类的成员变量的数量以及成员变量的信息。成员变量的信息包含变量名,类型,修饰符以及变量在constant_pool里的索引。
  •  methods_count,methods[]:当前类的方法数量以及方法的信息。方法的信息包含方法名,参数的数量和类型,返回值的类型,修饰符,以及方法在constant_pool里的索引,方法的可执行代码以及异常信息。
  •  attributes_count,attributes[]:attribution_info结构包含不一样种类的属性。field_info和method_info里都包含了attribute_info结构。

 

javap简要地给出了class文件的一个可读形式。当你用"java -verbose"命令来分析UserService.class时,会输出以下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 …
}

javap输出的内容太长,我这里只是提出了整个输出的一部分。整个的输出展现了constant_pool里的不一样信息,以及方法的内容。

关于方法的65565字节大小的限制是和method_info struct相关的。method_info结构包含Code,LineNumberTable,以及LocalViriable attribute几个属性,这个在“javap -verbose"的输出里能够看到。Code属性里的LineNumberTable,LocalVariableTable以及exception_table的长度都是用一个固定的2字节来表示的。所以,方法的大小是不能超过LineNumberTable,LocalVariableTable以及exception_table的长度的,它们都是65535字节。

许多人都在抱怨方法的大小限制,并且在JVM规范里还说名了”这个长度之后有可能会是可扩展的“。不过,到如今为止,尚未为这个限制作出任何动做。从JVM规范里的把class文件里的内容直接拷贝到方法区这个特色来看,要想在保持后向兼容性的同时来扩展方法区的大小是很是困难的。

 

若是由于Java编译器的错误而致使class文件的错误,会怎么样呢?或者,由于网络传输的错误致使拷贝的class文件的损坏呢?

为了预防这种场景,Java的类装载器经过一个严格并且慎密的过程来校验class文件。在JVM规范里详细地讲解了这方面的内容。

注意

咱们怎样可以判断JVM正确地执行了class文件校验的全部过程呢?咱们怎么来判断不一样提供商的不一样JVM实现是符合JVM规范的呢?为了可以验证以上两点,Oracle提供了一个测试工具TCK(Technology Compatibility Kit)。这个TCK工具经过执行成千上万的测试用例来验证一个JVM是否符合规范,这些测试里面包含了各类非法的class文件。只有经过了TCK的测试的JVM才能称做JVM。

和TCK类似,有一个组织JCP(Java Community Process;http://jcp.org)负责Java规范以及新的Java技术规范。对于JCP而言,若是要完成一项Java规范请求(Java Specification Request, JSR)的话,须要具有规范文档,可参考的实现以及经过TCK测试。任何人若是想使用一项申请JSR的新技术的话,他要么使用RI提供许可的实现,要么本身实现一个而且保证经过TCK的测试。

 

JVM结构

 

Java编写的代码会按照下图的流程来执行

图 1: Java代码执行流程.

类装载器装载负责装载编译后的字节码,并加载到运行时数据区(Runtime Data Area),而后执行引擎执行会执行这些字节码。

 

类加载器(Class Loader)

 

Java提供了动态的装载特性;它会在运行时的第一次引用到一个class的时候对它进行装载和连接,而不是在编译期进行。JVM的类装载器负责动态装载。Java类装载器有以下几个特色:

  •  层级结构:Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是全部装载器的父亲。
  • 代理模式:基于层级结构,类的装载能够在装载器之间进行代理。当装载器装载一个类时,首先会检查它是否在父装载器中进行装载了。若是上层的装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类。
  • 可见性限制:一个子装载器能够查找父装载器中的类,可是一个父装载器不能查找子装载器里的类。
  • 不容许卸载:类装载器能够装载一个类可是不能够卸载它,不过能够删除当前的类装载器,而后建立一个新的类装载器。

 

每一个类装载器都有一个本身的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会经过保存在命名空间里的类全局限定名(Fully Qualified Class Name)进行搜索来检测这个类是否已经被加载了。若是两个类的全局限定名是同样的,可是若是命名空间不同的话,那么它们仍是不一样的类。不一样的命名空间表示class被不一样的类装载器装载。

 

下图展现了类装载器的代理模型。

  

图 2: 类加载器代理模型

 

当一个类装载器(class loader)被请求装载类时,它首先按照顺序在上层装载器、父装载器以及自身的装载器的缓存里检查这个类是否已经存在。简单来讲,就是在缓存里查看这个类是否已经被本身装载过了,若是没有的话,继续查找父类的缓存,直到在bootstrap类装载器里也没有找到的话,它就会本身在文件系统里去查找而且加载这个类。

 

  • 启动类加载器(Bootstrap class loader):这个类装载器是在JVM启动的时候建立的。它负责装载Java API,包含Object对象。和其余的类装载器不一样的地方在于这个装载器是经过native code来实现的,而不是用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应用和企业级应用进行分离。换句话来讲,类装载器的代理模型能够用来保证不一样应用之间的相互独立。WAS类装载器使用这种层级结构,不一样的WAS供应商的装载器结构有稍许区别。

 

若是类装载器查找到一个没有装载的类,它会按照下图的流程来装载和连接这个类:

 

图 3: 类加载的各个阶段

每一个阶段的描述以下:

  •  Loading: 类的信息从文件中获取而且载入到JVM的内存里。
  •  Verifying:检查读入的结构是否符合Java语言规范以及JVM规范的描述。这是类装载中最复杂的过程,而且花费的时间也是最长的。而且JVM TCK工具的大部分场景的用例也用来测试在装载错误的类的时候是否会出现错误。
  • Preparing:分配一个结构用来存储类信息,这个结构中包含了类中定义的成员变量,方法和接口的信息。
  • Resolving:把这个类的常量池中的全部的符号引用改变成直接引用。
  • Initializing:把类中的变量初始化成合适的值。执行静态初始化程序,把静态变量初始化成指定的值。

JVM规范定义了上面的几个任务,不过它容许具体执行的时候可以有些灵活的变更。

运行时数据区(Runtime Data Areas)

图 4: 运行时数据区

 

 

运行时数据区是在JVM运行的时候操做所分配的内存区。运行时内存区能够划分为6个区域。在这6个区域中,一个PC Register,JVM stack 以及Native Method Statck都是按照线程建立的,Heap,Method Area以及Runtime Constant Pool都是被全部线程公用的。

  •  PC寄存器(PC register):每一个线程启动的时候,都会建立一个PC(Program Counter ,程序计数器)寄存器。PC寄存器里保存有当前正在执行的JVM指令的地址。
  • JVM 堆栈(JVM stack):每一个线程启动的时候,都会建立一个JVM堆栈。它是用来保存栈帧的。JVM只会在JVM堆栈上对栈帧进行push和pop的操做。若是出现了异常,堆栈跟踪信息的每一行都表明一个栈帧立的信息,这些信息它是经过相似于printStackTrace()这样的方法来展现的。

 

 

图 5: JVM堆栈

  • -栈帧(stack frame):每当一个方法在JVM上执行的时候,都会建立一个栈帧,而且会添加到当前线程的JVM堆栈上。当这个方法执行结束的时候,这个栈帧就会被移除。每一个栈帧里都包含有当前正在执行的方法所属类的本地变量数组,操做数栈,以及运行时常量池的引用。本地变量数组的和操做数栈的大小都是在编译时肯定的。所以,一个方法的栈帧的大小也是固定不变的。
  • -局部变量数组(Local variable array):这个数组的索引从0开始。索引为0的变量表示这个方法所属的类的实例。从1开始,首先存放的是传给该方法的参数,在参数后面保存的是方法的局部变量。
  • 操做数栈(Operand stack):方法实际运行的工做空间。每一个方法都在操做数栈和局部变量数组之间交换数据,而且压入或者弹出其余方法返回的结果。操做数栈所需的最大空间是在编译期肯定的。所以,操做数栈的大小也能够在编译期间肯定。
  •  本地方法栈(Native method stack):供用非Java语言实现的本地方法的堆栈。换句话说,它是用来调用经过JNI(Java Native Interface Java本地接口)调用的C/C++代码。根据具体的语言,一个C堆栈或者C++堆栈会被建立。
  •  方法区(Method area):方法区是全部线程共享的,它是在JVM启动的时候建立的。它保存全部被JVM加载的类和接口的运行时常量池,成员变量以及方法的信息,静态变量以及方法的字节码。JVM的提供者能够经过不一样的方式来实现方法区。在Oracle 的HotSpot JVM里,方法区被称为永久区或者永久代(PermGen)。是否对方法区进行垃圾回收对JVM的实现是可选的。
  •   运行时常量池(Runtime constant pool):这个区域和class文件里的constant_pool是相对应的。这个区域是包含在方法区里的,不过,对于JVM的操做而言,它是一个核心的角色。所以在JVM规范里特别提到了它的重要性。除了包含每一个类和接口的常量,它也包含了全部方法和变量的引用。简而言之,当一个方法或者变量被引用时,JVM经过运行时常量区来查找方法或者变量在内存里的实际地址。
  • 堆(Heap):用来保存实例或者对象的空间,并且它是垃圾回收的主要目标。当讨论相似于JVM性能之类的问题时,它常常会被说起。JVM提供者能够决定怎么来配置堆空间,以及不对它进行垃圾回收。

如今咱们再会过头来看看以前反汇编的字节码

1
2
3
4
5
6
7
8
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架构不一样,它本身负责内存的管理。它用索引例如15和23来代替实际的内存地址。15和23都是当前类(这里是UserService类)的常量池里的索引。简而言之,JVM为每一个类建立了一个常量池,而且这个常量池里保存了实际目标的引用。

每行反汇编代码的解释以下:

  •  aload_0:把局部变量数组中索引为#0的变量添加到操做数栈上。索引#0所表示的变量是this,便是当前实例的引用。
  • getfield #15:把当前类的常量池里的索引为#15的变量添加到操做数栈。这里添加的是UserAdmin的admin成员变量。由于admin变量是个类的实例,所以添加的是一个引用。
  • aload_1:把局部变量数组里的索引为#1的变量添加到操做数栈。来自局部变量数组里的索引为1的变量是方法的一个参数。所以,在调用add()方法的时候,会把userName指向的String的引用添加到操做数栈上。
  • invokevirtual #23:调用当前类的常量池里的索引为#23的方法。这个时候,经过getfile和aload_1添加到操做数栈上的引用都被做为方法的参数。当方法运行完成而且返回时,它的返回值会被添加到操做数栈上。
  •  pop:把经过invokevirtual调用的方法的返回值从操做数栈里弹出来。你能够看到,在前面的例子里,用老的类库编译的那段代码是没有返回值的。简而言之,正由于以前的代码没有返回值,因此不必吧把返回值从操做数栈上给弹出来。
  •  return:结束当前方法调用

下图能够帮助你更好地理解上面的内容。

 

图 6: Java字节码装载到运行时数据区示例

顺便提一下,在这个方法里,局部变量数组没有被修改。因此上图只显示了操做数栈的变化。不过,大部分的状况下,局部变量数组也是会改变的。局部变量数组和操做数栈之间的数据传输是使用经过大量的load指令(aload,iload)和store指令(astore,istore)来实现的。

在这个图里,咱们简单验证了运行时常量池和JVM栈的描述。当JVM运行的时候,每一个类的实例都会在堆上进行分配,User,UserAdmin,UserService以及String等类的信息都会保存在方法区。

执行引擎(Execution Engine)

经过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取Java字节码。它就像一个CPU同样,一条一条地执行机器指令。每一个字节码指令都由一个1字节的操做码和附加的操做数组成。执行引擎取得一个操做码,而后根据操做数来执行任务,完成后就继续执行下一条操做码。

不过Java字节码是用一种人类能够读懂的语言编写的,而不是用机器能够直接执行的语言。所以,执行引擎必须把字节码转换成能够直接被JVM执行的语言。字节码能够经过如下两种方式转换成合适的语言。

  • 解释器:一条一条地读取,解释而且执行字节码指令。由于它一条一条地解释和执行指令,因此它能够很快地解释字节码,可是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基原本说是解释执行的。
  • 即时(Just-In-Time)编译器:即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,而后在合适的时候,即时编译器把整段字节码编译成本地代码。而后,执行引擎就没有必要再去解释执行方法了,它能够直接经过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快不少。编译后的代码能够执行的很快,由于本地代码是保存在缓存里的。

不过,用JIT编译器来编译代码所花的时间要比用解释器去一条条解释执行花的时间要多。所以,若是代码只被执行一次的话,那么最好仍是解释执行而不是编译后再执行。所以,内置了JIT编译器的JVM都会检查方法的执行频率,若是一个方法的执行频率超过一个特定的值的话,那么这个方法就会被编译成本地代码。

图 7:Java编译器和JIT编译器

 

JVM规范没有定义执行引擎该如何去执行。所以,JVM的提供者经过使用不一样的技术以及不一样类型的JIT编译器来提升执行引擎的效率。

大部分的JIT编译器都是按照下图的方式来执行的:

图 8: JIT编译器

 

JIT编译器把字节码转换成一个中间层表达式,一种中间层的表示方式,来进行优化,而后再把这种表示转换成本地代码。

Oracle Hotspot VM使用一种叫作热点编译器的JIT编译器。它之因此被称做”热点“是由于热点编译器经过分析找到最须要编译的“热点”代码,而后把热点代码编译成本地代码。若是已经被编译成本地代码的字节码再也不被频繁调用了,换句话说,这个方法再也不是热点了,那么Hotspot VM会把编译过的本地代码从cache里移除,而且从新按照解释的方式来执行它。Hotspot VM分为Server VM和Client VM两种,这两种VM使用不一样的JIT编译器。

Figure 9: Hotspot Client VM and Server VM.

 

Client VM 和Server VM使用彻底相同的运行时,不过如上图所示,它们所使用的JIT编译器是不一样的。Server VM用的是更高级的动态优化编译器,这个编译器使用了更加复杂而且更多种类的性能优化技术。

IBM 在IBM JDK 6里不只引入了JIT编译器,它同时还引入了AOT(Ahead-Of-Time)编译器。它使得多个JVM能够经过共享缓存来共享编译过的本地代码。简而言之,经过AOT编译器编译过的代码能够直接被其余JVM使用。除此以外,IBM JVM经过使用AOT编译器来提早把代码编译器成JXE(Java EXecutable)文件格式来提供一种更加快速的执行方式。

大部分Java程序的性能都是经过提高执行引擎的性能来达到的。正如JIT编译器同样,不少优化的技术都被引入进来使得JVM的性能一直可以获得提高。最原始的JVM和最新的JVM最大的差异之处就是在于执行引擎。

Hotspot编译器在1.3版本的时候就被引入到Oracle Hotspot VM里了,JIT编译技术在Anroid 2.2版本的时候被引入到Dalvik VM里。

引入一种中间语言,例如字节码,虚拟机执行字节码,而且经过JIT编译器来提高JVM的性能的这种技术以及普遍应用在使用中间语言的编程语言上。例如微软的.Net,CLR(Common Language Runtime 公共语言运行时),也是一种VM,它执行一种被称做CIL(Common Intermediate Language)的字节码。CLR提供了AOT编译器和JIT编译器。所以,用C#或者VB.NET编写的源代码被编译后,编译器会生成CIL而且CIL会执行在有JIT编译器的CLR上。CLR和JVM类似,它也有垃圾回收机制,而且也是基于堆栈运行。

Java 虚拟机规范,Java SE 第7版

2011年7月28日,Oracle发布了Java SE的第7个版本,而且把JVM规也更新到了相应的版本。在1999年发布《The Java Virtual Machine Specification,Second Edition》后,Oracle花了12年来发布这个更新的版本。这个更新的版本包含了这12年来累积的众多变化以及修改,而且更加细致地对规范进行了描述。此外,它还反映了《The Java Language Specificaion,Java SE 7 Edition》里的内容。主要的变化总结以下:

  • 来自Java SE 5.0里的泛型,支持可变参数的方法
  • 从Java SE 6以来,字节码校验的处理技术所发生的改变
  • 添加invokedynamic指令以及class文件对于该指令的支持
  • 删除了关于Java语言概念的内容,而且指引读者去参考Java语言规范
  •  删除关于Java线程和锁的描述,而且把它们移到Java语言规范里

最大的改变是添加了invokedynamic指令。也就是说JVM的内部指令集作了修改,使得JVM开始支持动态类型的语言,这种语言的类型不是固定的,例如脚本语言以及来自Java SE 7里的Java语言。以前没有被用到的操做码186被分配给新指令invokedynamic,并且class文件格式里也添加了新的内容来支持invokedynamic指令。

Java SE 7的编译器生成的class文件的版本号是51.0。Java SE 6的是50.0。class文件的格式变更比较大,所以,51.0版本的class文件不可以在Java SE 6的虚拟机上执行。

尽管有了这么多的变更,可是Java方法的65535字节的限制仍是没有被去掉。除非class文件的格式完全改变,否者这个限制未来也是不可能去掉的。

值得说明的是,Oracle Java SE 7 VM支持G1这种新的垃圾回收机制,不过,它被限制在Oracle JVM上,所以,JVM自己对于垃圾回收的实现不作任何限制。也所以,在JVM规范里没有对它进行描述。

switch 语句里的String

Java SE 7里添加了不少新的语法和特性。不过,在Java SE 7的版本里,相对于语言自己而言,JVM没有多少的改变。那么,这些新的语言特性是怎么来实现的呢?咱们经过反汇编的方式来看看switch语句里的String(把字符串做为switch()语句的比较对象)是怎么实现的?

例如,下面的代码:

1
2
3
4
5
6
7
8
9
10
// SwitchTest
public class SwitchTest {
     public int doSwitch(String str) {
         switch (str) {
         case "abc" :        return 1 ;
         case "123" :        return 2 ;
         default :         return 0 ;
         }
     }
}

由于这是Java SE 7的一个新特性,因此它不能在Java SE 6或者更低版本的编译器上来编译。用Java SE 7的javac来编译。下面是经过javap -c来反编译后的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
C:Test>javap -c SwitchTest.classCompiled from "SwitchTest.java"
public class SwitchTest {
   public SwitchTest();
     Code:
        0 : aload_0
        1 : invokespecial # 1                  // Method java/lang/Object."<init>":()V
        4 : return  public int doSwitch(java.lang.String);
     Code:
        0 : aload_1
        1 : astore_2
        2 : iconst_m1
        3 : istore_3
        4 : aload_2
        5 : invokevirtual # 2                  // Method java/lang/String.hashCode:()I
        8 : lookupswitch  { // 2
                  48690 : 50
                  96354 : 36
                default : 61
           }
       36 : aload_2
       37 : ldc           # 3                  // String abc
       39 : invokevirtual # 4                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
       42 : ifeq          61
       45 : iconst_0
       46 : istore_3
       47 : goto          61
       50 : aload_2
       51 : ldc           # 5                  // String 123
       53 : invokevirtual # 4                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
       56 : ifeq          61
       59 : iconst_1
       60 : istore_3
       61 : iload_3
       62 : lookupswitch  { // 2
                      0 : 88
                      1 : 90
                default : 92
           }
       88 : iconst_1
       89 : ireturn
       90 : iconst_2
       91 : ireturn
       92 : iconst_0
       93 : ireturn

在#5和#8字节处,首先是调用了hashCode()方法,而后它做为参数调用了switch(int)。在lookupswitch的指令里,根据hashCode的结果进行不一样的分支跳转。字符串“abc"的hashCode是96354,它会跳转到#36处。字符串”123“的hashCode是48690,它会跳转到#50处。生成的字节码的长度比Java源码长多了。首先,你能够看到字节码里用lookupswitch指令来实现switch()语句。不过,这里使用了两个lookupswitch指令,而不是一个。若是反编译的是针对Int的switch()语句的话,字节码里只会使用一个lookupswitch指令。也就是说,针对string的switch语句被分红用两个语句来实现。留心标号为#5,#39和#53的指令,来看看switch()语句是如何处理字符串的。

在第#36,#37,#39,以及#42字节的地方,你能够看见str参数被equals()方法来和字符串“abc”进行比较。若是比较的结果是相等的话,‘0’会被放入到局部变量数组的索引为#3的位置,而后跳抓转到第#61字节。

在第#50,#51,#53,以及#56字节的地方,你能够看见str参数被equals()方法来和字符串“123”进行比较。若是比较的结果是相等的话,10’会被放入到局部变量数组的索引为#3的位置,而后跳转到第#61字节。

在第#61和#62字节的地方,局部变量数组里索引为#3的值,这里是'0',‘1’或者其余的值,被lookupswitch用来进行搜索并进行相应的分支跳转。

换句话来讲,在Java代码里的用来做为switch()的参数的字符串str变量是经过hashCode()和equals()方法来进行比较,而后根据比较的结果,来执行swtich()语句。

在这个结果里,编译后的字节码和以前版本的JVM规范没有不兼容的地方。Java SE 7的这个用字符串做为switch参数的特性是经过Java编译器来处理的,而不是经过JVM来支持的。经过这种方式还能够把其余的Java SE 7的新特性也经过Java编译器来实现。

总结

我不认为为了使用好Java必须去了解Java底层的实现。许多没有深刻理解JVM的开发者也开发出了不少很是好的应用和类库。不过,若是你更加理解JVM的话,你就会更加理解Java,这样你会有助于你处理相似于咱们前面的案例中的问题。

除了这篇文章里提到的,JVM仍是用了其余的不少特性和技术。JVM规范提供了是一种扩展性很强的规范,这样就使得JVM的提供者能够选择更多的技术来提升性能。值得特别说明的一点是,垃圾回收技术被大多数使用虚拟机的语言所使用。不过,因为这个已经在不少地方有更加专业的研究,我这篇文章就没有对它进行深刻讲解了。

对于熟悉韩语的朋友,若是你想要深刻理解JVM的内部结构的话,我推荐你参考《Java Performance Fundamental》(Hando Kim,Seoul,EXEM,2009)。这本书是用韩文写的,更适合你去阅读。我在写这本书的时候,参考了JVM规范,同时也参考了这本书。对于熟悉英语的朋友,你能够找到大量的关于Java性能的书籍。

相关文章
相关标签/搜索