Android开发经验谈

深入理解Java 虚拟机(2)

2017-07-11  本文已影响0人  唐先僧

由于原文太长,所以拆成两部分。

第一部分在这里

JVM 结构

Java 编写的代码通过下图所展示的流程执行。

图1:Java 代码执行过程

类加载器将编译后的 Java 字节码加载到运行时方法区,由执行引擎执行 Java 字节码。

类加载器

Java 提供了动态加载功能;当在运行时第一次引用一个类时类加载再加载并链接相应的类,而不是在编译时加载链接。动态加载由 JVM 的类加载器执行。Java 的类加载器特点如下:

每一个类加载器都有其独立的命名空间用于存储已经加载的类。当加载一个类时,它首先基于 FNCQ(Fully Qualified Class Name)在其命名空间中搜索检查该类是否已经被加载。如果一个类有相同的 FQCN 但是不同的命名空间,它会被当做不同的类。不同的命名空间意味着这个类已经被其他的类加载器加载。

下图说明了类加载器的代理模式。

图2:类加载器代理模式

当一个类加载器申请加载类时,会按照图中的顺序依次检查该类是否存在于类加载器缓存、父类加载器的缓存和它自己。也就是首先要检查该类是否已经加载到类加载器缓存中。如果没有,检查父类加载器。如果在 bootstrap 类加载器中还没有找到该类,申请加载的加载器就会在文件系统中搜索。

像 Web 应用服务(WAS)这样的框架使用这一架构保证 Web 应用和企业应用独立运行。换句话说,通过加载器代理模式保证应用独立运行。不同厂商提供的使用层级结构的 WAS 类加载器会有一些细微差别。

如果类加载器发现一个类没有被加载,就会按照下面图示的过程加载、链接。

图3:类加载各个阶段

每一个阶段的描述如下:

JVM 规范定义了这些任务。然而,它也允许灵活处理。

运行时数据区

图4:运行时数据区配置

运行时数据区是 JVM 程序在 OS 上运行时分配的内存。运行时数据区可以分为6个区域,每个线程独有的一个PC 寄存器,JVM 栈,Native 方法栈。所有线程共用的堆、方法区和运行时常量池。

让我回到全面讨论的反编译字节码。

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 为每一个类创建一个常量池,这个池中存放实际目标的引用。

这一段反编译代码每一行的解释如下。

下图将帮助你理解这些解释。

图6:加载到 运行时数据区的 Java 字节码示例

作为参考,在这个方法中,本地变量数组没有做任何变化。所以上图仅仅展示了操作数栈的变化。然而,在多数情况下,本地变量数组会发生变化。本地变量数组和操作数栈之间的数据交换通过一系列的加载指令(aload,iload)和存储指令(astore,istore)完成。

在上图中,我么已经简要的描述了运行时常量池和 JVM 栈。当 JVM 运行时,每一个类实例都会被分配到堆上,而类信息(包括 User, UserAdmin,UserServices 和String)会被存储到方法区。

执行引擎(Execution Engine)

通过类加载器分配到运行时数据区的字节码通过执行引擎执行。执行引擎以指令为单位读取字节码。它就像 CPU 执行机器命令一样一条一条的执行。每一个字节码命令都包含一个字节的操作码和附加的操作数。执行引擎读取一条操作码然后使用相应的操作数执行任务,完成以后再执行下一条操作码。

但是 Java 字节码是以人类能够理解的语言编写的,而不是机器能够直接执行的语言。所以执行引擎需要将字节码转换成机器中的 JVM 能够执行的语言。字节码会按照以下两种方式之一转换成合适的语言。

但是,JIT 编译器编译代码会比解释器逐条解释代码使用更长的时间。所以如果代码只需要执行一次,使用解释器会更好。所以,各种内部使用了 JIT 编译器的 JVM 都会检查方法被执行的频率,只有当一个方法被执行的频率高于某一个水平时才将它编译成 native code。

图7:Java 编译器和 JIT 编译器

JVM 规范并没有定义执行引擎如何运行。所以,JVM 厂商们通过各种技术来提高其执行引擎的效率,并发明各种类型的 JIT 编译器。

大多数 JIT 编译器像下图这样运行:

图8:JIT 编译器

JIT 编译器将字节码转换成一个中间表达式,IR(Intermediate Representation),然后执行优化,最后将中间表达式转换成 native code。

Oracle Hotspots 虚拟机使用的 JIT 编译器称为 Hotspot 编译器。它被称作 Hotspot 是因为 Hotspot编译器会根据 profiling 找到具有最高编译优先级的“热点”代码,并将热点编译成 native code。如果编译后的方法不再被频繁调用,这个方法就不再是热点,Hotspot 虚拟机会将相应的 native code 从缓存中移除,并使用解释模式运行。Hotspot 虚拟机分为服务端虚拟机和客户端虚拟机,两者使用了不同的 JIT 编译器。

图9:Hospot 的客户端虚拟机和服务端虚拟机

如上图所示,客户端虚拟机和服务端虚拟机使用相同的运行时,但是,它们使用不同的 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 版本”所包含的内容。主要的变化总结如下:

最大的变化是添加了 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
的性能。另外垃圾回收已被很多具有类似虚拟机能力的编程语言作为常用的性能优化手段。但因有很多资料对这一主题做详细的介绍,所以这里没有深入讨论。

本文译自:Understanding JVM Internals

上一篇下一篇

猜你喜欢

热点阅读