美文网首页Java 虚拟机程序员IT@程序员猿媛
【Java 虚拟机笔记】类加载机制相关整理

【Java 虚拟机笔记】类加载机制相关整理

作者: 58bc06151329 | 来源:发表于2019-03-12 16:28 被阅读42次

    文前说明

    作为码农中的一员,需要不断的学习,我工作之余将一些分析总结和学习笔记写成博客与大家一起交流,也希望采用这种方式记录自己的学习之旅。

    本文仅供学习交流使用,侵权必删。
    不用于商业目的,转载请注明出处。

    1. 概述

    • Class 文件由类装载器装载后,在 Java 虚拟机中将形成一份描述 Class 结构的元信息对象,通过该元信息对象可以获知 Class 的结构信息:如构造函数,属性和方法等,Java 允许用户借由这个 Class 相关的元信息对象间接调用 Class 对象的功能。
    • 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
    • Java 虚拟机类加载的生命周期是从类被加载到内存开始,直到卸载出内存为止的。
      • 整个生命周期分为 7 个阶段:加载、验证、准备、解析、初始化、使用、卸载
      • 验证、准备、解析三部分统称为连接。
    类的生命周期

    2. 类的加载过程

    • Java 类加载器把一个类加载到 Java 虚拟机中,要经过以下步骤。
      • 加载:查找和导入 Class 文件。
      • 链接:把类的二进制数据合并到 JRE 中。
        • 验证:检查载入 Class 文件数据的正确性。
        • 准备:给类的 静态变量 分配存储空间。
        • 解析:将符号引用转成直接引用。
      • 初始化:对类的 静态变量,静态代码块 执行初始化操作。

    2.1 加载(Loading)

    • 加载主要是将 .class 文件(并不一定是 .class。可以是 ZIP 包,网络中获取)中的二进制字节流读入到 Java 虚拟机中。
      • 在加载阶段,Java 虚拟机需要完成 3 件事。
        • 通过类的全限定名获取该类的二进制字节流。
        • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构(Class 常量池)。
        • 在内存中生成一个该类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
    • 虚拟机规范中并没有准确说明二进制字节流应该从哪里获取以及怎样获取,这里可以通过定义自己的类加载器去控制字节流的获取方式。
    • 加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

    2.1.1 类加载器

    • 对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
      • 即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类必定不相等。
    类加载器
    • 绝大部分 Java 程序都会使用到以下几种系统提供的类加载器。
      • 启动类加载器(Bootstrap ClassLoader),将存放于 <JAVA_HOME>\lib 目录中或者被 -Xbootclasspath 参数所指定的路径中的并且是虚拟机识别的(仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
        • 启动类加载器无法被 Java 程序直接引用。
        • Hotspot 中该类加载器使用 C++ 语言实现,是虚拟机自身的一部分。
        • JRockit 和 J9 中由 Java 语言实现,但关键方法的实现仍然使用 JNI 回调到 C 语言的实现上,这个类加载器的实例也无法被用户获取到。
        • 其他类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader
        • 启动类加载器无法被 Java 程序直接引用,用户编写自定义类加载器时,如果需要把加载器请求委派给引导类加载器,那么直接使用 null 代替即可。
      • 扩展类加载器(Extension ClassLoader) ,将 <JAVA_HOME>\lib\ext 目录下或者被 java.ext.dirs 系统变量所指定的路径中的所有类库加载。
        • 开发者可以直接使用扩展类加载器。
        • 这个加载器由 sun.misc.Launcher$ExtClassLoader 实现。
      • 应用程序类加载器(Application ClassLoader),也叫做系统类加载器,可以通过 ClassLoader 类中的 getSystemClassLoader() 方法获取,负责加载用户路径(classpath)上的类库。
        • 如果没有自定义类加载器,一般这个就是默认的类加载器。
        • 这个加载器由 sun.misc.Launcher$AppClassLoader 实现。
      • 自定义类加载器(Custom ClassLoader),负责加载用户自定义的 jar 包。

    双亲委派模型

    • 类加载器之间的这种层次关系叫做 双亲委派模型
      • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
      • 类加载器之间的父子关系一般不会以继承的关系来实现,而是都是用组合关系来复用父加载器的代码。
    • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
    • 双亲委派机制的最大优点就是使得 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。尤其是保证了基础类的统一性(避免同一个类被多个类加载器重复加载),保证了 Java 程序的稳定运行。
      • 例如 java.lang.Object 类,放在 rt.jar 中,无论是哪一个类加载器加载此类,最终都委派给处于最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。

    java.lang.ClassLoader

    //加载指定名称(包括包名)的二进制类型,供用户调用的接口
    public Class<?> loadClass(String name);
    //加载指定名称(包括包名)的二进制类型,同时指定是否解析(但是,这里的resolve参数不一定真正能达到解析的效果),供继承用
    protected synchronized Class<?> loadClass(String name, boolean resolve);
    protected Class<?> findClass(String name)
    //定义类型,一般在findClass方法中读取到对应字节码后调用,可以看出不可继承(说明:JVM已经实现了对应的具体功能,解析对应的字节码,产生对应的内部数据结构放置到方法区,所以无需覆写,直接调用就可以了)
    protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError{}
    
    • 实现双亲委派模型的主要代码。
    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;
        }
    }
    

    OSGi

    • Java 中通过 OSGi 实现模块化热部署,其关键则是自定义的类加载器机制的实现。
      • 每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。
    • 在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照以下顺序进行类搜索。
      1. 将以 java.* 开头的类委派给父类加载器加载。
      2. 否则,将委派列表名单内的类委派给父类加载器加载。
      3. 否则,将 import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载。
      4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
      5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。
      6. 否则,查找 Dynamic Import 列表中的 Bundle,委派给对应 Bundle 的类加载器加载。
      7. 否则,类查找失败。

    2.2 校验(Verification)

    • 验证是连接阶段的第一步,目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
    • 验证阶段大致分为 4 个验证动作。
      • 文件格式验证,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求,可查看 【Java 虚拟机笔记】类文件结构相关整理
        • 该阶段是基于二进制字节流验证的,只有通过了这个阶段的验证,字节流才会进入内存的方法去中存储,后面的 3 个验证都是基于方法区的存储结构进行的。
        • 主要的验证有:是否以魔数开头;主、次版本号是否在当前虚拟机处理范围内;常量池的常量数据类型是否被支持等。
      • 元数据验证,对字节码描述信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
        • 主要的验证有:是否有父类;是否继承了不被允许继承的类;如果该类不是抽象类,是否实现了其父类或接口要求实现的所有方法等。
      • 字节码验证,通过数据流和控制流分析,确定程序语义的合法性和逻辑性。
        • 该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事情。
        • 主要的验证有:保证任何时候操作数栈的数据类型与指令代码序列的一致性;跳转指令不会跳转到方法体以外的字节码指令上等。
        • 是验证过程中最复杂的一个阶段,由于数据流验证的高复杂性,JDK 1.6 之后进行了一项优化,为 Code 属性的属性表增加了 StackMapTable 属性,该属性描述了方法体中所有的基本块(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在验证期间就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。
          • JDK 1.6 的 HosSpot 虚拟机中提供了 -XX:-UseSplitVerifier 选项关闭这项优化,或者使用参数 -XX:+FailOverToOldVerifier 要求在类型校验失败的时候退回到旧的类型推导方式进行校验。
          • JDK 1.7 之后,对于主版本号大于 50 的 Class 文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到类型推导的校验方式。
      • 符号引用验证,保证解析动作能正常执行,如果无法通过符号引用验证,则会抛出异常(抛出 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等)。
        • 主要验证有:符号引用的类、字段、方法的访问性(public、private 等)是否可被当前类访问;指定类是否存在符合方法的字段描述符等。
    • 验证阶段非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverify:none 参数关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

    2.3 准备(Preparation)

    • 类变量(被 static 修饰的变量)在方法区分配内存,并设置 零值
      • 注意:这里是类变量,不是实例变量,实例变量是对象分配到堆内存时根据运行时动态生成的。
    • 基本数据类型的零值如下表所示。
    数据类型 零值
    int 0
    long 0L
    short (short) 0
    char '\u0000'
    byte (byte) 0
    boolean false
    float 0.0f
    double 0.0d
    reference null
    • 下例中,value 在准备阶段过后的初始值为 0 而不是 1,而把 value 赋值的 putstatic 指令将在初始化阶段才会被执行。
    public static int value = 1;
    
    • 特殊情况中,当类字段的字段属性是 ConstantValue 时,会在准备阶段初始化为指定的值,标注为 final 之后,value 的值在准备阶段初始化为 1 而非 0。
    public static final int value=1;
    

    2.4 解析(Resolution)

    • 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7符号引用 进行,分别对应常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info 和 CONSTANT_InvokeDynamic_info 7 种常量类型。
      • 符号引用(Symbolic References),以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
        • 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
        • 各种虚拟机实现的内存布局可以各不相同,但是能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
      • 直接引用(Direct References),可以是直接指向目录的指针、相对偏移量或是一个能间接定位到目标的句柄。
        • 直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,
        • 如果有了直接引用,那引用的目标必定已经在内存中存在。

    2.5 初始化(Initialization)

    • 类初始化阶段是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
      • 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化 类变量和其他资源
      • 初始化阶段是执行类构造器 <clinit>() 方法的过程。

    • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 static{} 中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
    public class Test
    {
        static
        {
            i = 0;                          //给变量赋值可以正常编译通过。
            System.out.println(i);          //这句编译器会提示:Cannot reference a field before it is defined(非法向前引用)。
        }
        static int i = 1;
    }
    
    • <clinit>() 方法与实例构造器 <init>() 方法不同,它不需要显示地调用父类构造器,虚拟机会保证在子类 <init>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。
      • 因此在虚拟机种第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object。
      • 由于父类的 <clinit>() 方法先执行,也就意味着 父类中定义的静态语句块要优先于子类的变量赋值操作
    public class Test {
    
        static class Parent {
            public static int A = 1;
    
            static {
                A = 2;
            }
        }
    
        static class Sub extends Parent {
            public static int B = A;
        }
    
        public static void main(String[] args) {
            System.out.println(Sub.B);
        }
    
    }
    /*print 
    2
    */
    
    • <clinit>() 方法对于类或者接口来说并不是必需的,如果 一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产 <clinit>() 方法
      • 通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。
      • 接口与类不同的是执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。
      • 当父接口中定义的变量使用时,父接口才会初始化。
      • 接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
    • 虚拟机会保证 一个类的 <clinit>() 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕
      • 如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
    public class Test {
    
        static class DeadLoopClass {
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass.");
                    while (true) {
    
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            Runnable script = new Runnable() {
                @Override public void run() {
                    System.out.println(Thread.currentThread() + "start.");
                    DeadLoopClass dlc = new DeadLoopClass();
                    System.out.println(Thread.currentThread() + "run over.");
                }
            };
    
            Thread thread1 = new Thread(script);
            Thread thread2 = new Thread(script);
            thread1.start();
            thread2.start();
        }
    
    }
    /*print
    Thread[Thread-0,5,main]start.
    Thread[Thread-1,5,main]start.
    Thread[Thread-0,5,main]init DeadLoopClass.
    */
    
    • 需要注意的是,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出 <clinit>() 方法后,其他线程唤醒之后不会再次进入 <clinit>() 方法。
      • 同一个类加载器下,一个类型只会初始化一次。
    public class Test {
    
        static class DeadLoopClass {
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass.");
                    try {
                        TimeUnit.SECONDS.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            Runnable script = new Runnable() {
                @Override public void run() {
                    System.out.println(Thread.currentThread() + "start.");
                    DeadLoopClass dlc = new DeadLoopClass();
                    System.out.println(Thread.currentThread() + "run over.");
                }
            };
    
            Thread thread1 = new Thread(script);
            Thread thread2 = new Thread(script);
            thread1.start();
            thread2.start();
        }
    
    }
    /*print
    Thread[Thread-0,5,main]start.
    Thread[Thread-1,5,main]start.
    Thread[Thread-0,5,main]init DeadLoopClass.
    Thread[Thread-0,5,main]run over.
    Thread[Thread-1,5,main]run over.
    */
    

    对类进行初始化的时机

    • 虚拟机规范严格规定了有且只有 5 种情况(JDK 1.7)必须对类进行 " 初始化 "(而加载、验证、准备自然需要在此之前开始)。
      • 使用 new 该类实例化对象的时候(new 创建实例字节码指令)。
      • 读取或设置类静态字段的时候(但被 final 修饰的字段,在编译器时就被放入常量池的静态字段除外 static final)(getstatic 和 putstatic 指令)。
      • 调用类静态方法的时候(invokestatic 指令)。
      • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
      • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
      • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
      • 当使用 JDK 1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getstatic、REF_putstatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

    不进行初始化情况

    • 通过子类引用父类的静态字段,不会导致子类初始化。
    public class Test {
    
        static class Parent {
            public static int A = 1;
    
            static {
                System.out.println("ParentClass init.");
            }
        }
    
        static class Sub extends Parent {
            static {
                System.out.println("SubClass init.");
            }
        }
    
        public static void main(String[] args) {
            System.out.println(Sub.A);
        }
    
    }
    /*print
    ParentClass init.
    1
    */
    
    • 通过数组定义来引用类,不会触发此类的初始化。
    public class Test {
    
        static class DeadLoopClass {
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass.");
                    try {
                        TimeUnit.SECONDS.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            DeadLoopClass dlc = new DeadLoopClass();
        }
    
    }
    /*print
    Thread[main,5,main]init DeadLoopClass.
    */
    
    public class Test {
    
        static class DeadLoopClass {
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass.");
                    try {
                        TimeUnit.SECONDS.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            DeadLoopClass[] dlcs = new DeadLoopClass[10];
        }
    
    }
    /*print
    */
    
    • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
    public class Test {
    
        static class DeadLoopClass {
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass.");
                }
            }
    
            public static final int A = 1;
        }
    
        public static void main(String[] args) {
            System.out.println(DeadLoopClass.A);
        }
    
    }
    /*print
    1
    */
    

    init 和 clinit 区别

    • init 和 clinit 方法执行 时机不同
      • init 是对象构造器方法,在程序执行 new 一个对象调用该对象类的 constructor 方法时才会执行 init 方法。
      • clinit 是类构造器方法,也就是在虚拟机进行类初始化阶段虚拟机会调用 clinit 方法。
    • init 和 clinit 方法执行 目的不同
      • init 是 instance 实例构造器,对非静态变量解析初始化。
      • clinit 是 Class 类构造器对静态变量,静态代码块进行初始化。

    static{} 静态代码块与 {} 普通代码块异同点

    • 相同点,都在虚拟机加载类时且在构造方法执行前执行,类中可以定义多个。
    • 不同点,静态代码块在普通代码块之前执行(静态代码块 -> 普通代码块 -> 构造方法)。
      • 静态代码块只在第一次执行 new 执行一次之后不再执行。
      • 普通代码块,new 一次执行一次。

    参考资料

    https://blog.csdn.net/w760079528/article/details/77845267
    https://blog.csdn.net/shengmingqijiquan/article/details/77508471
    http://www.cnblogs.com/ygj0930/p/6536048.html
    https://www.cnblogs.com/ITtangtang/p/3978102.html
    https://blog.csdn.net/noaman_wgs/article/details/74489549
    http://www.importnew.com/18548.html

    相关文章

      网友评论

        本文标题:【Java 虚拟机笔记】类加载机制相关整理

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