由于原文太长,所以拆成两部分。
JVM 结构
Java 编写的代码通过下图所展示的流程执行。
类加载器将编译后的 Java 字节码加载到运行时方法区,由执行引擎执行 Java 字节码。
类加载器
Java 提供了动态加载功能;当在运行时第一次引用一个类时类加载再加载并链接相应的类,而不是在编译时加载链接。动态加载由 JVM 的类加载器执行。Java 的类加载器特点如下:
- 层次结构:Java 类加载器以父-子关系按层组织,Bootstrap 类加载器是所有类加载器的父节点。
- 代理模式:基于层级结构,加载是在类加载器之间代理实现。当一个类被加载时,父类加载器会检查并决定该类是否存在父类加载器中。如果父类加载器拥有这个类,就使用这个类。如果没有,类加载器就申请加载。
- 限制可见性:子类加载器可以查看父类加载器中的类;但是父类加载器不能查看子类加载器中的类。
- 不可卸载:类加载器可以加载类,但是不能卸载类。取代卸载的处理方法是:当前类加载器可以被删除,然后再创建一个新的类加载器。
每一个类加载器都有其独立的命名空间用于存储已经加载的类。当加载一个类时,它首先基于 FNCQ(Fully Qualified Class Name)在其命名空间中搜索检查该类是否已经被加载。如果一个类有相同的 FQCN 但是不同的命名空间,它会被当做不同的类。不同的命名空间意味着这个类已经被其他的类加载器加载。
下图说明了类加载器的代理模式。
当一个类加载器申请加载类时,会按照图中的顺序依次检查该类是否存在于类加载器缓存、父类加载器的缓存和它自己。也就是首先要检查该类是否已经加载到类加载器缓存中。如果没有,检查父类加载器。如果在 bootstrap 类加载器中还没有找到该类,申请加载的加载器就会在文件系统中搜索。
- Bootstrap class loader:这个加载器在 JVM 运行时就被创建。它加载 Java API,包括 Object 类。和其他类加载器不一样的是,它是用 native 而不是 Java 实现的。
- Extansion class loader:它加载 Java API 以外的扩展类。同时也加载各种安全扩展函数。
- System class loader:如果说 bootstrap 类加载器和 extension 类加载器加载 JVM 组件,那么 System 类加载器就加载应用类。它加载用户指定的 $CLASSPATH 中的类。
- User-defined class loader:这是一个通过应用代码创建的类加载器。
像 Web 应用服务(WAS)这样的框架使用这一架构保证 Web 应用和企业应用独立运行。换句话说,通过加载器代理模式保证应用独立运行。不同厂商提供的使用层级结构的 WAS 类加载器会有一些细微差别。
如果类加载器发现一个类没有被加载,就会按照下面图示的过程加载、链接。
每一个阶段的描述如下:
- Loading:从文件获取类并加载到 JVM 的内存。
- Verifying:检查读取的类是否按照 Java 语言规范和 JVM 规范配置。这是类加载过程中最复杂的一个测试步骤,并且耗费的时间最长。多数 JVM TCK 的测试用例就是通过加载错误的类检查是否有验证错误发生。
- Preparing:准备数据结构,并分配类需要的内存,并标识类中定义好的字段、方法、和接口。
- Initializing:将类的属性初始化到合适的值。执行静态初始化,将静态字段初始化到预先定义的值。
JVM 规范定义了这些任务。然而,它也允许灵活处理。
运行时数据区
运行时数据区是 JVM 程序在 OS 上运行时分配的内存。运行时数据区可以分为6个区域,每个线程独有的一个PC 寄存器,JVM 栈,Native 方法栈。所有线程共用的堆、方法区和运行时常量池。
PC register:每一个线程都有一个 PC(程序计数器)寄存器,它在线程启动时被创建。PC 寄存器中存放有当前被执行的 JVM 指令的地址。
-
JVM stack:每一个线程都有一条 JVM 栈,同样也是在线程启动时被创建。它是用于存储结构(Stack Frame)的栈。JVM 只是将栈帧(stack frame)压入或者弹出 JVM 栈。如果发生任何异常,像 printStackTrace()这样的方法打印的调用栈信息每一行描述了一个栈帧。
图5:JVM Stack 配置 Stack frame:每当一个方法在 JVM 中执行时,就会创建一个栈帧(stack frame),并且该栈帧会被添加到当前线程的 JVM stack。当方法执行完毕,该栈帧会被移除。每一个栈帧都包含对本地变量数组、操作数栈、和该方法所属类的运行时常量池的引用。本地变量数组和操作数栈的大小是在编译时就确定的。所以每一个方法的栈帧大小是固定的。
本地变量数组:它的索引值从0开始。0指向该方法所述类的实例。从1开始,发送给该函数的参数将被保存。方法的本地变量将被存储在方法的参数之后。
操作数栈:这个一个方法的实际操作空间。每一个方法都在操作数栈和本地变量数组之间交换数据,并且将调用其他方法的结果压入或者弹出。操作数栈所需要的空间大小在编译时就可以确定。所以操作数栈的大小也可以在编译时就确定。
Native 方法栈:用 Java 以外的语言为 native 代码编写的栈。换句话说,它是一个用于通过 JNI 执行 C/C++ 代码的栈。根据具体的语言,会创建一个 C 或者 C++ 的栈。
方法区:方法区在 JVM 启动时创建,所有线程共享。JVM 读取的每一个类和接口的运行时常量池、字段和方法信息、静态变量、方法字节码都存放在这里。Oracle Hotspot JVM 称之为永久区或者永久代。对每一个 JVM 厂商来说这一区域的垃圾回收是可选的。
运行时常量池:和类文件格式中 constant_pool table 相关的一个区域。这个区域属于方法区,然而,它在 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 架构使用寄存器不同,它使用索引数比如 15 和 23,而不是使用内存地址,因为它自己管理内存。这里 15 和 23 是当前类(这里是指 UserServies 类)的常量池索引。即,JVM 为每一个类创建一个常量池,这个池中存放实际目标的引用。
这一段反编译代码每一行的解释如下。
- aload_0:将本地变量数组中的第 #0 个变量添加到操作栈。本地变量栈的第 #0 个变量总是 this,也就是当前类实例的引用。
- getfield #15:在当前类的常量池中,将第 #15 个元素添加到操作数栈。 UserAdmin admin 这个字段被添加。因为 admin 字段是一个类实例,所以添加的是其引用。
- aload_1:添加本地变量数组第#1 个元素到操作数栈。从本地变量数组的第#1个元素开始是方法参数。所以,调用 add()方法是传入的 String userName 的引用被添加到操作数栈。
- invokevirtual #23:调用当前类常量池中第#23元素对应的方法。同时,通过使用 getfield 添加的引用和通过使用 aload_1 添加的参数会被发送到将调用的方法。当方法执行完毕,结返回值被存放到操作数栈。
- pop:将使用 invokevirtual 的返回值弹出操作数栈。你可以发现使用旧版的库编译的代码没有返回值。即,旧版本没有返回值,所有不需要将返回值从操作数栈弹出。
- return:方法执行完毕。
下图将帮助你理解这些解释。
作为参考,在这个方法中,本地变量数组没有做任何变化。所以上图仅仅展示了操作数栈的变化。然而,在多数情况下,本地变量数组会发生变化。本地变量数组和操作数栈之间的数据交换通过一系列的加载指令(aload,iload)和存储指令(astore,istore)完成。
在上图中,我么已经简要的描述了运行时常量池和 JVM 栈。当 JVM 运行时,每一个类实例都会被分配到堆上,而类信息(包括 User, UserAdmin,UserServices 和String)会被存储到方法区。
执行引擎(Execution Engine)
通过类加载器分配到运行时数据区的字节码通过执行引擎执行。执行引擎以指令为单位读取字节码。它就像 CPU 执行机器命令一样一条一条的执行。每一个字节码命令都包含一个字节的操作码和附加的操作数。执行引擎读取一条操作码然后使用相应的操作数执行任务,完成以后再执行下一条操作码。
但是 Java 字节码是以人类能够理解的语言编写的,而不是机器能够直接执行的语言。所以执行引擎需要将字节码转换成机器中的 JVM 能够执行的语言。字节码会按照以下两种方式之一转换成合适的语言。
- 解释器(Interpreter):一条一条的读取、解释、执行字节码命令。由于是逐条解释、执行指令,它可以快速的解释一条字节码,但是执行解释的结果会较慢。这是解释语言的缺点。这一类“语言”就像口译者一样调用字节码。
- 及时编译器(JIT (Just-In-Time) compile):JIT 编译器的引入时为了解决解释器的缺点。执行引擎首先运行一个解释器,在适当的时候,JIT 编译器将整字节码转换成 native code。在此之后,执行引擎就不再解释方法,而是直接执行native code。执行 native code 会比逐条解释指令快的多。编译后的代码可以快速执行,因为native code 存储在缓存中。
但是,JIT 编译器编译代码会比解释器逐条解释代码使用更长的时间。所以如果代码只需要执行一次,使用解释器会更好。所以,各种内部使用了 JIT 编译器的 JVM 都会检查方法被执行的频率,只有当一个方法被执行的频率高于某一个水平时才将它编译成 native code。
JVM 规范并没有定义执行引擎如何运行。所以,JVM 厂商们通过各种技术来提高其执行引擎的效率,并发明各种类型的 JIT 编译器。
大多数 JIT 编译器像下图这样运行:
JIT 编译器将字节码转换成一个中间表达式,IR(Intermediate Representation),然后执行优化,最后将中间表达式转换成 native code。
Oracle Hotspots 虚拟机使用的 JIT 编译器称为 Hotspot 编译器。它被称作 Hotspot 是因为 Hotspot编译器会根据 profiling 找到具有最高编译优先级的“热点”代码,并将热点编译成 native code。如果编译后的方法不再被频繁调用,这个方法就不再是热点,Hotspot 虚拟机会将相应的 native code 从缓存中移除,并使用解释模式运行。Hotspot 虚拟机分为服务端虚拟机和客户端虚拟机,两者使用了不同的 JIT 编译器。
如上图所示,客户端虚拟机和服务端虚拟机使用相同的运行时,但是,它们使用不同的 JIT 编译器。服务端虚拟机使用的高级动态优化编译器使用了更复杂以及各种各样的性能优化技术。
IBM JVM 从IBM JDK 6 开始使用了 AOT (Ahead-Of-Time)编译器作为其 JIT 编译器。这意味着多个 JVM 通过共享缓存共享 native code。即:通过 AOT 编译器编译好的代码可以被其他 JVM 直接使用而不需要重新编译。另外 IBM JVM 还提供了快速执行方式,即通过 AOT 编译器将代码预编译成 JXE(Java EXecutable)文件格式。
大多数 java 性能优化都是通过提高执行引擎的性能完成。和 JIT 编译器一样,各种优化技术还在不断的发展,所以 JVM 的性能会得到持续提高。最新的 JVM和最初的 JVM 之间最大的差别就是执行引擎。
Oracle Hotspot 虚拟机从1.3版本开始引入 Hotspot 编译器,Android 2.2 版本开始在 Dalvik 虚拟机中引入了 JIT 编译器。
注释
JVM 通过中间语言来提高性能的技术(如引入字节码,虚拟机执行字节码以及 JIT 编译器等)在其他使用了中间语言的语言中也经常使用。例如微软的 .Net,CLR(Common Language Runtime)也是一种虚拟机,它执行的是一种的称为CIL(Common Intermediate Language)的字节码。CLR 也提供了像 JIT 编译器一样的 AOT 编译器。如果源码是用 C# 或者 VB.NET 编写、编译,编译器就会创建 CIL,CIL运行在使用了 JIT 编译器的 CLR 上面。CLR 像 JVM 一样使用了垃圾回收基于栈运行。
Java 虚拟机规范,Java SE 7 版本
2011年7月28日,Oracle 发布了 Java SE7, 并更新了相应的 JVM 规范。在1999年发布“Java 虚拟机规范,第二版”之后,Oracle 使用了12年时间来发布一个更新。这个更新版本包含了12年来积累的各种变化和修改,并且对规范提供更清晰的描述。另外,它也反应了随 Java Se 7 一同发布“Java 语言规范,Java SE7 版本”所包含的内容。主要的变化总结如下:
- 从 Java SE 5.0 开始引入泛型,支持可变参数方法
- 字节码验证流程技术从 Java SE 6 开始已经改变
- 为了支持动态类型语言,添加了 invokedynamic 指令以及相应的类文件格式
- 删除了 Java 语言本身概念的描述,将相关内容移动到 Java 语言规范中
最大的变化是添加了 invokedynamic 指令。这意味着这一变化发生在 JVM 内部的指令集,JVM 从 Java SE 7 版本开始会像支持 Java 语言一样支持动态类型语言,比如脚本语言。以前没有使用的操作码 186 被分配给这个新的指令(invokedynamic),新的内容也被添加到类文件格式以支持 invokedynamic。
通过 Java SE 7 的编译器编译的类文件版本是51.0。Java SE 6的版本是50.0。由于类文件格的变化,51.0 版本的类文件无法在 Java SE6 的 JVM 上运行。
尽管有了各种变化,但是Java 方法65535字节的限制仍然存在。除非 JVM 的类文件格式被创新性的更改,这一限制在未来不会被移除。
作为参考,Oracle java SE 7 虚拟机支持新的垃圾回收 G1,但是它仅限于 Oracle JVM,所以 JVM 本身没有限定任何垃圾回收类型。所以 JVM 规范中没有相应的描述。
switch 语句中的字符串
Java SE7 添加了各种语法和特性。但是和 Java SE 7 语言的各种变化相比,JVM 并没有那么多变化。所以,Java SE 7 的这些新功能是怎么实现的呢?我们将通过反编译代码来看看 switch 语句中的 String(将字符串比较添加到 switch()语句这样一个功能)是怎么实现的。
例如,我们写下了如下代码:
// 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 编译器编译。使用 Java SE 7版本的 javac 编译。以下是使用 javap -c 打印出来的编译结果。
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
比 Java 源码长的多的 字节码。首先你会看到 Java 字节码中 lookupswitch 指令被用于 switch() 语句,但是使用了两个lookupswitch 指令,而不是一个。当反编译使用int 作为比较的switch() 语句时,你会发现只使用了一个 lookupswitch 指令。这意味着 switch() 语句为了处理字符串被分成了两部分。通过分析被标注为#5,#39,#53 的这几条指令可以发现 switch() 语句是怎么处理字符串的。
首先,在#5 和 #8字节中,hashCode()方法被执行,然后根据hashCode() 方法返回的结果执行 switch(int) 方法。在lookupswitch 指令的括弧内,根据 hashCode 的结果各个分支跳转到不同的位置。字符串“abc” 的哈希值是 96345,就会跳转到#36字节。字符串“123”的哈希值是48690,所以跳转到#50字节。
在#36, #37, #39 和#42 字节,你会看到 str 参数的值被接收,然后作为参数调用 equals 方法与字符串“abc”进行比较。如果结果相同,‘0’ 就被添加到本地变量数组的第#3个位置,字符被移到第#61字节。
同样的,在#50, #51, #53 和#56 字节,你会看到 str 参数的值被接收,然后作为参数调用 equals 方法与字符串“123”进行比较。如果结果相同,‘1’ 就被添加到本地变量数组的第#3个位置,字符被移到第#61字节。
在#61 和#62 字节,本地变量数组第#3 位置的值(即'0','1' 或者其他的任何值)被用于查找分支好分支跳转。
换句话说,在 Java 代码中, switch() 语句接收的字符串通过 hashCode() 方法和 equals() 方法进行比较。根据比较的结果再次执行 switch()。
这样,编译后的字节码就和前面的版本的 JVM 规范没有差别。Java SE 7中的 switch 语句支持字符串这一新功能是通过 Java 编译器实现而不是 JVM 自身实现的。同样,Java SE 7的其他新功能也是通过 Java 编译器实现的。
结束语
虽然使用Java并不需要了解Java是如何被开发出来的。并且很多程序员在并没有深入了解 JVM 的情况下依然开发出了很多伟大的应用和类库。但是如果能够了解JVM,你将能够更深入的了解 Java,并在解决像文中所讨论的案例问题时有所帮助。
除了上文所述,JVM 还有很多特性和技术。JVM 规范为 JVM 厂商提供了灵活的规范,以便各个厂商使用各种不同的技术来提高 JVM
的性能。另外垃圾回收已被很多具有类似虚拟机能力的编程语言作为常用的性能优化手段。但因有很多资料对这一主题做详细的介绍,所以这里没有深入讨论。