【JVM】学习笔记5 ——类加载过程及类加载器

本文是个人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. 步骤

  1. 继承 ClassLoader 父类

  2. 要遵从双亲委派机制,重写 findClass 方法
    注意不是重写 loadClass 方法,否则不会走双亲委派机制

  3. 在findClass 中读取类文件的字节码

  4. 调用父类的 defineClass 方法来加载类

  5. 使用者调用该类加载器的 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. 方法内联

主要目的有两个:

  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的作用是作为指令关键字,确保本条指令不会因编译器编译器的优化而省略,且要求每次直接读值。

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值