JVM⑤类加载、运行期优化
类加载阶段
类加载的阶段:加载、链接、初始化
加载
加载:将 Java 类的字节码载入方法区。
说明
- 方法区内部采用 C++ 的 instanceKlass 描述 Java 类。
- 加载和链接可能交替运行,不一定是先后完成。
- 类加载时发现父类没有加载,则先加载父类。
浅聊 oop-klass
以 JDK 8 为例。
在 Hotspot 中,使用 oop-klass 模型,用于表示 Java 类和对象。
- oop(ordinary object pointer):普通对象指针,标识对象实例。
- klass(instanceKlass):类的元数据,表示类的成员变量、方法、常量池等结构。
简单来说,就是 Java 面向对象中,类与实例的关系。
- 堆中的对象(xx.class)是一个个的实例。
- 实例所属的类(klass),位于方法区。
JVM 不把 instanceKlass 暴露给 Java,也就是说 Java 不能直接访问 instanceKlass。
那 Java 如何访问到 Klass?
使用 new 关键字创建对象时,JVM 会创建一个 instanceOopDesc 实例,用于表示 java.lang.Class 对象。
- instanceOopDesc 实例,称为 instanceKlass 的 Java 镜像。
- 对象实例存放在堆中,对象实例的引用存放在栈中。
- 实例包括两部分信息
- 对象头:包含运行时数据,如多线程的锁信息
- 元数据:维护一个指针,指向对象所属类的 instanceKlass
- instanceKlass 中有一个
_java_mirror属性,指向 instanceOopDesc 实例。 - 可以看出,instanceOopDesc 实例和 instanceKlass 之间互相持有对对方的引用。
链接
链接阶段:验证、准备、解析
- 验证:类是否符合 JVM 规范,安全性检查
- 准备:为 static 变量分配空间,设置默认值
- 解析:将常量池中的符号引用解析为直接引用
验证
如:检查魔数,是否为 cafe babe
准备
-
static 变量的存储位置
- < JDK 1.7:存储于 instanceKlass 的末尾(本地内存)
- >=JDK 7:存储于 instanceOopDesc 实例的末尾(堆)
-
赋值问题
- 在准备阶段,static 变量仅分配空间和赋默认初始值。
- 在初始化阶段,才对变量赋值。
// 举例 static Object o = new Object(); // 准备阶段:分配内存空间,赋初始值为null // 初始化阶段:赋值为new Object() -
注意:若 static 变量是 final 的基本类型、字符串常量,则在准备阶段赋值。
解析
Oracle 官方文档中说到,常量池在最初都是符号引用。
解析阶段:将符号引用解析为直接引用。
-
符号引用

-
直接引用

初始化
初始化:调用方法。
- JVM 会保证
()V 的线程安全。 - 类初始化是懒惰的,可以理解为非必要不初始化。
触发时机
当主动引用一个类时,会触发初始化。
- main() 方法所在的类
- new 关键字创建对象
- Class.forName()
- 首次访问类的静态变量或静态方法(除 final 常量)
- 子类初始化,发现父类还没初始化:先触发父类的初始化
- 子类访问父类的静态变量:只触发父类初始化
何时不触发
当一个类被动引用是,不触发初始化。
- 访问类的静态常量(static final,基本类型或字符串)
- 获取某个类的 Class:类对象.class
- 创建某个类的数组
- Class.forName():第二个参数为 false,则触发加载,不触发初始化
- 类加载器的 loadClass():触发加载,不触发初始化
经典应用:单例模式
:静态内部类方式。
public final class Singleton{
private Singleton(){}
private static class LazyHolder{
private static final Singleton INSTANCE = new Singleton();
}
public Singleton getInstance(){
return LazyHolder.INSTANCE;
}
}
类加载器
类加载器:在类加载阶段,通过一个类的全限定名来获取此类的二进制字节流。
从 JVM 的角度,只有 2 种加载器
- 启动类加载器(Bootstrap ClassLoader):C++ 实现,属于虚拟机的一部分
- 其它类加载器: Java 实现(继承自抽象类
java.lang.ClassLoader),独立于虚拟机,。
从 Java 程序员的角度,有 3 种加载器
在有需要的情况下,还可以编写自定义的类加载器。
说明:
- 启动类加载器无法被 Java 程序访问,显示为 null
- 应用程序类加载器是系统默认的类加载器
| 名称 | 默认加载范围 | |
|---|---|---|
| Bootstrap ClassLoader |
启动类(根) | JAVA_HOME/jre/lib/* |
| Extension ClassLoader |
扩展类 | JAVA_HOME/jre/lib/ext |
| Application ClassLoader |
应用程序类(系统类) | classpath |
双亲委派模型
The parent-delegation Model
双亲委派模型:调用类加载器的 loadClass() 方法时,查找类的规则。
-
亲:理解为上级比较好,而不是“父亲”(因为不是继承关系,而是级别关系)
-
双:理解为 2 个(应用程序类加载器,有 2 级垂直结构的上级,会向上委派两次)

工作机制(!)
向上委派,向下加载
- 如果一个类加载器收到了类加载的请求,会把请求委托给父加载器去完成(而不是自己加载)
- 依次向上,直到启动类加载器。
- 从启动类加载器开始,根据类的全限类名进行扫描查找。
- 若在扫描范围内找到该类,则进行加载
- 若找不到,则由下一级类加载器去尝试加载
- 如果所有类加载器都无法完成加载,则抛出异常。
源码:loadClass()
- name:类的二进制名
- resolve:若 true 则解析类(链接阶段)
放一下 loadClass() 的源码,稍后来分析。

源码分析(!)
大处着眼:方法结构
方法体:分为以下几个部分
-
findLoadedClass():根据类名,查找对应的已加载类 -
if (c == null):检查类是否已加载,未加载则进入 if 块。 -
if (resolve):根据方法调用传递的参数,判断是否要解析类。 -
返回被加载的类,若无法加载则为 null。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); if (c == null){ ... } if (resolve) { resolveClass(c); } return c; } }
小处着手:具体代码(!)
类加载的核心代码:if (c == null) 块
分为 try 块和 if 块,分别对应双亲委派模型的 “向上委托” 和 “向下加载”
- try 块:向上委托,直至启动类加载器
- 若 parent == null,说明上级是启动类加载器
- 此时调用 findBootstrapClassOrNull(),查找启动类加载器中的对应加载类(根据全限类名)
- 若返回被加载的类,说明 c 已被启动类加载器加载。
- 若返回 null,说明启动类加载器无法加载当前类。
- if 块:向下加载
- 若 c == null,说明上级类加载器无法完成类加载操作。
- 调用 findClass(),则当前的类加载器尝试去加载类(根据全限类名扫描)
- 若加载成功,则返回被加载后的类。
- 否则,抛出 ClassNotFoundException 异常
- (下一级类加载器会捕获异常,但不做任何操作)
可以看出:双亲委派模型就是从应用程序类加载器开始,递归向上级调用 loadClass() 方法。
- 只要在某次递归中,
c != null说明 c 已被加载。 - 若递归结束,
c == null说明 c 无法被加载,抛出 ClassNotFoundException 异常。
类解析:
if (resolve)
根据调用 loadClass() 时传递的参数。
-
若显式指定 resovle == true,则会触发类链接。
-
若显式指定为 false 或不指定,则不会触发类链接
// loadClass()的重载方法,默认为false public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); }
经典问题:自定义String
自定义 java.lang.String
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("I'm the true String, haha!");
}
}
执行 main()

浅分析一下
- 回顾类加载阶段:main() 所在的类会触发初始化。
- 初始化之前,需要经过加载和链接。
- 类加载器收到类加载请求,依次向上委托父加载器,直到启动类加载器。
- 启动类加载器
- 扫描
JAVA_HOME/jre/lib/*中的类 - 发现全限类名为
java.lang.String的类,对其加载
- 扫描
- 类加载完成(当然还包括链接和初始化),执行 main() 方法
- 真正的
java.lang.String中没有定义 main(),因此报错。
运行期优化
早期优化:
晚期优化:运行期优化

两个结构,一个概念
- 解释器(Interpreter):对 Java 程序进行解释执行(平台无关性)
- 即时编译器(Just In Time Compiler):为提高热点代码的执行效率,将热点代码编译成与本地相关的机器码,并进行各层次的优化(平台有关性)
- Client Compiler:也叫 C1 编译器
- Server Compiler:也叫 C2 编译器
- 热点代码(Hot Spot Code):当 JVM 发现某个方法或代码块的运行特别频繁,就认定是热点代码。
工作机制
当程序启动时:解释器可以首先发挥作用,省去编译的时间,直接将代码解释执行。
随着代码执行次数越来越多:JIT 编译器开始发挥作用,将代码编译成本地机器码,提高执行效率。
分层编译(Tiered Compilation)
先了解一个概念:Profiling:性能监控功能
- 部分开启:方法调用次数、回边次数统计等;
- 完全开启:除了部分开启的统计信息外,还包括分支跳转、虚方法调用版本等全部的统计信息。
根据编译器编译、优化的规模与耗时,划分出不同的编译层次。
| 使用 | 执行方式 | 开启 Profiling | |
|---|---|---|---|
| 第 0 层 | 解释器 | 解释 | 不开启 |
| 第 1 层 | C1 编译器 | 编译 | 不开启 |
| 第 2 层 | C1 编译器 | 编译 | 部分开启 |
| 第 3 层 | C1 编译器 | 编译 | 完全开启 |
| 第 4 层 | C2 编译器 | 编译 | 开启,并进行激进优化 |
优化技术
编译器的优化技术有多种,如下所示。



以下通过几个简单的例子,来展示其中的几种优化技术。
注意:JIT 编译器对于代码的优化是建立在字节码或机器码之上的,而不是直接修改 Java 源码。
实例
假设 foo() 是热点代码
static Class A {
int value;
final int getValue() {
return value;
}
}
public void foo () {
// a是A的对象实例
x = a.getValue();
y = a.getValue();
sum = x + y;
}
-
方法内联(Inlining)
- 去除方法调用成本:如查找方法版本、建立栈帧
- 为其他优化建立良好的基础,因此通常作为优化序列的前面
public void foo () { x = a.value; y = a.value; sum = x + y; } -
冗余访问消除(Redundant Loads Elimination)
- 由于 x 和 y 访问同一个变量(假设变量值没有被修改),可以保证 x 和 y 的值相同。
- 因此对 y 赋值时,不用去访问 value 变量,直接将局部变量 x 的值赋给 y 即可。
- 若
a.value是一项表达式,此项优化可以视为公共子表达式消除(Common SubexpressionElimination)
public void foo () { x = a.value; y = x; sum = x + y; } -
复写传播(Copy Propagation)
- 在程序的逻辑中,x 和 y 完全相等,没有必要用一个额外的变量 y。
- 因此用 x 来代替 y。
public void foo() { x = A.value; x = x; sum = x + x; } -
无用代码消除(Dead Code Elimination)
- 永远不会被执行的代码
- 完全没有意义的代码。
public void foo() { x = A.value; sum = x + x; }
经过四次优化后,代码效果完全一致,但是代码省略了许多语句。
具有代表性的技术
先对几个技术作简单了解,之后系统学习 JVM 的时候再来补充和完善。
方法内联(重要技术)
- 去除方法调用成本:如查找方法版本、建立栈帧
- 为其他优化建立良好的基础,因此通常作为优化序列的前面
逃逸分析(前沿技术)
逃逸:在方法内部定义的局部变量,被外部方法所引用
- 方法逃逸:如作为调用参数传递到其他方法
- 线程逃逸:如赋值给可以在其他线程中访问的实例变量
- 不逃逸、方法逃逸、线程逃逸,是对象从低祷告的不同逃逸程度。
公共子表达式消除(语言无关)
-
公共子表达式:如果表达式 E 在之前已经被计算过了,并且 E 中所有变量的值都没有发生变化,称 E 称公共子表达式。
-
对于公共子表达式,没必要花时间重新计算,直接用前面计算过的表达式结果代替 E
// 举个例子 int x = (a * b) + c + (b * a) // 消除后 int x = E + 12 + c * E
数组边界检查消除(语言有关)
- 数组边界检查:Java 中访问数据元素时,系统会自动进行上下界的范围检查。超出范围则抛出数组下标越界异常。
- 为了安全,数组边界检查是必须做的。
- 但是,没有必要再每次访问的时候都进行。
举个例子:数组下标是常量,如 foo[3]
只要在编译期分析,确定 foo.length 并判断下标 3 没有越界,执行的时候就无需判了。
再举个常见例子:使用循环变量来访问数组,如 for 循环
只要在编译期分析,循环变量的范围永远在 [0, foo.length) 之间,那么在循环中就可以把整个数组数组边界检查消除了。