本文是个人JVM学习笔记整理,学习顺序主要基于 黑马程序员的JVM视频 相关知识点参考 深入理解Java虚拟机(第三版) 欢迎大家讨论交流并指正错误。
本篇笔记为类加载篇2,涉及类加载过程、类加载器 及JVM 运行期间代码优化。
前文:
【JVM】学习笔记1——JVM基本概念和结构
【JVM】学习笔记2——垃圾回收基本概念与垃圾回收算法
【JVM】学习笔记3——垃圾回收器及调优
【JVM】学习笔记4——字节码指令 及 编译器代码处理
3. 类加载阶段 重要
3.1. 加载
将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
-
_java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用(java通过这个访问c++类)
-
_super 即父类
-
_fields 即成员变量
-
_methods 即方法
-
_constants 即常量池
-
_class_loader 即类加载器
-
_vtable 虚方法表
-
_itable 接口方法表
注意:
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的
类镜像和堆中的类对象的关系:
- instanceKlass 这样的 元数据 是存储在方法区(1.8 后的元空间内),但 _java_mirror是存储在堆中
- 堆中的Person.class就是 _java_mirror(类镜像),持有元空间中Klass的内存地址。Klass中的_java_mirror也持有Person.class的内存地址。
- new Person实例化对象时,对象头中包含class地址,找到Person.class,再找到元空间的中的klass
3.2. 链接
3.2.1. 验证
验证字节码是否符合JVM规范,安全性检查。(如魔数部分是否符合规范CAFABABE)
3.2.2. 准备
为静态 变量分配空间,设置默认值
-
static 变量在 JDK 7 之前存储于 instanceKlass 末尾(存储在方法区中),从 JDK 7 开始,存储于 _java_mirror 末尾(存储在堆中)
-
static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
-
如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段 值就确定了,赋值在准备阶段完成
-
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
3.2.3. 解析
将常量池中的符号引用解析为直接引用
在未解析的类张,常量池中的类对象(方法、属性)仅仅是一个声明,而没有对象的实例化引用。
解析会将类 常量池中的对象,链接到实例化的引用上。
3.3. 初始化
初始化即调用 <cinit>()V ,虚拟机会保证这个类的『构造方法』的线程安全
3.3.1. 初始化的时机
类初始化是懒惰的
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
会导致初始化的情况:
-
main 方法所在的类,总会被首先初始化
-
首次访问这个类的静态变量或静态方法时(如访问A.a);
-
子类初始化,如果父类还没初始化,会引发父类的初始化(如访问B.c, A\B都会被初始化)
-
通过子类访问父类的静态变量,只会触发父类的初始化,子类不会初始化 (如访问B.a; A会被初始化,B不会被初始化)
-
Class.forName 会初始化类
-
new 会导致初始化
不会导致类初始化的情况
-
访问类的 static final 静态常量(基本类型和字符串)不会触发初始化(链接准备阶段就完成了静态常量的赋值,所以不需要初始化)
如访问B.b -
类对象.class 不会触发初始化(这里访问的是_java_mirror,加载阶段就生成了)
如访问 B.class -
创建该类的数组不会触发初始化(只分配了空间)
-
类加载器的 loadClass 方法
-
Class.forName 的参数 2 为 false 时
3.3.2. 懒惰初始化单例模式
懒惰的:使用时才创建实例,而不会提前创建实例
单例模式:整个jvm中,类的实例对象只有一个
利用了类加载的特性,只有在第一次使用时候才会进行初始化,可以通过静态内部类初始化。
如下代码中:
class Singleton {
public static void test() {
System.out.println("test");
}
private Singleton() {}
private static class LazyHolder{
private static final Singleton SINGLETON = new Singleton();
static {
System.out.println("lazy holder init");
}
}
public static Singleton getInstance() {
return LazyHolder.SINGLETON;
}
}
如果没有用到getInstance()方法,就不会用到LazyHolder类,即不会对LazyHolder进行加载、链接、初始化,那么Singleton()构造方法就不会执行,此对象实例就不会被创建。
比如只访问test()静态方法,就不会造成对象实例的创建。
那么就可以通过getInstance()创建单一的实例。
4. 类加载器
4.1. 类型
名称 | 加载哪的类 | 说明 |
---|---|---|
启动类加载器 Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
拓展类加载器 Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
应用类加载器 Application ClassLoader | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
4.2. 启动类加载器
这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。
-Xbootclasspath/a 参数:
- -Xbootclasspath 表示设置 bootclasspath
- 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
- 可以用这个办法替换核心类
如:
java -Xbootclasspath:<new bootclasspath>
java -Xbootclasspath/a:<追加路径>
java -Xbootclasspath/p:<追加路径>
4.3. 扩展类加载器
这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。
JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。
注意
如果在拓展类路径目录ext下有同名的类,那么类加载时候会通过拓展类加载器找到此同名的类进行加载
4.4. 双亲委派模式
指调用类加载器的 loadClass 方法时,查找类的规则
双亲委派:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
即总结来说:
自底向上委派加载(检查有没有加载)直到最顶层的启动类加载器,自顶向下反馈是否加载。
类加载的源码:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
//检查是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//如果有上级,尝试通过上级加载此类
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//如果上级是null,证明上级是启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
//如果仍然未被加载,证明上级没有此类
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 此加载器尝试自己加载,这里可以重写findClass类自定义加载
c = findClass(name);
//记录耗时
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4.5. 破坏双亲委派—线程上下文类加载器
双亲委派模式被破坏其实出现了三次
1、第一次被破坏是在双亲委派出现前就存在的,在最早版本的jdk中就有了类加载器的概念和抽象类。为了兼容已有代码,未ClassLoader类中添加了findClass方法,引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。
2、双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变 的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
- 比如JDBC驱动加载时候,启动类首先需要加载DriverManager,来实现驱动的加载,但是JDBC需要调用各个厂商实现的驱动类,这些类不在启动类加载路径下,因此启动类加载器无法直接加载这些类。
引入线程上下文类加载器,在启动类加载器中通过 线程上下文类加载器,去请求子类加载器(应用类加载器)完成类加载的行为
这种行 为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,
Java中涉及服务提供者接口(Service Provider Interface,SPI)的加载基本上都采用这种方式来完成,例如JNDI、 JDBC、JCE、JAXB和JBI等。
3、第三次“被破坏”是由于用户对程序动态性的追求而导致的,如代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
4.6. 自定义类加载器
4.6.1. 什么时候需要
1)想加载非 classpath 路径中的类文件(加载任意路径)
2)框架设计,通过接口来使用不同的实现,达到解耦软件的目的
3)同一个类可能有新旧版本,希望这些类隔离,不同版本的同名类都可以加载,常见于 tomcat 容器
4.6.2. 步骤
-
继承 ClassLoader 父类
-
要遵从双亲委派机制,重写 findClass 方法
注意不是重写 loadClass 方法,否则不会走双亲委派机制 -
在findClass 中读取类文件的字节码
-
调用父类的 defineClass 方法来加载类
-
使用者调用该类加载器的 loadClass 方法
4.6.3. 示例
编写类加载器,加载存在的类文件:
class MyClassLoader extends ClassLoader {
@Override // 重写findClass方法,name 就是类名称
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = "e:\\myclasspath\\" + name + ".class";
try {
//二进制字节流
ByteArrayOutputStream os = new ByteArrayOutputStream();
Files.copy(Paths.get(path), os);
// 得到字节数组
byte[] bytes = os.toByteArray();
// byte[] -> *.class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException("类文件未找到", e);
}
}
}
注意:
-
使用同一个类加载器的实例化 加载同一个类,得到的比较结果是true。
-
使用不同的类加载器实例化,加载同一个类,得到的结果是false。
这里会认为两个类不同,就可以实现类间相互隔离。
-
唯一确定类的方式:包名、类名一致,且类加载器也相同。
MyClassLoader classLoader = new MyClassLoader();
Class<?> c1 = classLoader.loadClass("MapImpl1");
Class<?> c2 = classLoader.loadClass("MapImpl1");
System.out.println(c1 == c2); //true
MyClassLoader classLoader2 = new MyClassLoader();
Class<?> c3 = classLoader2.loadClass("MapImpl1");
System.out.println(c1 == c3); // false 类加载器不同,类也不同,即相互隔离不会产生冲突
c1.newInstance();
5. 运行期优化
5.1. 即时编译
5.1.1. 分层编译
JVM 将执行状态分成了 5 个层次:
-
0 层,解释执行(Interpreter)(字节码加载到解释器,逐字节解释执行)
-
1 层,使用 C1 即时编译器编译执行(不带 profiling)
-
2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
-
3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
-
4 层,使用 C2 即时编译器编译执行(C2比C1优化更彻底)
当同样的字节码执行了多次(反复调用),达到了一个阈值,那么JVM的执行就会由解释执行进化到即时编译器执行。
profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等。
即时编译器(JIT)与解释器的区别:
-
解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
-
JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
-
解释器是将字节码解释为针对所有平台都通用的机器码
-
JIT 会根据平台类型,生成平台特定的机器码
但并不需要把所有代码都编译为机器码
- 对于不常用的代码(占大部分,且执行次数少),无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;
- 对于热点代码(少部分,但执行次数多),则可以将其编译成机器码,以达到理想的运行速度。
- 执行效率: Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由来),优化之。
逃逸分析:
对于以下代码
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n",i,(end - start));
}
JVM会采用一种优化手段——逃逸分析,即发现新建的对象是否逃逸,是否在循环外被调用。如果发现未使用,jvm会通过C2即时编译器优化,直接替换这段字节码不再生成Object对象。
可以使用 -XX:-DoEscapeAnalysis 参数关闭逃逸分析。
5.1.2. 方法内联
主要目的有两个:
-
一是去除方法调用的成本(如查找方法版本、建立栈帧等);
-
二是为其他优化建立良好的基础。
因此各种编译器一般都会把内联优化放在优化序列最靠前的位置。
所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置。
如果发现一个方法是热点方法,并且代码长度不太长时,会进行内联。
如一个方法:
private static int square(final int i) {
return i * i;
}
调用他:
System.out.println(square(9));
进行方法内联后变为:
System.out.println(9 * 9);
如果每次都调用的是9,还可以认为他是常量不变化,进而做常量折叠的优化:
System.out.println(81);
5.1.3. 字段优化
public void test1() {
for (int i = 0; i < elements.length; i++) {
doSum(elements[i]);
}
}
static void doSum(int x) {
sum += x;
}
在允许doSum方法进行内联时,会将test1优化为以下代码顺序:
public void test1() {
// elements.length 时 首次读取会将elements对象缓存到局部变量 -> int[] local
for (int i = 0; i < elements.length; i++) {
// 后续 999 次 求长度,直接从 local 中取
sum += elements[i]; // 1000 次取下标 i 的元素 也直接从 local 中取
}
}
在方法中会将elements对象缓存到局部变量中,便于循环中的调用,如果没有进行方法内联,elements也不会进行缓存
上述代码相当于手动进行elements对象缓存优化
public void test2() {
int[] local = this.elements;
for (int i = 0; i < local.length; i++) {
doSum(local[i]);
}
}
for-each 方法会在编译期进行创建局部变量存储elements对象,相当于elements对象缓存优化
public void test3() {
for (int element : elements) {
doSum(element);
}
}
对于三种情况进行测试,计算其分数和运行速度:
不允许内联
允许内联
在内联情况下将 elements 添加 volatile 修饰符,volatile的作用是作为指令关键字,确保本条指令不会因编译器编译器的优化而省略,且要求每次直接读值。