第二篇章:类加载子系统

类加载器子系统

本专栏学习内容来自尚硅谷宋红康老师的视频以及《深入理解JVM虚拟机》第三版

有兴趣的小伙伴可以点击视频地址观看,也可以点击下载电子书

作用

在这里插入图片描述

  • 类加载器子系统负责从文件系统或网络中加载.class文件,.class文件在文件开头有特定的标识
  • ClassLoader只负责.class文件的加载,至于它是否能够运行,则有Execution Engine决定
  • 加载的类信息存放在一块称为方法区的内存空间。除了类的信息外,方法去还会存放运行时常量池信息,可能还包括字符串字面量和数值常量(这部分常量信息时.class文件中常量池部分的内存映射)

类的加载过程

如下图所示,类的加载过程分为三步,分别是加载——链接——初始化

在这里插入图片描述

加载

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

链接

验证(Verification)

目的在于确保.class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。从整体上看,验证阶段大致上会完成下面四个阶段的检验动作

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

如下图所示,这是文件格式验证中的一小点,所有能够被JVM所识别的字节码文件的起始都是CA FE BA BE,可以亲切的称呼它为“咖啡宝贝”

在这里插入图片描述

准备(Preparation)

为类变量分配内存并且设置该类型的默认初始值,比如int类型为0,引用类型为null。

需要注意的是这里不包含用final修饰的static,因为final在编译时就会分配了,准备阶段会显式初始化。

不会为实例变量设置初始值,类变量会分配在方法区中,而实例变量会随对象一起分配到Java堆中。

//在这个例子中,变量a在preparation阶段会被赋值为0,到了initialization阶段才会被赋值为1
public class Demo01 {
    private static int a = 1;
    
    public static void main(String[] args) {
        System.out.println(a);
    }
}
解析(Resolution)

解析是将常量池内的符号的引用转化为直接引用的过程,事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。

还是看上述例子,虽然代码只有短短的几行,但是里面生成了很多对象,这些对象在常量池中是以符号存在,而解析的作用就是将这些符号转换成具体的对象。

以下是上述代码反编译的结果,可以看到它不仅加载了本身的类,还加在了Object、System类

在这里插入图片描述

初始化

  • 初始化阶段就是执行类构造器方法<clinit>()的过程
  • 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来的
  • 构造器方法中指令按语句在源文件中出现的顺序执行
  • <clinit>()不同于类构造器,Java中所说的类构造器在虚拟机中是<init>()
  • 若该类具有父类,JVM会保证在子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

这里给大家推荐一款IDE插件,jclasslib Bytecode Viewer,可以更好的观察.class文件反编译的结果

验证

验证:构造器方法中指令按语句在源文件中出现的顺序执行

如下图所示,按照小黄原始逻辑,应该先定义变量再执行赋值,所以输出应该是20,但JVM给小黄上了一课。

其实小黄的逻辑是正确的,确实是先定义变量,但在JVM中定义静态变量的过程在链接中的准备阶段就已经定义了一个b=0的变量,在初始化中按照顺序执行,所以结果为10!

这里顺带提几句字节码文件的指令

bipush 20:入栈,范围为-128~127的int型

putstatic #3:从栈顶取值,保存到#3的对象中

在这里插入图片描述

验证:JVM会保证在子类的<clinit>()执行前,父类的<clinit>()已经执行完毕

如下图所示,在给Son类中的b赋值时,会调用Son中a的值,由此可以证明父类的a已经初始化完成

在这里插入图片描述

验证:虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

通过以下代码我们可以发现一个线程初始化DeadThread时,其他线程是无法初始化DeadThread类的。

需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条现场退出<clinit>()方法后,其他线程唤醒后不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。

public class Demo04 {
    public static void main(String[] args) {
        Runnable r = () ->{
            System.out.println(Thread.currentThread().getName() + ":开始");
            DeadThread deadThread = new DeadThread();
            System.out.println(Thread.currentThread().getName() + ":结束");
        };

        Thread t1 = new Thread(r,"线程1");
        Thread t2 = new Thread(r,"线程2");

        t1.start();
        t2.start();
    }
}

class DeadThread{
    static {
        if (true){
            System.out.println(Thread.currentThread().getName() + ":初始化当前类");
            while (true){

            }
        }
    }
}

//结果
线程2:开始
线程1:开始
线程2:初始化当前类

类加载器的分类

JVM支持两种类型的类加载器,分别是引导类加载器(BootStrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。

自定义类加载器不是我们传统概念上由开发人员自定义的一类类加载器,而是将所有派生于ClassLoader抽象类的类加载器都划分为自定义类加载器。

如下图所示,无论类加载器的类型如何划分,在程序中我们最常见的始终只有3个,这三者是包含关系,不是继承关系。

在这里插入图片描述

使用类加载器

从Java的结构树上看,AppClassLoader和ExtClassLoader都间接的继承了ClassLoader抽象类,所以两者同属于自定义类加载器。我们可以通过get方法获取某个类加载器的上级。引导类加载器是不属于Java的本身的,他是用C、C++实现的,所以在这里获取不到引导类加载器。

从以下代码中可以看出,自定义类是使用系统类加载器加载的,而String的类加载器是引导类加载器,这里要提一句,Java的核心类库都是使用引导类加载器加载的

public class Demo05 {
    public static void main(String[] args) {
        //系统类加载器 - AppClassLoader
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        //扩展类加载器 - ExtClassLoader
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@7440e464

        //引导类加载器
        ClassLoader bootStrapClassLoader = extClassLoader.getParent();
        System.out.println(bootStrapClassLoader);//null

        //自定义类的类加载器
        ClassLoader classLoader = Demo05.class.getClassLoader();
        System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        //String的类加载器
        ClassLoader stringClassLoader = String.class.getClassLoader();
        System.out.println(stringClassLoader);//null
    }
}

虚拟机自带的加载器

引导类加载器(启动类加载器,Bootstrap ClassLoader)
  • 这个类加载器使用C/C++语言实现的,嵌套在JVM内部
  • 它用来加载Java核心库,用于提供JVM自身需要的类
  • 并不继承于java.lang.ClassLoader,没有父加载器
  • 用于加载扩展类加载器以及系统类加载器,并指定为它们的父类加载器
  • 处于安全考虑,Bootstrap启动类加载器之加载包名为java、javax、sun等开头的类

运行以下代码,可以获取到使用引导类加载器加载的类

public class Demo06 {
    public static void main(String[] args) {
        System.out.println("***********引导类加载器**********");
        URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        for (URL url : urLs){
            System.out.println(url.toExternalForm());
        }
    }
}

//结果
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/resources.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/rt.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/sunrsasign.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/jsse.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/jce.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/charsets.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/lib/jfr.jar
file:/Library/Java/JavaVirtualMachines/jdk1.8.0_321.jdk/Contents/Home/jre/classes
扩展类加载器(Extension ClassLoader)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
  • 派生于ClassLoader类
  • 父类加载器为引导类加载器
  • 从java.ext.dirs系统属性所指定目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会有扩展类加载器加载

运行以下代码,可以获取到使用扩展类加载器加载的类

public class Demo06 {
    public static void main(String[] args) {
        System.out.println("***********扩展类加载器**********");
        String property = System.getProperty("java.ext.dirs");
        for (String path : property.split(";")){
            System.out.println(path);
        }
    }
}

//结果
D:\Java\jdk1.8.0_291\jre\lib\ext
C:\WINDOWS\Sun\Java\lib\ext
系统类加载器(System ClassLoader)
  • Java语言编写,由sun.misc.Launcher$AppClassLoader实现
  • 派生于ClassLoader抽象类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性
  • 该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

获取类加载器的方法

public class Demo07 {
    public static void main(String[] args) throws ClassNotFoundException {
        //获取当前类的类加载器 - 系统类加载器
        ClassLoader demo07 = Class.forName("com.example.practice2.Demo07").getClassLoader();
        ClassLoader classLoader = Demo07.class.getClassLoader();
        System.out.println(demo07); //sun.misc.Launcher$AppClassLoader@18b4aac2
        System.out.println(classLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2

        //获取当前线程上下文的类加载器
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println(contextClassLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2

        //获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2
    }
}

双亲委派机制

工作原理

JVM对Class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将他的class文件加载到内存生成class对象。而加载某个类的class文件时,Java虚拟机采用的是双亲委派机制,即把请求交由父类处理,它是一种任务委派模式。

通过以下这个代码从概念上理解什么是双亲委派机制,创建一个java.lang.String类,这是跟Java核心包下的String类路径相同的,执行代码我们发现并没有输出静态代码块中的语句。

在这里插入图片描述

public class Demo08 {
    public static void main(String[] args) {
        String s = new String();
        System.out.println("test");
    }
}

这就验证了双亲委派机制,当java.lang.String类需要被加载的时,系统类加载器会将它委托给扩展类加载器,扩展类加载器会将它委托给引导类加载器,引导类加载器发现该类是在java包下的,应该由他来加载,由他加载后将结果返回。

有点类似于递归的思想,如果引导类加载器不加载该类,那么扩展类加载器会尝试加载该类,发现也不是它的任务,最后返回给系统类加载器进行加载。

代码实现

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

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异常
                // 说明父类无法加载该类
            }

            if (c == null) {
                // 当父类加载器无法加载该类是,调用本身的findClass尝试进行加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // 记录统计数据(可忽略)
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心 API被随意篡改

上述例子中我们举了String的例子,试想一下如果没有双亲委派机制,直接由系统类加载器加载,如果对方使用网络方式传入String对象中有恶意代码,那么程序中只要使用到String都会加载它的恶意代码,对程序造成了极大的危害。

再来看一个例子,如果在自定义的java.lang包下创建一个核心包中不存在的类,加载时会直接报错,引导类加载器会检测到这是禁止的一个类名,不允许加载。

在这里插入图片描述

沙箱安全机制

自定义的String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件,报错信息说没有main方法,就是因为加载的是核心包下的String类。这样可以保证对Java核心源代码的保护,这就是沙箱安全机制。

在这里插入图片描述

类加载的时机

在JVM中表示两个class对象是否为同一个类存在两个必要条件

  • 类的完整类名必须一致,包括包名。
  • 加载这两个类的ClassLoader必须相同

如以下代码所示,一个简单的自定义类加载器,通过自定义类加载器加载Demo11类,通过getClass()方法得到结果加载的类属于com.example.practice2.Demo11,当我们使用instanceof与com.example.practice2.Demo11类比较时,返回false。这是o使用自定义类加载器加载,而com.example.practice2.Demo11类使用系统类加载器加载,虽然他们来自同一个Class文件,但在JVM中仍然是两个互相独立的类,做对象所属类型检查时结果自然为false。

public class Demo11 {
    public static void main(String[] args) throws Exception {
        ClassLoader myClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1)+".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);
                }
            }
        };

        Object o = myClassLoader.loadClass("com.example.practice2.Demo11").newInstance();
        System.out.println(o.getClass()); //class com.example.practice2.Demo11
        System.out.println(o instanceof com.example.practice2.Demo11); //false
    }
}

对类加载器的引用

JVM必须知道一个类是由启动类加载器加载的还是由用户类加载器加载的。

如果一个类是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为信息的一部分保存在方法区中。

当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

对类的主动使用和被动使用

Java程序对类的使用方式分为:主动使用和被动使用

  • 主动使用分为七种情况
    • 创建类的实例
    • 访问某个类或接口的静态变量,或者对该静态变量赋值
    • 调用类的静态方法
    • 反射
    • 初始化一个类的子类
    • Java虚拟机启动时被标明为启动类的类
    • JDK7开始提供的动态语言支持
  • 除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化

来看一下几种被动使用类的案例

通过子类引用父类的静态字段,不会导致子类初始化

以下代码结果只会输出SuperClass,对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化

public class Demo09 {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

class SuperClass{
    static {
        System.out.println("SuperClass");
    }

    public static int value = 123;
}

class SubClass extends SuperClass{
    static {
        System.out.println("SubClass");
    }
}

通过数组定义来引用类,不会触发此类的初始化

public class Demo09 {
    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

class SuperClass{
    static {
        System.out.println("SuperClass");
    }

    public static int value = 123;
}

常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的 类的初始化

以下代码运行后,并没有输出SuperClass,虽然在Java代码中引用了SuperClass.value,但其实在编译阶段通过常量传播优化,已经将此常量的值直接存在NotInitialization类的常量池中,以后NotInitialization对常量SuperClass.value的引用,实际上都被转化成了NotInitialization类对自身常量池的引用了。这两个类在编译成Class文件后就不存在任何联系了。

public class Demo09 {
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}

class SuperClass{
    static {
        System.out.println("SuperClass");
    }

    public final static int value = 123;
}

最后,接口的加载过程与类加载过程稍有不同,当一个类在初始化时,要求其父类全部已经初始化完成;但是一个接口在初始化时,并不要求其父接口全部都初始化完成,只有在真正使用到父接口的时候才会初始化

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值