19

深入Java类加载全流程,值得你收藏 - 阿伟~ - 博客园

 4 years ago
source link: https://www.cnblogs.com/sy270321/p/12258421.html?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入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中的声明周期,这个生命周期总共包含了七个阶段:我画一张图,如下,我们逐个分析一下类的生命周期的每一步。

706455-20200204095346534-2005405466.png

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

类的加载指的是把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二进制数据就会被卸载,当下次使用时就会被重新加载,初始化等。

但是,注意了 由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);
}

表格整理一下流程


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK