深入学习理解JVM系列(六) -- 深入剖析虚拟机类加载机制
一、概述
- 类加载机制:将Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型
- 类型的加载、连接和初始化在程序运行期间完成——拓展性和灵活性
- Java动态扩展:运行期动态加载和动态连接
二、类加载的时机
- 类的生命周期(从被加载到内存,到卸载出内存):加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段
- 连接(Linking):验证、准备、解析
- 确定顺序:加载、验证、准备、初始化、卸载
不确定:解析,可以在初始化之后(动态绑定) - 必须初始化的情况(对类型的主动引用):
- 遇到new、getstatic、putstatic、invokestatic这四条字节码指令
- 能够生产这四条指令的Java代码:
- new关键字实例化对象
- 读取或设置一个类型的静态字段
- 调用一个类型的静态方法
- 使用Java.lang.reflect对类型进行反射调用,触发该类型初始化
- 初始化子类时,其父类还没有初始化,触发父类初始化(父接口不会,接口和类初始化的唯一区别)
- 虚拟机启动时,首先初始化主类(main()方法的类)
- JDK7新加入的动态语言支持,java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_p utStatic、REF_invokeStatic、 REF_newInvokeSpecial四种类型的方法句柄,且这个方法句柄对应的类没有进行过初始化,触发其初始化
- 接口中定义了被default关键字修饰的接口方法时,如果有这个接口的实现类发生了初始化,该接口要在其之前被初始化
- 被动引用不会触发初始化:
- 通过子类引用父类的静态字段,不会导致字类初始化,只会触发父类初始化
- 通过数组定义来引用类,不会导致该类的初始化(定义数组时,元素为该类对象)
- 引用常量时,不会触发该类初始化(因为常量在编译阶段会存入调用类的常量池中,所以本质上没有引用到定义常量的类)
三、类加载的过程
1. 加载(Loading)
- 目的:完成三个任务:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
- 获取二进制字节流:
- 从ZIP压缩包读取->JAR格式
- 从网络获取(Web Applet)
- 运行时计算生成(动态代理,java.lang.reflect.Proxy)
- 其他文件生成(JSP文件生成)
- 从数据库读取
- 从加密文件中获取
- 非数组类加载:
- 虚拟机内置的引导类(启动类)加载器加载
- 自定义类加载器(重写类加载器的findClass()或loadClass()方法)加载
- 数组类:
- 不通过类加载器创建,由Java虚拟机在内存中动态生成
- 数组的组件类型(去掉一个维度的类型)
- 引用类型:正常加载过程,加载组件类型,数组C将被标识在该类加载器的类名称空间上(类型与类加载器一起确定唯一性)
- 不是引用类型:Java虚拟机将该数组C标记为与引导类(启动类)加载器关联
- 数组类的可访问性与组件类型的可访问性一致(public)
- 加载完成后,二进制字节流将被存储在方法区中
- 加载阶段与连接阶段的部分动作(验证)是交叉进行的
2. 验证
- 目的:连接第一步,确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的约束要求,保证信息当作代码运行后不会危害虚拟机安全
- 验证阶段的工作量在类加载过程中占的比重最大
- 非必要执行,若代码被反复使用验证过,可以使用-Xverify:none参数关闭大部分验证措施
- 四个阶段的检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式规范,该阶段基于二进制字节流进行,验证完成后,字节流才能进入方法区存储
- 是否以魔数(0xCAFEBABE)开头
- 主、次版本号是否在虚拟机接受范围
- 常量池中的常量是否有不支持的常量类型(检查常量tag标志)
- 指向常量池的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
- 元数据验证(针对数据类型):对字节码描述的信息进行语义分析
- 该类是否有父类(除了java.lang.Object,都应该有父类)
- 父类是否继承了不允许被继承的类(final类)
- 非抽象类,是否实现了父类/接口要求实现的方法
- 类中的字段、方法是否与父类有矛盾(覆盖父类final字段;不符合规则的重载)
- 文件格式验证:验证字节流是否符合Class文件格式规范,该阶段基于二进制字节流进行,验证完成后,字节流才能进入方法区存储
- 字节码验证(最复杂:针对类的方法体即Class文件的Code属性):通过数据流分析和控制流分析,确定程序语义合法、符合逻辑
- 操作数栈的数据类型与指令代码序列一致
- 跳转指令不会跳转到方法体外
- 方法体中的类型转换有效
- 停机问题(Halting Problem):无法保证通过字节码验证的方法体一定没有问题
- 转移至javac编译期(方法体的Code属性新增了StackMapTable属性,检查该属性)
- 符号引用验证:发生在虚拟机将符号引用转化为直接引用时(在解析阶段),确保解析正常执行,验证该类是否缺少/禁止访问依赖的外部类、方法、字段等
- 通过字符串描述的全限定名是否能找到对应的类
- 指定类中是否存在符合方法的字段描述符
- 验证类、字段、方法的可访问性(private、protected、public、)
3. 准备
- 为类中定义的类变量(静态变量、static变量)分配内存并为类变量设置初始值(零值)
- JDK7以后(取消永久代),类变量随着Class对象存放在Java堆中
- 不包括实例变量内存分配,实例变量在对象实例化时,一起分配在Java堆中
- 类变量赋值操作在初始化阶段
4. 解析
- 将常量池中的符号引用转化为直接引用
- 符号引用:以一组符号(字面量)描述所引用的目标,与虚拟机的内存布局无关
- 直接引用:直接指向目标的指针、相对偏移量、间接定位的句柄,与虚拟机的内存布局相关
- 除了invokedynamic指令,虚拟机可以对第一次解析的结果进行缓存
- 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用
5. 初始化
- 执行类构造器clinit()方法
- clinit()方法由Javac编译器自动收集(收集顺序为源文件中出现顺序):
- 类中的所有类变量的赋值动作
- 静态语句块(static{}块)中的语句,只能访问定义之前的变量,后面会出现非法前向引用
- clinit()方法非必须,当类中没有上述两项时(类编赋值和静态语句块)
- clinit()方法与类的构造函数(实例构造器init()方法)不同,不需要显示调用父类构造器,父类的clinit()方法执行在子类的clinit()方法之前,所以java.lang.Object的clinit()方法第一个被执行
- 父类中的静态语句块优先于字类的变量赋值操作
- 接口没有静态语句块,执行接口的clinit()方法不需要先执行父接口的clinit()方法,接口的实现类在初始化时,也不会执行接口的clinit()方法
- 一个类的clinit()方法在多线程环境中必须被正确地加锁同步,如果多个线程同时区初始化一个类,只会有一个线程执行类的clinit()方法,其他线程需要阻塞等待
- 同一个类加载器下,一个类型只会被初始化一次
4. 类加载器(类的加载阶段)
类加载器(Class Loader):通过一个类的全限定名来获取描述该类的二进制字节流(虚拟机外部实现)
1. 类与类加载器
唯一性:任意一个类,都必须由加载它的类加载器和类本身一起确立其在JVM中是唯一的
- 每一个类加载器,都拥有一个独立的类名称空间
- 比较两个类是否相等的基础是,这两个类必须由同一个类加载器加载才行
2. 双亲委派模型
- 类加载架构:三层类加载器、双亲委派
- 三层类加载器:
- 启动类加载器(Bootstrap Class Loader 引导类加载器)
- 搜索范围:加载存放在<JAVA_HOME>\lib目录或者被-Xbootclasspath参数指定的路径中存放的类库
- C++语言实现,虚拟机自身一部分,只加载包为java、javax、sun等开头的类
- 使用null代表启动类加载器
- 扩展类加载器(Extension Class Loader 平台类加载器)
- 搜索范围:加载存放在<JAVA_HOME>\lib\ext目录或者被java.ext.dirs系统变量所指定路径中的类库(通用性类库)
- Java语言实现,虚拟机外部,由sun.misc.Launcher类的内部类ExtClassLoader类实现,继承自抽象类java.lang.ClassLoader
- 应用程序类加载器(Application Class Loader 系统类加载器)
- 搜索范围:加载用户类路径(ClassPath)上的类库
- Java语言实现,虚拟机外部,由sun.misc.Launcher类的内部类AppClassLoader类实现,继承自java.lang.ClassLoader,是ClassLoader类中的getSystem- ClassLoader()方法的返回值
- 若程序中没有自定义的类加载器,应用程序类加载器是程序中默认的类加载器
- 启动类加载器(Bootstrap Class Loader 引导类加载器)
- 双亲委派模型(Parents Delegation Model):
- 描述类加载器之间的层次关系
- 除了启动类加载器,其余的类加载器都有自己的父类加载器
- 父子关系不是继承关系,而是组合(Composition)关系,复用父类加载器代码
- 工作流程:
- 一个类加载器收到类加载请求
- 将请求委派给父类加载器
- 加载请求最终都会传送给顶层的启动类加载器
- 父类加载器完成加载请求
- 父类加载器无法完成加载请求(搜索范围没有找到这个类),将请求返回给子类加载器去加载(一层层传递)
- 优点:
- 优先级:Java中的类随着它的类加载器,具有了优先级的层次关系,越基础的类由越上层的加载器加载(java.lang.Object类都由启动类加载器加载)
- 一致性:当出现当用户编写的类与系统类重名时,会始终加载系统类,不会出现错误
- 保证Java程序稳定运作
- 实现简单,在java.lang.ClassLoader中的loadClass()方法中实现
- 先检查请求加载的类型是否已经被加载过
- 没有则调用父加载器的loadClass()方法
- 若父加载器为空(没有父加载器)则默认使用启动类加载器作为父加载器
- 父类加载器加载失败,抛出ClassNotFoundException异常(无法完成加载请求)
- 再调用自己的findClass()方法尝试进行加载
4. 破坏双亲委派模型
双亲委派模型不是一个具有强制性约束的模型
- 3次被破坏:
- 第一次被破坏:
- 问题:用户自定义类加载器出现在双亲委派模型引入之前
- 解决:在java.lang.ClassLoader中添加一个新的protected方法findClass(),并让用户编写类加载逻辑时去重写这个方法,而不是在loadClass()中编写代码
- 第二次被破坏:
- 问题:基础类型调用回用户代码(JNDI服务:由启动类加载器加载,但需要调用在应用程序的ClassPath下的JNDI服务提供者接口(SPI)的代码)
- 解决:线程上下文类加载器(Thread Context Class Loader),实现父类加载器请求字类加载器完成类加载
- 第三次被破坏
- 问题:用户对程序动态性(代码热替换Hot Swap、模块热部署Hot Deployment)的追求
- 解决:OSGI实现热部署,通过自定义类加载器,每一个程序模块(Bundle)都有一个自己的类加载器,需要更换Bundle时,连同类加载器一起换掉
- 第一次被破坏: