简书 溪渠
转载请注明原创出处,谢谢!
如果读完觉得有收获的话,欢迎点赞加关注
Virtual Machine
JRE由Java API以及JVM组合而成。JVM负责的工作就是通过ClassLoader识别Java程序,并且使用Java API执行它。
一个Virtual Machine可以理解为使用软件方式实现的机器,可以像一台真实的物理机一样执行应用程序。最初,Java设定的运行环境就是隔离物理环境的虚拟机,以满足WORA(Write Once Run Anywhere)。因而JVM可以运行在各种各样的硬件之上,执行Java字节码,却不需要改变Java程序代码。
JVM的一些特性如下:
- Stack-based virtual machine:大多数流行的计算机架构(如Intel x86和ARM)基于寄存器运行,然而JVM基于栈运行。
- Symbolic reference:所有的类型(class和interface,不包括原始类型)都是通过符号引用引用,而不是基于内存地址的引用。
- Garbage collection:一个类的实例被用户代码创建,并由垃圾回收机制自动回收。
- Guarantees platform independence by clearly defining the primitive data type:像C/C++这类语言对于不同的平台,int类型有不同的大小。JVM明确定义了原始类型的大小以保证兼容性和平台无关性。
- Network byte order:Java的Class文件采用网络字节顺序,即big endian,这是为了保证平台无关性。
Sun公司开发了Java,但是任何人都可以遵循Java虚拟机规范开发JVM。因此市面上有多款虚拟机,比较出名的如Oracle Hotspot JVM和IBM JVM。谷歌安卓系统中的Dalvik VM也是一款JVM,尽管它没有遵循Java虚拟机规范,它也是基于寄存器架构的。
Java字节码
为了实现WORA,JVM采用了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实例的方法。
直观的来看com.nhn.user.UserAdmin.addUser()方法仍然是存在的,那么为什么会出现NoSuchMethodError?
原因
原因很简单,就是应用程序代码没有使用新的库重新编译,在UserAadmin类的Class文件中使用的仍然是无返回值方法,而新的库提供的是带返回值的方法。异常中提示了错误的原因。
java.lang.NoSuchMethodError: com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V
NoSuchMethodError是由于"com.nhn.user.UserAdmin.addUser(Ljava/lang/String;)V"方法找不到产生的。在Java字节码的描述中,"L<classname>;"表示类的实例,最后一个V是方法的返回值,这里表示无返回值。
让我们接着聊Java字节码,Java字节码是JVM必要的组成要素。Java编译器并不直接将高级语言转换为机器语言(CPU指令),它将开发者理解的语言转换为JVM理解的字节码。由于Java字节码是一种平台无关的代码,在不同的硬件环境或者不同的操作系统之上只要安装了JRE环境,那么它就可以执行,且不需要任何其他改变。字节码的大小几乎与源代码大小相同。
实际上Java中Class文件是二进制文件,无法直接理解,因此JVM供应商提供了javap这个工具,可以解析字节码文件。在上面的例子中,通过javap -c UserAdmin.class
得到如下内容:
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
可以看到addUser方法在第四行被调用,"5: invokevirtual"。"#23"表示与索引23相关的方法要被调用,并且可以看到由javap工具注释的方法。invokevirtual是Java字节码中的一个OpCode,用于方法调用。在字节码中包含四种方法调用的OpCode:invokeinterface,invokespecial,invokestatic,invokevirual,它们的含义如下:
- invokeinterface: 调用接口方法
- invokespecial: 调用构造器方法,private方法,或者超类方法
- invokestatic: 调用静态方法
- invokevirtual: 调用实例方法
Java字节码指令集由OpCode和Operand(操作数)组成,像invokevirtual这种OpCode需要2字节的Operand。
当使用新的库编译上面的程序代码之后,反汇编得到的内容如下:
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”。
在上面反汇编的结果中,代码前面的数字代表了什么含义?
数字表示字节数,或者说是字节下标。OpCode本身使用一个byte进行表示,所以最多有256中字节码指令,上面例子中的一些指令的字节码表示为:
aload_0=0x2a,getfield=0xb4,invokevirtual=0xb6
。
aload_0和aload_1不需要Operand,而getfield需要2字节的Operand,所以aload_1字节数是4。在16进制编辑器中查看字节码如下:
2a b4 00 0f 2b b6 00 17 57 b1
在Java字节码中,“L;”表示类的实例,“V”表示void类型,下面的表格总结了其他类型的表示。
表1:字节码中的类型表示
字节码 | 类型 | 描述 |
---|---|---|
B | byte | signed byte |
C | char | Unicode字符 |
D | double | 双精度浮点 |
F | float | 单精度浮点 |
I | int | integer |
J | long | long integer |
L<classname> | reference | 类实例 |
S | short | signed short |
Z | boolean | true or false |
[ | reference | one array dimension |
表2:字节码示例
Java Code | Java Bytecode Expression |
---|---|
double d[][][]; | [[[D |
Object mymethod(int i, double d, Thread t) | (IDLjava/lang/Threadd;)Ljava/lang/Object |
更多关于Java字节码指令集可以查看"The Java Virtual Machine Specification"中“6. The Java Virtual Machine Instruction Set”。
Class文件格式
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个byte如下。
ca fe ba be 00 00 00 32 00 28 07 00 02 01 00 1b
下面根据这个例子说明一下class文件格式。
- magic:class文件的前4个byte是魔数,这是预先定义好的,用于区分class文件,值总是0xCAFEBABE。当文件的前4个byte是0xCAFEBABE时,可以认为它是Java class文件。
- minor_version,major_version:接下来的4个字节表示class版本。使用JDK1.6编译的class版本是50,使用JDK1.5编译的class版本是49。JVM保持向后兼容性,这表示低版本编译的代码可以运行在高版本虚拟机中,而高版本编译的代码不能跑在低版本的JVM中,将会抛出java.lang.UnsupportedClassVersionError。
- constant_pool_count,constant_pool[]:接下来是class的常量池信息。constant_pool_count是0x28,表示constant_pool有(40-1)个indexes。
- access_flags:表示类的修饰语,比如public,final,abstract,interface等。
- this_class,super_class:类以及超类在常量池中的index
- interfaces_count,interfaces[]:常量池中记录接口数量的index以及所有的接口信息
- fields_count,fields[]:类包含的字段数量以及所有字段信息。字段信息包含名称,类型信息,修饰语和在常量池中的index。
- methods_count,methods[]:类中方法的数量以及所有方法的信息。方法的信息包含名称,类型,参数数量,返回类型,修饰语,在常量池中index,方法可执行代码,异常信息。
- attributes_count,attributes[]:field_info和method_info会用到attribute_info结构。
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 …
}
由于篇幅的限制,这里只截取了部分输出。
JVM结构
Java代码执行的流程如下。
Java代码执行流程
类加载器将字节码加载到运行时数据区,然后执行引擎执行字节码。
类加载器
Java提供了动态加载特性,类加载器在运行时只在第一次指向类时加载和连接类。类加载器特性如下:
- 层次结构:Java中的类加载器被组织为一种父子关系的层次结构。Bootstrap类加载器是所有类加载器的parent。
- 委托模式:当需要加载一个类时,首先在父类加载器中查找是否存在该类,如果有那么直接使用,如果没有,再由该类加载器加载该类。
- 可见性限制:子类加载器可以在父类加载器中查找类,反之不可以。
- 不允许卸载:类加载器可以加载类,不可以卸载类。需要卸载类时,可以删除当前类加载器,创建一个新的类加载器。
每个类加载器都有自己的命名空间,其中保存了所有加载的类。当一个类加载器加载一个类时,通过全限定名称查找该类。两个拥有相同全限定名的类在不同的命名空间时,这两个类是不一样的,也说明它们由不同的类加载器加载。
下图说明了类加载器的委托模型。
类加载器委托模型当需要加载一个类时,按照上图顺序检查每个类加载器缓存中是否存在该类,如果在Bootstrap类加载器中也找不到,那么需要当前类加载器从文件系统中加载。
- Bootstrap class loader:JVM启动时被创建,负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类。
- Extension class loader:
- 负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包
- System class loader:负责加载classpath指定的jar包
- User-defined class loader:开发者自定义实现的类加载器,如tomcat和jboss都会根据j2ee规范自行实现类加载器。
一个类加载后的具体流程如图。
类加载阶段各个阶段流程说明:
- Loading:从文件中获取类字节码,并加载到JVM内存。
- Verifying:验证该类是否遵循Java语言规范以及JVM规范。这个过程是加载中最为复杂和耗时的阶段。
- Preparing:为保存类的字段、方法、接口等内容的数据结构分配内存。
- Resolving:改变常量池中的符号引用为直接引用。
- Initializing:执行静态初始化块,初始化静态变量。
Runtime Data Area
运行时数据区运行时数据区是JVM启动后分配的内存区域,分为六个部分,其中每个线程都有自己的PC Register(程序计数器)、JVM Stack(虚拟机栈)、Native Method Stack(本地方法栈),Heap(堆)、Method Area(方法区)、Runtime Constant Pool(运行时常量池)是被所有线程共享的。
- 程序计数器:每个线程有自己的程序计数器,记录当前正在执行的JVM指令地址,这里不会发生GC。
- 虚拟机栈:每个线程有自己的虚拟机栈,伴随着方法的调用与退出,栈帧进栈出栈。当有异常发生时,printStackTrace方法可以打印所有的栈帧。
栈帧:栈帧是在方法执行时创建的,加入线程的虚拟机栈,在方法结束时,从栈中移除。栈帧包含局部变量数组引用、操作数栈引用、当前类的运行时常量池引用。局部变量以及操作数栈在编译时已经确定了,所以方法的栈帧大小也是确定的。
局部变量数组:数组下标是从0开始,0表示类的实例引用,从1开始表示的是方法的传入参数,紧接着入参,就是方法体内部的局部变量了。
操作数栈:表示方法实际的工作空间。
- 本地方法栈:这个栈中调用的都是本地方法,或者说是通过JNI调用的C/C++代码。
- 方法区:方法去包括运行时常量池、字段信息、方法信息、静态变量、类和接口的字节码。在Hotspot虚拟机中,方法区叫做永久代。对于各个虚拟机供应商而言,方法区的垃圾回收是可选的。
- 运行时常量池:这个区域包含在方法去中,它的内容与class文件中的constant_pool息息相关。它包含了所有方法、字段的引用,每当需要使用一个方法或者字段时,通过它找到方法或者字段实际的内存地址。
- 堆:类的实例对象存储的地方,也是垃圾回收的目标区域。
回到前面我们讨论过的反汇编后的class文件内容。
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使用栈,上图中的15和23表示的是类中运行时常量池的索引,它是自己管理内存的。简而言之,JVM为每一个类创建了运行时常量池,而常量池中存储了实际操作对象的引用。
网友评论