虚拟机(Virtual Machine)
JRE是由Java API和JVM组成的。JVM的主要作用是通过Class Loader来加载Java程序,并且按照Java API来执行加载的程序。
虚拟机是通过软件的方式来模拟实现的机器(比如说计算机),它可以像物理机一样运行程序。设计虚拟机的初衷是让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字节码之前,我们先通过实例来简单了解它。这个案例是一个在开发环境出现的真实案例的总结。
现象
一个一直运行正常的应用突然无法运行了。在类库被更新之后,返回下面的错误。
- 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);
- }
- // 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文件表明了这个方法是有返回值的。你可以从下面的错误信息里看到答案。
- 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()方法进行反汇编得到的。
- 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: 调用一个接口方法
- invokespecial: 调用一个初始化方法,私有方法或者父类的方法
- invokestatic:调用静态方法
- invokevirtual:调用实例方法
Java字节码的指令集由操作码和操作数组成。类似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”的方法。
在上面的反汇编代码里,代码前面的数字代码什么呢?
它表示的是字节偏移。大概这就是为什么运行在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的下一条指令是跳过两个字节,写在第四个字节的位置上的。十六进制编译器里查看字节码的结果如下所示。
- 2a b4 00 0f 2b b6 00 17 57 b1
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代码没有正常运行,而是出现了下面的错误。
- 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"。
- goto [branchbyte1] [branchbyte2]
- jsr [branchbyte1] [branchbyte2]
- goto_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
- jsr_w [branchbyte1] [branchbyte2] [branchbyte3] [branchbyte4]
有了这两个指令,索引超过65535的分支也是可用的。因此,Java方法的65535字节的限制就可以解除了。不过,由于Java class文件的更多的其他的限制,使得Java方法还是不能超过65535字节。
为了展示其他的限制,我会简单讲解一下class 文件的格式。
Java 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];}
上面的内容是来自《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以及是不是接口的标志。
- this_class, super_class: The index in the constant_pool for the class corresponding to this and super, respectively.
- interfaces_count, interfaces[]: The index in the the constant_pool for the number of interfaces implemented by the class and each interface.
- 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时,会输出如下的内容:
- 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),然后执行引擎执行会执行这些字节码。
1.方法区
也称"永久代” 、“非堆”, 它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。
运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
2.虚拟机栈
描述的是java 方法执行的内存模型:每个方法被执行的时候 都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。声明周期与线程相同,是线程私有的。
局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。
3.本地方法栈
与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务。
4.堆
也叫做java 堆、GC堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建。该内存区域存放了对象实例及数组(所有new的对象)。其大小通过-Xms(最小值)和-Xmx(最大值)参数设置,-Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G,默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列;当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation=来指定这个比列,对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。
由于现在收集器都是采用分代收集算法,堆被划分为新生代和老年代。新生代主要存储新创建的对象和尚未进入老年代的对象。老年代存储经过多次新生代GC(Minor GC)任然存活的对象。
新生代:
程序新创建的对象都是从新生代分配内存,新生代由Eden Space和两块相同大小的Survivor Space(通常又称S0和S1或From和To)构成,可通过-Xmn参数来指定新生代的大小,也可以通过-XX:SurvivorRation来调整Eden Space及Survivor Space的大小。
老年代:
用于存放经过多次新生代GC任然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:①.大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。②.大的数组对象,切数组中无引用外部对象。
老年代所占的内存大小为-Xmx对应的值减去-Xmn对应的值。
![](https://img-my.csdn.net/uploads/201209/30/1349009313_6062.jpg)
5.程序计数器
是最小的一块内存区域,它的作用是当前线程所执行的字节码的行号指示器,在虚拟机的模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖计数器完成。
类加载器(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提供者可以决定怎么来配置堆空间,以及不对它进行垃圾回收。
现在我们再回过头来看看之前反汇编的字节码。
- 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()语句的比较对象)是怎么实现的?
例如,下面的代码:
- // SwitchTest
- public class SwitchTest {
- public int doSwitch(String str) {
- switch (str) {
- case "abc": return 1;
- case "123": return 2;
- default: return 0;
- }
- }
- }
- 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”进行比较。如果比较的结果是相等的话,'1’会被放入到局部变量数组的索引为#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性能的书籍。
By Se Hoon Park, Messaging Platform Development Team, NHN Corporation.
深入探讨 Java 类加载器
类加载器(class loader)是 Java™中的一个很重要的概念。类加载器负责加载 Java 类的字节代码到 Java 虚拟机中。本文首先详细介绍了 Java 类加载器的基本概念,包括代理模式、加载类的具体过程和线程上下文类加载器等,接着介绍如何开发自己的类加载器,最后介绍了类加载器在 Web 容器和 OSGi™中的应用。
类加载器是 Java 语言的一个创新,也是 Java 语言流行的重要原因之一。它使得 Java 类可以被动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 就出现了,最初是为了满足 Java Applet 的需要而开发出来的。Java Applet 需要从远程下载 Java 类文件到浏览器中并执行。现在类加载器在 Web 容器和 OSGi 中得到了广泛的使用。一般来说,Java 应用的开发人员不需要直接同类加载器进行交互。Java 虚拟机默认的行为就已经足够满足大多数情况的需求了。不过如果遇到了需要与类加载器进行交互的情况,而对类加载器的机制又不是很了解的话,就很容易花大量的时间去调试ClassNotFoundException
和NoClassDefFoundError
等异常。本文将详细介绍 Java 的类加载器,帮助读者深刻理解 Java 语言中的这个重要概念。下面首先介绍一些相关的基本概念。
类加载器基本概念
顾名思义,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成java.lang.Class
类的一个实例。每个这样的实例用来表示一个 Java 类。通过此实例的newInstance()
方法就可以创建出该类的一个对象。实际的情况可能更加复杂,比如 Java 字节代码可能是通过工具动态生成的,也可能是通过网络下载的。
基本上所有的类加载器都是 java.lang.ClassLoader
类的一个实例。下面详细介绍这个 Java 类。
java.lang.ClassLoader
类介绍
java.lang.ClassLoader
类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个 Java 类,即java.lang.Class
类的一个实例。除此之外,ClassLoader
还负责加载 Java 应用所需的资源,如图像文件和配置文件等。不过本文只讨论其加载类的功能。为了完成加载类的这个职责,ClassLoader
提供了一系列的方法,比较重要的方法如表 1所示。关于这些方法的细节会在下面进行介绍。
表 1. ClassLoader 中与加载类相关的方法
方法 | 说明 |
---|---|
getParent() | 返回该类加载器的父类加载器。 |
loadClass(String name) | 加载名称为 name 的类,返回的结果是 java.lang.Class 类的实例。 |
findClass(String name) | 查找名称为 name 的类,返回的结果是 java.lang.Class 类的实例。 |
findLoadedClass(String name) | 查找名称为 name 的已经被加载过的类,返回的结果是 java.lang.Class 类的实例。 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组 b 中的内容转换成 Java 类,返回的结果是 java.lang.Class 类的实例。这个方法被声明为final 的。 |
resolveClass(Class<?> c) | 链接指定的 Java 类。 |
对于 表 1中给出的方法,表示类名称的 name
参数的值是类的二进制名称。需要注意的是内部类的表示,如 com.example.Sample$1
和com.example.Sample$Inner
等表示方式。这些方法会在下面介绍类加载器的工作机制时,做进一步的说明。下面介绍类加载器的树状组织结构。
类加载器的树状组织结构
Java 中的类加载器大致可以分成两类,一类是系统提供的,另外一类则是由 Java 应用开发人员编写的。系统提供的类加载器主要有下面三个:
- 引导类加载器(bootstrap class loader):它用来加载 Java 的核心库,是用原生代码来实现的,并不继承自
java.lang.ClassLoader
。 - 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过
ClassLoader.getSystemClassLoader()
来获取它。
除了系统提供的类加载器以外,开发人员可以通过继承 java.lang.ClassLoader
类的方式实现自己的类加载器,以满足一些特殊的需求。
除了引导类加载器之外,所有的类加载器都有一个父类加载器。通过 表 1中给出的 getParent()
方法可以得到。对于系统提供的类加载器来说,系统类加载器的父类加载器是扩展类加载器,而扩展类加载器的父类加载器是引导类加载器;对于开发人员编写的类加载器来说,其父类加载器是加载此类加载器 Java 类的类加载器。因为类加载器 Java 类如同其它的 Java 类一样,也是要由类加载器来加载的。一般来说,开发人员编写的类加载器的父类加载器是系统类加载器。类加载器通过这种方式组织起来,形成树状结构。树的根节点就是引导类加载器。图 1中给出了一个典型的类加载器树状组织结构示意图,其中的箭头指向的是父类加载器。
图 1. 类加载器树状组织结构示意图
![类加载器树状组织结构示意图](https://i-blog.csdnimg.cn/blog_migrate/6fde9382d1f1992741bb3c248fd9ae55.png)
代码清单 1演示了类加载器的树状组织结构。
清单 1. 演示类加载器的树状组织结构
public class ClassLoaderTree { public static void main(String[] args) { ClassLoader loader = ClassLoaderTree.class.getClassLoader(); while (loader != null) { System.out.println(loader.toString()); loader = loader.getParent(); } } }
每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 getClassLoader()
方法就可以获取到此引用。代码清单 1中通过递归调用getParent()
方法来输出全部的父类加载器。代码清单 1的运行结果如代码清单 2所示。
清单 2. 演示类加载器的树状组织结构的运行结果
sun.misc.Launcher$AppClassLoader@9304b1 sun.misc.Launcher$ExtClassLoader@190d11
如 代码清单 2所示,第一个输出的是 ClassLoaderTree
类的类加载器,即系统类加载器。它是 sun.misc.Launcher$AppClassLoader
类的实例;第二个输出的是扩展类加载器,是sun.misc.Launcher$ExtClassLoader
类的实例。需要注意的是这里并没有输出引导类加载器,这是由于有些 JDK 的实现对于父类加载器是引导类加载器的情况,getParent()
方法返回null
。
在了解了类加载器的树状组织结构之后,下面介绍类加载器的代理模式。
类加载器的代理模式
类加载器在尝试自己去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,依次类推。在介绍代理模式背后的动机之前,首先需要说明一下 Java 虚拟机是如何判定两个 Java 类是相同的。Java 虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。比如一个 Java 类com.example.Sample
,编译之后生成了字节代码文件Sample.class
。两个不同的类加载器ClassLoaderA
和ClassLoaderB
分别读取了这个Sample.class
文件,并定义出两个java.lang.Class
类的实例来表示这个类。这两个实例是不相同的。对于 Java 虚拟机来说,它们是不同的类。试图对这两个类的对象进行相互赋值,会抛出运行时异常ClassCastException
。下面通过示例来具体说明。代码清单 3中给出了 Java 类com.example.Sample
。
清单 3. com.example.Sample 类
package com.example; public class Sample { private Sample instance; public void setSample(Object instance) { this.instance = (Sample) instance; } }
如 代码清单 3所示,com.example.Sample
类的方法 setSample
接受一个 java.lang.Object
类型的参数,并且会把该参数强制转换成 com.example.Sample
类型。测试 Java 类是否相同的代码如代码清单 4所示。
清单 4. 测试 Java 类是否相同
public void testClassIdentity() { String classDataRootPath = "C:\\workspace\\Classloader\\classData"; FileSystemClassLoader fscl1 = new FileSystemClassLoader(classDataRootPath); FileSystemClassLoader fscl2 = new FileSystemClassLoader(classDataRootPath); String className = "com.example.Sample"; try { Class<?> class1 = fscl1.loadClass(className); Object obj1 = class1.newInstance(); Class<?> class2 = fscl2.loadClass(className); Object obj2 = class2.newInstance(); Method setSampleMethod = class1.getMethod("setSample", java.lang.Object.class); setSampleMethod.invoke(obj1, obj2); } catch (Exception e) { e.printStackTrace(); } }
代码清单 4中使用了类FileSystemClassLoader
的两个不同实例来分别加载类com.example.Sample
,得到了两个不同的java.lang.Class
的实例,接着通过newInstance()
方法分
别生成了两个类的对象obj1
和 obj2
,最后通过 Java 的反射 API 在对象obj1
上调用方法setSample
,试图把对象obj2
赋值给obj1
内部的instance
对象。代码清单 4的运行结果如代码清单 5所示。
清单 5. 测试 Java 类是否相同的运行结果
java.lang.reflect.InvocationTargetException at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25) at java.lang.reflect.Method.invoke(Method.java:597) at classloader.ClassIdentity.testClassIdentity(ClassIdentity.java:26) at classloader.ClassIdentity.main(ClassIdentity.java:9) Caused by: java.lang.ClassCastException: com.example.Sample cannot be cast to com.example.Sample at com.example.Sample.setSample(Sample.java:7) ... 6 more
从 代码清单 5给出的运行结果可以看到,运行时抛出了 java.lang.ClassCastException
异常。虽然两个对象 obj1
和 obj2
的类的名字相同,但是这两个类是由不同的类加载器实例来加载的,因此不被 Java 虚拟机认为是相同的。
了解了这一点之后,就可以理解代理模式的设计动机了。代理模式是为了保证 Java 核心库的类型安全。所有 Java 应用都至少需要引用 java.lang.Object
类,也就是说在运行的时候,java.lang.Object
这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成的话,很可能就存在多个版本的java.lang.Object
类,而且这些类之间是不兼容的。通过代理模式,对于 Java 核心库的类的加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。
不同的类加载器为相同名称的类创建了额外的名称空间。相同名称的类可以并存在 Java 虚拟机中,只需要用不同的类加载器来加载它们即可。不同类加载器加载的类之间是不兼容的,这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间。这种技术在许多框架中都被用到,后面会详细介绍。
下面具体介绍类加载器加载类的详细过程。
加载类的过程
在前面介绍类加载器的代理模式的时候,提到过类加载器会首先代理给其它类加载器来尝试加载某个类。这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,有可能不是同一个。真正完成类的加载工作是通过调用defineClass
来实现的;而启动类的加载过程是通过调用loadClass
来实现的。前者称为一个类的定义加载器(defining loader),后者称为初始加载器(initiating loader)。在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类com.example.Outer
引用了类com.example.Inner
,则由类com.example.Outer
的定义加载器负责启动类com.example.Inner
的加载过程。
方法 loadClass()
抛出的是 java.lang.ClassNotFoundException
异常;方法defineClass()
抛出的是java.lang.NoClassDefFoundError
异常。
类加载器在成功加载某个类之后,会把得到的 java.lang.Class
类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即loadClass
方法不会被重复调用。
下面讨论另外一种类加载器:线程上下文类加载器。
线程上下文类加载器
线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 java.lang.Thread
中的方法getContextClassLoader()
和setContextClassLoader(ClassLoader cl)
用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)
方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在线程中运行的代码可以通过此类加载器来加载类和资源。
前面提到的类加载器的代理模式并不能解决 Java 应用开发中会遇到的类加载器的全部问题。Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JAXP 的 SPI 接口定义包含在javax.xml.parsers
包中。这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JAXP SPI 的Apache Xerces所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类。如 JAXP 中的javax.xml.parsers.DocumentBuilderFactory
类中的newInstance()
方法用来生成一个新的DocumentBuilderFactory
的实例。这里的实例的真正的类是继承自javax.xml.parsers.DocumentBuilderFactory
,由 SPI 的实现所提供的。如在 Apache Xerces 中,实现的类是org.apache.xerces.jaxp.DocumentBuilderFactoryImpl
。而问题在于,SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的;SPI 实现的 Java 类一般是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的代理模式无法解决这个问题。
线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。
下面介绍另外一种加载类的方法:Class.forName
。
Class.forName
Class.forName
是一个静态方法,同样可以用来加载类。该方法有两种形式:Class.forName(String name, boolean initialize, ClassLoader loader)
和Class.forName(String className)
。第一种形式的参数name
表示的是类的全名;initialize
表示是否初始化类;loader
表示加载时使用的类加载器。第二种形式则相当于设置了参数initialize
的值为true
,loader
的值为当前类的类加载器。Class.forName
的一个很常见的用法是在加载数据库驱动的时候。如Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance()
用来加载 Apache Derby 数据库的驱动。
在介绍完类加载器相关的基本概念之后,下面介绍如何开发自己的类加载器。
开发自己的类加载器
虽然在绝大多数情况下,系统默认提供的类加载器实现已经可以满足需求。但是在某些情况下,您还是需要为应用开发出自己的类加载器。比如您的应用通过网络来传输 Java 类的字节代码,为了保证安全性,这些字节代码经过了加密处理。这个时候您就需要自己的类加载器来从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出要在 Java 虚拟机中运行的类来。下面将通过两个具体的实例来说明类加载器的开发。
文件系统类加载器
第一个类加载器用来加载存储在文件系统上的 Java 字节代码。完整的实现如 代码清单 6所示。
清单 6. 文件系统类加载器
public class FileSystemClassLoader extends ClassLoader { private String rootDir; public FileSystemClassLoader(String rootDir) { this.rootDir = rootDir; } protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] classData = getClassData(name); if (classData == null) { throw new ClassNotFoundException(); } else { return defineClass(name, classData, 0, classData.length); } } private byte[] getClassData(String className) { String path = classNameToPath(className); try { InputStream ins = new FileInputStream(path); ByteArrayOutputStream baos = new ByteArrayOutputStream(); int bufferSize = 4096; byte[] buffer = new byte[bufferSize]; int bytesNumRead = 0; while ((bytesNumRead = ins.read(buffer)) != -1) { baos.write(buffer, 0, bytesNumRead); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } private String classNameToPath(String className) { return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; } }
如 代码清单 6所示,类 FileSystemClassLoader
继承自类 java.lang.ClassLoader
。在表 1中列出的java.lang.ClassLoader
类的常用方法中,一般来说,自己开发的类加载器只需要覆写findClass(String name)
方法即可。java.lang.ClassLoader
类的方法loadClass()
封装了前面提到的代理模式的实现。该方法会首先调用findLoadedClass()
方法来检查该类是否已经被加载过;如果没有加载过的话,会调用父类加载器的loadClass()
方法来尝试加载该类;如果父类加载器无法加载该类的话,就调用findClass()
方法来查找该类。因此,为了保证类加载器都正确实现代理模式,在开发自己的类加载器时,最好不要覆写loadClass()
方法,而是覆写findClass()
方法。
类 FileSystemClassLoader
的 findClass()
方法首先根据类的全名在硬盘上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过defineClass()
方法来把这些字节代码转换成java.lang.Class
类的实例。
网络类加载器
下面将通过一个网络类加载器来说明如何通过类加载器来实现组件的动态更新。即基本的场景是:Java 字节代码(.class)文件存放在服务器上,客户端通过网络的方式获取字节代码并执行。当有版本更新的时候,只需要替换掉服务器上保存的文件即可。通过类加载器可以比较简单的实现这种需求。
类 NetworkClassLoader
负责通过网络下载 Java 类字节代码并定义出 Java 类。它的实现与 FileSystemClassLoader
类似。在通过NetworkClassLoader
加载了某个版本的类之后,一般有两种做法来使用它。第一种做法是使用 Java 反射 API。另外一种做法是使用接口。需要注意的是,并不能直接在客户端代码中引用从服务器上下载的类,因为客户端代码的类加载器找不到这些类。使用 Java 反射 API 可以直接调用 Java 类的方法。而使用接口的做法则是把接口的类放在客户端中,从服务器上加载实现此接口的不同版本的类。在客户端通过相同的接口来使用这些实现类。网络类加载器的具体代码见下载。
在介绍完如何开发自己的类加载器之后,下面说明类加载器和 Web 容器的关系。
类加载器与 Web 容器
对于运行在 Java EE™容器中的 Web 应用来说,类加载器的实现方式与一般的 Java 应用有所不同。不同的 Web 容器的实现方式也会有所不同。以 Apache Tomcat 来说,每个 Web 应用都有一个对应的类加载器实例。该类加载器也使用代理模式,所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。这是 Java Servlet 规范中的推荐做法,其目的是使得 Web 应用自己的类的优先级高于 Web 容器提供的类。这种代理模式的一个例外是:Java 核心库的类是不在查找范围之内的。这也是为了保证 Java 核心库的类型安全。
绝大多数情况下,Web 应用的开发人员不需要考虑与类加载器相关的细节。下面给出几条简单的原则:
- 每个 Web 应用自己的 Java 类文件和使用的库的 jar 包,分别放在
WEB-INF/classes
和WEB-INF/lib
目录下面。 - 多个应用共享的 Java 类文件和 jar 包,分别放在 Web 容器指定的由所有 Web 应用共享的目录下面。
- 当出现找不到类的错误时,检查当前类的类加载器和当前线程的上下文类加载器是否正确。
在介绍完类加载器与 Web 容器的关系之后,下面介绍它与 OSGi 的关系。
类加载器与 OSGi
OSGi™是 Java 上的动态模块系统。它为开发人员提供了面向服务和基于组件的运行环境,并提供标准的方式用来管理软件的生命周期。OSGi 已经被实现和部署在很多产品上,在开源社区也得到了广泛的支持。Eclipse 就是基于 OSGi 技术来构建的。
OSGi 中的每个模块(bundle)都包含 Java 包和类。模块可以声明它所依赖的需要导入(import)的其它模块的 Java 包和类(通过 Import-Package
),也可以声明导出(export)自己的包和类,供其它模块使用(通过 Export-Package
)。也就是说需要能够隐藏和共享一个模块中的某些 Java 包和类。这是通过 OSGi 特有的类加载器机制来实现的。OSGi 中的每个模块都有对应的一个类加载器。它负责加载模块自己包含的 Java 包和类。当它需要加载 Java 核心库的类时(以java
开头的包和类),它会代理给父类加载器(通常是启动类加载器)来完成。当它需要加载所导入的 Java 类时,它会代理给导出此 Java 类的模块来完成加载。模块也可以显式的声明某些 Java 包和类,必须由父类加载器来加载。只需要设置系统属性org.osgi.framework.bootdelegation
的值即可。
假设有两个模块 bundleA 和 bundleB,它们都有自己对应的类加载器 classLoaderA 和 classLoaderB。在 bundleA 中包含类com.bundleA.Sample
,并且该类被声明为导出的,也就是说可以被其它模块所使用的。bundleB 声明了导入 bundleA 提供的类com.bundleA.Sample
,并包含一个类com.bundleB.NewSample
继承自com.bundleA.Sample
。在 bundleB 启动的时候,其类加载器 classLoaderB 需要加载类com.bundleB.NewSample
,进而需要加载类com.bundleA.Sample
。由于 bundleB 声明了类com.bundleA.Sample
是导入的,classLoaderB 把加载类com.bundleA.Sample
的工作代理给导出该类的 bundleA 的类加载器 classLoaderA。classLoaderA 在其模块内部查找类com.bundleA.Sample
并定义它,所得到的类com.bundleA.Sample
实例就可以被所有声明导入了此类的模块使用。对于以java
开头的类,都是由父类加载器来加载的。如果声明了系统属性org.osgi.framework.bootdelegation=com.example.core.*
,那么对于包com.example.core
中的类,都是由父类加载器来完成的。
OSGi 模块的这种类加载器结构,使得一个类的不同版本可以共存在 Java 虚拟机中,带来了很大的灵活性。不过它的这种不同,也会给开发人员带来一些麻烦,尤其当模块需要使用第三方提供的库的时候。下面提供几条比较好的建议:
- 如果一个类库只有一个模块使用,把该类库的 jar 包放在模块中,在
Bundle-ClassPath
中指明即可。 - 如果一个类库被多个模块共用,可以为这个类库单独的创建一个模块,把其它模块需要用到的 Java 包声明为导出的。其它模块声明导入这些类。
- 如果类库提供了 SPI 接口,并且利用线程上下文类加载器来加载 SPI 实现的 Java 类,有可能会找不到 Java 类。如果出现了
NoClassDefFoundError
异常,首先检查当前线程的上下文类加载器是否正确。通过Thread.currentThread().getContextClassLoader()
就可以得到该类加载器。该类加载器应该是该模块对应的类加载器。如果不是的话,可以首先通过class.getClassLoader()
来得到模块对应的类加载器,再通过Thread.currentThread().setContextClassLoader()
来设置当前线程的上下文类加载器。
总结
类加载器是 Java 语言的一个创新。它使得动态安装和更新软件组件成为可能。本文详细介绍了类加载器的相关话题,包括基本概念、代理模式、线程上下文类加载器、与 Web 容器和 OSGi 的关系等。开发人员在遇到ClassNotFoundException
和NoClassDefFoundError
等异常的时候,应该检查抛出异常的类的类加载器和当前线程的上下文类加载器,从中可以发现问题的所在。在开发自己的类加载器的时候,需要注意与已有的类加载器组织结构的协调。
类的加载、链接、初始化
Java字节代码的表现形式是字节数组(byte[]), 而Java类在JVM中的表现形式是java. lang. Class类的对象。 一个Java类从字节代码到能够在JVM中被运用, 需要经过加载、链接和初始化这三个步骤。
这三个步骤中, 对开发人员直接可见的是Java类的加载, 通过运用Java类加载器(class loader)可以在运行时辰静态的加载一个Java类;而链接和初始化则是在运用Java类之前会发生的举措。 本文会详细引见Java类的加载、链接和初始化的进程。
Java类的加载
Java类的加载是由类加载器来完成的。 普通来说, 类加载器分成两类:启动类加载器(bootstrap)和用户自定义的类加载器(user-defined)。 两者的区别在于启动类加载器是由JVM的原生代码实现的, 而用户自定义的类加载器都继承自Java中的java. lang. ClassLoader类。 在用户自定义类加载器的部分, 普通JVM都会提供一些根本实现。 应用顺序的开发人员也可以依据需要编写自己的类加载器。 JVM中最常运用的是零碎类加载器(system), 它用来启动Java应用顺序的加载。 通过java. lang. ClassLoader的getSystemClassLoader()方法可以获取到该类加载器对象。
类加载器需要完成的最终功能是定义一个Java类, 即把Java字节代码转换成JVM中的java. lang. Class类的对象。 但是类加载的进程并不是这么简单。 Java类加载器有两个比较重要的特征:层次组织构造和代理形式。 层次组织构造指的是每个类加载器都有一个父类加载器, 通过getParent()方法可以获取到。 类加载器通过这种父亲-后代的方式组织在一起, 构成树状层次构造。 代理形式则指的是一个类加载器既可以自己完成Java类的定义任务, 也可以代理给其它的类加载器来完成。
由于代理形式的存在, 启动一个类的加载进程的类加载器和最终定义这个类的类加载器能够并不是一个。 前者称为初始类加载器, 然后者称为定义类加载器。 两者的关联在于:一个Java类的定义类加载器是该类所导入的其它Java类的初始类加载器。 比方类A通过import导入了类 B, 那么由类A的定义类加载器负责启动类B的加载进程。
普通的类加载器在尝试自己去加载某个Java类之前, 会首先代理给其父类加载器。 当父类加载器找不到的时候, 才会尝试自己加载。 这个逻辑是封装java.lang.ClassLoader 类的loadClass()方法中的。 普通来说, 父类优先的战略就足够好了。 在某些状况下, 能够需要采取相反的战略, 即先尝试自己加载, 找不到的时候再代理给父类加载器。
这种做法在Java的Web容器中比较常见, 也是Servlet规范推荐的做法。 比方, Apache Tomcat为每个Web应用都提供一个独立的类加载器, 运用的就是自己优先加载的战略。 IBM WebSphere Application Server则允许Web应用选择类加载器运用的战略。
类加载器的一个重要用途是在JVM中为相同名称的Java类创立隔离空间。 在JVM中, 判断两个类是否相同, 不仅是依据该类的二进制名称, 还需要依据两个类的定义类加载器。 只有两者完全一样, 才认为两个类的是相同的。 因此, 即便是异样的Java字节代码, 被两个不同的类加载器定义之后, 所失掉的Java类也是不同的。 假如试图在两个类的对象之间停止赋值操作, 会抛出java. lang. ClassCastException。
这个特性为异样名称的Java类在JVM中共存创造了条件。 在实际的应用中, 能够会要求同一名称的Java类的不同版本在JVM中可以同时存在。 通过类加载器就可以满足这种需求。 这种技术在OSGi中失掉了广泛的应用。
Java类的链接
Java类的链接指的是将Java类的二进制代码合并到JVM的运行状态之中的进程。 在链接之前, 这个类必需被成功加载。 类的链接包括验证、准备和解析等几个步骤。 验证是用来确保Java类的二进制表示在构造上是完全正确的。 假如验证进程出现错误的话, 会抛出java. lang. VerifyError错误。 准备进程则是创立Java类中的静态域, 并将这些域的值设为默许值。 准备进程并不会执行代码。
在一个Java类中会包含对其它类或接口的形式援用, 包括它的父类、所实现的接口、方法的形式参数和前往值的Java类等。 解析的进程就是确保这些被援用的类能被正确的找到。 解析的进程能够会导致其它的Java类被加载。
不同的JVM实现能够选择不同的解析战略。 一种做法是在链接的时候, 就递归的把所有依赖的形式援用都停止解析。 而另外的做规律能够是只在一个形式援用真正需要的时候才停止解析。 也就是说假如一个Java类只是被援用了, 但是并没有被真正用到, 那么这个类有能够就不会被解析。 思索上面的代码:
public class LinkTest . . . {
public static void main(String[] args) . . . {
ToBeLinked toBeLinked = null; System. out. println(Test link. );
}
}
类 LinkTest援用了类ToBeLinked, 但是并没有真正运用它, 只是声明了一个变量, 并没有创立该类的实例或是访问其中的静态域。 在 Oracle的JDK 6中, 假如把编译好的ToBeLinked的Java字节代码删除之后, 再运行LinkTest, 顺序不会抛出错误。 这是由于ToBeLinked类没有被真正用到, 而Oracle的JDK 6所采用的链接战略使得ToBeLinked类不会被加载, 因此也不会发现ToBeLinked的Java字节代码实际上是不存在的。 假如把代码改成ToBeLinked toBeLinked = new ToBeLinked();之后, 再按照相同的方法运行, 就会抛出异常了。 由于这个时候ToBeLinked这个类被真正运用到了, 会需要加载这个类。
Java类的初始化
当一个Java类第一次被真正运用到的时候, JVM会停止该类的初始化操作。 初始化进程的主要操作是执行静态代码块和初始化静态域。 在一个类被初始化之前, 它的直接父类也需要被初始化。 但是, 一个接口的初始化, 不会引起其父接口的初始化。 在初始化的时候, 会按照源代码中从上到下的顺序依次执行静态代码块和初始化静态域。 思索上面的代码:
public class StaticTest . . . {
public static int X = 10;
public static void main(String[] args) . . . {
System. out. println(Y); //输入60
}
static . . . {
X = 30;
}
public static int Y = X * 2;
}
在上面的代码中, 在初始化的时候, 静态域的初始化和静态代码块的执行会从上到下依次执行。 因此变量X的值首先初始化成10, 后来又被赋值成30;而变量Y的值则被初始化成60。
Java类和接口的初始化只有在特定的机遇才会发生, 这些机遇包括:
创立一个Java类的实例。 如:MyClass obj = new MyClass()
调用一个Java类中的静态方法。 如:MyClass. sayHello()
在顶层Java类中执行assert语句。
通过Java反射API也能够形成类和接口的初始化。
需要注意的是, 当访问一个Java类或接口中的静态域的时候, 只有真正声明这个域的类或接口才会被初始化。 思索上面的代码:
class B . . . {
static int value = 100;
static . . . {
System. out. println(Class B is initialized. ); //输入
}
}
class A extends B . . . {
static . . . {
System. out. println(Class A is initialized. ); //不会输入
}
}
public class InitTest . . . {
public static void main(String[] args) . . . {
}
应用服务器