任何程序都需要加载到内存才能与 CPU 进行交流
同理, 字节码. class 文件同样需要加载到内存中, 才可以实例化类
ClassLoader 的使命就是提前加载. class 类文件到内存中
在加载类时, 使用的是 Parents Delegation Model(溯源委派加载模型)
Java 的类加载器是一个运行时核心基础设施模块, 主要是在启动之初进行类的加载, 链接, 初始化
Java 类加载过程
第一步, Load 阶段
读取类文件产生二进制流, 并转为特定数据结构, 初步校验 cafe babe 魔法数, 常量池, 文件长度, 是否有父类等, 然后创建对应类的 java.lang.Class 实例
第二步, Link 阶段
包括验证, 准备, 解析三个步骤
验证是更详细的校验, 比如 final 是否合规, 类型是否正确, 静态变量是否合理等
准备阶段是为静态变量分配内存, 并设定默认值, 解析类和方法确保类与类之间的相互引用正确性, 完成内存结构布局
第三步, Init 阶段
执行类构造器 < clinit> 方法, 如果赋值运算是通过其他类的静态方法来完成的, 那么会马上解析另外一个类, 在虚拟机栈中执行完毕后通过返回值进行赋值
类加载是一个将. class 字节码文件实例化成 Class 对象并进行相关初始化的过程.
在这个过程中, JVM 会初始化继承树上还没有被初始化过的所有父类, 并且会执行这个链路上所有未执行过的静态代码块, 静态变量赋值语句等.
某些类在使用时, 也可以按需由类加载器进行加载.
全小写的 class 是关键字, 用来定义类
而首字母大写的 Class, 它是所有 class 的类
这句话理解起来有难度, 类已经是现实世界中某种事物的抽象, 为什么这个抽象还是另外一个类 Class 的对象?
示例代码如下:
● 第 1 处说明:
Class 类下的 newInstance()在 JDK9 中已经置为过时, 使用
getDeclaredConstructor().newInstance()
的方式
着重说明一下 new 与 newInstance 的区别
new 是强类型校验, 可以调用任何构造方法, 在使用 new 操作的时候, 这个类可以没有被加载过
而 Class 类下的 newInstance 是弱类型, 只能调用无参构造方法
如果没有默认构造方法, 就拋出
InstantiationException
异常;
如果此构造方法没有权限访问, 则拋
IllegalAccessException
异常
Java 通过类加载器把类的实现与类的定义进行解耦, 所以是实现面向接口编程, 依赖倒置的必然选择.
● 第 2 处说明:
可以使用类似的方式获取其他声明, 如注解, 方法等
类的反射信息
● 第 3 处说明: private 成员在类外是否可以修改?
通过 setccessible(true), 即可使用 Class 类的 set 方法修改其值
如果没有这一步, 则抛出如下异常:
回到类加载中, 类加载器是如何定位到具体的类文件并读取的呢?
类加载器存在权力等级制度
最高的一层是家族中威望最高的 Bootstrap, 它是在 JVM 启动时创建的, 通常由与操作系统相关的本地代码实现, 是最根基的类加载器, 负责装载最核心的 Java 类, 比如 Object,System, String 等
第二层是在 JDK9 版本中, 称为
Platform ClassLoader
, 即平台类加载器, 用以加载一些扩展的系统类, 比如 xml, 加密, 压缩相关的功能类等
JDK9 之前的加载器是
Extension ClassLoader
第三层是
Application ClassLoader
的应用类加载器, 主要是加载用户定义的 CLASSPATH 路径下的类
第二, 三层类加载器为 Java 语言实现, 用户也可以自定义类加载器
查看本地类加载器的方式如下:
在 JDK8 环境中, 执行结果如下
AppClassLoader 的 Parent 为 Bootstrap, 它是通过 C/C++ 实现的, 并不存在于 JVM 体系内, 所以输出为 null
低层次的当前类加载器, 不能覆盖更高层次类加载器已经加载的类
如果低层次的类加载器想加载一个未知类, 要非常礼貌地向上逐级询问:"请问, 这个类已经加载了吗?"
被询问的高层次类加载器会自问两个问题
我是否已加载过此类
如果没有, 是否可以加载此类
只有当所有高层次类加载器在两个问题的答案均为 "否" 时, 才可以让当前类加载器加载这个未知类
左侧绿色箭头向上逐级询问是否已加载此类, 直至 Bootstrap ClassLoader, 然后向下逐级尝试是否能够加载此类, 如果都加载不了, 则通知发起加载请求的当前类加载器, 准予加载
在右侧的三个小标签里, 列举了此层类加载器主要加载的代表性类库, 事实上不止于此
通过如下代码可以查看 Bootstrap 所有已加载类库
执行结果
Bootstrap 加载的路径可以追加, 不建议修改或删除原有加载路径
在 JVM 中增加如下启动参数, 则能通过 Class.forName 正常读取到指定类, 说明此参数可以增加 Bootstrap 的类加载路径:
-Xbootclasspath/a:/Users/sss/book/ easyCoding/byJdk11/src
如果想在启动时观察加载了哪个 jar 包中的哪个类, 可以增加
-XX:+TraceClassLoading
此参数在解决类冲突时非常实用, 毕竟不同的 JVM 环境对于加载类的顺序并非是一致的
有时想观察特定类的加载上下文, 由于加载的类数量众多, 调试时很难捕捉到指定类的加载过程, 这时可以使用条件断点功能
比如, 想查看 HashMap 的加载过程, 在 loadClass 处打个断点, 并且在 condition 框内输入如图
设置条件断点
在学习了类加载器的实现机制后, 知道双亲委派模型并非强制模型, 用户可以自定义类加载器, 在什么情况下需要自定义类加载器呢?
隔离加载类
在某些框架内进行中间件与应用的模块隔离, 把类加载到不同的环境
比如, 阿里内某容器框架通过自定义类加载器确保应用中依赖的 jar 包不会影响到中间件运行时使用的 jar 包
修改类加载方式
类的加载模型并非强制, 除 Bootstrap 外, 其他的加载并非一定要引入, 或者根据实际情况在某个时间点进行按需进行动态加载
扩展加载源
比如从数据库, 网络, 甚至是电视机机顶盒进行加载
防止源码泄露
Java 代码容易被编译和篡改, 可以进行编译加密. 那么类加载器也需要自定义, 还原加密的字节码.
实现自定义类加载器的步骤
继承 ClassLoader
重写 findClass()方法
调用 defineClass()方法
一个简单的类加载器实现的示例代码如下
由于中间件一般都有自己的依赖 jar 包, 在同一个工程内引用多个框架时, 往往被迫进行类的仲裁
按某种规则 jar 包的版本被统一指定, 导致某些类存在包路径, 类名相同的情况, 就会引起类冲突, 导致应用程序出现异常
主流的容器类框架都会自定义类加载器, 实现不同中间件之间的类隔离, 有效避免了类冲突.
1 加载的定位
"加载" 是 "类加载"(Class Loading)过程的第一步
1.1 加载的过程
在加载的过程中, JVM 主要做 3 件事情
通过一个类的全限定名来获取定义此类的二进制字节流(class 文件)
在程序运行过程中, 当要访问一个类时, 若发现这个类尚未被加载, 并满足类初始化的条件时, 就根据要被初始化的这个类的全限定名找到该类的二进制字节流, 开始加载过程
将这个字节流的静态存储结构转化为方法区的运行时数据结构
在内存中创建一个该类的 java.lang.Class 对象, 作为方法区该类的各种数据的访问入口
程序在运行中所有对该类的访问都通过这个类对象, 也就是这个 Class 对象是提供给外界访问该类的接口
1.2 加载源
JVM 规范对于加载过程给予了较大的宽松度. 一般二进制字节流都从已经编译好的本地 class 文件中读取, 此外还可以从以下地方读取
zip 包
Jar,War,Ear 等
其它文件生成
由 JSP 文件中生成对应的 Class 类.
数据库中
将二进制字节流存储至数据库中, 然后在加载时从数据库中读取. 有些中间件会这么做, 用来实现代码在集群间分发
网络
从网络中获取二进制字节流. 典型就是 Applet.
运行时计算生成
动态代理技术, 用 PRoxyGenerator.generateProxyClass 为特定接口生成形式为 "*$Proxy" 的代理类的二进制字节流.
1.3 类和数组加载过程的区别
数组也有类型, 称为 "数组类型". 如:
String[] str = new String[10];
这个数组的数组类型是 Ljava.lang.String, 而 String 只是这个数组的元素类型
当程序在运行过程中遇到 new 关键字创建一个数组时, 由 JVM 直接创建数组类, 再由类加载器创建数组中的元素类型.
而普通类的加载由类加载器创建. 既可以使用系统提供的引导类加载器, 也可以由用户自定义的类加载器完成 (即重写一个类加载器的 loadClass() 方法)
1.4 加载过程的注意点
JVM 规范并未给出类在方法区中存放的数据结构
类完成加载后, 二进制字节流就以特定的数据结构存储在方法区中, 但存储的数据结构是由虚拟机自己定义的, 虚拟机规范并没有指定
JVM 规范并没有指定 Class 对象存放的位置
在二进制字节流以特定格式存储在方法区后, JVM 会创建一个 java.lang.Class 类的对象, 作为本类的外部访问接口
既然是对象就应该存放在 Java 堆中, 不过 JVM 规范并没有给出限制, 不同的虚拟机根据自己的需求存放这个对象
HotSpot 将 Class 对象存放在方法区.
加载阶段和链接阶段是交叉的
类加载的过程中每个步骤的开始顺序都有严格限制, 但每个步骤的结束顺序没有限制. 也就是说, 类加载过程中, 必须按照如下顺序开始:
加载 -> 链接 -> 初始化
但结束顺序无所谓, 因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉
2 验证
验证阶段比较耗时, 它非常重要但不一定必要(因为对程序运行期没有影响), 如果所运行的代码已经被反复使用和验证过, 那么可以使用 - Xverify:none 参数关闭, 以缩短类加载时间
2.1 验证的目的
保证二进制字节流中的信息符合虚拟机规范, 并没有安全问题
2.2 验证的必要性
虽然 Java 语言是一门安全的语言, 它能确保程序猿无法访问数组边界以外的内存, 避免让一个对象转换成任意类型, 避免跳转到不存在的代码行. 也就是说, Java 语言的安全性是通过编译器来保证的.
但是我们知道, 编译器和虚拟机是两个独立的东西, 虚拟机只认二进制字节流, 它不会管所获得的二进制字节流是哪来的, 当然, 如果是编译器给它的, 那么就相对安全, 但如果是从其它途径获得的, 那么无法确保该二进制字节流是安全的.
通过上文可知, 虚拟机规范中没有限制二进制字节流的来源, 在字节码层面上, 上述 Java 代码无法做到的都是可以实现的, 至少语义上是可以表达出来的, 为了防止字节流中有安全问题, 需要验证!
2.3 验证的过程
文件格式验证
验证字节流是否符合 Class 文件格式的规范, 并且能被当前的虚拟机处理.
本验证阶段是基于二进制字节流进行的, 只有
通过本阶段验证, 才被允许存到方法区
后面的三个验证阶段都是基于方法区的存储结构进行, 不会再直接操作字节流.
通过上文可知, 加载开始前, 二进制字节流还没进方法区, 而加载完成后, 二进制字节流已经存入方法区
而在文件格式验证前, 二进制字节流尚未进入方法区, 文件格式验证通过之后才进入方法区
也就是说, 加载开始后, 立即启动了文件格式验证, 本阶段验证通过后, 二进制字节流被转换成特定数据结构存储至方法区中, 继而开始下阶段的验证和创建 Class 对象等操作
这个过程印证了: 加载和验证是交叉进行的
元数据验证
对字节码描述信息进行语义分析, 确保符合 Java 语法规范.
字节码验证
本阶段是验证过程的最复杂的一个阶段.
本阶段对方法体进行语义分析, 保证方法在运行时不会出现危害虚拟机的事件.
字节码验证将对类的方法进行校验分析, 保证被校验的方法在运行时不会做出危害虚拟机的事, 一个类方法体的字节码没有通过字节码验证, 那一定有问题, 但若一个方法通过了验证, 也不能说明它一定安全
符号引用验证
发生在 JVM 将符号引用转化为直接引用的时候, 这个转化动作发生在解析阶段, 对类自身以外的信息进行匹配校验, 确保解析能正常执行.
3 准备
完成两件事情
为已在方法区中的类的静态成员变量分配内存
为静态成员变量设置初始值
初始值为 0,false,null 等
public static final int value = 123;
准备阶段后 a 的值为 0, 而不是 123, 要在初始化之后才变为 123, 但若被 final 修饰的常量如果有初始值, 那么在编译阶段就会将初始值存入 constantValue 属性中, 在准备阶段就将 constantValue 的值赋给该字段(此处将 value 赋为 123).
4 解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程.
5 初始化
真正开始执行类中定义的 Java 程序代码(或者说是字节码)
初始化阶段就是执行类构造器 clinit()的过程.
clinit()方法由编译器自动产生, 收集类中 static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句. 在准备阶段, 类中静态成员变量已经完成了默认初始化, 而在初始化阶段, clinit()方法对静态成员变量进行显示初始化.
5.1 初始化过程的注意点
clinit()方法是 IDE 自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的, IDE 收集的顺序是由语句在源文件中出现的顺序所决定的.
静态代码块只能访问到出现在静态代码块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问.
- public class Test {
- static {
- i=0;
- System.out.println(i);// 编译失败:"非法向前引用"
- }
- static int i = 1;
- }
实例构造器 init()需要显示调用父类构造函数, 而类的 clinit()不需要调用父类的类构造函数, 虚拟机会确保子类的 clinit()方法执行前已经执行完毕父类的 clinit()方法. 因此在 JVM 中第一个被执行的 clinit()方法的类肯定是 java.lang.Object.
如果一个类 / 接口中没有静态代码块, 也没有静态成员变量的赋值操作, 那么编译器就不会为此类生成 clinit()方法.
接口也需要通过 clinit()方法为接口中定义的静态成员变量显示初始化.
接口中不能使用静态代码块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 clinit()方法. 不同的是, 执行接口的 clinit()方法不需要先执行父接口的 clinit()方法. 只有当父接口中的静态成员变量被使用到时才会执行父接口的 clinit()方法.
虚拟机会保证在多线程环境中一个类的 clinit()方法别正确地加锁, 同步. 当多条线程同时去初始化一个类时, 只会有一个线程去执行该类的 clinit()方法, 其它线程都被阻塞等待, 直到活动线程执行 clinit()方法完毕.
其他线程虽会被阻塞, 只要有一个 clinit()方法执行完, 其它线程唤醒后不会再进入 clinit()方法. 同一个类加载器下, 一个类型只会初始化一次.
来源: http://www.jianshu.com/p/e635f00d2dd8