1. 类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:、
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,加载过程必须按这种顺序进行。由于 Java 语言支持运行时绑定(动态绑定),在某些情况下,解析阶段可以在初始化之后再开始,这些阶段通常都是互相交叉地混合式进行,通常会在一个阶段执行的过程中调用、激活另一个阶段。
值得注意的是,虽然 JVM 规范没有强制约束在什么情况下进行加载,但是 JVM 规范严格规定了有且只有 5 种情况必须立即对类进行 “初始化”:
- 使用 new 关键字实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候并且该类没有初始化过。
- 当初始化一个类的时候,如果发现它的父类还没有初始化,则先触发其父类的初始化。
- 当 JVM 启动的时候,用户指定的执行的主类(包含 main() 方法的那个类)。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且该方法句柄没有初始化。
这 5 种场景中的行为称为对一个类进行主动引用,那么所有引用类的方式都不会触发初始化,称为被动引用。
2. 类加载的过程
2.1 加载
在加载阶段,虚拟机需要完成的 3 件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。二进制字节流获取的来源并没有严格指明,可以是 Class 文件、ZIP 包(JAR、EAR、WAR 格式的基础)、网络中获取、运行时计算生成、其他文件(如 JSP 文件)等等。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
值得注意的的是,加载阶段和链接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,链接阶段可能已经开始。
注意:
- instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),而元空间又位于本地内存中,但 _java_mirror 是存储在堆中
- InstanceKlass 和 *.class (Java 镜像类)互相保存了对方的地址
- 类的对象在对象头中保存了 *.class 的地址,让对象可以通过其找到方法区中的 instanceKlass,从而获取类的各种信息
2.2 链接
2.2.1 验证
验证阶段是为了确保 Class 文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。大致有 4 个阶段的检验动作:
- 文件格式验证:保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个 Java 类型信息的要求。
- 元数据验证:对类的元数据信息进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangError 异常的子类。
2.2.2 准备
为 static 变量分配空间,并设置 static 变量初始值,这些变量所使用的内存都将在方法区中进行分配。
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
2.2.3 解析
解析是 JVM 将常量池中的符号引用解析为直接引用的过程。
符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标内存即可。
直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
2.3 初始化
到了初始化阶段,才真正开始执行类中定义的 Java 程序代码,初始化阶段是执行类构造器 clinit() V 方法的过程,虚拟机会保证这个类的『构造方法』的线程安全。
clinit() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。
注意:编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如
public class Test {
static {
i = 0;
System.out.println(i);
}
static int i = 1;
}
发生时机 类的初始化的懒惰的,以下情况会初始化
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
不会导致类初始化的情况
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 类加载器的 loadClass 方法
- Class.forName 的参数 2 为 false 时
验证类是否被初始化,可以看改类的静态代码块是否被执行。
4. 类加载器
以 JDK 8 为例
4.1 启动类加载器
可通过在控制台输入指令,使得类被启动类加器加载
4.2 拓展类加载器
如果 classpath和 JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载,当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载。
4.3 双亲委派模式
双亲委派模式,即调用类加载器 ClassLoader 的 loadClass 方法时,查找类的规则。 以下是 loadClass 方法的源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
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.4 自定义类加载器
使用场景
- 想要加载非 classpath 随意路径中的文件
- 通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类都希望予以隔离,不同应用的同类名都可以加载,不冲突,常见于 Tomcat 容器
步骤
- 继承 ClassLoader 父类
- 遵从双亲委派机制,重写 findClass 方法,不是重写 loadClass 方法,否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 de?neClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
破坏双亲委派模式
- 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即 JDK1.2 面世以前的“远古”时代。建议用户重写 findClass() 方法,在类加载器中的 loadClass() 方法中也会调用该方法
- 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的。如果有基础类型又要调用回用户的代码,此时也会破坏双亲委派模式。
- 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
5. 运行期优化
5.1 分层编译
JVM 将执行状态分成了 5 个层次:
- 0层:解释执行,用解释器将字节码翻译为机器码
- 1层:使用 C1 即时编译器编译执行(不带 pro?ling)
- 2层:使用 C1 即时编译器编译执行(带基本的profiling)
- 3层:使用 C1 即时编译器编译执行(带完全的profiling)
- 4层:使用 C2 即时编译器编译执行
pro?ling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等。
即时编译器(JIT)与解释器的区别:
- 解释器
1.1 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释 1.2 是将字节码解释为针对所有平台都通用的机器码 - 即时编译器
2.1 JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译 2.2 根据平台类型,生成平台特定的机器码
对于大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由 来),并优化这些热点代码。
逃逸分析 逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。 逃逸分析的 JVM 参数如下:
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数。
对象逃逸状态: 1、全局逃逸(GlobalEscape) 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
2、参数逃逸(ArgEscape) 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。 3、没有逃逸 即方法中的对象没有发生逃逸
逃逸分析优化 针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化: 1、锁消除 我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁。
例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作。 锁消除的 JVM 参数如下:
- 开启锁消除:-XX:+EliminateLocks
- 关闭锁消除:-XX:-EliminateLocks
锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。
2、标量替换 首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。 对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。 标量替换的 JVM 参数如下:
- 开启标量替换:-XX:+EliminateAllocations
- 关闭标量替换:-XX:-EliminateAllocations
- 显示标量替换详情:-XX:+PrintEliminateAllocations
标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上
3、栈上分配 当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能
5.2 方法内联
5.2.1 内联函数
C++ 是否为内联函数由自己决定,Java 由编译器决定。Java 不支持直接声明为内联函数,如果想让它内联,你只能够向编译器提出请求: 关键字 final 修饰 用来指明那个函数是希望被 JVM内联的,如:
public final void doSomething() {
}
总的来说,一般的函数都不会被当做内联函数,只有声明了 final 后,编译器才会考虑是不是要把你的函数变成内联函数。
JVM 内建有许多运行时优化,首先短方法更利于JVM 推断。流程更明显,作用域更短,副作用也更明显。如果是长方法 JVM 可能直接就崩了。
第二个原因则更重要:方法内联
如果 JVM 监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身,如:
private int add4(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
方法调用被替换后
private int add4(int x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}
|