深入浅出 Java 虚拟机(二)类加载机制及深入解析字节码

本文章为《深入浅出 Java 虚拟机》系列课程学习笔记,侵删。学习地址为 深入浅出 Java 虚拟机

1 类加载过程

类的加载过程非常复杂,主要有这几个过程:加载、验证、准备、解析、初始化。在大多数情况下,类会按照图中给出的顺序进行加载。
在这里插入图片描述

  1. 加载:将外部的 .class 文件,加载到 Java 的方法区内。加载阶段主要是找到并加载类的二进制数据,比如从 jar 包里或者 war 包里找到它们。
  2. 验证:肯定不能任何 .class 文件都能加载,那样太不安全了,容易受到恶意代码的攻击。验证阶段在虚拟机整个类加载过程中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。像一些低版本的 JVM,是无法加载一些高版本的类库的,就是在这个阶段完成的。
  3. 准备:为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。
  4. 解析:将符号引用替换为直接引用。符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。解析阶段负责把整个类激活,串成一个可以找到彼此的网。
  5. 初始化:初始化成员变量,到了这一步,才真正开始执行一些字节码。该步包含以下几种规则:(1)static 语句块只能访问到定义在 static 语句块之前的变量 (2)JVM 会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。所以,JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外,也意味着父类中定义的 static 语句块要优先于子类的。

1.1 常见问题:<cinit> 方法和 <init> 方法有什么区别?

static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以 static 代码块只会执行一次,它对应的是 <cinit> 方法。

而对象初始化就不一样了。通常,我们在 new 一个新对象的时候,都会调用它的构造方法,就是 <init>,用来初始化对象的属性。每次新建对象的时候,都会执行。

在这里插入图片描述

1.2 类加载器

我们先来介绍几个不同等级的类加载器:

  1. Bootstrap ClassLoader:任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。这个加载器是 C++ 编写的,随着 JVM 启动。
  2. Extention ClassLoader:扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。这个加载器是个 Java 类,继承自 URLClassLoader。
  3. App ClassLoader:这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码,会首先尝试使用这个类加载器进行加载。
  4. Custom ClassLoader:自定义加载器,支持一些个性化的扩展功能。

1.3 双亲委派机制

除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。
在这里插入图片描述
我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效。

在这里插入图片描述
这个模型的好处在于 Java 类有了一种优先级的层次划分关系。比如 Object 类,这个毫无疑问应该交给最上层的加载器进行加载,即使是你覆盖了它,最终也是由系统默认的加载器进行加载的。如果没有双亲委派模型,就会出现很多个不同的 Object 类,应用程序会一片混乱。

1.4 一些自定义加载器

下面我们来谈谈打破双亲委派机制的一些案例。为了支持一些自定义加载类多功能的需求,Java 设计者其实已经作出了一些妥协。

案例:tomcat

tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构。
在这里插入图片描述
对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。这个加载器用来隔绝不同应用的 .class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的。

如何在同一个 JVM 里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。

那么 tomcat 是怎么打破双亲委派机制的呢?可以看图中的 WebAppClassLoader,它加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。

但是你自己写一个 ArrayList,放在应用目录里,tomcat 依然不会加载。它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。

1.5 如何替换 JDK 的类

当 Java 的原生 API 不能满足需求时,比如我们要修改 HashMap 类,就必须要使用到 Java 的 endorsed 技术。我们需要将自己的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是,java.lang 包下面的类除外,因为这些都是特殊保护的。

因为我们上面提到的双亲委派机制,是无法直接在应用中替换 JDK 的原生类的。但是,有时候又不得不进行一下增强、替换,比如你想要调试一段代码,或者比 Java 团队早发现了一个 Bug。所以,Java 提供了 endorsed 技术,用于替换这些类。这个目录下的 jar 包,会比 rt.jar 中的文件,优先级更高,可以被最先加载到。

1.6 如何加载一个远程的 .class 文件?怎样加密 .class 文件?

实现一个新的类加载器即可。

2 深入了解字节码

工欲善其事,必先利其器。我们先来介绍两个分析字节码的小工具。

  1. javap:javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。
  2. jclasslib:是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理,非常的人性化。

2.1 类加载和对象创建

接下来我们学习一个稍微复杂点的例子,来具体看一下类加载和对象创建的过程。

class B {
    private int a = 1234;

    static long C = 1111;

    public long test(long num) {
        long ret = this.a + num + C;
        return ret;
    }
}

public class A {
    private B b = new B();

    public static void main(String[] args) {
        A a = new A();
        long num = 4321 ;

        long ret = a.b.test(num);

        System.out.println(ret);
    }
}

当虚拟机遇到一条 new 指令时,首先会检查这个指令的参数能否在常量池中定位一个符号引用。然后检查这个符号引用的类字节码是否加载、解析和初始化。如果没有,将执行对应的类加载过程。

拿我们上面的代码来说,执行 A 代码,在调用 private B b = new B() 时,就会触发 B 类的加载。

在这里插入图片描述
A 和 B 会被加载到元空间的方法区,进入 main 方法后,将会交给执行引擎执行。这个执行过程是在栈上完成的。接下来我们详细看一下虚拟机栈上的执行过程。

我们先编译源代码 A.java,然后使用 javap 命令查看 A 和 B 的字节码。注意 javap 中的如下字样。

1: invokespecial #1   // Method java/lang/Object."<init>":()V

可以看到对象的初始化,首先是调用了 Object 类的初始化方法。注意这里是 <init> 而不是 <cinit>。

接下来,我们就可以使用更加直观的工具 jclasslib,来查看字节码中的具体内容了。我们以 B.class 文件为例,来查看它的内容。

首先,我们能够看到 Constant Pool(常量池),这些内容,就存放于我们的 Metaspace 区域,属于非堆。
在这里插入图片描述
常量池包含 .class 文件常量池、运行时常量池、String 常量池等部分,大多是一些静态内容。

接下来,可以看到两个默认的 <init> 和 <cinit> 方法。以下截图是 test 方法的 code 区域:
在这里插入图片描述
继续往下看,我们看到了 LocalVariableTable 的三个变量。其中,slot 0 指向的是 this 关键字。该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。如果没有这些信息,那么在 IDE 中引用这个方法时,将无法获取到方法名,取而代之的则是 arg0 这样的变量名。

在这里插入图片描述
本地变量表的 slot 是可以复用的。注意一个有意思的地方,index 的最大值为 3,证明了本地变量表同时最多能够存放 4 个变量。

另外,我们观察到还有 LineNumberTable 等选项。该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系,有了这些信息,在 debug 时,就能够获取到发生异常的源代码行号。

2.2 test 函数执行过程

test 函数同时使用了成员变量 a、静态变量 C,以及输入参数 num。我们此时说的函数执行,内存其实就是在虚拟机栈上分配的。下面这些内容,就是 test 方法的字节码。

public long test(long);
   descriptor: (J)J
   flags: ACC_PUBLIC
   Code:
     stack=4, locals=5, args_size=2
        0: aload_0
        1: getfield      #2                  // Field a:I
        4: i2l
        5: lload_1
        6: ladd
        7: getstatic     #3                  // Field C:J
       10: ladd
       11: lstore_3
       12: lload_3
       13: lreturn
     LineNumberTable:
       line 13: 0
       line 14: 12
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0      14     0  this   LB;
           0      14     1   num   J
          12       2     3   ret   J

我们介绍一下比较重要的 3 三个数值。

首先,注意 stack 字样,它此时的数值为 4,表明了 test 方法的最大操作数栈深度为 4。JVM 运行时,会根据这个数值,来分配栈帧中操作栈的深度。

相对应的,locals 变量存储了局部变量的存储空间。它的单位是 Slot(槽),可以被重用。其中存放的内容,包括:this,方法参数,异常处理器的参数,方法体中定义的局部变量。

args_size 就比较好理解。它指的是方法的参数个数,因为每个方法都有一个隐藏参数 this,所以这里的数字是 2。

2.3 字节码执行过程

我们稍微回顾一下 JVM 运行时的相关内容。main 线程会拥有两个主要的运行时区域:Java 虚拟机栈和程序计数器。其中,虚拟机栈中的每一项内容叫作栈帧,栈帧中包含四项内容:局部变量报表、操作数栈、动态链接和完成出口。

我们的字节码指令,就是靠操作这些数据结构运行的。下面我们看一下具体的字节码指令。
在这里插入图片描述

0: aload_0

把第 1 个引用型局部变量推到操作数栈,这里的意思是把 this 装载到了操作数栈中。

对于 static 方法,aload_0 表示对方法的第一个参数的操作。

在这里插入图片描述

1: getfield #2

将栈顶的指定的对象的第 2 个实例域(Field)的值,压入栈顶。#2 就是指的我们的成员变量 a。

#2 = Fieldref           #6.#27         // B.a:I
...
#6 = Class             #29           // B
#27 = NameAndType       #8:#9         // a:I

在这里插入图片描述

i2l

将栈顶 int 类型的数据转化为 long 类型,这里就涉及我们的隐式类型转换了。

lload_1

将第一个局部变量入栈。也就是我们的参数 num。这里的 l 表示 long,同样用于局部变量装载。你会看到这个位置的局部变量,一开始就已经有值了。

在这里插入图片描述

ladd

把栈顶两个 long 型数值出栈后相加,并将结果入栈。

在这里插入图片描述

getstatic #3

根据偏移获取静态属性的值,并把这个值 push 到操作数栈上。

在这里插入图片描述

ladd

再次执行 ladd。

在这里插入图片描述

lstore_3

把栈顶 long 型数值存入第 4 个局部变量。还记得我们上面的图么?slot 为 4,索引为 3 的就是 ret 变量。

注意这里,我们首先把变量存放到了变量报表,然后又拿出这个值,把它入栈。为什么会有这种多此一举的操作?原因就在于我们定义了 ret 变量。JVM 不知道后面还会不会用到这个变量,所以只好傻瓜式的顺序执行。

在这里插入图片描述

lload_3

正好与上面相反。上面是变量存入,我们现在要做的,就是把这个变量 ret,压入虚拟机栈中。

在这里插入图片描述

lreturn

从当前方法返回 long。

发布了128 篇原创文章 · 获赞 238 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/Geffin/article/details/104503443