每个使用Java的开发者都知道Java字节码是在JRE中运行(JRE: Java 运行时环境)。JVM则是JRE中的核心组成部分,承担分析和执行Java字节码的工作,而Java程序员通常并不需要深入了解JVM运行情况就可以开发出大型应用和类库。尽管如此,如果你对JVM有足够了解,就会对Java有更好的掌握,并且能解决一些看起来简单但又尚未解决的问题。
所以,在本篇文章中,我将会介绍JVM工作原理,内部结构,Java字节码的执行及指令的执行顺序,并会介绍一些常见的JVM错误及其解决方案。最后会简单介绍下Java SE7带来的新特性。
虚拟机
JRE由Java API和JVM组成,JVM通过类加载器(Class Loader)加类Java应用,并通过Java API进行执行。
虚拟机(VM: Virtual Machine)是通过软件模拟物理机器执行程序的执行器。最初Java语言被设计为基于虚拟机器在而非物理机器,重而实现WORA(一次编写,到处运行)的目的,尽管这个目标几乎被世人所遗忘。所以,JVM可以在所有的硬件环境上执行Java字节码而无须调整Java的执行模式。
JVM的基本特性:
基于栈(Stack-based)的虚拟机: 不同于Intel x86和ARM等比较流行的计算机处理器都是基于寄存器(register)架构,JVM是基于栈执行的。
符号引用(Symbolic reference): 除基本类型外的所有Java类型(类和接口)都是通过符号引用取得关联的,而非显式的基于内存地址的引用。
垃圾回收机制: 类的实例通过用户代码进行显式创建,但却通过垃圾回收机制自动销毁。
通过明确清晰基本类型确保平台无关性: 像C/C++等传统编程语言对于int类型数据在同平台上会有不同的字节长度。JVM却通过明确的定义基本类型的字节长度来维持代码的平台兼容性,从而做到平台无关。
网络字节序(Network byte order): Java class文件的二进制表示使用的是基于网络的字节序(network byte order)。为了在使用小端(little endian)的Intel x86平台和在使用了大端(big endian)的RISC系列平台之间保持平台无关,必须要定义一个固定的字节序。JVM选择了网络传输协议中使用的网络字节序,即基于大端(big endian)的字节序。
Sun 公司开发了Java语言,但任何人都可以在遵循JVM规范的前提下开发和提供JVM实现。所以目前业界有多种不同的JVM实现,包括Oracle Hostpot JVM和IBM JVM。Google公司使用的Dalvik VM也是一种JVM实现,尽管其并未完全遵循JVM规范。与基于栈机制的Java 虚拟机不同的是Dalvik VM是基于寄存器的,Java 字节码也被转换为Dalvik VM使用的寄存器指令集。
Java 字节码
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 。他们每个的含义如下:
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的下一个字节就是下一个指令的