【安卓】扒一扒,类加载的幕后
前些天写了《Java字节码「随身手册」》及《如何读懂晦涩的 Class 文件》两篇文章,主要从字节码方面了解类的构成。
这篇文章则从 JVM 加载字节码文件的时机及类加载流程来认识内存中类的生命周期。
阅读本文你能收获到
- 掌握 JVM 何时加载一个类
- 掌握类的生命周期
类加载时机
Java源文件通过编译器编译转化为Class文件,这些 Class 文件包含着 JVM 虚拟机指令及程序运行的逻辑。
而类加载器根据类文件全限定名来读取文件二进制字节流,将其存放于运行时方法区内,通过在堆内创建一个 java.lang.Class 对象来标识类在运行时内存的数据结构。
那么在程序执行过程中,何时会尝试读取 Class文件进行类加载呢?
有以下几种场景:
- 遇到 new,getstatic,putstatic,invokestatic 4个指令,对应程序中 new 实例对象,读取或设置静态非 final 字段,调用静态方法。
- 使用 java.lang.reflect 包的方法对类进行反射调用,对应调用 Class#forName 等方法;
- 初始化一个类但其父类未初始化时触发父类初始化;
- JVM 启动时用户指定一个要执行的主类(包含 main 方法的类),会先初始化该类;
- 使用 JDK1.7 java.lang.invoke.MethodHandle 实例且解析结果为 REF_getStatic,REF_putStatic,REF_invokeStatic 方法句柄且该句柄对应的类无初始化时触发其初始化;
- 使用 JDK1.8 定义一个带有默认方法的接口时如该接口实现类发生初始化,会先初始化该接口。
说白了就是:如果类A在代码逻辑中使用类B的 static final 变量,类B也不会尝试加载。
知道类何时被加载,那么类被加载过程经历了什么?
类生命周期
上图是我简单梳理类加载的流程,大致分为 5 个大阶段:
- Loading 加载
Linking 连接
- Verification 验证
- Preparation 准备
- Resolution 解析
- Initialization 初始化
- Using 使用
- Unloading 卸载
Loading(加载)
通过加载Class文件二进制字节数据并将其映射成程序可读 Class 对象结构。
加载流程大致经历以下步骤:
- 通过类的全限定名来获取定义类的二进制字节流,字节流可来源于任何地方,不局限于从Java源码编译而来,比如网络传输。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区这个类的各种数据访问入口。
这个阶段的工作由类加载器控制字节流的获取方法来完成,但是数组类是由虚拟机直接创建,但是其元素类型还是需要类加载器加载。
Linking(连接)
连接阶段包含 Verification (验证),Preparation(准备) 和 Resolution(解析)。该阶段的部分步骤可能存在加载流程中。
下面三个流程启动是按照前后顺序的,但是每个流程之间可能会出现重叠。
Verification(验证)
确保Class文件的字节流中包含的信息符合当前虚拟机的要求并且不会危害虚拟机。
校验内容大致分成 4 个部分
- 文件格式验证。
主要用于校验字节流是否符合Class文件格式的规范,这部分可以参考 Class文件结构表 这篇文章。校验内容也包含 是否以魔术OxCAFEBABE开头,主次版本号是否在当前虚拟机处理范围之内 等等。字节码只有经过这个阶段的校验后才会进入方法区存储结构。 - 元数据验证。主要用于校验类的元数据信息语义是否正常,保证不存在不符合Java语言规范的元数据信息。比如除了 java.lang.Object 之外的类是否有父类;如果不是抽象类是否实现了其父类或者接口中要求子类覆盖实现的所有方法等。
- 字节码验证。主要通过数据流和控制流分析,确定程序语义是否合法合理,针对类的方法体进行校验分析,保证方法在运行时不会作出危害虚拟机安全的事件。比如保证跳转指针不会跳转到方法体外的字节码指令上;保证任意时刻操作栈的数据类型和指令代码序列都能配合工作,不会出现栈顶放一个 int 类型却按照 long 类型加载到本地变量表中等。
- 符号引用验证。发生在虚拟机将符号引用转化为直接引用(解析阶段)时进行校验。校验的内容不局限于符号引用中通过字符串描述的全限定名是否能找到对应的类;在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段等。确保解析阶段中解析行为能正常执行。
Preparation(准备)
为类变量分配内存并设置类变量初始值。
比如声明以下静态变量
public static int A = 1;public static final int B = 2;
在准备阶段为类A申请内存之后初始化值为 0,其真正赋值 1 要在类构造器 < clinit >() 中完成。而 B 就不一样了,在编译时 javac 就会为类B生成ConstantValue属性,该阶段进行赋值为 2(字段属性表)。
Resolution(解析)
将常量池中符号引用替换成直接引用。
符号引用是一组用来描述所引用目标的字面量,其形式已经明确定义在Class文件格式中。
直接引用则可为指向目标的指针,相对偏移量或一个能间接定位到目标的句柄。
前者与虚拟机实现的内存布局无关,后者有关。如果有了直接引用,那么其目标必定存在内存中。
只要用到以下 16 个操作符号引用的字节码指令,则执行指令之前先对所引用的符号引用进行解析:
anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield,putstatic。
因此虚拟机可根据实际需要来判读到底是在类被加载器加载时就对常量池的符号引用进行解析,还是等到一个符号引用将要被使用前才进行解析。
解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类,分别对应:
CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info,CONSTANT_InterfaceMethodref_info,CONSTANT_MethodType_info,CONSTANT_MethodHandle_info,CONSTANT_InvokeDynamic_info,
感兴趣的朋友可参考 深入理解Java虚拟机:JVM高级特定及最佳实践一书或官方文档中涉及的解析内容,篇幅较大这里不做阐述。
Initialization(初始化)
执行类中定义的Java代码,根据程序定义的计划来初始化类变量和其他资源。
编译器自动收集类中所有类变量赋值行为和静态语句块,合并产生clinit方法,其方法内语句的执行顺序和源文件中出现的顺序一致。
虚拟机保证这份代码在单/多线程环境中被正确且执行一次,在子类的 clinit 方法调用之前父类 clinit 方法已被调用。
值得一提的是,接口也会生成 clinit 方法,但和类不同。接口执行 clinit 方法不需要先执行父接口的 clinit 方法,只有父接口中定义的变量使用时父接口才会初始化 clinit 方法。
clinit方法是一个线程安全的操作,能保证多线程环境下被正确地加锁,同步。如果多个线程同时初始化一个类,则只有一个线程去执行并且其他线程需要阻塞等待。
Using(使用)
类的使用阶段,即类的Class对象存在之后到被卸载之前的这段时间。
这个阶段程序可能产生大量的类的实例对象并执行对象行为。 哪怕内存中没有任何类的实例对象也不一定会被卸载。
Unloading(卸载)
类被加载之后, Class 和 Meta 信息会被存放在PermGen space区域, 该区域在程序运行期间一般是永久保存的。
如果想要卸载一个类, 必须满足以下三个条件。
- 该类所有的实例都已被 GC 回收;
- 加载该类的 ClassLoader 实例已被 GC 回收;
- 该类的 java.lang.Class 对象没被引用。
JVM 规范中指出Bootstrap Classloader在运行期间是不可能被卸载的,Extension Classloader加载的类型在运行期间基本也不太可能被卸载。
这是因为程序会直接或间接范围到某些标准扩展类如 javax.xx 开头的类,如果开发者自定义类加载器用来加载了这些类, 除非这些类的上下文非常简单且,才可能借助虚拟机垃圾回收功能尝试卸载。
尽管卸载的条件很苛刻, 但是还是存在可能性。由于 GC 的时机并不可预测, 所以卸载Class也是不可控的。
以上是 【安卓】扒一扒,类加载的幕后 的全部内容, 来源链接: utcz.com/a/100248.html