代码编译的结果从本地机器码转换为字节码, 是存储格式发展的一小步, 确实编程语言发展的一大步
概述
虚拟机把描述一个类的数据从 Class 文件加载到内存, 并对数据进行校验转换解析和初始化, 最终形成可以被虚拟机使用的 Java 类型, 这就是虚拟机的类加载机制
类加载的时机
类加载的整个生命周期包括: 加载验证准备解析初始化使用卸载 7 个过程, 其中验证准备和解析统称为连接
虚拟机没有对什么时候进行类的加载有强制约束, 但是对于初始化阶段, 虚拟机规范则是严格规定了有且只有 5 中情况必须立即对类进行初始化(加载验证准备和初始化自然得在初始化前完成):
遇到 newgetstaticputstatic 和 invokestatic 这四条字节码指令时, 如果类没有进行过初始化, 则需要触发器初始化 (初始化自然存在类的加载) 这四条指令最常见的场景: 使用 new 关键字实例化对象获取或设置一个类的静态字段 (被 final 修饰的除外) 的时候和代用一个类的静态方法时
使用 java.lang.reflect 包的方法对类进行反射调用的时候, 如果没有对类进行过初始化, 则触发初始化
当初始化一个子类时, 发现其父类没有初始化时, 需先触发父类的初始化
当虚拟机启动时, 用户需要指定一个要执行的主类 (含有 main 方法的类) 时, 虚拟机会先初始化这个类
当使用 JDK1.7 的动态语言支持时, 如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄, 并且这个方法句柄所对应的类没有进行过初始化, 则需要先触发其初始化
类加载的过程
加载
类加载阶段, 虚拟机需要完成以下 3 件事件:
通过一个类的全限定名获取定义该类的二进制字节流
将字节流代表的静态存储结构转换为方法区的运行时数据结构
在方法区中生成一个代表这个类的 java.lang.Class 对象, 作为方法区这个类的各种数据的访问入口
一个非数组类的加载, 既可以使用虚拟机提供的引导类加载器来完成, 也可以自定义类加载器完成 (即重写一个类加载器的 loadClass 方法) 对于数组类而言, 情况有所不一样, 数组类本身不通过类加载器创建, 而是由虚拟机自己创建
验证
验证是连接阶段的第一步, 目的是保证 Class 文件的字节流包含的信息符合当前虚拟机的要求, 保证输入的字节流能正确被解析并存储于方法区验证阶段主要包括以下 4 个阶段: 文件格式验证元数据验证字节码验证符号引用验证
文件格式验证
第一阶段验证字节流知否符合 Class 文件的格式规范, 并且能当前被虚拟机处理这一阶段主要验证点:
是否以魔数开头
版本是否在本机虚拟机处理范围内
指向常量的各种索引值是否有指向不存在的常量
......
元数据验证
第二阶段主要是对类的元数据信息进行语义分析, 保证不存在不符合 Java 语言规范的元数据验证点有:
这个类是否有父类
这个类是否继承了不允许贝继承的类(被 final 修饰的类)
如果这个类不是抽象类, 是否实现了其父类或继承的接口要求实现的方法
......
字节码验证
第三个阶段是验证过程中最复杂的阶段, 主要目的是通过数据流和控制流分析程序语义是否合法这个阶段对类的方法体进行校验, 保证被校验方法运行时不会危害虚拟机:
保证跳转指令不会跳转到方法体以外的字节码指令
保证方法体中的类型转换时正确的
......
符号引用验证
最后一个阶段发生在虚拟机将符号引用转换为直接引用的时候, 这个转换动作发生在连接的第三个阶段 - 解析符号引用可以看做是对常量池中各种符号引用进行校验, 验证点有:
符号引用中通过字符串描述的全限定名是否找到对应的类或接口
符号引用中的类字段方法的访问性是否可被当前类访问
......
准备
准备阶段是正式为类变量 (被 static 修饰的变量, 不包括实例变量) 分配内存并设置类变量初始值的阶段, 这些内存在方法区进行分配还有, 这里所说的初始值通常情况下是指数据类型的零值
public static int value = 123
value 变量在准备阶段后的初始值为 0, 而不是 123, 因为这个时候并未开始执行任何 java 方法, 而把 value 赋值为 123 的 putstatic 指令时程序被编译后, 存放于类构造器方法中的, 所以把 value 赋值为 123 的操作是在初始化阶段才执行的
上面提到, 通常情况下是数据类型的零值, 但是有一些特殊情况就不一样: 如果类变量被 final 修饰, 在准备阶段 , 类变量就会被初始化为指定的值
public static final int value = 123;
在准备阶段, value 的值就会被赋值为 123.
解析
解析阶段就是虚拟机将常量池内的符号引用替换为直接引用的过程虚拟机规范并未对什么时候进行解析阶段有规定, 只要求了 ** 在执行 anewarraycheckcastgetfieldgetstaticinstanceofinvokedynamicinvokeinterfaceinvokespecialinvokeestaticinvolevirtualldcldc_wmultianewarraynewputstatic 和 putfield 这 16 个用于操作符号引用的字节码指令之前, 先对他们所使用的符号引用进行解析 ** 所以逊尼基可以根据需要来判断是在类被加载器加载时就对符号引用进行解析或是在符号引用在被使用前才去解析
解析动作主要针对类或接口字段类方法接口方法方法类型方法句柄和调用点限定符 7 类符号引用进行解析
类或接口的解析
假设当前代码所处的类为 D, 如果想把一个从为解析过的符号引用 N 解析到一个类或接口 C 的直接引用, 虚拟机完成整个解析阶段的过程为分一下 3 步:
如果 C 是不是一个数组类型, 虚拟机将会把符号引用 N 的圈定类名传递给 D 的类加载器去加载这个类 C 在加载的过程中由于需要验证, 可能又会触发其他类的加载, 一档加载过程出现错误, 解析过程直接失败
如果 C 是一个数组类型, 数组元素也是对象类型的话, N 的描述符将会是类似[Ljava/lang/Integer 的形式那将会按照第一点的规则加载数组元素类型, 接着由虚拟机生成一个代表此数组维度和元素的数组对象
如果前面的步骤斗没有出现错误, 再解析完成前还需要进行符号引用的验证, 确认 D 是否具备对 C 的访问权限, 如果 D 没有对 C 的访问权限, 抛出 java.lang.IllegalAccessEroor 异常
字段解析
要解析一个未被解析过的字段的符号引用, 首先会对字段表内的 class_index 项索引的 CONSTANT_Class_info 符号引用解析, 也就是字段所属的类或接口的符号引用如果在解析这个类或接口的符号引用出现异常, 都会导致字段解析的失败如果这个类或接口解析成功, 将对这个字段所属的类或接口用 C 表示, 然后对 C 进行后续的字段搜索:
如果 C 本身就包含了简单名称和字段描述符都与目标字段相同的字段, 则返回这个字段的 直接引用, 查找结束
否则, 如果在 C 中实现了接口, 将会按照继承关系从下到上递归搜索每个接口和它的父接口, 然后按照步骤 1 去查找
否则, 如果 C 不是 object 类的话, 按照继承关系从下往上递归搜索其父类, 然后按照步骤 1 区查找
否则, 查找失败, 抛出 java.lang.NoSuchFieldError 异常
类方法解析
类方法的解析的第一个步骤与字段解析一样, 也需要先解析出类方法表的 claaa_index 索引的方法所属类或接口的符号引用, 如果解析成功, 用 C 表示这个类, 接下来虚拟机按照以下步骤进行类方法的搜索:
在类 C 中查找是否有简单名称和描述符都与目标匹配的方法, 如果有返回这个方法的直接引用, 查找结束
否则在类 C 的父类中递归查找
否则在类 C 的接口或父接口中查找
否则查找失败, 抛出 java.lang.NoSuchMethodError 异常
接口方法解析
接口方法解析与类方法解析类方法解析类似, 这里不再冗余
初始化
初始化阶段是类加载过程的最后一步在前面的类加载过程中, 除了在加载阶段可以自动定义加载器参与类的加载过程外, 其余的动作完全由虚拟机主导和控制到了初始化阶段, 才真正开始执行类中定义的 Java 代码
在准备阶段, 变量已经被赋值为系统要求的零值, 而在初始化阶段, 则根据程序员通过程序制定的主观计划去初始化类变量和其他资源或者说初始化阶段是执行类构造器方法的过程
方法是由编译器自动收集类中的所有类变量的复制操作和静态语句块 (static{}) 中的所有语句合并而生的静态语句块只能访问到定义在静态语句前的变量, 定义在它之后的变量, 只能在静态语句块中赋值而不能访问
方法与类的构造函数不同, 它不需要显示地调用父类构造器, 虚拟机会保证在子类的方法执行前执行父类的方法
方法并不是必须的, 如果一个类没有静态语句块, 也没有变量的赋值操作, 编译器可以不为这个类生成方法
虚拟机会保证一个类的方法在多线程环境下被正确地加锁同步, 如果多线程同时去初始化一个类, 只会有一个线程执行方法
类与类加载器
虚拟机设计团队把类加载阶段的通过一个类的全限定名获取此类的二进制字节流这个动作放到 Java 虚拟机外部实现, 以便让开发人员自己决定如何获取所需要的类, 实现这个动作的代码模块称为类加载器
对于任意一个类, 都需要加载它的类加载器和这个类本身异同确定其所在虚拟机的唯一性通俗地说, 比较两个类是否相等, 只有在相同的类加载器的前提下才有意义, 否则, 即使这两个类来自于同一个 Class 文件, 被同一个虚拟机加载, 只要类加载器不一样, 这两个类就不可能相等
双亲委派模型
从虚拟机的角度来讲, 只存在两种不同的类加载器: 一种是启动类加载器, 是虚拟机的一部分; 另一种是其他的类加载器, 独立于虚拟机之外, 而且全都继承于抽象类 java.lang.ClassLoader.
从开发人员的角度来看, 绝大部分 java 程序都会使用到一下 3 中系统提供的类加载器:
启动类加载器
这个类负责将放在 < JAVA_HOME>\lib 目录下的并且被虚拟机识别的 (按照文件名识别, 名字不符合的类库即使放在 lib 目录下也不会被加载) 类库加载到虚拟机内存中启动类加载器无法被 java 程序直接引用
拓展类加载器
它负责加载 < JAVA_HOME>\lib\ext 目录下的所有类库, 开发者可以直接使用拓展类加载器
引用程序类加载器
它负责加载用户类路径 (ClassPath) 下所指定的类库, 开发者可以直接使用如果程序中没有自定义自己的类加载器, 一般情况下这个就是程序默认的类加载器
双亲委派模型要求除了顶层的启动类加载器外, 其余的类加载器都应该有自己的父类加载器但是这里的类加载器之间的斧子关系不是以继承关系实现的, 而是使用组合关系来复用父类加载器
双亲委派模型的工作流程:
如果一个类加载器收到了一个类加载请求, 它不会自己去加载这个类, 而是将请求委派给它的父类加载器去加载, 每一个层次的类加载器都是这样, 因此所有的类加载请求最终都会落到顶层的启动类加载器, 只有当父类加载器五法加载这个请求时(它的搜索范围中没有找到所需的类), 子加载器才会尝试自己去加载使用双亲委派的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系
来源: https://juejin.im/post/5aa677a56fb9a028c06a7a8b