JVM类加载机制

在日常的开发中遇到1个问题
我们要部署一个系统给甲方,但是又不想让甲方知道我们的代码是怎么写的,需要Java的代码如果要加密不让别人获取到有什么办法?

研发人员都知道我们写的Java代码是.java文件,然后被编译成.class文件后,JVM可以解释执行class文件。
我们写的Java代码不会给甲方,给甲方的是编译好的包,也就是一堆class文件,但是class文件也有可能被反编译,获取到.java文件。
想了很久,通过对class文件加密,在使用时通过自定义的ClassLoader加载class,然后在自定义的ClassLoader里再增加一个验证秘钥方法,通过秘钥才能继续执行方法加载class文件。

(1) JVM加载class过程

java-class-life-cycle

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。

其中验证、准备、解析三个部分统称为连接(Linking)。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始。

(1.1) 加载

Class文件又称字节码文件,一种二进制文件,它是由某种语言经过编译而来,注意这里并不一定是Java语言,还有可能是 Scala、Groovy 等,Class文件运行在Java虚拟机上。
Java虚拟机不与任何一种语言绑定,它只与Class文件这种特定的二进制文件格式所关联。

加载,是指查找字节流,并且据此创建类的过程。

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。
Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。
由于泛型参数会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

类加载器 作用 实现
启动类加载器(bootstrap Class Loader) 启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 Lib 目录下 Jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类) 启动类加载器是由 C++ 实现的。
扩展类加载器(extension Class Loader) 负责加载相对次要、但又通用的类,比如存放在 JRE 的 Lib/ext 目录下 Jar 包中的类(以及由系统变量 Java.ext.dirs 指定的类) 由 Java 核心类库提供
应用类加载器(application Class Loader) 负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -Cp/-Classpath、系统变量 Java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。 由 Java 核心类库提供

Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。
即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。
在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

(1.2) 验证

虚拟机可以接受任何语言编译而成的Class文件,因此也给虚拟机带来了安全隐患,为了提供语言无关性的功能就必须做好安全防备措施,避免危险有害的类文件载入到虚拟机中,对虚拟机造成损害。所以在类加载的第二大阶段就是验证,这一步工作是虚拟机安全防护的关键所在,其中检查的步骤就是对class文件按照《Java虚拟机规范》规定的内容来对其进行验证。

验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。
验证的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,并且不会危害虚拟机自身的安全。
从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

(1.2.1) 文件格式验证

验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,如:
是否以魔数0xCAFEBABE开头
主、次版本号是否在当前Java虚拟机接受范围之内
常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

(1.2.2) 元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,如:

这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
这个类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)

(1.2.3) 字节码验证

通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,如:

保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况
保证任何跳转指令都不会跳转到方法体以外的字节码指令上
保证方法体中的类型转换总是有效的,例如父类=子类对象是合法的,返回来就是非法的

(1.2.4) 符号引用验证

符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,即解析阶段。主要目的是检查该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源,如:
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问


(1.3) 准备

准备阶段的目的,则是为被加载类的静态字段分配内存。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,当类变量被final修饰时,在准备阶段就直接会被复制,不是使用初始值,如:

public static int a = 123;
public static final int B = 123;
在准备阶段a的值是0,B的值是123。

下面是,基本数据类型的初始值:
类型|默认值
—|—| int | 0 long | 0L byte | (byte)0 short | (short)0 char| ‘\u000’ float | 0.0f double | 0.0d boolean | false reference| null

(1.4) 解析

解析阶段的目的,是将符号引用解析成为实际引用。

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,引用的目标并不一定是已经加载到虚拟机内存当中的资源;
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在;


(1.5) 初始化

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
只有当初始化完成之后,类才正式成为可执行的状态。

初始化阶段就是执行**类构造器 ()**方法的过程,它是真正开始执行Java代码的阶段,比如给类属性赋真实的值。

public static int a = 123;
在初始化阶段后,a的值才等于123。

(1) 方法是由编译器自动收集类中的所有类变量(static变量)的赋值动作和静态语句块(static{}块)中的语句合并产生的,收集顺序是源文件中的代码顺序;
(2) 方法不是必须的,如果我们的源文件中没有静态语句块和静态属性的赋值,那么久不会有() 方法。
(3) 方法在多线程情况下会通过加锁的方式来保证同步,并且只会被执行一次
子类() 方法执行之前需要保证先执行父类的() 方法,所以Object类的() 方法是第一个执行的

类的初始化触发条件

类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
public class Singleton {
  private Singleton() {}
  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }
  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

著名的单例延迟初始化例子中,只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。

(1.5) 使用

(1.6) 卸载


(2) 类加载器

通过 JVM 参数 -verbose:class 来打印类加载的先后顺序

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

(2.1) 类加载器类型

** 启动类加载器(Bootstrap Class Loader) **

启动类加载器(Bootstrap Class Loader):负责加载存放在lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中;

** 扩展类加载器(Extension Class Loader) **

扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库;

** 应用程序类加载器(Application Class Loader) **

应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现,也称为”系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器;

(2.2) 双亲委派模型

如图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(ParentsDelegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:所有类的加载都委托给父加载器去完成,当父加载器无法加载这个类的时候,子加载器才会尝试加载。

双亲委派模型最大的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,保证同一个类只会被一个加载器加载。

(2.2.1) 双亲委派模型的实现

先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

(2.2.2) 破坏双亲委派模型

双亲委派模型主要出现过3次较大规模“被破坏”的情况:

在1.2之前,由于实现自定义类加载器只有覆盖loadClass()方法,导致了双亲委派模型的破坏,在1.2之后引入了findClass()方法之后得以解决。
基础类型无法调用回用户的代码,如JNDI、JDBC、JCE、JAXB和JBI等,他们的接口定义是基础类型,但是他们的实现是各各厂商,这就导致了基础类型需要调用用户代码。后来引入线程上下文类加载器(Thread Context ClassLoader)得以解决。
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(HotDeployment)等。

(2.2.3) 自定义类加载器

自定义类加载器需要继承ClassLoader类,为了不破坏双亲委派模型,自定义类加器建议覆盖findClass()方法,不建议覆盖loadClass()方法。下面是我实现的一个加载加密class文件、防止反编译核心代码的类加载器。

(2.3) 获取ClassLoader几种方式

// 方式一:获取当前类的 ClassLoader
clazz.getClassLoader()
// 方式二:获取当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader()
// 方式三:获取系统的 ClassLoader
ClassLoader.getSystemClassLoader()
// 方式四:获取调用者的 ClassLoader
DriverManager.getCallerClassLoader()

(3) 遇到的问题

org.springframework.beans.factory.BeanDefinitionStoreException: Failed to read candidate component class

org.springframework.beans.factory.BeanDefinitionStoreException: Failed to read candidate component class: URL [jar:file:/cn/wkq/java/test/classtest.jar!/com/xx/yy/HelloServiceImpl.class]; nested exception is java.lang.ArrayIndexOutOfBoundsException: 

Spring在扫描指定包路径下的类时,并不会一一用类加载器加载它们,而是自己把类文件当成普通文件从本地磁盘中读进来变成一个字节数组(并没有经过JVM类加载过程),然后用ASM去解析这个字节数组得到这个类的元数据,然后判断这个类的元数据里面是否有@Component等相关Spring的注解。如果有的话后面才会进一步使用类加载器去加载这个类,没有的话就不会尝试去加载。

References

[1] 《深入理解Java虚拟机:JVM高级特性与最佳实践》 周志明
[2] 深入理解JVM - 类加载机制
[3] 03 | Java虚拟机是如何加载Java类的?
[4] 80%以上Javaer可能不知道的一个Spring知识点