JVM之类加载

JVM之类加载
aYu1. 类加载阶段
加载
- 将类的字节码载入方法区(1.8后为元空间, 在本地内存中)中, 内部采用 C++ 的 instanceKlass 描述 java 类, 它的重要 field 有:
- _java_mirror 即 java 的类镜像, 例如对 String 来说, 它的镜像类就是 String.class, 作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
- 如果这个类还有父类没有加载, 先加载父类
- 加载和链接可能是交替运行的
- instanceKlass保存在方法区。JDK 8以后, 方法区位于元空间中, 而元空间又位于本地内存中
- _java_mirror则是保存在堆内存中
- InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
- 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass, 从而获取类的各种信息
链接
验证: 验证类是否符合JVM规范, 安全性检查
用支持二进制的编辑器修改 HelloWorld.class 的魔数(CAFEBABE
->CAFEBABA
), 在控制台运行报错了, 这里说的是通过检查魔数发现这不是一个class文件, 所以不能运行
Error: A JNI error has occurred, please check your installation and try again
Exception in thread “main” java.lang.ClassFormatError: Incompatible magic value 1128351301 in class file HelloWorld
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)准备: 为 static 变量分配空间, 设置默认值
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾, 从 JDK 7 开始, 存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤, 分配空间在准备阶段完成, 赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型, 以及字符串常量, 那么编译阶段值就确定了, 赋值在准备阶段完成
- 如果 static 变量是 final 的, 但属于引用类型, 那么赋值也会在初始化阶段完成将常量池中的符号引用解析为直接引用
解析
将常量池中的符号引用解析为直接引用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class Demo {
public static void main(String[] args) throws ClassNotFoundException, IOException {
ClassLoader classLoader = Demo.class.getClassLoader();
Class<?> c = classLoader.loadClass("com.ayu4.C");
// new C();
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}使用 HSDB 工具可以看到使用 ClassLoader 加载类 C, 类 D 是不会被加载的, 并且在类 C 中, 类 D 还只是一个未被解析的常量符号
而使用
new C()
的时候则会执行 C 里面的静态代码D d = new D()
, 所以类 C 和 类 D 都会被加载
初始化
()V
方法
初始化即调用
, 虚拟机会保证这个类的构造方法的线程安全
发生的时机
概括得说, 类初始化是懒惰的
- main 方法所在的类, 总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化, 如果父类还没初始化, 会引发
- 子类访问父类的静态变量, 只会触发父类的初始化
- Class.forName
- new 会导致初始化
不会导致类初始化的情况
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 类加载器的 loadClass 方法
- Class.forName的参数 2 为 false时
测试代码:
1 | public class Demo { |
练习
从字节码分析, 使用 a, b, c 这三个常量是否会导致 E 初始化
1 | public class Load { |
典型应用 - 完成懒惰初始化单例模式
1 | public class Load { |
以上的实现特点是:
- 懒惰实例化
- 初始化时的线程安全是有保障的
2. 类加载器
类加载器虽然只用于实现类的加载动作, 但它在Java程序中起到的作用却远超类加载阶段
对于任意一个类, 都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间。这句话可以表达得更通俗一些: 比较两个类是否“相等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这两个类来源于同一个 Class 文件, 被同一个 Java 虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等!
以 JDK8 为例:
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap, 显示为 null |
Application ClassLoader | classpath | 上级为 Extension |
自定义加载器 | 自定义 | 上级为 Application |
启动类加载器
用 Bootstrap 类加载器加载类:
1 | public class F { |
因为需要加参数, 需要在控制台与运行:
1 | java -Xbootclasspath/a:. com.ayu.Load |
因为 Bootstrap ClassLoader 是用 C++ 写的, 打印 null 就说明使用了Bootstrap ClassLoader
- -Xbootclasspath 表示设置 bootclasspath
- 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
- 可以用这个办法替换核心类
java -Xbootclasspath:
java -Xbootclasspath/a:<追加路径>
java -Xbootclasspath/p:<追加路径>
扩展类加载器
如果 classpath 和 JAVA_HOME/jre/lib/ext 下有同名类, 加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了, 则不会再次加载。
1 | public class G { |
输出:
1 | classpath G init |
再写一类 G:
1 | public class G { |
打个 jar 包
1 | jar -cvf my.jar com/ayu/G.class |
将 jar 包拷贝到 JAVA_HOME/jre/lib/ext
重新执行
1 | ext G init |
双亲委派模式
双亲委派模式, 即调用类加载器ClassLoader 的 loadClass 方法时, 查找类的规则
loadClass源码
1 | protected Class<?> loadClass(String name, boolean resolve) |
线程上下文类加载器
我们在使用 JDBC 时, 都需要加载 Driver 驱动, 不知道你注意到没有, 不写
1 | Class.forName("com.mysql.jdbc.Driver") |
也是可以让 com.mysql.jdbc.Driver
正确加载的, 你知道是怎么做的吗?
看一下 DriverManager 的源码:
1 | // 注册驱动的集合 |
先来看一下 DriverManager 的类加载器:
1 | System.out.println(DriverManager.class.getClassLoader()); |
打印 null, 表示它的类加载器是 Bootstrap ClassLoader, 会到 JAVA_HOME/jre/lib 下搜索类, 但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包, 这样问题来了, 在 DriverManager 的静态代码块中, 怎么能正确加载 com.mysql.jdbc.Driver 呢?
继续看 loadInitialDrivers() 方法:
1 | private static void loadInitialDrivers() { |
先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化, 关联的是应用程序类加载器, 因此 可以顺利完成类加载 再看 1)
它就是大名鼎鼎的 Service Provider Interface (SPI) 约定如下, 在 jar 包的 META-INF/services 包下, 以接口全限定名名为文件, 文件内容是实现类名称
这样就可以使用
1 | ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class); |
来得到实现类, 体现的是 面向接口编程+解耦 的思想, 在下面一些框架中都运用了此思想:
- JDBC
- Servlet 初始化器
- Spring 容器
- Dubbo (对 SPI 进行了扩展)
接着看 ServiceLoader.load 方法:
1 | public static <S> ServiceLoader<S> load(Class<S> service) { |
线程上下文类加载器是当前线程使用的类加载器, 默认就是应用程序类加载器, 它内部又是由 Class.forName 调用了线程上下文类加载器完成类加载, 具体代码在 ServiceLoader 的内部类 LazyIterator 中:
1 | private S nextService() { |
自定义类加载器
场景:
- 想加载非 classpath 随意路径中的类文件
- 通过接口来使用实现, 希望解耦时, 常用在框架设计
- 这些类希望予以隔离, 不同应用的同名类都可以加载, 不冲突, 常见于 tomcat 容器
步骤:
- 继承 ClassLoader 父类
- 要遵从双亲委派机制, 重写 findClass 方法
不是重写 loadClass 方法, 否则不会走双亲委派机制 - 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
破坏双亲委派模式
- 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2面世以前的“远古”时代
- 建议用户重写findClass()方法, 在类加载器中的loadClass()方法中也会调用该方法
- 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
- 如果有基础类型又要调用回用户的代码, 此时也会破坏双亲委派模式
- 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的
- 这里所说的“动态性”指的是一些非常“热”门的名词: 代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
3. 运行期优化
即时编译
分层编译
JVM 将执行状态分成了 5 个层次:
- 0层: 解释执行, 用解释器将字节码翻译为机器码
- 1层: 使用 C1 即时编译器编译执行(不带 profiling)
- 2层: 使用 C1 即时编译器编译执行(带基本的profiling)
- 3层: 使用 C1 即时编译器编译执行(带完全的profiling)
- 4层: 使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据, 例如方法的调用次数, 循环的 回边次数等
即时编译器(JIT)与解释器的区别:
- 解释器
- 将字节码解释为机器码, 下次即使遇到相同的字节码, 仍会执行重复的解释
- 是将字节码解释为针对所有平台都通用的机器码
- 即时编译器
- 将一些字节码编译为机器码, 并存入 Code Cache, 下次遇到相同的代码, 直接执行, 无需再编译
- 根据平台类型, 生成平台特定的机器码
对于大部分的不常用的代码, 我们无需耗费时间将其编译成机器码, 而是采取解释执行的方式运行;另一方面, 对于仅占据小部分的热点代码, 我们则可以将其编译成机器码, 以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2, 总的目标是发现热点代码(hotspot名称的由来), 并优化这些热点代码。
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是, Java Hotspot 虚拟机可以分析新创建对象的使用范围, 并决定是否在 Java 堆上分配内存的一项技术
逃逸分析的 JVM 参数如下:
开启逃逸分析: -XX:+DoEscapeAnalysis
关闭逃逸分析: -XX:-DoEscapeAnalysis
显示分析结果: -XX:+PrintEscapeAnalysis
逃逸分析技术在 Java SE 6u23+ 开始支持, 并默认设置为启用状态, 可以不用额外加这个参数
对象逃逸状态
全局逃逸(GlobalEscape)
- 即一个对象的作用范围逃出了当前方法或者当前线程, 有以下几种场景:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
参数逃逸(ArgEscape)
- 即一个对象被作为方法参数传递或者被参数引用, 但在调用过程中不会发生全局逃逸, 这个状态是通过被调方法的字节码确定的
没有逃逸
- 即方法中的对象没有发生逃逸
逃逸分析优化
针对上面第三点, 当一个对象没有逃逸时, 可以得到以下几个虚拟机的优化
锁消除
我们知道线程同步锁是非常牺牲性能的, 当编译器确定当前对象只有当前线程使用, 那么就会移除该对象的同步锁
例如, StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的, 但大部分情况下, 它们都只是在当前线程中用到, 这样编译器就会优化移除掉这些锁操作
锁消除的 JVM 参数如下:
- 开启锁消除: -XX:+EliminateLocks
- 关闭锁消除: -XX:-EliminateLocks
锁消除在 JDK8 中都是默认开启的, 并且锁消除都要建立在逃逸分析的基础上
标量替换
首先要明白标量和聚合量, 基础类型和对象的引用可以理解为标量, 它们不能被进一步分解。而能被进一步分解的量就是聚合量, 比如: 对象
对象是聚合量, 它又可以被进一步分解成标量, 将其成员变量分解为分散的变量, 这就叫做标量替换。
这样, 如果一个对象没有发生逃逸, 那压根就不用创建它, 只会在栈或者寄存器上创建它用到的成员标量, 节省了内存空间, 也提升了应用程序性能
标量替换的 JVM 参数如下:
- 开启标量替换: -XX:+EliminateAllocations
- 关闭标量替换: -XX:-EliminateAllocations
- 显示标量替换详情: -XX:+PrintEliminateAllocations
标量替换同样在 JDK8 中都是默认开启的, 并且都要建立在逃逸分析的基础上
栈上分配
当对象没有发生逃逸时, 该对象就可以通过标量替换分解成成员标量分配在栈内存中, 和方法的生命周期一致, 随着栈帧出栈时销毁, 减少了 GC 压力, 提高了应用程序性能
方法内联
内联函数
内联函数就是在程序编译时, 编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换
例如:
1 | private static int square(final int i) { |
如果发现 square 是热点方法, 并且长度不太长时, 会进行内联, 所谓的内联就是把方法内代码拷贝粘贴到调用者的位置:
1 | System.out.println(9 * 9); |
还能够进行常量折叠(constant folding)的优化
1 | System.out.println(81); |
字段优化
创建 maven 工程:
1 | <dependency> |
测试代码:
1 | // 预热代码, 让JIT编译器对代码进行充分优化 |
测试结果如下(每秒吞吐量, 分数越高的更好):
1 | Benchmark Mode Cnt Score Error Units |
接下来禁用 doSum 方法内联
1 | // 控制调用方法时是不是要进行方法内联;不允许内联 |
测试结果如下(得分明显下降):
1 | Benchmark Mode Cnt Score Error Units |
分析:
在刚才的示例中, doSum 方法是否内联会影响 elements 成员变量读取的优化
如果 doSum 方法内联了, 刚才的 test1 方法会被优化成下面的样子(伪代码):
1 |
|
可以节省 1999 次 Field 读取操作, 但如果 doSum 方法没有内联, 则不会进行上面的优化
本地变量访问长度、数据时, 不需要去 class 元数据那里找, 在本地变量就可以找到了, 相当于手动优化。但是方法内联是由虚拟机来优化的。所以, test3 方法与test2 方法是等价的, test1 方法是运行期间优化了, test2 方法是手动优化了, test3 方法的 foreach 是编译期间优化了。
反射优化
测试代码:
1 | public class ReflectTest { |
foo.invoke 前面 0 ~ 15 (共16) 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现
1 | class NativeMethodAccessorImpl extends MethodAccessorImpl { |
下面, 我们从源码的角度来分析反射底层是如何实现优化的:
先在 foo.invoke(null) 处打上断点, debug 模式启动
点击进去 invoke() 方法实现, 找到 MethodAccessor 的实现类, 在 invoke() 方法上打上新断点
通过 Idea 查看 Evaluate, 来查看反射多次调用后, 由 JVM 动态生成的实现类名 - GeneratedMethodAccessor1, 这个名字在后面需要用的。且此时 this.numInvocations 已经到了 16了
接下来, 我们通过阿里的 arthas 工具来进行调试:
run 模式运行代码, 会停在
System.in.read()
运行 arthas:
java -jar arthas-boot.jar
通过
jad sun.reflect.GeneratedMethodAccessor1
将 JVM 对反射优化后的类的字节码反编译出来
注意:
通过查看 ReflectionFactory 源码可知
- sun.reflect.noInflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1, 但首次生成比较耗时, 如果仅反射调用一次, 不划算)
- sun.reflect.inflationThreshold 可以修改膨胀阈值