[整理] java虚拟机类加载机制


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

在java语言里,类型的加载、连接、初始化都是在程序运行期间完成的,这种策略让java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是确为java应用提供了极高的扩展性和灵活性,java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类加载的过程

一个类型从被加载到虚拟机内存中开始,到卸载除内存为止,它的整个声明周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接。

这七个阶段的发生顺序如下图所示:

上图中,加载、验证、准备、初始化和卸载这个五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后开始,这是为了支持java语言的运行时绑定特性(也称为动态绑定或晚绑定)。

加载阶段

虚拟机需要完成以下三件事情:

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

验证阶段

大致上会完成下面四个阶段的检验动作。

  1. 文件格式验证:验证字节流是否符合Class文件的格式的规范,并且是否能被当前版本的虚拟机处理。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述信息符合java虚拟机规范的要求。
  3. 字节码验证:这个阶段是整个验证过程最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:这个阶段的校验行为发生在虚拟机符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段发生。符号引用验证可看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某这些外部类、方法、字段等资源。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要、但却不是必须要执行的阶段。如果程序的全部代码(都已经被反复使用和验证过),在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备:

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

关于准备阶段,有两个容易产生混淆的概念这里需要着重强调:

  • 首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。
  • 其次这里所说的初始值通常情况是数据类型的零值,假设一个类型变量的定义为:
    public static int value = 666; 那变量value在准备阶段过后的初始值为0而不是666,value赋值为666要在类的初始化阶段才会被执行。

解析:

解析阶段是java虚拟机将常量池内的符号引用替换为直接引用的过程,先来看看符号引用和直接引用的概念:

  • 符号引用:
    以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机的内存布局可以各不相同,但是它们接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的Class文件格式中。

  • 直接引用:
    直接引用是可以直接指向目标的指针、相对偏移量或者一个能间接定位的目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同的虚拟机中翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

解析主要包括类或者接口的解析、字段解析、方法解析、接口方法解析。

初始化:

类的初始化阶段是类加载过程的最后一个步骤,初始化完成之后,java虚拟机才会正在的开始执行类中编写的java程序代码,将主导权移交给应用程序。

在准备阶段中,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器()方法的过程。

()并不是指程序员在java代码中直接编写的类构造方法,它是javac编译器自动生成的。

类加载器:

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

需要注意的是对于同一个类,不同的类加载器加载之后,它们的类型是不同的。
站在java虚拟机的角度来看,只存在两种不同的类加载器:

  • 一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;
  • 一种是其他所有的类加载器,这些类加载器都由java语言实现,独立存在于虚拟机外部,并且全部继承自抽象类java.lang.Classloader。

站在java开发人员的角度来看,类加载器就应当划分得更细致一些。
自JDK1.2以来,java一直保持着三层类加载器、双亲委派的类加载架构。我们这里只针对java8及之前版本来介绍三层类加载器以及双亲委派模型。

  • 启动类加载器(Bootstrap):
    这个类加载器负责加载存放在lib目录,或者被-Xbootclasspath参数所指定的路径下存放的class类。启动类加载程序无法被java程序直接引用,如果需要把加载器请求委派给引导类加载器去处理,直接用null代替即可。

  • 扩展类加载器(Extension):
    这个类加载器是由java实现的,负责加载\lib\ext目录中或者被java.ext.dirs系统变量指定的路径中所有的类库,主要包含java用户(公司团队或者个人)开发的扩展类库。

  • 应用程序类加载器(App):
    这个加载器也是由java实现的,它负责加载用户类路径上所有的类库,也可以理解为除了启动类加载器和扩展加载器加载以外的所有类库都是由应用程序类加载器来加载。

双亲委派模型

如图所示,双亲委派的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,它会先检查请求加载的类型是否被当前加载器加载过,如果没有则把这个请求委派给父类(注意这里的父类并不是真正意义上的父类,源码中是以组合的形式来体现父子的关系的)加载器去完成,类加载实现的主要源码在java.lang.Classloader的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 {
                        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();
                    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;
        }
    }

这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

为什么要采用双亲委派模型来实现类的加载呢?
首先一个显而易见的好处就是java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都会委派给模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类。

反之,如果没有使用双亲委派模型,如果我自己也写一个java.lang.Object类,那系统中就会出现多个Object类,java类型体系中最基础的行为也就无从保障。

posted @ 2020-12-06 13:06  哆啦梦乐园  阅读(65)  评论(0编辑  收藏  举报