详解类生到死的来龙去脉

类生命周期和加载过程

一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、校验(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)

前五个部分(加载,校验,准备,解析,初始化)统称为类加载

1)加载 加载阶段也可以称为"装载"阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流 ,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的"class 文件"。 如果找不到二进制表示形式,则会抛出 NoClassDefFound 错误。

装载阶段并不会检查 classfile 的语法和格式。 类加载的整个过程主要由 JVM 和 Java 的类加载系统共同完成, 当然具体到 loading 阶段则是由 JVM 与具体的某一个类加载器(java.lang.classLoader)协作完成的。

2)校验 链接过程的第一个阶段是 校验,确保 class 文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。

校验过程检查 classfile 的语义,判断常量池中的符号,并执行类型检查, 主要目的是判断字节码的合法性 ,比如 magic number, 对版本号进行验证 。 这些检查过程中可能会抛出 VerifyErrorClassFormatErrorUnsupportedClassVersionError

因为 classfile 的验证属是链接阶段的一部分,所以这个过程中可能需要加载其他类,在某个类的加载过程中,JVM 必须加载其所有的超类和接口。

如果类层次结构有问题(例如,该类是自己的超类或接口,死循环了),则 JVM 将抛出 ClassCircularityError。 而如果实现的接口并不是一个 interface,或者声明的超类是一个 interface,也会抛出 IncompatibleClassChangeError

3)准备

然后进入准备阶段,这个阶段将会创建静态字段,并将其初始化为标准默认值(比如null或者0 值),并分配方法表,即在方法区中分配这些变量所使用的内存空间。

请注意,准备阶段并未执行任何 Java 代码。

例如:

public static int i = 1;

在准备阶段i的值会被初始化为 0,后面在类初始化阶段才会执行赋值为 1;但是下面如果使用 final 作为静态常量,某些 JVM 的行为就不一样了:

public static final int i = 1; 对应常量 i,在准备阶段就会被赋值 1

4)解析 然后进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接口方法解析。

编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。

解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。

加载一个 class 时, 需要加载所有的 super 类和 super 接口。

5)初始化 JVM 规范明确规定, 必须在类的首次"主动使用"时才能执行类初始化

初始化的过程包括执行:

  • 类构造器方法
  • static 静态变量赋值语句
  • static 静态代码块

如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初始化。所以其实在 java 中初始化一个类,那么必然先初始化过 java.lang.Object 类,因为所有的 java 类都继承自 java.lang.Object。

只要尊重语言的语义,在执行下一步操作之前完成 装载,链接和初始化 这些步骤,如果出错就按照规定抛出相应的错误,类加载系统完全可以根据自己的策略,灵活地进行符号解析等链接过程。 为了提高性能,HotSpot JVM 通常要等到类初始化时才去装载和链接类。 因此,如果 A 类引用了 B 类,那么加载 A 类并不一定会去加载 B 类(除非需要进行验证)。 主动对 B 类执行第一条指令时才会导致 B 类的初始化,这就需要先完成对 B 类的装载和链接。

类加载(初始化)时机

了解了类的加载过程,再看看类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  • 当虚拟机启动时,初始化用户指定的主类,就是启动执行的 main 方法所在的类;
  • 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类,就是 new 一个类的时候要初始化;
  • 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  • 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  • 子类的初始化会触发父类的初始化
  • 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化
  • 使用反射 API 对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化;
  • 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

同时以下几种情况不会执行类初始化:

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化
  • 定义对象数组,不会触发该类的初始化。
  • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  • 通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。
  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化 ,这个参数是告诉虚拟机,是否要对类进行初始化。Class.forName("jvm.Hello")默认会加载 Hello 类。
  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但是不初始化)。

Class.forName(), classLoader.loadClass() 等 Java API, 反射API, 以及 JNI_FindClass 都可以启动类加载。 JVM 本身也会进行类加载。 比如在 JVM 启动时加载核心类,java.lang.Object, java.lang.Thread 等等。

类加载器机制

类加载过程可以描述为"通过一个类的全限定名 a.b.c.XXClass 来获取描述此类的 Class 对象",这个过程由"类加载器(ClassLoader) "来完成。这样的好处在于,子类加载器可以复用父加载器加载的类

系统自带的类加载器分为三种:

  • 启动类加载器(BootstrapClassLoader)
  • 扩展类加载器(ExtClassLoader)
  • 应用类加载器(AppClassLoader)

启动类加载器是由 JVM 内部实现的,在 Java 的 API 里无法拿到,但是可以侧面看到和影响它。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher定义的,扩展类加载器和应用类加载器一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法

  1. 启动类加载器(bootstrap class loader): 用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class) 。可以看做是 JVM 自带的,在代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null
  2. 扩展类加载器(extensions class loader)负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。
  3. 应用类加载器(app class loader)负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径 。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器 。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载

此外还可以自定义类加载器 。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器。

类加载机制有三个特点

  1. 双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。(将加载类的任务委托给父类加载器来加载
  2. 负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
  3. 缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。

自定义类加载器

自行实现类加载器来加载其他格式的类,对加载方式、加载数据的格式进行自定义处理,只要能通过 classloader 返回一个 Class 实例即可。这就大大增强了加载器灵活性。

可以试着实现一个可以用来处理简单加密的字节码的类加载器,用来保护class 字节码文件不被使用者直接拿来破解。

希望加载的一个 Hello 类:

java 复制代码
package jvm;

public class Hello {
    static {
        System.out.println("Hello Class Initialized!");
    }
}

这个 Hello 类非常简单,就是在初始化的时候,打印出来一句"Hello Class Initialized!"。

假设这个类的内容非常重要,我们不想把编译到得到的 Hello.class 给别人,但是我们还是想别人可以调用或执行这个类,应该怎么办呢?一个简单的思路是,把这个类的 class 文件二进制作为字节流先加密 一下,然后尝试通过自定义的类加载器来加载加密后的数据。为了演示简单,使用 jdk 自带的 Base64 算法,把字节码加密成一个文本。在下面这个例子里,实现一个 HelloClassLoader,它继承自 ClassLoader 类,但是希望它通过我们提供的一段 Base64 字符串,来还原出来,并执行Hello 类里的打印一串字符串的逻辑。

java 复制代码
package jvm;

import java.util.Base64;

// 继承 ClassLoader 类
public class HelloClassLoader extends ClassLoader {

    public static void main(String[] args) {
        try {
            // 直接通过class实例newInstance创建对象
            new HelloClassLoader().findClass("jvm.Hello").newInstance(); // 加载并初始化Hello类
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }
    }

    // 查找具有指定二进制名称(全限定名)的类,重写ClassLoader方法
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        String helloBase64 = "yv66vgAAADQAHwoABgARCQASABMIABQKABUAFgcAFwcAGAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2N" +
                "hbFZhcmlhYmxlVGFibGUBAAR0aGlzAQALTGp2bS9IZWxsbzsBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhDAAHAAgHABkMABoAGwEAGEhlb" +
                "GxvIENsYXNzIEluaXRpYWxpemVkIQcAHAwAHQAeAQAJanZtL0hlbGxvAQAQamF2YS9sYW5nL09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2" +
                "YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAgABAAcACA" +
                "ABAAkAAAAvAAEAAQAAAAUqtwABsQAAAAIACgAAAAYAAQAAAAMACwAAAAwAAQAAAAUADAANAAAACAAOAAgAAQAJAAAAJQACAAAAAAAJsgACEgO2AASxAAAAAQAK" +
                "AAAACgACAAAABgAIAAcAAQAPAAAAAgAQ";

        byte[] bytes = decode(helloBase64);	
        // 将字节数组转换为类class的实例
        return defineClass(name,bytes,0,bytes.length);
    }

    // 直接将Base64加密字段解析为字节数组	
    public byte[] decode(String base64){
        return Base64.getDecoder().decode(base64);
    }

}

自定义类加载器 继承自 ClassLoader,并覆盖了 findClass 方法,该方法用于根据类名查找和加载类的字节码

defineClass方法:将字节数组转换成class的实例,拿到class实例之后可以直接调用反射方法创建对象

直接执行main方法,控制台会输出Hello Class Initialized!字样。

成功执行了Hello类的代码,但是完全不需要有Hello这个类的class文件 。此外,需要说明的是两个没有关系的自定义类加载器之间加载的类是不共享的(只共享父类加载器,兄弟之间不共享),这样就可以实现不同的类型沙箱的隔离性,可以用多个类加载器,各自加载同一个类的不同版本,大家可以相互之间不影响彼此,从而在这个基础上可以实现类的动态加载卸载,热插拔的插件机制等,具体信息可以参考OSGi等模块化技术。

相关推荐
The_Ticker几秒前
CFD平台如何接入实时行情源
java·大数据·数据库·人工智能·算法·区块链·软件工程
程序猿阿伟几秒前
《C++ 实现区块链:区块时间戳的存储与验证机制解析》
开发语言·c++·区块链
傻啦嘿哟18 分钟前
如何使用 Python 开发一个简单的文本数据转换为 Excel 工具
开发语言·python·excel
大数据编程之光23 分钟前
Flink Standalone集群模式安装部署全攻略
java·大数据·开发语言·面试·flink
初九之潜龙勿用23 分钟前
C#校验画布签名图片是否为空白
开发语言·ui·c#·.net
爪哇学长36 分钟前
双指针算法详解:原理、应用场景及代码示例
java·数据结构·算法
Dola_Pan40 分钟前
C语言:数组转换指针的时机
c语言·开发语言·算法
ExiFengs40 分钟前
实际项目Java1.8流处理, Optional常见用法
java·开发语言·spring
paj12345678942 分钟前
JDK1.8新增特性
java·开发语言
IT古董1 小时前
【人工智能】Python在机器学习与人工智能中的应用
开发语言·人工智能·python·机器学习