类加载机制

概述

虚拟机是如何加载Class文件的?

Class文件进入虚拟机后会有什么变化?

​ 在Java中我们知道有编译期和运行期,其中编译期为我们写的java代码通过javac编译成一个一个的class文件,而运行期则为将class文件通过jvm加载到内存中,通过一些列操作变成可以jvm直接使用的Java类型。这为Java带来了极高的扩展性和灵活性,例如动态链接和到动态加载,具体下来可用面向接口编程和一系列设计模式进行举例。

类的加载时机

​ 一个类型从加载到jvm内存中开始,到卸载出内存为止,整个生命周期经历了:

请添加图片描述

​ 其中加载,验证,准备,初始化是按部就班的开始的,解析则不一定,一些情况中解析是在初始化后的,而这也是为了支持java的运行时绑定特性。

​ 一般类的加载在《java虚拟机规范》中并没有强制要求,根据具体的虚拟机实现完成,而类的初始化则严格按照规定,有且只有六中情况,必须对类进行初始化,而前面的步骤自然也要提前完成。

  1. 遇到new,getstatic,putstatic,invokestatic 这四条字节码指令时,如果当前系统中还没加载这个类,则必须对其进行初始化。其中具体场景有:
    1. 通过new关键字创建实例时,对类进行初始化。
    2. 读取或设置一个类型的静态字段(static final 不算,因为在编译期间就把结果放在了常量池中,读取数据时,不需要初始化该类)
    3. 调用一个类的静态方法。
  2. 使用reflect包进行反射时,对类进行初始化
  3. 在该类初始化时发现其父类还没有初始化,则先将其父类进行初始化,再初始化当前类
  4. 当虚拟机启动时,用户需要执行一个主类(执行main()方法的类),虚拟机会先初始化这个类
  5. jdk7新加入的动态语言支持时,如果有一个java.long.invoke.MethodHandle实例最后解析为REF_getStatus,REF_putStatus,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,当这个方法句柄的类没有初始化时,则需要先初始化。
  6. 当一个接口中定义了JDK8新加入的默认方法,(被default关键字修饰的接口方法)时,该类的实现类发生初始化时,需要先对其接口初始化

​ 这6中方法时有且仅有的使触发类型进行初始化的场景,也被称为主动引用,其他引用方式的类型都不会触发初始化了,也被称为被动引用。下面举几个例子:

class SuperClass{
    static {
        System.out.println("this is SuperClass");
    }
    static int i = 10;
}
class SubClass extends SuperClass{
    static {
        System.out.println("this is SubClass");
    }
}
class NotInitialization{
    public static void main(String[] args) {
        /*
        通过-XX:+TraceClassLoading可以查看类的加载情况
        可以发现三个类的被加载了,但是SubClass类并没有初始化,也就是其中的static静态代码块并没执行
        而这里我们通过子类去调用父类的静态变量,则只是初始化父类信息
        
        运行结果:
        this is SuperClass
        10
         */
        System.out.println(SubClass.i);
    }
}
class NotInitialization{
    public static void main(String[] args) {
        //这里我们可以发现,在创建类型数组时,也只是对该类型进行加载,而没有初始化
        SuperClass[] list = new SuperClass[10];
    }
}
class NotInitialization{
    public static void main(String[] args) {
        /**
         * 我们可发现这里也没有打印静态代码块内容i,连ConstClass类都没有加载
         * 这是因为ConstClass.i 在编译期间通过常量的传播优化,已经将该值传入到
         * NotInitialization类的常量池中,这时候对ConstClass.i的引用就变成了对自身常量池的引用
         */
        System.out.println(ConstClass.i);
    }
}
class ConstClass{
    static {
        System.out.println("this is ConstClass");
    }
    public static final  int i = 10;
}

​ 而接口的加载与类的加载有一些不同,接口也有初始化的过程,与类一致,但是没有static静态代码块,编译器仍会为接口生成“”类构造器,用来初始化接口中定义的成员变量,而接口与类的真正区别在于,类的初始化之前会将父类都进行初始化,而接口在初始化时,并不要求父接口都完成初始化,只有在真正使用父接口时(如引用接口中的常量)才会初始化。

类加载的过程

加载

​ 在加载阶段,java虚拟机需要完成以下三件事

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

《java虚拟机规范》对这三点要求并不是很具体,供开发人员操作的空间很大

就比如说获取二进制字节流的方式就有很多

  1. 从ZIP压缩包获取,常见的JAR包,WAR包
  2. 从网络中获取,常见的有Web Applet
  3. 运行时计算生成,动态代理技术
  4. 从其他文件生成,JSP文件生成对应的Class文件
  5. 。。。

​ 相对于类加载过程的其他阶段,非数组类型的加载阶段(准确来说是加载阶段中获取类的二进制字节流的动作)是开发者可控性最高的阶段,加载阶段既可以使用java虚拟机内置的启动类加载器来完成。也可以由用户定义的类加载器完成。

​ 对于数组类而言,情况也有所不同,数组类本身不通过类加载器创建,是由Java虚拟机在内存中动态构造出来的,但数组类与类加载仍有很大关系,因为里面的元素还是要靠类加载器完成加载。一个数组类在创建中遵守以下几个规则。

  1. 如果数组类的元素为引用型数据,就递归采用原先定义好的类加载过程去加载组件,数组类将被标记在加载该组件类型的类加载器的类名称空间上。(!!!一个类型必须与类加载器一起确定唯一性)
  2. 如果数组类元素不是引用型(列入int[])Java虚拟机将会把数组类标记为与启动类加载器关联。
  3. 数组类的可访问性与他的组件类型的可访问性一致,如果组件类型不是引用类型,他的数组类的可访问性将默认为public,可被所有的类和接口访问到。

​ 加载阶段结束后,Java虚拟机外部的二进字节流就按照虚拟机所设定的格式存储在方法区之中,类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中类型数据的外部接口

​ 加载阶段与连接阶段的部分动作是交叉进行的,加载阶段尚未完成,连接阶段可能就已经开始。

类加载器

​ 对于任何一个类,都需要它的类加载器与类本身共同确定在虚拟机中的唯一性,每个类加载器都有自己的名称空间,通俗来说判断两个类是否相等,前提条件就是是否是同一个类加载器加载的,换句话说:即使是同一个Class文件源,通过不同的类加载器,两个类也就势必不同。

代码测试

这个案例中我们可以看到我们自己定义了一个类记载器,然后来加载当前这个类,并输出实例对象,都没有问题,但最后使用instance时做类型判定时发现输出为false。

这是因为我们在类的初始化中所述,当java含有main方法的启动类执行时,会默认进行类的初始化,这个时候它是由系统默认的类加载器,而当我们在main方法中创建了一个类加载器时并加载该类,在虚拟机中创建的完全就是不同的类了。

package org.fenixsoft.classloading;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {

    //org.fenixsoft.classloading.ClassLoaderTest@47089e5f
    //false
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader myloader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream inputStream = getClass().getResourceAsStream(fileName);
                    if (inputStream == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[inputStream.available()];
                    inputStream.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object instance = myloader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
        System.out.println(instance);
        System.out.println(instance instanceof org.fenixsoft.classloading.ClassLoaderTest);
    }
}
双亲委派机制

​ 从java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该加载器是用C++实现的(这里仅限HotSpot),是虚拟机的一部分,另外一种是其他所有的类加载器,又java实现,独立于虚拟机外部,并全部继承抽象类java.lang.ClassLoader

​ 从JDK1.2以来,Java一直保持着三层类加载器,双亲委派的类加载架构,尽管该架构,在java模块化系统出现了一些调整,但未改变主体架构。

下面我们来介绍什么是三层类加载器,什么是双亲委派。

  1. 启动类加载器(BootStrap Class Loader)
    1. 只加载<JAVA_HOME>/bin目录下的文件
    2. 被-Xbootclasspath参数指定的路径下文件
    3. 按照文件名(rt.jar,tools.jar)可被识别的类库
    4. 启动类加载器无法被java程序直接调用,当自定义类加载器时,需要交给启动类加载器,使用null代替即可。

通过java.lang.Class.getClassLoader()方法作为示例

 /**
     * Returns the class loader for the class.  Some implementations may use
     * null to represent the bootstrap class loader. This method will return
     * null in such implementations if this class was loaded by the bootstrap
     * class loader.
     *
     * <p> If a security manager is present, and the caller's class loader is
     * not null and the caller's class loader is not the same as or an ancestor of
     * the class loader for the class whose class loader is requested, then
     * this method calls the security manager's {@code checkPermission}
     * method with a {@code RuntimePermission("getClassLoader")}
     * permission to ensure it's ok to access the class loader for the class.
     *
     * <p>If this object
     * represents a primitive type or void, null is returned.
     *
     * @return  the class loader that loaded the class or interface
     *          represented by this object.
     * @throws SecurityException
     *    if a security manager exists and its
     *    {@code checkPermission} method denies
     *    access to the class loader for the class.
     * @see java.lang.ClassLoader
     * @see SecurityManager#checkPermission
     * @see java.lang.RuntimePermission
     */
    @CallerSensitive
    public ClassLoader getClassLoader() {
        ClassLoader cl = getClassLoader0();
        if (cl == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
        }
        return cl;
    }
  1. 扩展类加载器(Extension Class Loader):

    1. 这个类加载器是在类sun.misc.Launcher&ExtClassLoader中以Java代码形式实现的

    2. 主要负责加载<JAVA_HOME>/lib/ext目录中

    3. 或者被java.ext.dirs系统变量所指定的路径中所有的类库

    4. 这是Java系统类库的扩展机制,也就是可以将具有通用性的类库放置在ext目录里扩展Java SE的功能

      不过在JDK9之后被模块化带来的天然扩展能力替代

    5. 由于是Java编写的,开发者可直接调用

  2. 应用程序类加载器(Application Class Loader):

    1. sun.misc.Launcher$AppClassLoader来实现的。
    2. 是ClassLoader.getSystemClassLoader()方法的返回值
    3. 负责加载用户路径(ClassPath)上所有的类库
    4. 可被直接调用,如果没有自己定义,它就是系统默认的类加载器

请添加图片描述

​ 双亲委派模型要求:除了顶部的启动类加载器,剩下的类加载器都应有自己的父类加载器,这里的父类不是指继承,通常指的是组合,也就是复用父类加载器的代码。

这里的通常是指:双亲委派模型并不是强制性约束,而是推荐的一种类加载器实现的最佳实现

双亲委派模型的工作流程:

​ 当类加载器受到类加载请求时,并不会自己直接尝试加载,而是交给父类加载器,所有的请求最终都应该到启动类加载器,如果父类反馈无法加载(搜索范围找不到所需的类),子类再自己去尝试加载。

双亲委派的好处?

​ 一个好处就是可以让类也保持一种层级关系,比如java.lang.Object,每个类的超类,存放在rt.jar下,无论哪个类加载器加载,都会发送到启动类加载器加载,因此Object类在各种类加载器环境下都能保证是同一个类。

​ 这里如果不使用双亲委派模型,就会导致类关系混乱。比如用各自不同的类加载器加载Object,系统中就会有不同的Object类。比如我们自己写一个Object类,或者一个String类,编译可以通过,但是无法运行,这也是一种强有力的保护措施。

双亲委派的代码:

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 {
												// 用Bootstrap
                        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;
        }
    }
破坏双亲委派模型

验证

​ 确保class文件的二进制流数据符合java虚拟机规范,不会损害虚拟机。我们可以知道java编译器会先对代买进行格式检查,如果正确才编译成class文件,但是class文件并不一定是编译后的,也可以通过0,1直接在二进制编辑器中生成的,所以虚拟机需要再对该数据进行校验。

​ 验证阶段一般分为:文件格式校验,元数据校验,字节码校验,符号引用校验。

  1. 文件格式校验:

    1. 该阶段只要检查文件版本,魔数,索引等一些规范数据是否正常
    2. 如果没有问题,字节流才被允许进入java虚拟机内存的方法区进行存储,也就是后面的阶段都是针对方法区的存储结构进行的,不会直接读取,操作字节流了。
  2. 元数据校验:

    1. 针对类的信息进行检查,比如父类信息,子类是否实现父类所有要求实现的方法。
    2. 保证符合《Java语义规范》
  3. 字节码校验:

    1. 这一部分是整个校验部分最复杂的,负责检查语义是否合法,比如类中的方法体,进行校验分析,保证不会损害虚拟机。
    2. 需要注意如果一个类型的方法体的字节码没有通过字节码验证,说明一定有问题,但如果验证通过了,也无法保证其一定安全。

    这里引申一个概念:“停机问题”

    通俗的讲:通过程序去判断一个程序是否有问题是无法保证准确的。

    1. 后面因为设计团队不想在这个阶段花费太多的时间,JDK6之后javac编译器和虚拟机进行一项联合优化,把尽可能多的校验放在javac中,具体做法是在方法体Code属性的属性表新添加一个“StackMapTable”属性。以后虚拟机只检查它就好了,不过也有一定被篡改欺骗的风险。
  4. 符合引用校验:

    1. 发生在虚拟机将符号引用变为直接引用的时候。
    2. 判断该类是否缺少或者被禁止访问它所需要的外部资源,例如外部类,方法,字段。
    3. 这里可以做一个优化,当一个程序运行的全部代码已经被反复的使用和验证后,则可以在生产环境使用-Xverify:none参数来关闭大部分的类校验措施,缩短虚拟机加载的时间。

准备

​ 这一阶段是正式为类中定义的静态变量(static修饰的)进行初始化零值,也就是类型的默认值。这一部分数据在JDK7之前是放在方法区的,后面类变量则会随着Class对象存放在堆中。

解析

符号引用:可以指向没有加载到虚拟机中内容

直接引用:直接指向目标的指针,也就是必须是虚拟机内的

​ 将常量池中的符号引用变为直接引用。

类或接口解析

​ 假设当前代码在A类中,需要将一个符号引用B解析为一个直接引用C,则解析需要以下三步。

  1. 如果C不是一个数组类型,虚拟机会把代表B的全限定名交给A的类加载器去加载C类。
  2. 如果是数组,则按照第一步去加载元素类型。
  3. 如果上面都没问题,C已经是一个类或者接口了,但这个时候还需要进行符号引用验证,就是看是否有权限访问。
    1. 如果可以访问说明肯定是下面三种情况的一种:
      1. C为public,且与A在同一个模块儿
      2. C是public,但与A不在一个模块儿,但是 C允许A访问
      3. C不是public,但是与D在同一个包
字段解析

​ 通俗的说现在我需要解析C类的一个变量,如果C类本身含有则直接返回直接引用,如果没有就从下向上到父类和接口中寻找,如果有则返回直接引用,如果没有则抛出java.lang.NoSuchFieldError,如果成功返回来了引用还需要判断有没有权限,如果没有则派出java.lang.IllegalAccessError异常。

​ 而实际中如果父类和接口都有同名字段,则可能拒绝编译。

public class text1 {
    public static void main(String[] args) {
        C c = new C();
        // 爆出:Reference to 'i' is ambiguous, both 'B.i' and 'A.i' match
        // 无法编译
        System.out.println(c.i);
    }
}

interface A {int i = 1;}

class B {int i = 2;}

class C extends B implements A { }
方法解析

​ 与字段解析类似,解析符号引用方法,看是否本类是否有,如果有则返回直接引用,没有去父类找,如果还没有去看自己实现的接口与父接口有没有该方法,如果有说明该类是一个抽象类爆出java.lang.ABstractMethodError异常,否则返回java.lang.NoSuchMethodError,如果成功返回了,还得继续判断权限,如果没有返回:java.lang.IllegalAccessError.

接口方法解析

​ 与方法解析类似。

初始化

​ 虚拟机开始正式执行类中编写的java程序代码。对静态代码块儿进行执行,对静态变量进行赋值。这里注意:静态代码块儿与静态变量优先级一致,谁在前面先执行谁。以下例子就能说明问题:

class A {
    static {
        i = 0;
        //Error:(13, 28) java: 非法前向引用 无法进行编译
        System.out.println(i);
    }
    static  int i = 1;
}

虚拟机会保证子类初始化前,先将父类进行初始化。

public class text1 {
    public static void main(String[] args) {
        // 2
        System.out.println(B.B);
    }
}
class A {
    static  int A = 1;
    static {
        A = 2;
    }
}
class B extends A {
    static int B = A;
}
  1. 上述初始化过程其实是执行了生成的<clinit>()方法,它并不是必须的,如果类和接口没有静态代码块也没有静态变量赋值,就不会生成这个方法
  2. 接口没有静态代码块和静态变量,但是可以进行初始化值,所以也会生成<clinit>()方法,但是接口不同的是,初始化时只执行自己的<clinit>()方法,不会管父类的,只有使用父类定义变量时,才会使用。
  3. 虚拟机需要保证,在多线程情况下,类的初始化只完成一次,所以进行同步枷锁,只有一个线程去执行<clinit>()方法,其他线程阻塞,如果该方法持续很久,就会导致多线程一直阻塞这是很隐蔽的。
public class text1 {
    public static void main(String[] args) {
      //会导致线程一直阻塞。
        new Thread(()->{
            System.out.println(Thread.currentThread()+" start");
            new A();
            System.out.println(Thread.currentThread()+" init");
        }).start();
        new Thread(()->{
            System.out.println(Thread.currentThread()+" start");
            new A();
            System.out.println(Thread.currentThread()+" init");
        }).start();
    }
}
class A {
    static {
        if (true) {
            System.out.println(Thread.currentThread()+"init DeadLoopClass");
            while (true) { }
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值