likes
comments
collection
share

Jvm学习笔记(四) 类加载原理

作者站长头像
站长
· 阅读数 39

Jvm类加载机制

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

和编译时需要链接的语言不同,java中类型的链接、加载、初始化都在运行时进行,虽然会额外增加一点性能开销,但是大大提高了动态扩展性。比如在写面向接口的程序时,可以在运行时再加载具体的类型。注意,class文件虽然叫文件,但是它的定义是二进制流,也就是说,任何的二进制流都可以作为class文件,比如从服务器获取的网络数据,也可以作为class加载到本地程序。

类加载流程

一个类型从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期是: 加载、验证、准备、解析、初始化、使用、卸载。其中,验证、准备、解析三个过程统称为连接,如图所示:

Jvm学习笔记(四) 类加载原理

在这个顺序中,除了解析这一步骤外,顺序都是确定的,解析这一步有时候会在初始化之后执行,这是为了支持运行时绑定的特性。《Java虚拟机规范》并没有要求什么时候执行加载,但是规定了在初始化之前必须执行加载、验证、准备。有且只有六中情况下必须执行初始化。

  1. 遇到 new、getstatic、putstatic、invokestatic四个字节码指令,如果没有执行过初始化,需要先执行初始化。这四条指令在java中也是比较一目了然的。
  2. 使用java.lang.reflect使用反射时,这个也比较好理解。
  3. 当初始化类时,发现其父类还未初始化,就会先执行父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个执行的主类,就是写main方法的类作为入口。
  5. 当使用JDK 7 新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getstatic、REF_putstati、REF_invokestatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先出发其初始化。
  6. 当一个接口中定义了JDK8新增的Default方法,如果接口的实现类进行了初始化,那么这个类会先进行初始化。

以上六中方式被称为对一个类型进行主动引用,除此之外,所有引用的方式都不会触发初始化,称为被动引用。比如:

  1. 子类引用父类的静态字段,只会初始化父类不会初始化子类。
  2. 通过数组来定义引用类,不会触发此类的初始化。
  3. 常量在编译阶段会存入调用类的常量池中,调用常量本质上没有引用到定义的类,所以不会触发定义常量的类的初始化。(常量在编译阶段会进行常量传播优化,统一加入到常量池)

这里提到的初始化,在class中对应着<clinit>()方法。也就是类的static{}代码块,而不是构造函数。

加载

加载阶段主要完成了三件事:

  1. 通过一个类全限定名来获取此类的二进制流。

  2. 将字节流所代表的静态存储结转化为方法区的运行时数据结构。

  3. 在内存中生成代表这个类的java.lang.Class对象,作为方法区这个类的各个数据的访问入口。

    对于非数组的类,既可以使用虚拟机里自带的类加载器,也可以自己实现类加载器classLoader来控制字节流的获取。对于数组而言,他是虚拟机直接在内存中动态构造出来,但是数组本身的元素类型最终还是要靠类加载器加载:

  4. 如果数组的元素类型是引用类型,会递归采用上面的类加载方式,数组c会被标识在加载该组件的类加载器的名称空间上。

  5. 如果数组元素非引用类型,将会把数组c标记为与气动类加载器关联。

  6. 数组类的可访问性与组件类型的访问性一致,如果是非引用类型,默认是public。

    加载阶段结束后,虚拟机就将外部二进制流按照规范所设定格式存储在方法区之中了,同时会生成一个Class对象,作为程序访问方法区中类型数据的外部接口。

    加载阶段和连接阶段的部分动作是交叉进行的,也就是加载阶段还未结束,连接阶段就会开始。

验证

验证是连接阶段的第一步,这一步的主要作用是确保class字节流中包含的信息符合《Java虚拟机规范》的全部约束,保证这些代码运行后不会危害虚拟机本身。验证阶段主要有四步:

  1. 文件格式校验
  2. 元数据校验
  3. 字节码验证
  4. 符号引用验证

文件格式校验

文件格式校验主要检查字节流是否符合class文件规范,并且能被当前版本虚拟机处理。主要校验包括魔数、版本号、常量池中是否有不支持的常量类型、指向常量池的索引是否指向了不存在的常量等等信息,这个阶段是基于二进制流的。只有通过了这个阶段的校验,字节流才会被加载到内存中,后面的三个阶段全部基于方法区的存储结构,不会再读区操作字节流了。

元数据验证

这一步主要对字节码描述的信息进行语义检查,以保证其符合《java语言规范》(注意是java语言)。比如这个类是否有父类、是否继承了不被允许继承的类、继承抽象类是否首先类所有抽象方法等。

编译器的许多语法校验感觉就是基于这一步。

字节码验证

这一步主要对方法体中的字节码code进行校验,保证被校验的数据在运行时不会做出危害虚拟机的行为,比如

  • 保证任意时刻操作数栈道数据类型与指令代码序列能配合工作,不会出现数据类型不匹配的问题。
  • 保证任何跳转指令不会跳转到方法体之外的字节码指令上。
  • 保证类型转换都是有效的,比如可以把子类赋给父类,但是反过来不行。
  • ...

符号引用验证

最后一个校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三步——解析阶段发生。符号引用验证可以看作是类对自身以外的各类信息进行匹配性校验,通俗的说就是该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。主要内容为:

  • 符号引用中的类全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的可以访问性是否能被当前类访问
  • ...

虚拟机的验证阶段只有通过和不通过的差别,对于生产环境,可以通过-Xverify:none参数来关闭大部分类的校验措施,减少虚拟机加载的时间。

准备

准备阶段是正式为类中定义的变量(静态变量)分配内存并设置初始值的阶段。从逻辑上看,这些变量的内存需要在方法区中分配,但是方法区本身是一个逻辑分区,在jdk7以前。HotSpot使用永久代来实现方法区,但是在JDK7以后,将Class对象一起放到了Java堆中,所以这里的说法是一种逻辑概念的表述。

准备阶段有两个重点,首先内存分配的仅仅是类变量而不是实例变量,其次初始化值是“零值”而不是具体定义的值,比如static int a=123;在这个阶段,a的值是int类型的初始值0而不是123。也有例外,当定义常量static final int a=123;时,会将常量值从常量池中获取赋值。

解析

解析阶段是将符号引用替换为直接引用的过程(对应验证阶段的符号引用验证)。符号引用在class文件中以CONSTANT_Class_info、CONSTANT_Fielddref_info、CONSTANT_Methodref_info等类型。具体定义

  • 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的时候即可。符号引用目标不一定已经加载到虚拟机中,

  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者一个间接定位目标的句柄,直接引用和虚拟机是直接相关的,不同的u虚拟机解析出来的地址一般不同,如果有了直接引用,说明引用的目标必定已经在虚拟机的内存中存在。

    《java虚拟机规范》并没有规定解析阶段发生的具体时间,有17个操作符前实现解析即可,究竟是类被加载器加载时候就对常量池中的符号引用进行解析还是等到一个符号引用将要被使用前采取解析,由虚拟机自己实现。

初始化

初始化是类加载过程中最后一个步骤,初始化阶段就是执行类的<clinit>()方法指令,这个方法并不是程序员自己写的而是由虚拟机生成。

  • <clinit>() 方法由编译器自动收集类中所有类变量的赋值动作和静态代码块static{}合并产生,收集的顺序按照源码文件中的顺序,静态代码块只能访问到在它定义之前到变量,对于在它之后定义的变量只能赋值不能访问。
  • <clinit>()不会显式的调用父类的<clinit>()因为虚拟机会保证子类的类初始化时父类一定已经被初始化了。
  • 由于父类初始化先调用,所以父类的静态代码块优先于子类变量的赋值操作。
  • <clinit>()对于类或者接口不是必须的,如果没有静态代码块或者变量的赋值操作,不会生成该方法。
  • 接口中不能有静态代码块,但是任然有变量初始化的操作,因为也会生成<clinit>()方法,但是不会先去执行父接口的初始化方法,只有当父接口中的变量被使用到时父接口才会被初始化。此外,接口的实现也一样不会去调用接口的初始化操作。
  • java虚拟机保证了<clinit>()在多线程环境中被正确加锁了,如果多线程初始化类。会出现阻塞的现象,如果在初始化中有耗时操作,会造成多线程阻塞,这个现象非常隐蔽需要注意。

类加载器

上面讲到了类加载的流程,其中第一步加载用到了类加载器,这一节主要讲一下类加载器。

Java虚拟机设计团队有意把类加载中的 “通过类的全限定名来获取二进制流”的操作放到虚拟机之外也就是java代码中,以便让程序员自己决定该如何加载所需要的类。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有独立的命名空间。也就是说哪怕是同一个class文件,不同的加载器加载出来也是不同的,比如instanceof 等关键字或者class等equals()方法都会失去效果。

双亲委托机制

ClassLoader采用双亲委托的机制来加载类,既一个classloader在加载一个类时候,不会自己去加载,会先将任务交给父类去实现,如果父类可以正确加载,则直接返回父类结果,否则才会自己去加载这个类,这样做就为了不让程序员可以随意加载替换系统预定义的类型,防止破坏整个系统。

JDK8为止类加载器有三层:

  • 启动类加载器:加载存放在<JAVA_HOME>\lib目录下或者通过-Xbootclasspath参数指定的路径中存放的、能被虚拟机识别的文件。启动类加载器无法被虚拟机识别,用户如果希望委托该加载器,直接使用null即可:
    public ClassLoader getClassLoader(){
      ClassLoader cl=getCLassLoader0();
      if(cl==null){
        return null;
      }
      SecurityManager sm=System.getSecurityManager();
      if(sm!=null){
        ClassLoader ccl=ClassLoader.getCallerClassLoader();
        if(ccl!=null && ccl!=cl && !cl.isAncestor(ccl)){
          sm.checkPermission(SecurityConstans.GET_CLASSLOADER_PERMISSION);
        }
      }
      return cl;
    }
    
  • 扩展类加载器:负责加载<JAVA_HOME>\lib\ext 目录中或者被java.ext.dirs系统变量所指定的路径中的所有类库。这是一个扩展性的加载器,允许用户将具有通用性的类库放在ext目录里以扩展SE的功能,开发者可以直接在Java代码中使用这个类加载class文件。
  • 应用程序类加载器:由getSystemClassLoader()得到,负责加载用户类路径ClassPath上的所有类库,一般情况下都是使用这个加载器加载。

JDK9之前,类加载器就是这三层,用户可以自定义类加载器,基本上的继承关系是这样:

Jvm学习笔记(四) 类加载原理

实现一个自定义的类加载器:

class Test{
    public static void main(String[] args) {
        ClassLoader myLoader=new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try{
                    String fileName=name+".class";
                     InputStream is=getClass().getResourceAsStream(fileName);
                     if(is==null){
                        return super.loadClass(name);
                     }
                     byte[] b=new byte[is.available()];
                     is.read(b);
                     return defineClass(name, b, 0,b.length);
                }catch(IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };
        try {
            Object obj=myLoader.loadClass("Hello").getDeclaredConstructor().newInstance();
            System.out.println("Class Name: "+obj.getClass());
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

这是ClassLoader中基于双亲委托的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) {
                    // 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
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委托机制作为推荐的classloader模型,在很多领域有被运用,比如Android的art虚拟机中,对于android字节文件dex文件的加载,就用到了PathCLassLoader和DexClassLoader,基于这两个加载器的加载原理,实现了热更新的字节码替换。当然,双亲委托并不是强制的,业界也有过数次破坏这个机制,毕竟所有的架构其实都是服务于业务的,要理解到一个机制的缺点才能更好的运用,详细破坏原因大家可以翻一下书。

结语

这一章主要介绍了一下类加载的机制,让大家对于一个类怎么从class文件变成内存中的数据有一个概念,对于我而言,有两个核心知识点:

  1. 类是从字节码中加载而来
  2. 类加载器就是根据全限定名去确定字节码的位置

这两个知识点算是帮我解开了一些以往的迷惑和混沌感,结合上一章的class文件解析,让我对整个java项目中的class文件之间的串联出一整个完整的、可执行的原因项目有了一个更高的认识。

关联

Jvm学习笔记(一)内存模型

Jvm学习笔记(二)GC

Jvm学习笔记(三) class文件分析

转载自:https://juejin.cn/post/7218756983502553125
评论
请登录