深入JVM内部

溪渠
转载请注明原创出处,谢谢!
如果读完觉得有收获的话,欢迎点赞加关注

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;"表示类的实例,最后一个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字节码指令集由OpCodeOperand(操作数)组成,像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 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代码执行的流程如下。


深入JVM内部_第1张图片
Java代码执行流程

类加载器将字节码加载到运行时数据区,然后执行引擎执行字节码。

类加载器

Java提供了动态加载特性,类加载器在运行时只在第一次指向类时加载和连接类。类加载器特性如下:

  • 层次结构:Java中的类加载器被组织为一种父子关系的层次结构。Bootstrap类加载器是所有类加载器的parent。
  • 委托模式:当需要加载一个类时,首先在父类加载器中查找是否存在该类,如果有那么直接使用,如果没有,再由该类加载器加载该类。
  • 可见性限制:子类加载器可以在父类加载器中查找类,反之不可以。
  • 不允许卸载:类加载器可以加载类,不可以卸载类。需要卸载类时,可以删除当前类加载器,创建一个新的类加载器。

每个类加载器都有自己的命名空间,其中保存了所有加载的类。当一个类加载器加载一个类时,通过全限定名称查找该类。两个拥有相同全限定名的类在不同的命名空间时,这两个类是不一样的,也说明它们由不同的类加载器加载。

下图说明了类加载器的委托模型。

深入JVM内部_第2张图片
类加载器委托模型

当需要加载一个类时,按照上图顺序检查每个类加载器缓存中是否存在该类,如果在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规范自行实现类加载器。

一个类加载后的具体流程如图。

深入JVM内部_第3张图片
类加载阶段

各个阶段流程说明:

  • Loading:从文件中获取类字节码,并加载到JVM内存。
  • Verifying:验证该类是否遵循Java语言规范以及JVM规范。这个过程是加载中最为复杂和耗时的阶段。
  • Preparing:为保存类的字段、方法、接口等内容的数据结构分配内存。
  • Resolving:改变常量池中的符号引用为直接引用。
  • Initializing:执行静态初始化块,初始化静态变量。

Runtime Data Area

深入JVM内部_第4张图片
运行时数据区

运行时数据区是JVM启动后分配的内存区域,分为六个部分,其中每个线程都有自己的PC Register(程序计数器)、JVM Stack(虚拟机栈)、Native Method Stack(本地方法栈),Heap(堆)、Method Area(方法区)、Runtime Constant Pool(运行时常量池)是被所有线程共享的。

  • 程序计数器:每个线程有自己的程序计数器,记录当前正在执行的JVM指令地址,这里不会发生GC。
  • 虚拟机栈:每个线程有自己的虚拟机栈,伴随着方法的调用与退出,栈帧进栈出栈。当有异常发生时,printStackTrace方法可以打印所有的栈帧。
深入JVM内部_第5张图片
虚拟机栈

栈帧:栈帧是在方法执行时创建的,加入线程的虚拟机栈,在方法结束时,从栈中移除。栈帧包含局部变量数组引用、操作数栈引用、当前类的运行时常量池引用。局部变量以及操作数栈在编译时已经确定了,所以方法的栈帧大小也是确定的。
局部变量数组:数组下标是从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为每一个类创建了运行时常量池,而常量池中存储了实际操作对象的引用。

你可能感兴趣的:(深入JVM内部)