当前位置:   article > 正文

Android工程师进阶第三课 类加载机制ClassLoader详解_appclassloader

appclassloader

第05讲:深入理解 ClassLoader 的加载机制

本课时我们讲解类加载器 ClassLoader。


在第 3 课时我们介绍了 Java 字节码文件(.class)的格式。一个完整的 Java 程序是由多个 .class 文件组成的,在程序运行过程中,需要将这些 .class 文件加载到 JVM 中才可以使用。而负责加载这些 .class 文件的就是本课时要讲的类加载器(ClassLoader)。

Java 中的类何时被加载器加载

在 Java 程序启动的时候,并不会一次性加载程序中所有的 .class 文件,而是在程序的运行过程中,动态地加载相应的类到内存中。


通常情况下,Java 程序中的 .class 文件会在以下 2 种情况下被 ClassLoader 主动加载到内存中:

  1. 调用类构造器

  2. 调用类中的静态(static)变量或者静态方法

Java 中 ClassLoader

JVM 中自带 3 个类加载器:

  1. 启动类加载器 BootstrapClassLoader

  2. 扩展类加载器 ExtClassLoader (JDK 1.9 之后,改名为 PlatformClassLoader)

  3. 系统加载器 APPClassLoader

以上 3 者在 JVM 中有各自分工,但是又互相有依赖。

APPClassLoader 系统类加载器

部分源码如下:



可以看出,AppClassLoader 主要加载系统属性“java.class.path”配置下类文件,也就是环境变量 CLASS_PATH 配置的路径。因此 AppClassLoader 是面向用户的类加载器,我们自己编写的代码以及使用的第三方 jar 包通常都是由它来加载的。

ExtClassLoader 扩展类加载器

部分源码如下:



可以看出,ExtClassLoader 加载系统属性“java.ext.dirs”配置下类文件,可以打印出这个属性来查看具体有哪些文件:



结果如下:


BootstrapClassLoader 启动类加载器

BootstrapClassLoader 同上面的两种 ClassLoader 不太一样。


首先,它并不是使用 Java 代码实现的,而是由 C/C++ 语言编写的,它本身属于虚拟机的一部分。因此我们无法在 Java 代码中直接获取它的引用。如果尝试在 Java 层获取 BootstrapClassLoader 的引用,系统会返回 null。


BootstrapClassLoader 加载系统属性“sun.boot.class.path”配置下类文件,可以打印出这个属性来查看具体有哪些文件:



结果如下:



可以看到,这些全是 JRE 目录下的 jar 包或者 .class 文件。

双亲委派模式(Parents Delegation Model)

既然 JVM 中已经有了这 3 种 ClassLoader,那么 JVM 又是如何知道该使用哪一个类加载器去加载相应的类呢?答案就是:双亲委派模式

双亲委派模式

所谓双亲委派模式就是,当类加载器收到加载类或资源的请求时,通常都是先委托给父类加载器加载,也就是说,只有当父类加载器找不到指定类或资源时,自身才会执行实际的类加载过程。


其具体实现代码是在 ClassLoader.java 中的 loadClass 方法中,如下所示:



解释说明:

  1. 判断该 Class 是否已加载,如果已加载,则直接将该 Class 返回。

  2. 如果该 Class 没有被加载过,则判断 parent 是否为空,如果不为空则将加载的任务委托给parent。

  3. 如果 parent == null,则直接调用 BootstrapClassLoader 加载该类。

  4. 如果 parent 或者 BootstrapClassLoader 都没有加载成功,则调用当前 ClassLoader 的 findClass 方法继续尝试加载。


那这个 parent 是什么呢? 我们可以看下 ClassLoader 的构造器,如下:



可以看出,在每一个 ClassLoader 中都有一个 CLassLoader 类型的 parent 引用,并且在构造器中传入值。如果我们继续查看源码,可以看到 AppClassLoader 传入的 parent 就是 ExtClassLoader,而 ExtClassLoader 并没有传入任何 parent,也就是 null。

举例说明

比如执行以下代码:


Test test = new Test();


默认情况下,JVM 首先使用 AppClassLoader 去加载 Test 类。

  1. AppClassLoader 将加载的任务委派给它的父类加载器(parent)—ExtClassLoader。

  2. ExtClassLoader 的 parent 为 null,所以直接将加载任务委派给 BootstrapClassLoader。

  3. BootstrapClassLoader 在 jdk/lib 目录下无法找到 Test 类,因此返回的 Class 为 null。

  4. 因为 parent 和 BootstrapClassLoader 都没有成功加载 Test 类,所以AppClassLoader会调用自身的 findClass 方法来加载 Test。


最终 Test 类就是被 AppClassLoader 加载到内存中,可以通过如下代码印证此结果:



打印结果为:



可以看出,Test 的 ClassLoader 为 AppClassLoader 类型,而 AppClassLoader 的 parent 为 ExtClassLoader 类型。ExtClassLoader 的 parent 为 null。

注意:“双亲委派”机制只是 Java 推荐的机制,并不是强制的机制。我们可以继承 java.lang.ClassLoader 类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写 findClass(name) 方法;如果想破坏双亲委派模型,可以重写 loadClass(name) 方法。

自定义 ClassLoader

JVM 中预置的 3 种 ClassLoader 只能加载特定目录下的 .class 文件,如果我们想加载其他特殊位置下的 jar 包或类时(比如,我要加载网络或者磁盘上的一个 .class 文件),默认的 ClassLoader 就不能满足我们的需求了,所以需要定义自己的 Classloader 来加载特定目录下的 .class 文件。

自定义 ClassLoader 步骤

  1. 自定义一个类继承抽象类 ClassLoader。

  2. 重写 findClass 方法。

  3. 在 findClass 中,调用 defineClass 方法将字节码转换成 Class 对象,并返回。

用一段伪代码来描述这段过程如下:


自定义 ClassLoader 实践

首先在本地电脑上创建一个测试类 Secret.java,代码如下:



测试类所在磁盘路径如下图:



接下来,创建 DiskClassLoader 继承 ClassLoader,重写 findClass 方法,并在其中调用 defineClass 创建 Class,代码如下:



最后,写一个测试自定义 DiskClassLoader 的测试类,用来验证我们自定义的 DiskClassLoader 是否能正常 work。



解释说明:

  • ① 代表需要动态加载的 class 的路径。

  • ② 代表需要动态加载的类名。

  • ③ 代表需要动态调用的方法名称。

最后执行上述 testClassLoader 方法,并打印如下结果,说明我们自定义的 DiskClassLoader 可以正常工作。


注意:上述动态加载 .class 文件的思路,经常被用作热修复和插件化开发的框架中,包括 QQ 空间热修复方案、微信 Tink 等原理都是由此而来。客户端只要从服务端下载一个加密的 .class 文件,然后在本地通过事先定义好的加密方式进行解密,最后再使用自定义 ClassLoader 动态加载解密后的 .class 文件,并动态调用相应的方法。

Android 中的 ClassLoader

本质上,Android 和传统的 JVM 是一样的,也需要通过 ClassLoader 将目标类加载到内存,类加载器之间也符合双亲委派模型。但是在 Android 中, ClassLoader 的加载细节有略微的差别。


在 Android 虚拟机里是无法直接运行 .class 文件的,Android 会将所有的 .class 文件转换成一个 .dex 文件,并且 Android 将加载 .dex 文件的实现封装在 BaseDexClassLoader 中,而我们一般只使用它的两个子类:PathClassLoader 和 DexClassLoader。

PathClassLoader

PathClassLoader 用来加载系统 apk 和被安装到手机中的 apk 内的 dex 文件。它的 2 个构造函数如下:



参数说明:

  • dexPath:dex 文件路径,或者包含 dex 文件的 jar 包路径;

  • librarySearchPath:C/C++ native 库的路径。

PathClassLoader 里面除了这 2 个构造方法以外就没有其他的代码了,具体的实现都是在 BaseDexClassLoader 里面,其 dexPath 比较受限制,一般是已经安装应用的 apk 文件路径。


当一个 App 被安装到手机后,apk 里面的 class.dex 中的 class 均是通过 PathClassLoader 来加载的,可以通过如下代码验证:



打印结果如下:


DexClassLoader

先来看官方对 DexClassLoader 的描述:

A class loader that loads classes from .jar and .apk filescontaining a classes.dex entry. 

This can be used to execute code notinstalled as part of an application.

很明显,对比 PathClassLoader 只能加载已经安装应用的 dex 或 apk 文件,DexClassLoader 则没有此限制,可以从 SD 卡上加载包含 class.dex 的 .jar 和 .apk 文件,这也是插件化和热修复的基础,在不需要安装应用的情况下,完成需要使用的 dex 的加载。


DexClassLoader 的源码里面只有一个构造方法,代码如下:



参数说明:

  • dexPath:包含 class.dex 的 apk、jar 文件路径 ,多个路径用文件分隔符(默认是“:”)分隔。

  • optimizedDirectory用来缓存优化的 dex 文件的路径,即从 apk 或 jar 文件中提取出来的 dex 文件。该路径不可以为空,且应该是应用私有的,有读写权限的路径。

使用 DexClassLoader 实现热修复

理论知识都是为实践作基础,接下来我们就使用 DexClassLoader 来模拟热修复功能的实现。

创建 Android 项目 DexClassLoaderHotFix

项目结构如下:



ISay.java 是一个接口,内部只定义了一个方法 saySomething。



SayException.java 实现了 ISay 接口,但是在 saySomething 方法中,打印“something wrong here”来模拟一个线上的 bug。



最后在 MainActivity.java 中,当点击 Button 的时候,将 saySomething 返回的内容通过 Toast 显示在屏幕上。



最后运行效果如下:


创建 HotFix patch 包

新建 Java 项目,并分别创建两个文件 ISay.java 和 SayHotFix.java。




ISay 接口的包名和类名必须和 Android 项目中保持一致。SayHotFix 实现 ISay 接口,并在 saySomething 中返回了新的结果,用来模拟 bug 修复后的结果。


将 ISay.java 和 SayHotFix.java 打包成 say_something.jar,然后通过 dx 工具将生成的 say_something.jar 包中的 class 文件优化为 dex 文件。


dx --dex --output=say_something_hotfix.jar say_something.jar


上述 say_something_hotfix.jar 就是我们最终需要用作 hotfix 的 jar 包。

将 HotFix patch 包拷贝到 SD 卡主目录,并使用 DexClassLoader 加载 SD 卡中的 ISay 接口

首先将 HotFix patch 保存到本地目录下。一般在真实项目中,我们可以通过向后端发送请求的方式,将最新的 HotFix patch 下载到本地中。这里为了演示,我直接使用 adb 命令将 say_somethig_hotfix.jar 包 push 到 SD 卡的主目录下:


adb push say_something_hotfix.jar /storage/self/primary/ 


接下来,修改 MainActivity 中的逻辑,使用 DexClassLoader 加载 HotFix patch 中的 SayHotFix 类,如下:



注意:因为需要访问 SD 卡中的文件,所以需要在 AndroidManifest.xml 中申请权限。


最后运行效果如下:


总结

  • ClassLoader 就是用来加载 class 文件的,不管是 jar 中还是 dex 中的 class。

  • Java 中的 ClassLoader 通过双亲委托来加载各自指定路径下的 class 文件。

  • 可以自定义 ClassLoader,一般覆盖 findClass() 方法,不建议重写 loadClass 方法。

  • Android 中常用的两种 ClassLoader 分别为:PathClassLoader 和 DexClassLoader。


第06讲:Class 对象在执行引擎中的初始化过程

在上一课时我详细介绍了 ClassLoader 的使用,包括它的主要作用就是用来将 class 字节码加载到内存中。那 JVM 加载 class 文件的具体过程是怎样的呢?本课时我们就来了解一下这一详细过程以及当中存在的问题。


一个 class 文件被加载到内存中需要经过 3 大步:装载、链接、初始化。其中链接又可以细分为:验证、准备、解析 3 小步。因此用一张图来描述 class 文件加载到内存的步骤如下所示。


装载

什么是装载

装载是指 Java 虚拟机查找 .class 文件并生成字节流,然后根据字节流创建 java.lang.Class 对象的过程。

 

这一过程主要完成以下 3 件事:


1)ClassLoader 通过一个类的全限定名(包名 + 类名)来查找 .class 文件,并生成二进制字节流:其中 class 字节码文件的来源不一定是 .class 文件,也可以是 jar 包、zip 包,甚至是来源于网络的字节流。


2)把 .class 文件的各个部分分别解析(parse)为 JVM 内部特定的数据结构,并存储在方法区。

还记得在课时 03 中介绍的 .class 文件结构吗?在这里 JVM 会将这些 .class 文件的结构转化为 JVM 内部的运行时数据结构。这点同 JSON 解析过程有点类似:如果你做过 Android 开发,应该都使用过 GsonFormat 将后端开发返回的 JSON 结构转化为一个运行时 Bean 类,当程序运行时使用这个 Bean 类去解析处理 JSON 数据。


3)在内存中创建一个 java.lang.Class 类型的对象:

接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个 Class 类型的类对象是提供给外界访问该类的接口。

加载时机

一个项目经过编译之后,往往会生成大量的 .class 文件。当程序运行时,JVM 并不会一次性的将这些 .class 文件全部加载到内存中。那 JVM 是什么时候加载某 .class 文件呢?对此,Java 虚拟机规范中并没有严格规定,不同的虚拟机实现会有不同实现。不过以下两种情况一般会对 class 进行装载操作。

  • 隐式装载:在程序运行过程中,当碰到通过 new 等方式生成对象时,系统会隐式调用 ClassLoader 去装载对应的 class 到内存中;

  • 显示装载:在编写源代码时,主动调用 Class.forName() 等方法也会进行 class 装载操作,这种方式通常称为显示装载。

链接

链接过程分为 3 步:验证、准备、解析。

验证:

验证是链接的第一步,目的是为了确保 .class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危及虚拟机本身的安全。主要包含以下几个方面的检验。

  1. 文件格式检验:检验字节流是否符合 class 文件格式的规范,并且能被当前版本的虚拟机处理。 

  2. 元数据检验:对字节码描述的信息进行语义分析,以保证其描述的内容符合 Java 语言规范的要求。 

  3. 字节码检验:通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。 

  4. 符号引用检验:符号引用检验可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。


实例分析:

我使用以下 Foo.java 来演示验证阶段的几种情况:



使用 javac 编译 Foo.java 生成 Foo.class 字节码文件,然后使用 16 进制编辑器打开 Foo.class 文件,部分如下:



正常情况下,使用 java Foo 执行结果如下:



如果使用 16 进制编辑器修改 class 文件中的魔数,如下所示:



将“cafe babe”修改为“cafe babb”,重新运行则会报如下错误:



class 文件中魔数后的“0034”为版本号,如果将其修改为“0035”则会报如下错误:



版本号“0034”之后的“0036”是常量池计数器,表示常量池中有 54 个常量。如果将这个值进行修改也有可能造成运行时错误,比如我将“0036”改为“0032”:



重新执行 java Foo,则会报如下错误:


     


虽说 JVM 会检查各种对 class 字节码文件的篡改行为,但是依然无法百分百保证 class 文件的安全性。比如我还是用 Foo.java 举例,在 Foo.java 中的 print 方法中,分别打印出父类的自身类的 hashCode 值,分别是:2018699554 和 111。我们可以在 class 字节码的基础上进行篡改,将父类的 hashCode 也返回 111。


通过 javap  -v  Foo 命令可以查看Foo.class 中常量池的具体信息:



因为篇幅原因,我只截取了部分常量池的内容。图中 1 处指向了父类 Object的hashCode 方法,图中 2 处指向了 Foo 的 hashCode 方法。在课时 03 中,我们了解已经了解了 CONSTANT_Methodref_info 结构如下:



其中 class_index 就是指向方法的所属类(图中为 16,转化为 16 进制为 0X10),因此只需要使用 16 进制编辑器将指向 Object 的 class_index 改为执行 Foo 的 class_index 即可。具体修改如下:



将图中 0X10 改为 0X02 并保存,重新运行 java Foo 效果如下:



可以看出,虽然在 Java 源文件中调用的是 super.hashCode() 方法,但是经过篡改之后,Foo.class 文件成功通过 JVM 的校验,并成功执行最终打印出我们想要的结果。


注意:上面的实例也说明即使没有 Java 源文件,在某种程度上,工程师还是可以对编译之后的 class 字节码文件进行篡改。这也是为什么我们在项目中经常会使用混淆,甚至是使用一些三方的加固软件,来保证我们所编写的代码的安全性。  

准备

准备是链接的第 2 步,这一阶段的主要目的是为类中的静态变量分配内存,并为其设置“0值”。比如:


public static int value = 100;


在准备阶段,JVM 会为 value 分配内存,并将其设置为 0。而真正的值 100 是在初始化阶段设置。并且此阶段进行内存分配的仅包括类变量,而不包括实例变量(实例变量将会在对象实例化时随着对象一起分配在 Java 堆中)。


有一种情况比较特殊--静态常量,比如:


public static final int value = 100;


以上代码会在准备阶段就为 value 分配内存,并设置为 100。


Java 中基本类型的默认”0值“如下:

  • 基本类型(int、long、short、char、byte、boolean、float、double)的默认值为 0;

  • 引用类型默认值是 null;

解析

解析是链接的最后一步,这一阶段的任务是把常量池中的符号引用转换为直接引用,也就是具体的内存地址。在这一阶段,JVM 会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。


比如上面 Foo.java 中编译之后 main 方法的字节码如下:



在 main 方法中通过 invokevirtual 指令调用了 print 方法,“Foo.print:()V"就是一个符号引用,当

main 方法执行到此处时,会将符号引用“Foo.print:()V”解析(resolve)成直接引用,可以将直接引用理解为方法真正的内存地址。

对于符号引用和直接引用,可以将其与生活中的微信聊天进行类比,在微信好友列表中,保存的是好友的名称或者别名(也就是符号引用),当我们真正给某个好友发消息时,计算机(JVM)会根据好友的名称找到对象计算机的 IP 地址(直接引用)并成功将消息发送给这一地址。 

初始化

这是 class 加载的最后一步,这一阶段是执行类构造器<clinit>方法的过程,并真正初始化类变量。比如:


public static int value = 100;


在准备阶段 value 被分配内存并设置为 0,在初始化阶段 value 就会被设置为 100。

初始化的时机 

对于装载阶段,JVM 并没有规范何时具体执行。但是对于初始化,JVM 规范中严格规定了 class 初始化的时机,主要有以下几种情况会触发 class 的初始化:

  1. 虚拟机启动时,初始化包含 main 方法的主类;

  2. 遇到 new 指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作;

  3. 当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作;

  4. 子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;

  5. 使用反射 API 进行反射调用时,如果类没有进行过初始化则需要先触发其初始化;

  6. 第一次调用 java.lang.invoke.MethodHandle 实例时,需要初始化 MethodHandle 指向方法所在的类。

初始化类变量

在初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有 static 关键字修饰的信息,而没有 static 修饰的语句块在实例化对象的时候才会执行。


比如以下代码:



然后在 ClassInitTest.java 中访问 ClassInit 的 value 值,如下:



执行上述代码,打印日志如下:



可以看出,非静态代码块并没有被执行。如果将 ClassInitTest.java 修改如下:



加了一行代码,使用 new 创建 ClassInit 对象实例。再次执行后非静态代码块也将会被执行,如下:


被动引用

上述的 6 种情况在 JVM 中被称为主动引用,除此 6 种情况之外所有引用类的方式都被称为被动引用。被动引用并不会触发 class 的初始化。


最典型的就是在子类中调用父类的静态变量,比如有以下两个类:



可以看出 Child 继承自 Parent 类,如果直接使用 Child 来访问 Parent 中的 value 值,则不会初始化 Child 类,比如如下代码:



执行上述代码,打印如下效果:



可以看出,Child 中的静态代码块并没有被执行。也就是说 JVM 并没有对 Child 执行初始化操作。


对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类 Child 来引用父类 Parent 中定义的静态字段,只会触发父类 Parent 的初始化而不会触发子类 Child 的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,可以通过 XX:+TraceClassLoading 参数来查看,比如使用如下命令再次执行 NonInitTest:


 java -XX:+TraceClassLoading NonInitTest


查看部分打印日志如下:



可以看出,虽然只有 Parent 被初始化,但是 Parent 和 Child 都经过了装载和验证阶段,并被加载到内存中。

class 初始化和对象的创建顺序

关于 class 的初始化还有一点经常会在面试中被提及,那就是对象的初始化顺序。当我们在代码中使用 new 创建一个类的实例对象时,类中的静态代码块、非静态代码块、构造函数之间的执行顺序是怎样的。


比如以下代码:



在 main 方法中执行了 2 次 new Child() 的操作,执行上述代码结果如下:



总结一下对象的初始化顺序如下:


静态变量/静态代码块 -> 普通代码块 -> 构造函数


1. 父类静态变量和静态代码块;

2. 子类静态变量和静态代码块;

3. 父类普通成员变量和普通代码块;

4. 父类的构造函数;

5. 子类普通成员变量和普通代码块;

6. 子类的构造函数。

更多参考链接:

JVM符号引用转换直接引用的过程?

JVM里的符号引用如何存储?

大话Java对象在虚拟机中是什么样子?

JVM源码分析之Java类的加载过程

总结:

这节课主要介绍了 .class 文件被加载到内存中所经过的详细过程,主要分 3 大步:装载、链接、初始化。其中链接中又包含验证、准备、解析 3 小步。

  1. 装载:指查找字节流,并根据此字节流创建类的过程。装载过程成功的标志就是在方法区中成功创建了类所对应的 Class 对象。

  2. 链接:指验证创建的类,并将其解析到 JVM 中使之能够被 JVM 执行。

  3. 初始化:则是将标记为 static 的字段进行赋值,并且执行 static 标记的代码语句 。


声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/你好赵伟/article/detail/247748
推荐阅读
相关标签
  

闽ICP备14008679号