JVM 字节码文件与类加载

该篇文章是建立在 JVM 运行时数据区(栈和堆)JVM GCJVM Hotspot 虚拟机与 Dalvik&ART 虚拟机堆栈的区别 三篇文章的基础上讲解,所以在阅读前建议先理解看完上述文章,会对该篇文章的理解很有帮助。

在之前的篇章中对运行时数据区、执行引擎的 GC 都有详细的介绍和说明,该篇文章将会讲解执行引擎中的解释器、分析器以及类加载子系统相关的知识。

在这里插入图片描述

前端编译器与后端编译器

我们都知道代码要运行起来,首先就会经过编译,例如将用 Java 语言编写的代码编译为 .class 文件,在 Android 中就是将代码编译为 .dex 文件,.dex 文件可以认为是多个 .class 文件的集合。将代码编译为字节码文件后,想要运行这些字节码文件同样的也需要编译器将字节码编译为机器码让系统可以识别。

也就是说,从编写代码到机器可识别,会经过两个编译器:

在这里插入图片描述

上图是表示各类语言字符串(也可以理解为各种开发语言)经过编译器处理后转换为 JVM 可以识别的 .class 字节码文件,该编译器称为前端编译器;.class 文件会再经过解释器和 JIT 转换成机器码,解释器和 JIT 充当的就是后端编译器。其实简单理解就是下图转换过程:

在这里插入图片描述

前端编译器和后端编译器的作用:

  • 前端编译器:将可读的字符串(例如高级语言 Java、Kotlin 等)转换成汇编指令

  • 后端编译器:将汇编指令转换成机器指令

解释执行和 JIT&AOT

在这里插入图片描述

当编写的代码经过前端编译器编译为字节码文件后,程序在运行时会经过执行引擎将字节码文件编译为机器可识别的机器指令。

上图是 Android ART 虚拟机的执行引擎,执行引擎有解释器和 Android 的 JIT(Just In Time,即时编译器),还有 AOT(Ahead Of Time) 预先编译,也就是分别有三种方式将字节码编译为本地机器码:

  • 解释器解释执行:程序运行过程中逐行进行代码编译

  • JIT:程序运行过程中,将一些热点代码(常用的代码)进行编译缓存,要执行时从 JIT 的缓存获取执行

  • AOT:运行之前,将所有代码打包编译成机器码

AOT 在 Android N 之前的预先编译方案是在应用安装时就预先编译成本地机器码,在 Android N 及之后是将在设备充电或闲置时开启一个后台进程编译。这在文章 JVM Hotspot 虚拟机与 Dalvik&ART 虚拟机堆栈的区别 有详细说明。

“类”的生命周期

在开发语言中我们说到类就是指代的 Class,但这里说的不是这个 Class,而是代指的字节码文件。下面即将讲解字节码文件的生命周期,即从字节码文件到程序运行能够使用到卸载的过程。

字节码文件的读取解析

通过前端编译器编译到字节码文件后,字节码文件信息如下:

在这里插入图片描述

上面已经将一个字节码文件的主要信息标记出来,当然在这里并不是要详细讲字节码结构是怎样的,有兴趣可以到官网 class 字节码结构 自行了解。

我们可以思考一个问题:现在拿到了字节码文件,如果是由你设计一个后端编译器,你会怎么设计?或者换个说法,你会用哪些步骤解析上面的字节码,让程序跑起来?

既然生成的是文件,而且也放在磁盘里面,那 第一步首先就是要 IO 读取

可以看到上图的字节码中有很多信息,例如 cafebabe 是一个魔数用于头校验确认是否是合法的字节码文件,如果字节码头文件不是这个就认为是无效的;还有各定义了 2 bytes 作为 Java 主版本号和副版本号;常量池计数器标记确认后面的字节码要读多少;还有其他信息等等。

可以发现这些数据项的顺序、一个或多个字节读取时所代表的是什么信息我们得先定义好,所以 第二步就是提前定义好专门的数据结构对数据项进行数据读取解析

“类”的生命周期

经过上面两个步骤,已经将字节码文件 IO 读取并且用提前定义好的数据结构解析完毕。但要让程序能够跑起来还是不够的,还需要将数据运行起来。所以需要具体的讲解“类”(字节码文件)的生命周期。

在这里插入图片描述

类的生命周期大体上可以分为 5 个阶段:加载(Loading)-> 链接(Linking)-> 初始化(Initialization)-> 使用(Using)-> 卸载(Unloading)。

其中链接(Linking)又可以分为三个阶段:验证(Verification)-> 准备(Preparation)-> 解析(Resolution)。

加载阶段

加载阶段加载的是什么?

加载阶段就是所谓的类加载,实际上指的是将字节码文件通过类加载器加载到方法区,并且在内存中构建出一个具体的承载类信息的实例对象 java.lang.Class(该数据处于运行时数据区-方法区),每个类都有一个 Class 类型的对象

所以在加载阶段必须完成三件事情:

  • 通过类的全名获取类的二进制数据流

  • 解析类的二进制数据类型到方法区中

  • 创建一个 java.lang.Class 类的实例,作为这个类在方法区的各种数据的访问入口

在这里插入图片描述

怎么理解这个步骤呢?我们在 Java 中有对象和类的概念,对象产生就要有一个类模版,例如汽车对象是要有汽车类,苹果对象要有苹果类;类也是一个对象,什么产生它的?就是 java.lang.Class:

Class clazz = Class.forName("com.example.Person");

public class Person {
	private int name;

	public void getAge() {}
}

Person p = new Person();

上面的步骤理解起来就是:将字节码文件读取到方法区->解析字节码文件的数据生成至堆区->生成 java.lang.Class。例如上面的例子就是将 Person 类的 name 和 getAge() 方法的数据信息给到 java.lang.Class,所以为什么在反射时为什么可以通过 class.getDeclaredField() 或 class.getDeclaredMethod() 能获取到类的信息,就是在这一步完成的。

需要注意的是,基本数据类型数组的加载并不是由类加载器负责创建,而是由 JVM 在运行时根据需要直接创建,数组不存在类加载的概念;其他的都通过类加载器加载

int[] a = new int[]; // 没有发生类加载

Person[] p = new Person[]; // 会发生 Person 的类加载,但 Person[] 数组没有

链接阶段

链接阶段分成了三个部分:验证 -> 准备 -> 解析。

在这里插入图片描述

验证阶段是为了保证加载的字节码是合法、合理并且符合规范的。主要是格式检查、语义检查、字节码验证和符号引用验证。

准备阶段是为了给类的静态变量分配内存并赋值。这也是为什么我们在使用基本数据类型时能直接用会有默认值,就在这个阶段赋值的。静态数据是放在方法区,它有两个注意点:

  • 不会为实例变量分配初始值,类变量会分配在方法区中,而实例变量是会随着对象一起分配在堆中

  • 不会有代码执行

解析阶段是为了将类、接口、字段、方法的符号引用转换为直接引用

在这里插入图片描述

符号引用机会说 #28 这些信息,如果方法已经被加载,对应在类中就会出现一个 java.lang.Class 实例,直接引用就是该实例的内存地址。

初始化阶段

初始化阶段就是给给相关的变量去赋予初始值

public class Person {
	private String name = "vincent"; // 初始值 vincent
}

初始化阶段最重要的工作是执行初始化方法 <cinit()>,该方法由编译器生成并调用

在这里插入图片描述

上面是 jclasslib 解析出来的数据,但 <cinit> 并不是指的构造方法,它有两个作用:

  • 将字节码文件中读取解析的这个类的数据信息写到 java.lang.Class

  • 执行静态成员和静态代码块的字节码指令(方法实际上就是一堆的指令操作)

使用阶段

上面的 <cinit> 的调用会涉及到对象使用的两种情况出现,分别是主动使用场景和被动使用场景。主动使用场景是 <cinit> 会被调用,被动使用场景是 <cinit> 不会被调用。

主动使用场景:

  • 创建一个类的实例,使用 new、反射、clone、反序列化等操作

  • 当使用类、接口的静态字段及静态方法时

  • 使用反射类时 Class.forName("com.example.demo")

  • 子类初始化时,需要优先触发父类初始化

  • main 方法所在的类

被动使用场景:

  • 当访问一个静态字段时,只有正在使用的这个字段的类才会被初始化

  • 通过数组定义类引用,不会触发此类初始化

  • 引用常量不会触发此类或接口的初始化。因为常量在链接的准备阶段已经赋值

  • 调用 ClassLoader 类的 loadClass() 加载一个类,并不是对类的主动使用不会导致类初始化

卸载阶段

在这里插入图片描述

类加载器的内部实现是用一个 Java 集合存放所加载类的引用,另一个方面,一个 Class 对象总是会引用它的类加载器,通过调用它的 getClassLoader() 就能获得它的类加载器。由此可见,代表某个类的 Class 实例与其类的加载器之间为双向关联关系。

上图中可以看到 Sample 类的 Class 对象和 ClassLoader 对象是双向关联的关系。

但要卸载 Sample 类对象腾出空间,就是让 GC Root 不可达才会被回收,所以要做到卸载需要断开 GC Root 的引用:

在这里插入图片描述

当 Sample 被类加载、链接、初始化后,它的生命周期开始;当 Simple 类的 Class 对象不再被引用,Class 对象就会结束生命周期,Sample 类在方法区内的数据也会被卸载,从而结束 Sample 类的生命周期。

从字节码文件到类卸载步骤梳理

上面已经具体分析了类的生命周期从字节码文件读取加载到类卸载的整个流程,接下来再简单梳理下所有步骤:

  • 编写的 Java 代码(或其他高级语言)由前端编译器编译为 .class 字节码文件

  • 根据提前定义好的数据结构读取解析字节码文件

  • 将解析出来的数据推到方法区

  • 将类的信息提取出来推到堆区生成 java.lang.Class 对象(加载阶段)

  • 验证、准备、解析、初始化、使用阶段

  • GC Root 不可达卸载类

在这里插入图片描述

类加载器的分类

在上面有讲到前端编译器编译代码后生成的 .class 文件是存放在磁盘中的,要将它解析读取到方法区就需要相关的工具,也就是类加载器。

在这里插入图片描述

类加载器主要分为四种:启动类加载器(系统类加载器)、扩展类加载器、应用程序类加载器和自定义类加载器。

类加载器的职责是 读取指定目录下的 .class 字节码文件和解析文件

  • 启动类加载器:加载 jdk/jre/lib/ 目录下的 jar 内容

  • 扩展类加载器:加载 jdk/jre/lib/ext/ 目录下的 jar 内容

  • 应用程序类加载器:加载自己写的代码内容

  • 自定义类加载器:提供自己去写类加载器的方案,自己去加载指定某个路径或某个文件,只要是符合 JVM 字节码规范的文件

因为 JVM 的类加载器只支持单个 .class 字节码文件的读取,而 Android 是 .dex 文件是多个 .class 文件的集合,为了能够加载 .dex 文件 Android 是自己重写了一套类加载器,可以认为就是应用程序类加载器:

在这里插入图片描述

实际在加载 .dex 文件的类加载器就是 PathClassLoader,我们有可以用一个简单的程序验证下:

Log.i("ClassLoaderTest", "getClassLoader = " + getClassLoader());
Log.i("ClassLoaderTest", "getParent = " + getClassLoader().getParent());
Log.i("ClassLoaderTest", "getParent().getParent = " + getClassLoader().getParent().getParent());

输出:
getClassLoader = dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.example.demo.classloading-G0tBQ4MM1Jzn5GiOBFw_tw==/base.apk],nativeLibraryDirectories=[/data/app/com.example.demo.classloading-G0tBQ4MM1Jzn5GiOBFw_tw==/lib/x86, /system/lib]]] // PathClassLoader 读取的 apk 和 /system/lib 目录
getParent = java.lang.BootClassLoader@593ef48
getParent().getParent = null // 返回了 null

看上面的日志输出你可能会有疑惑:按照上面的类加载器关系,你说 Android 的类加载器是应用程序类加载器,那正常来讲 parent 就是扩展类加载器和启动类加载器才对,为什么会是 null?

需要注意的是,类加载器并不是继承关系,每个类加载器都是独立的,每个类加载器各自干自己的事情业务独立,加载代码的路径也不一样。所以说是类加载器分类,倒不如说是类加载器的种类

在 Android 中 ART 虚拟机不等同与 JVM,上面有启动类加载器和扩展类加载器的那套体系是 JVM的,所以 Android 自己的一套类加载器体系不能和 JVM 的类加载器体系作比较。Android 的类加载器体系就是 BootClassLoader -> BaseDexClassLoader -> PathClassLoader。

类加载的步骤

类加载器加载类有三个关键的方法:loadClass、findClass 和 defineClass。

loadClass

ClassLoader.java

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        return c;
}

上面的步骤其实非常简单:

  • findLoadedClass() 从自己的缓存查找是否已经加载了类,有则直接取出来返回 Class

  • 没有缓存,找上一层的 ClassLoader 加载 parent.loadClass()

  • 上面的 ClassLoader 也没找到,自己加载 findClass()

这个步骤就是所谓的双亲委派机制,如果一个类加载器在接到加载类的请求时,缓存找不到,会先委托父 ClassLoader 类加载器去加载,依次递归,都找不到才自己处理。

所以双亲委派机制它的本质是 ClassLoader 是带缓存的、会从上到下查找加载类的过程

双亲委派机制其实在翻译为中文的时候是有误导的,在英文中双亲为 Parent,其实它只是一种往上委托 ClassLoader 查找类字节码的机制。

loadClass() 的作用就是为了完成双亲委派机制。类加载就是物理读取字节码文件的动作

findClass、defineClass

ClassLoader.java

protected Class<?> findClass(String name) throws ClassNotFoundException {
	throw new ClassNotFoundException(name);
}

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
    throws ClassFormatError
{
    throw new UnsupportedOperationException("can't load this type of class file");
}

URLClassLoader.java

protected Class<?> findClass(final String name)
    throws ClassNotFoundException
{
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                	// 获取字节码文件的路径
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (ClassNotFoundException) pae.getException();
    }
    if (result == null) {
        throw new ClassNotFoundException(name);
    }
    return result;
}

private Class<?> defineClass(String name, Resource res) throws IOException {
    long t0 = System.nanoTime();
    int i = name.lastIndexOf('.');
    URL url = res.getCodeSourceURL();
    if (i != -1) {
        String pkgname = name.substring(0, i);
        // Check if package already loaded.
        Manifest man = res.getManifest();
        definePackageInternal(pkgname, man, url);
    }
    // Now read the class bytes and define the class
    java.nio.ByteBuffer bb = res.getByteBuffer();
    if (bb != null) {
        // Use (direct) ByteBuffer:
        CodeSigner[] signers = res.getCodeSigners();
        CodeSource cs = new CodeSource(url, signers);
        // Android-removed: Android doesn't use sun.misc.PerfCounter.
        // sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
        return defineClass(name, bb, cs);
    } else {
        byte[] b = res.getBytes(); // 字节码数组
        // must read certificates AFTER reading bytes.
        CodeSigner[] signers = res.getCodeSigners();
        CodeSource cs = new CodeSource(url, signers);
        // Android-removed: Android doesn't use sun.misc.PerfCounter.
        // sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
        return defineClass(name, b, 0, b.length, cs); 
    }
}

父类 ClassLoader 对 findClass() 和 defineClass() 不做任何事情,而是将操作交由下面具体的 ClassLoader 处理。

findClass() 就是找到一个字节码文件的路径,然后读取这个字节码文件出来,形成一个字节码数组

defindClass() 会将字节码文件读取完后进行校验(链接的验证阶段),该方法调用结束也就是类加载结束

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值