JVM⑤类加载、运行期优化


类加载阶段

类加载的阶段:加载、链接、初始化

加载

加载将 Java 类的字节码载入方法区

说明

  1. 方法区内部采用 C++ 的 instanceKlass 描述 Java 类。
  2. 加载和链接可能交替运行,不一定是先后完成。
  3. 类加载时发现父类没有加载,则先加载父类。

浅聊 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 官方文档中说到,常量池在最初都是符号引用

解析阶段将符号引用解析为直接引用

  • 符号引用

    image-20220304204408022

  • 直接引用

    image-20220304204413941

初始化

初始化调用()V方法。

  • JVM 会保证 ()V 的线程安全。
  • 类初始化是懒惰的,可以理解为非必要不初始化。

触发时机

当主动引用一个类时,会触发初始化。

  1. main() 方法所在的类
  2. new 关键字创建对象
  3. Class.forName()
  4. 首次访问类的静态变量或静态方法(除 final 常量)
  5. 子类初始化,发现父类还没初始化:先触发父类的初始化
  6. 子类访问父类的静态变量:只触发父类初始化

何时不触发

当一个类被动引用是,不触发初始化。

  1. 访问类的静态常量(static final,基本类型或字符串)
  2. 获取某个类的 Class:类对象.class
  3. 创建某个类的数组
  4. Class.forName():第二个参数为 false,则触发加载,不触发初始化
  5. 类加载器的 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 种加载器

在有需要的情况下,还可以编写自定义的类加载器。

说明

  1. 启动类加载器无法被 Java 程序访问,显示为 null
  2. 应用程序类加载器是系统默认的类加载器
名称 默认加载范围
Bootstrap
ClassLoader
启动类(根) JAVA_HOME/jre/lib/*
Extension
ClassLoader
扩展类 JAVA_HOME/jre/lib/ext
Application
ClassLoader
应用程序类(系统类) classpath

双亲委派模型

The parent-delegation Model

双亲委派模型调用类加载器的 loadClass() 方法时,查找类的规则。

  • :理解为上级比较好,而不是“父亲”(因为不是继承关系,而是级别关系)

  • :理解为 2 个(应用程序类加载器,有 2 级垂直结构的上级,会向上委派两次)

    image-20211220171114248

工作机制(!)

向上委派,向下加载

  1. 如果一个类加载器收到了类加载的请求,会把请求委托给父加载器去完成(而不是自己加载)
  2. 依次向上,直到启动类加载器
  3. 从启动类加载器开始,根据类的全限类名进行扫描查找
    • 若在扫描范围内找到该类,则进行加载
    • 若找不到,则由下一级类加载器去尝试加载
  4. 如果所有类加载器都无法完成加载,则抛出异常。

源码:loadClass()

  • name:类的二进制名
  • resolve:若 true 则解析类(链接阶段)

放一下 loadClass() 的源码,稍后来分析。

image-20220304223253922

源码分析(!)

大处着眼:方法结构

方法体:分为以下几个部分

  • 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 块向上委托,直至启动类加载器
    1. 若 parent == null,说明上级是启动类加载器
    2. 此时调用 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()

image-20220305003734661

浅分析一下

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

运行期优化

早期优化

晚期优化:运行期优化

image-20220305113523460

两个结构,一个概念

  • 解释器(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 编译器 编译 开启,并进行激进优化

优化技术

编译器的优化技术有多种,如下所示。

image-20220305122111120

image-20220305122143424

image-20220305122209222

以下通过几个简单的例子,来展示其中的几种优化技术。

注意: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;
}
  1. 方法内联(Inlining)

    • 去除方法调用成本:如查找方法版本、建立栈帧
    • 为其他优化建立良好的基础,因此通常作为优化序列的前面
    public void foo () {
        x = a.value;
        y = a.value;
        sum = x + y;
    }
    
  2. 冗余访问消除(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;
    }
    
  3. 复写传播(Copy Propagation)

    • 在程序的逻辑中,x 和 y 完全相等,没有必要用一个额外的变量 y。
    • 因此用 x 来代替 y。
    public void foo() {
        x = A.value;
        x = x;
        sum = x + x;
    }
    
  4. 无用代码消除(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) 之间,那么在循环中就可以把整个数组数组边界检查消除了。

JVM