深入 Java 类加载全流程,值得你收藏

先测试一番,全对的就走人

//题目一
class Parent1{
    public static String parent1 = "hello parent1";
    static { System.out.println("Parent1 静态代码块"); }
}
class Children1 extends Parent1{
    public static String children1 = "hello children1";
    static {System.out.println("Children1 静态代码块");}
}
//----------------------------------------------------------------
//题目二
class GrandParent2{
    static { System.out.println("GrandParent2静态代码块"); }
}
class Parent2 extends GrandParent2{
    public static String parent2="hello parent2";
    static{ System.out.println("Parent2 静态代码块");}
}
class Children2 extends Parent2{
    public static String children2 ="hello children2";
    static{ System.out.println("Children2 静态代码块");}
}
//----------------------------------------------------------------
//题目三
class GrandParent3{
    static { System.out.println("GrandParent3静态代码块"); }
}
class Parent3 extends GrandParent3{
    public final static String parent3="hello parent3";
    static{ System.out.println("Parent3 静态代码块");}
}
class Children3 extends Parent3{
    public static String children3 ="hello children3";
    static{ System.out.println("Children3 静态代码块");}
}
//测试
public class ClassLoaderTest {
    public static void main(String[] args) {
        //测试一的输出
        System.out.println(Children1.children1);
        System.out.println("-------------------------------");
        //测试二的输出
        System.out.println(Children2.parent2);
        System.out.println("--------------------------------");
        //测试三的输出
        System.out.println(Children3.parent3);
    }
    //你认为输出什么呢
}
答案如下

Parent1 静态代码块
Children1 静态代码块
hello children1


GrandParent2静态代码块
Parent2 静态代码块
hello parent2


hello parent3

如果看清到这里,你的回答和结果一致,那么你真的懂了,可以转载给他人了,如果出乎你的意料,请认真看完。

什么是类加载(或者初始化)

Java源代码经过编译之后转换成class文件,在系统运行期间当需要某个类的时候,如果内存中还没该class文件,那么JVM需要对这个类的class文件进行加载,连接,初始化,JVM通常会连续完成这三步,这个过程叫做类的加载或者初始化, 类从磁盘加载到内存必须经历这三个阶段的。

重点是:类的加载都是在程序运行期间完成的,这提供了无限可能,意味着你可以在某个阶段对类的字节码进行修改,JVM也确实提供了这样的功能。

类的加载并不是对象的创建,类的加载是在为对象创建前做一些信息准备。

类的生命周期

我们明白了什么是类的加载,那么从类的加载到最后类的卸载成为类在JVM中的声明周期,这个生命周期总共包含了七个阶段:我画一张图,如下,我们逐个分析一下类的生命周期的每一步。

深入Java类加载全流程,值得你收藏

这是类的生命周期的,但它不总是按照这个固定的流程进行的,我们先知道这个就行,后面再说。

加载

类的加载指的是把class文件从磁盘读入内存中,将其放入元数据区域并且创建一个Class对象,放入堆中,Class对象是类加载的最终产品,Class对象并不是new出来的对象。

元数据区域存储的信息

  1. 这个类型的完整有效名
  2. 这个类型的直接父类完整有效名
  3. 这个类型的修饰符(public final abstract等)
  4. 这个类型的直接接口的列表

Class对象中包含的如下信息,这也是我们能够通过Class对象获取类的很多信息的原因

  1. 类的方法代码,方法名,字段等
  2. 类的返回值
  3. 类的访问权限

加载class文件有很多种方式,可以从磁盘上读取,可以从网络上读取,可以从zip等归档文件中读取,可以从数据库中读取

验证

验证的目的是验证class文件的正确性,是否能够被当前JVM虚拟机执行,主要包含了一些部分验证,验证非常重要,但不是必须的(正常情况下都是正确的)
文件格式验证:比如JDK8加载的是JDK6下编译的class文件,这肯定不行。
元数据验证:确保字节码描述信息符合Java语言规范的要求,你理解为校验外壳,比如类中是否实现了接口的所有方法。
字节码验证:确定程序语义执行是合法的,校验内在,校验方法体,防止字节码执行过程中危害JVM虚拟机。
符合引用验证:其对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,比如:符号引用中的类、字段、方法的访问性是否可被当前类访问,通过全限定名,是否能找到对应的类。

准备(重点)

验证完成之后,JVM就开始为类变量(静态变量) 分配内存,设置初始化值, 记住两点

  1. 不会为成员变量分配内存的。
  2. 初始化值是指JVM默认的指,不是程序中指定的值。

看如下代码,你就明白了:

//类变量,初始化值是 null, 不是123
public static String s1 = "123"
//成员变量
public String s2 = "456"

但有一个特殊,如果一个类变量是final修饰的常量,那么在准备阶段就会被赋值为程序中指定的值,如下代码,初始值是123

//初始值是123,不是null
public static final String s1 = "123"

为什么会这样呢?两行代码的区别在于final,final在Java中代表着不可变,不能赋值了之后重新赋值,所以一开始就必须赋值为用户想要的默认值,而不是Java语言的默认值。而不是final修时的变量有可能在之后发生变化,所以就先赋值为Java语言的默认值。

解析

解析阶段主要是将常量池中的符号引用转换为直接引用,解析动作主要包含类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用。

符号引用包括什么呢?

  1. 类和方法的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符,

直接引用是是什么呢?一个指向目标的指针地址或者句柄。
举个例子如下:

// 123 是一个符号引用,123所对应的内存中的地址是一个直接引用。
public static final String s1 = "123"

常量池是什么呢?,常量池包含好多种,字符串常量池,class常量池,运行时常量池,这里指的是class常量池。我们写的每一个Java类被编译后,就会形成一份class文件,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译器生成的各种字面量和符号引用,每个class文件都有一个class常量池。

比如解析阶段,找不到某个字段就抛出NoSuchFieldError,同理NoSuchMethodError

初始化(重点)

初始化阶段用户定义的Java代码才会真正开始执行,一般来说当首次主动使用某个类的时候就会对该类初始化,初始化某个类时也会初始化这个类的父类,这里的首次主动使用,大家要理解清楚了,第二次使用时不会初始化的。类的初始化其实就是执行类构造器的过程,这个不是我们代码定义的构造方法。

下面列举了JVM初始化类的时机:

  1. 创建对象时(比如:new Person())
  2. 访问类变量时
  3. 调用类的静态方法时
  4. 反射加载某个类是(Class.forName("....."))
  5. Java虚拟机启动时被标明为启动类的类(单测时),Main方法的类。

初始化时类变量会被赋予真正的值,也就是开发人员在代码中定义的值,也会执行静态代码块。

JVM初始化类的步骤:

  1. 若该类还没有被加载和连接,则程序先加载并连接该类
  2. 若该类的父类还没有初始化,则先初始化该类的夫类
  3. 若该类中有静态代码块,则系统依次执行这些代码块

上面提到了首次主动使用时初始化类,那么就有被动使用,被动使用是什么意思呢?比如说通过子类引用父类的静态字段,那么子类会初始化吗?答案是不会的,所以下面测试的子类的静态代码块是不会执行的。

class Parent4{
    public final static String parent4="hello parent4";
}

class Children4 extends Parent4{
    static{ System.out.println("Children4 静态代码块");}
}
public class ClassLoaderTest {
    public static void main(String[] args) {
        //测试四的输出
        System.out.println(Children4.parent4);
    }
}

再说一个点解析时有提到常量池的概念,在经过初始化后,类就被加载到内存中去了,这个时候jvm就会将class常量池中的内容存放到运行时常量池中,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中是一致的

上面还有一个关键字一般来说,那么不一般呢?类加载器并不需要等到某个类被首次主动使用时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它.

使用

使用就比较简单了,JVM初始化完成后,就开始按照顺寻执行用户代码了。

卸载

类卸载有个前提,就是class的引用是空的,要么程序中手动置为空,要么进程退出时JVM销毁class对象,然后JVM退出。只要class引用不存在,那么这个类就可以回收了。

你自己可以试验一下,写一个classload类加载器,写一个Test测试类,实际测试一下,我的测试代码如下:

public class ClassTest {
    public static void main(String[] args){
        ClassLoaderMy classLoader = new ClassLoaderMy();
        classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
        Class clazz = classLoader.findClass("jvm.Test类中有一个静态代码块。");
        Object obj = clazz.newInstance();
        System.out.println("1:"+clazz.hashCode());
        obj=null;
        System.out.println("2:"+clazz.hashCode());
        classLoader = null;
        System.out.println("3:"+clazz.hashCode());
        clazz = null;

        System.out.println("此时 obj classloader clazz 都为空了");

        classLoader = new ClassLoaderMy();
        classLoader.setRoot("D:\\github\\java_common\\target\\classes\\");
        clazz = classLoader.findClass("jvm.Test");
        System.out.println("4:"+clazz.hashCode());
        obj = clazz.newInstance();
    }
    //打印结果如下,看之前你猜一猜。Test类中有一个静态代码块。
}

初始化了
1:1775282465
2:1775282465
3:1775282465
此时 obj classloader clazz 都为空了
4:1267032364
初始化了

最终结果你会发现,前三个hashcode的值是一样的,第四个的值发生了变化,说明class文件被卸载了后重新加载生成了新的class对象,否则,同一个对象的hashcode是不会发生变化的,而且Test类的静态代码块执行了两遍,完整代码地址如下:

https://github.com/sunpengwei1992/java_common/tree/master/src/jvm

我画了一张图,方便大家更好的理解,如下,当左边的三个变量都指向为null时,最右边的元数据区域的代表Class对象的Test二进制数据就会被卸载,当下次使用时就会被重新加载,初始化等。

深入Java类加载全流程,值得你收藏

但是,注意了 由JVM自带的类加载器加载的类,在JVM生命周期中,始终不会被卸载,
JVM自带的类加载器包括根类加载器,扩展类加载器,系统类加载器,这些回头单聊。

解密测试题目

接下来我们聊一聊一开始的测试题,其实看到这里,想必大家都明白了吧,还是说一说。

第一个不用讲了,都会。

第二题:子类Children2,父类Parent2, 祖父类GrandParent2,我们通过Chidlren2打印父类Parent2的静态变量,类加载时,发现有父类存在,逐层往上加载,那么Parent2和GrandParent2都会被加载,所以Parent2和GrandParent2的静态代码块都会被执行,而Children2就不会被加载了,因为不符合首次主动使用的条件。

第三题:同样的道理,只是Parent3和GrandParent3的静态代码块为什么没执行呢,因为Parent3的静态变量是final类型的,在准备阶段就已经完成了,不需要再逐层往上加载了.

提一下接口的加载

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会加载该接口,如下代码,执行main方法,Parent5接口是不会被加载的,parent5变量也是不会被初始化的。

interface Parent5{
    public final static  String parent5 = "hello parent5";
}
interface Children5 extends Parent5{
    public final static String children5 = "hello children5";
}
public static void main(String[] args) {
    System.out.println(Children5.children5);
}

表格整理一下流程

深入Java类加载全流程,值得你收藏

深入Java类加载全流程,值得你收藏

本作品采用《CC 协议》,转载必须注明作者和本文链接
那小子阿伟
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!