美文网首页
JVM虚拟机类加载机制原理

JVM虚拟机类加载机制原理

作者: 南风nanfeng | 来源:发表于2018-11-29 17:32 被阅读14次

    1.概述

    虚拟机把描述类的数据加载到内存,对其校验、转换、解析和初始化,最终形成可以被直接引用的Java类型。这就是虚拟机的类加载机制。

    2.类加载的时机

    类从加载到虚拟机的内存中到卸载出内存为止。整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段,其中验证、准备、解析3个部分统称为连接。


    loadingclass.jpg

    其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的。而解析则不一定,在某些情况下是在初始化之后再开始。

    虚拟机规范严格规定有且只有5种情况必须对类进行“初始化”(而加载、验证、准备自然在此之前):

    • 第一、遇到new、getstatic、putstatic和invokestatic四个字节码指令时,如果类没有初始化,则先触发初始化操作。生成4个指令的常见Java代码场景是:new实例化对象、读取或者设置类的静态成员变量、以及调用类的静态方法。
    • 第二、使用java.lang.reflect包的方法对类进行反射调用的时候。
    • 第三、当初始化一个类时,发现父类还未初始化,则先触发父类的初始化。
    • 第四、当虚拟机启动时,用户需要指定执行的入口类(包含main方法的那个类),虚拟机先初始化这个类。
    • 第五、JDK1.7以后的动态语言,如果java.lang.invoke.MethodHandle实例的最后解析结果:REF_getStatic、REF_putStatic、REF_invokeStatic的句柄,这些方法对应的类没有初始化,则先触发其初始化。
      除此之外,所有引用类的方法都不会触发初始化,称为被动引用。
      下面用3个例子说明被动引用:
      例1
    public class SuperClass {
        public static int value = 123;
    
        static {
            System.out.println("SuperClass init");
        }
    }
    
    public class SubClass extends SuperClass {
        static {
            System.out.println("SubClass init");
        }
    }
    
    public class NoInitialization {
        public static void main(String[] args) {
            System.out.println(SuperClass.value);
        }
    }
    
    运行结果:
    SuperClass init
    123
    

    上述例子说明,只有定义这个静态字段的类才会被初始化,通过子类引用父类的静态变量,只会触发父类的初始化。

    例2

    public class NoInitialization {
        public static void main(String[] args) {
    //        System.out.println(SuperClass.value);
            SuperClass[] sca = new SuperClass[10];
            System.out.println(sca);
        }
    }
    
    运行结果:
    [Lsw.melody.vm_chapter7.SuperClass;@5b480cf9
    

    运行后并没有输出“SuperClass init”,说明SuperClass的初始化没有被触发。触发的是[Lsw.melody.vm_chapter7.SuperClass的初始化阶段,这并不是一个合法的类名,是由虚拟机自动生成的,继承自java.lang.Object的子类,创建动作有newarray触发。

    例3

    public class ConstClass {
        static {
            System.out.println("Const init");
        }
        public static final String Hi = "Hello World";
    }
    
    public class NoInitialization {
        public static void main(String[] args) {
            System.out.println(ConstClass.Hi);
        }
    }
    
    运行结果:
    Hello World
    

    上述代码并没有输出Const init,说明没有触发类ConstClass的初始化。这是应为Hi是个被final修饰的常量,被引用后,其实在编译阶段就经过常量传播优化,已经将次常量的值“Hello World”存储到NoInitialization的常量池中,以后对ConstClass.Hi的引用实际上是对NoInitialization自身常量池的引用。也就是说NoInitialization的class文件中没有ConstClass的符合引用入口,这两个类在编译成class后就没有任何联系了。

    3.类加载的过程

    3.1 加载

    在加载阶段,虚拟机要完成3个事情:

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

    对于数组而言,情况有所不同,数组类本身不通过类加载器创建,由虚拟机直接创建。但数组类与类加载器有很密切的联系,因为数组类的数据元素最终靠类加载器创建,数据类创建过程遵循以下原则:

    1. 如果数组的组件类型是引用类型,那就递归加载组件类型数据,数组类将在加载该组件类型的类加载器的类名空间上被标记。
    2. 如果数组类的组件类型不是引用类型,如int[],虚拟机会把数组类标记为与引导类加载器关联。
    3. 数组类的可见性与它的组件类型的可见性相同,如果组件类型不是引用类型,那么数组类的可见性默认为public。

    加载阶段完成后,虚拟机外部的二进制字节流按照既定的格式存储在方法区中。然后内存中实例化java.lang.Class对象,用来作为程序访问这些类型数据的外部接口。

    3.2 验证

    验证是连接的第一步,目的是Class文件字节流中信息的安全性,是否符合当前虚拟机的要求,并且不会危害虚拟的安全。这个阶段非常重要,直接决定了虚拟机是否能够承受恶意代码的攻击。验证阶段大致上分为4个阶段的动作:第一、文件格式验证;第二、元数据验证;第三、字节码验证;第四、符号引用验证。

    3.2.1 文件格式验证

    主要验证Class文件格式是否符合虚拟机规范,并且能被当前虚拟机处理。

    3.2.2 元数据校验

    这个阶段主要对字节码描述的信息进行语义判断,保证符合Java语言规范的要求,下面简要罗列几点:

      1. 这个类是否有父类(实际上除了java.lang.Object外,所有类都应当有父类)。
      1. 这个类的父类是否继承了不允许被继承的类(比如final修饰的类)。
      1. 如果这个类不是抽象类,是否实现了父类或接口中要求实现的所有方法。
    3.2.3 字节码验证

    这个阶段是最复杂的验证。目的是确定程序方法语义的合法性、符合逻辑。保证校验类的方法在运行期间不会做出破坏虚拟机的事情。

    3.2.4 符号引用验证

    最后一个阶段的验证发生在虚拟机将符号引用转化为直接引用的时候,符号引用可以看做类对自身以外的信息进行匹配校验。需要检验以下内容:

      1. 符号引用的类、变量、方法的访问行(private、protected、public)是否能被当前访问。
      1. 通过符号引用描述的字符串全限定名能否找到所描述的字段、方法、类。
        对于虚拟机的类加载机制来说,验证阶段非常重要,但不一定必要,可以考虑使用-Xverify:none关闭大部分的校验,提高类加载效率。

    3.3 准备

    准备阶段目的是为类的静态变量设置初始值。这个过程中使用的内存都在方法区中分配好。这里强调一下类的静态变量(就是被static修饰的变量)是在这个阶段设置初始值,而类的成员变量则随类实例化时被一起在堆区中分配。其次,这里设置初始值通常情况下是设置与数据类型匹配的零值:

    public static int value = 123;
    

    准备阶段为value分配初始值为0,而不是123。因为这时候尚未执行Java方法,而把value赋值为123是在putstatic指令被程序编译以后,所以把value赋值为123是在初始化阶段进行的。

    上面提到通常情况下准备阶段是赋予零值,也有特殊情况,比如被final修饰的变量,在准备阶段就要赋予value指定的值:

    public static final int value = 123;
    

    3.4 解析

    解析阶段目的是把虚拟机常量池中的符号引用转化为直接引用。解释一下解析阶段中,符号引用和直接引用的关系。

    • 符号引用是以一组符号表示引用的目标。符号与虚拟机的内存布局无关,符号引用的类型并不一定加载到内存中。
    • 直接引用可以是指向对象的指针,对象在内存中的偏移量或是一个能够间接定位到目标的句柄。直接引用跟虚拟机的内存布局有关。

    3.5 初始化

    初始化是类加载机制执行的最后一个阶段,以上步骤全部由虚拟机主导和控制。到了初始化阶段,才真正开始执行类的代码。
    准备阶段,类变量已经被赋予初始值,而初始化阶段才真正赋予程序预制的初始值。从另一个角度上看,初始化阶段是类执行构造器<clinit>()方法的过程。
    <clinit>()方法的特点:

      1. <clinit>()方法有编译器收集类变量的赋值动作和静态块(static{})中的语句产生的。
      1. <clinit>()与构造器方法不同,<clinit>()不需要显示的调用父类构造器。虚拟机保证执行子类的<clinit>()方法前,父类的<clinit>()方法已经执行完毕,因此虚拟机中第一个被执行<clinit>()方法的类肯定是java.lang.Object。
      1. 由于是父类的<clinit>()方法先执行,说明父类的静态块优先于子类执行。
      1. <clinit>()方法并不是必须的,如果类中没有静态语句块,也没有对变量的赋值,那么编译器就不会被该类生成<clinit>()方法。
      1. 接口中不能使用静态块,但是接口仍然可以赋值变量,所以接口中也会产生<clinit>()方法。但接口与类不同的是,接口的<clinit>()方法不需要先执行父类的<clinit>()方法。只有当父类接口的变量被使用时,才会执行接口的<clinit>()方法。另外,接口的实现类在初始化时也不会执行接口的<clinit>()方法。

    4. 类加载器

    4.1 类与类加载器

    类加载器用来加载类。如果判断两个类是否相等,前提是两个类的类加载器必须相同,否则,即使两个类来自同一个类名空间,类加载器不同,实例化的类必然不同。下面看不同类加载器下instanceOf的结果。

    public class ClassLoaderTest {
        public static void main(String[] args) throws Exception {
            ClassLoader classLoader = new ClassLoader() {
                @Override
                public Class<?> loadClass(String name) throws ClassNotFoundException {
                    String filename = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream in  = getClass().getResourceAsStream(filename);
                    if (in == null) {
                        return super.loadClass(name);
                    }
                    try {
                        byte[] bytes = new byte[in.available()];
                        in.read(bytes);
                        return defineClass(name, bytes, 0, bytes.length);
                    } catch (IOException e) {
                        e.printStackTrace();
                        throw new ClassNotFoundException(name);
                    }
                }
            };
    
            Object obj = classLoader.loadClass("sw.melody.vm_chapter7.ClassLoaderTest").newInstance();
            System.out.println(obj.getClass());
            System.out.println(obj instanceof sw.melody.vm_chapter7.ClassLoaderTest);
        }
    }
    
    运行结果:
    class sw.melody.vm_chapter7.ClassLoaderTest
    false
    

    上例实例化了两个ClassLoaderTest的对象,一个来自虚拟机默认的类加载器在调用入口方法main时加载该类,另一个是自定义的类加载器显示的加载该类。说明类加载器不同,类也不等。

    4.2 双亲委派模型

    从Java虚拟机的角度看,只有两种类加载器。一种是启动类加载器,即BootstrapClassLoader,这个加载器是C++实现的,是虚拟机的一部分;而另一种加载器统称为其他加载器,均有Java语言实现,独立于虚拟机外部,且均继承自java.lang.ClassLoader。

    从Java开发者的角度,类加载器可以分的更细致一些,大致分为以下3种系统提供的类加载器。

      1. 启动类加载器(BootstrapClassLoader),这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或被-Xbootclasspath参数指定的路径的类库加载到内存中。这个类加载器无法被Java程序直接引用,用户如果希望把加载请求委派给引导类加载器,那直接用null代替即可,看ClassLoader.getClassLoader(Class<?> caller)代码片段,返回null说明是启动类加载器。
        // Returns the class's class loader, or null if none.
        static ClassLoader getClassLoader(Class<?> caller) {
            // This can be null if the VM is requesting it
            if (caller == null) {
                return null;
            }
            // Circumvent security check since this is package-private
            return caller.getClassLoader0();
        }
    
      1. 扩展类加载器(Extension ClassLoader),这个加载器有sun.misc.Launcher$ExtClassLoader实现的,负责加载<JAVA_HOME>\lib\ext目录下的类库,或者被java.ext.dirs系统变量指定的所有类库,开发者可以直接使用这个类库。
      1. 应用程序加载器(Application ClassLoader)、这个加载器是由sun.misc.Launcher$AppClassLoader实现的。由于这个类加载器是ClassLoader.getSystemClassLoader()的返回值,所以一般也称为系统类加载器。它负责加载用户类路径上的类库,开发者可以直接使用这个类加载器,如果程序中没有自定义累加器,一般情况下这个类加载器就是默认的程序类加载器。

    应用程序中,类加载器是相互配合使用的。它们的关系如下图所示。


    classloader.jpg

    图中的这种关系被称为类的双亲委派模型。要求除了顶层的启动类加载器外,类加载器必须有自己的父类类加载器,这里的类加载器的父子关系一般不会以继承来实现。而是组合来利用上层类加载器,双亲委派模型的工作过程是这样的:如果一个类加载器接到了加载器类请求,它首先不会自己尝试加载这个类,而是委派给父类加载器去完成,父类加载不了,自己再去加载。需要说明的是,双亲委派模型并不是强制的约束模型。

    4.3 破坏双亲委派类加载器

    上文说到双亲委派类加载器并不是一个强制的模型。到目前为止,该模型有3次较大规模的破坏。

    • 第一、JDK1.2以前还未出现双亲委派类加载器,所以向前兼容了。
    • 第二、由于双亲委派类加载器本身缺陷导致。比如JNDI是Java标准服务被放进JDK1.3,但是需要调用各厂商实现的接口,很明显启动类加载器不认识这些类库。后来Java设计团队为了解决这个问题,引入了一个不太优雅的设计:线程上下文类加载器,通过java.lang.Thread的setContextClassLoader()方法进行设置,如果线程还没设置,它将会从父线程继承一个。这就导致很尴尬的是父类加载器请求子类加载器去完成类加载的动作,这种关系与双亲委派类加载器刚好是逆向的。Java中设计SPI加载的,如JNDI、JDBC、JCE等都是如此。
    • 第三、由于对程序动态性追求导致,即热部署、热更新等。OSGI目前是Java模块化标准,它的核心是使用了自定义的类加载器机制实现的。

    相关文章

      网友评论

          本文标题:JVM虚拟机类加载机制原理

          本文链接:https://www.haomeiwen.com/subject/rumbqqtx.html