JVM第六卷---类加载机制

x33g5p2x  于2022-02-24 转载在 Java  
字(6.7k)|赞(0)|评价(0)|浏览(377)

类加载机制

Java虚拟机把描述类结构的数据从Class文件中加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制

在Java语言里面,类型的加载,连接和初始化过程都是在程序运行期间完成的,这也为java语言动态扩展提供了可能,例如:在编写一个面向接口的应用时(参考mybaits的mapper),可以等待运行时再指定其实际的实现类,用户可以通过Java预置的自定义类加载器,让某个本地应用程序在运行时从网络或其他地方加载一个二进制流作为程序代码一部分

一个类型从被加载到虚拟机内存到卸载出内存为止,生命周期大体如下: 加载,验证,准备,解析,初始化,使用,卸载

验证,准备,解析也可以看出是连接阶段

加载,验证,准备,初始化和卸载五个阶段的顺序是确定的,但是解析过程可以在初始化完成后再开始,这是为了支持Java语言运行时绑定的特性

加载

在加载阶段主要做三件事情:

  • 通过一个类的全限定名或获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口

通过一个类的全限定名或获取定义此类的二进制字节流,并没有指明二进制字节流必须得从某个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是存储在堆中
  • 可以通过前面介绍的 HSDB 工具查看

加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器完成。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机自行定义。

类型数据在方法区创建好后,会在Java堆内存中实例化一个Class类对象,这个对象将作为程序访问方法区中的类型数据的外部接口

链接

验证

验证类是否符合 JVM规范,安全性检查

用 UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数,在控制台运行

验证阶段主要分为以下几个步骤:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

为 static 变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于_java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶 段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成

JDK8以后,类变量会随着Class对象一起存放在Java堆中

类变量在准备阶段会被附上初始值,这里初始值指的是零值,至于真正赋值操作,需要等待初始化阶段,在类构造器完成,但是例外在于常量是在准备阶段就进行初始化的,引用类型常量除外

解析

将常量池中的符号引用解析为直接引用

nn

初始化----< cinit >()V 方法

初始化阶段实际就是执行类构造器clinit方法的过程,该方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并而成的

初始化即调用 < cinit >()V ,虚拟机会保证这个类的『构造方法』的线程安全

发生的时机

概括得说,类初始化是【懒惰的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致类初始化的情况

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
  • 类对象.class 不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时

只有接口中定义了类变量,并且被使用到的时候,才会触发接口的类初始化方法,如果一个类或者接口没有静态语句块和静态变量,那么编译器可以不为当前类生成类初始化方法

实验

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");
    }
}

验证(实验时请先全部注释,每次只执行其中一个)

public class Load3 {
    static {
        System.out.println("main init");
    }

    public static void main(String[] args) throws ClassNotFoundException {
        // 1. 静态常量(基本类型和字符串)不会触发初始化
        System.out.println(B.b);
        // 2. 类对象.class 不会触发初始化
        System.out.println(B.class);
        // 3. 创建该类的数组不会触发初始化
        System.out.println(new B[0]);
        // 4. 不会初始化类 B,但会加载 B、A
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        cl.loadClass("cn.itcast.jvm.t3.B");
        // 5. 不会初始化类 B,但会加载 B、A
        ClassLoader c2 = Thread.currentThread().getContextClassLoader();
        Class.forName("cn.itcast.jvm.t3.B", false, c2);
        // 1. 首次访问这个类的静态变量或静态方法时 
        System.out.println(A.a);
        // 2. 子类初始化,如果父类还没初始化,会引发 
        System.out.println(B.c);
        // 3. 子类访问父类静态变量,只触发父类初始化 
        System.out.println(B.a);
        // 4. 会初始化类 B,并先初始化类 A 
        Class.forName("cn.itcast.jvm.t3.B");
    }
}

练习

从字节码分析,使用 a,b,c 这三个常量是否会导致 E 初始化

a和b不会,但是c会,因为c是引用类型,赋值过程在类初始化阶段完成,a和b在编译阶段就完成了赋值操作,而c在编译阶段获得的是零值

典型应用 - 完成懒惰初始化单例模式


以上的实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的

Java虚拟机会保证一个类的clinit方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit方法,其他线程都需要阻塞等待,直到活动线程执行完毕clinit方法

类加载器

Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述类的二进制字节流",这个动作放到Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称为"类加载器"

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下,才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

这里的相等,包括Class对象的equals方法,isAssignableFrom方法,isInstance方法的返回结果,也包括了使用instanceOf关键字做对象所属关系判定等各种情况。

以 JDK 8 为例:

Bootstrap类加载器由c++编写,所以Extension扩展类加载器无法直接访问,会显示null

这里只针对HotSpot虚拟机而言,BootStrap类加载器由c++编写,其他虚拟机也有使用java编写的

启动类加载器

用 Bootstrap 类加载器加载类:

public class F {
    static {
        System.out.println("bootstrap F init");
    }
}

执行

public class Load5_1 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("com.dhy.F");
        System.out.println(aClass.getClassLoader());
    }
}

输出

#加载命令参数
java -Xbootclasspath/a:. com.dhy.F
bootstrap F init 
null
  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
  • java -Xbootclasspath:< new bootclasspath>
  • java -Xbootclasspath/a:<追加路径>
  • java -Xbootclasspath/p:<追加路径>

扩展类加载器

双亲委派模式

所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则

注意

这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系

源码:

举例分析类加载过程:

总结:是否已经加载,父类先尝试加载,父类找不到,再由当前类加载器加载

线程上下文类加载器

我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写

Class.forName("com.mysql.jdbc.Driver")

也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?

让我们追踪一下源码:

先不看别的,看看 DriverManager 的类加载器:

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() 方法:

  • 先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器,因此 可以顺利完成类加载
  • 再看 1)它就是大名鼎鼎的 Service Provider Interface (SPI)
  • 约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类名称

文件里面的内容是接口实现类名

这样就可以使用

来得到实现类,体现的是【面向接口编程+解耦】的思想,在下面一些框架中都运用了此思想:

  • JDBC
  • Servlet 初始化器
  • Spring 容器
  • Dubbo(对 SPI 进行了扩展)

接着看 ServiceLoader.load 方法:

线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器,它内部又是由Class.forName 调用了线程上下文类加载器完成类加载,具体代码在 ServiceLoader 的内部类LazyIterator 中:

线程上下文类加载器可以通过setContextLoader设置当前上下文类加载器,否则默认为应用程序类加载器

自定义类加载器

问问自己,什么时候需要自定义类加载器

1)想加载非 classpath 随意路径中的类文件
2)都是通过接口来使用实现,希望解耦时,常用在框架设计
3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

步骤:

  1. 继承 ClassLoader 父类
  2. 要遵从双亲委派机制,重写 findClass 方法,注意不是重写 loadClass 方法,否则不会走双亲委派机制
  3. 读取类文件的字节码
  4. 调用父类的 defineClass 方法来加载类
  5. 使用者调用该类加载器的 loadClass 方法

示例:

准备好一个类文件:

自定义类加载器:

/**
 * <p>
 *     自定义类加载器
 * </p>
 */
public class MyClassLoader extends ClassLoader {

    /**
     * @param name 要加载类的名称
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path="C:\\Users\\zdh\\Desktop\\"+name+".class";
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            Files.copy(Paths.get(path),byteArrayOutputStream);
            //将class文件二进制流读取并加载进内存
            Class<?> aClass = defineClass(name, byteArrayOutputStream.toByteArray(), 0, byteArrayOutputStream.toByteArray().length);
            return aClass;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

用自定义类加载器来加载Test类进内存中

/**
 * @author 大忽悠
 * @create 2022/2/24 19:44
 */
public class Main {
    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
        MyClassLoader myClassLoader = new MyClassLoader();
        Class<?> test = myClassLoader.findClass("Test");
        //这里会调用Test类的类构造器初始化
        test.newInstance();

        MyClassLoader myClassLoader1 = new MyClassLoader();
        Class<?> test1 = myClassLoader.findClass("Test");
        //false--->不同类加载加载同一个类.class文件,得到的类也会被认为是不同的
        System.out.println(test==test1);
    }
}

破坏双亲委派模型的几种做法

  • 重写ClassLoader的loadClass方法,这里应该是重写findClass方法
  • 线程上下文类加载器—>SPI.JNDI,ServiceLoader
  • 代码热替换,模块热部署—OSGI,Jigsaw,
  • jdk9模块化下的类加载器

破坏不一定是坏事,也算是对技术的一种革新,不能抱着固有的技术停滞不前

相关文章