Trembling ! Java类的加载过程详解

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/J080624/article/details/82284072

【1】类的生命周期

一个类从加载进内存到卸载出内存为止,一共经历7个阶段:

加载—>验证—>准备—>解析—>初始化—>使用—>卸载

其中,类加载包括5个阶段:

加载—>验证—>准备—>解析—>初始化

在类加载的过程中,以下3个过程称为连接:

验证—>准备—>解析

因此,JVM的类加载过程也可以概括为3个过程:

加载—>连接—>初始化

C/C++在运行前需要完成预处理、编译、汇编、连接;而在Java中,类加载(加载、连接、初始化)是在程序运行期间完成的。

在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处—–提高程序的灵活性。

Java语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载和动态连接。

扫描二维码关注公众号,回复: 3112040 查看本文章

【2】类加载的时机

① 类加载过程中每个步骤的顺序

我们已经知道,类加载的过程包括:加载、连接、初始化;连接又分为:验证、准备、解析;所以说类加载的过程一共分为5步:加载、验证、准备、解析、初始化。

其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。

而解析过程会发生在初始化过程中。


② 类加载过程中“初始化”开始的时机

JVM规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始(解析除外)。这些过程具体在何时开始,JVM规范并没有定义,不同的虚拟机可以根据具体的需求自定义。

初始化开始的时机

  1. 在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic等。这四个指定对应的Java代码场景是:

    • 通过new创建对象;
    • 读取、设置一个类的静态成员变量(不包括final修饰的静态变量);
    • 调用一个类的静态成员函数,即静态方法;
  2. 使用java.lang.reflect进行反射调用的时候,如果类没有初始化,那就需要初始化。

  3. 当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类。

  4. 当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类。


③ 主动引用与被动引用

JVM规范中要求在程序运行过程中,“当且仅当”出现上述4个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。

其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用。

那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。


④ 被动引用的场景示例

示例一:

public class Fu{
    public static String name="柴毛毛";
    static{
        System.out.println("父类被初始化!");
    }
}

public class Zi extends Fu{
    static{
        System.out.println("子类被初始化!");
    }
}

public TestClass{
    public void static main(String[] args){
        System.out.println(Zi.name);
    }
}

测试结果:

父类被初始化!
柴毛毛

原因分析:

本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。

但由于这个静态成员变量属于Fu类,Zi类只是间接调用Fu类中的静态成员变量,因此Zi类调用name属性属于间接引用。而Fu类调用name属性属于直接引用,由于JVM只初始化直接引用的类,因此只有Fu类被初始化。

这里需要注意的是,main方法在其他类中,如果在Zi类中执行main方法,JVM同样会初始化Zi类:

public class Zi extends Fu {
    static{
        System.out.println("子类被初始化!");
    }

    public static void main(String[] args){

        System.out.println(Zi.name);

    }
}

此时输出结果:

父类被初始化!
子类被初始化!
柴毛毛

示例二:

public class A {

    public static void main(String[] args){

        Fu[] arr = new Fu[10];

    }
}

输出结果:为空,并没有输出“父类被初始化!”。

原因分析:

这个过程看似满足初始化时机的第一条:遇到new创建对象时若类没被初始化,则初始化该类。

但现在通过new要创建的是一个数组对象,而非Fu类对象,因此也属于间接引用,不会初始化Fu类。


示例三:

public class Fu {

    public static final String name="柴毛毛";
    static{
        System.out.println("父类被初始化!");
    }
}

public class A {

    public static void main(String[] args){

        System.out.println(Fu.name);

    }
}

输出结果:

柴毛毛

原因分析:

本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。

但是Fu类的静态成员变量被final修饰,它已经是一个常量。

被final修饰的常量在Java代码编译(java源文件编译成class字节码)的过程中就会被放入引用它的class文件的常量池中(这里是A的常量池)。所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。

有说法称之为“常量传播优化”。


示例四:访问以下final修饰的static常量会触发类的初始化。

public class LFim {
    /*
     * 在复杂类型中只有字符串不会触发初始化过程!!
     */
    public static final String STRING="LangShen";
    //不会触发初始化过程!!,没有经历自动装箱,
    //字符串是编译期直接保存在常量池中的!!!
    //获取值时处理方法和它们不一样,它是一个常量表,
    //一个字符序列对应一个对象来获取!!!

    /*
     * 以下都会触发初始化过程!!
     * 
     * 这些初始化指的是类的初始化!!!
     */
    public static final ClassA CLASSA=null;
//自定义类,会触发初始化过程!,当然你就更别说new 一个对象了,new 一个也会触发初始化过程!!

    public static final Integer INTEGER=45;
    //会触发初始化过程!!,因为经历了自动装箱

    public static final Character CHARACTER='X';
    //会触发初始化过程!!因为经历了自动装箱

    public static final String STRING2=new String("456");
    //会触发初始化过程!!,是new 出来的!!!
    static{
        System.out.println("初始化静态代码块!!");
    }
}

⑤ 接口的初始化

接口和类都需要初始化,接口和类的初始化过程基本一样。

不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到父接口中的东西时,才初始化父接口。


【3】类加载的过程1—加载

通过之前的介绍可知,类加载过程共有5个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。下面详细介绍这5个过程JVM所做的工作。

“加载”是类加载过程的第一步。

① 加载的过程

在加载过程中,JVM主要做3件事:

  • 通过一个类的全限定名来获取这个类的二进制字节流,即class文件:
    在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。

  • 将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;

  • 在内存中创建一个java.lang.Class类型的对象:
    接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个class类型的类对象是提供给外界访问该类的接口。


② 从哪里加载?

JVM规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取:

  • 从压缩包中读取
    如:jar、War、Ear等
  • 从其他文件中动态生成
    如:从JSP文件中生成Class类
  • 从数据库中读取
    将二进制字节流存储进数据库中,然后在加载的时候从数据库中读取。有些中间件会这么做,用来实现代码在集群间分发。
  • 从网络中获取
    从网络中获取二进制字节流,典型的就是Applet。

③ 类和数组加载过程的区别

数组也有类型,称为“数组类型”。如:

String[] str = new String[10];

这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。

当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类。再由类加载器创建数组中的元素类。

而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。


④ 加载过程的注意点

  • JVM规范并未给出类在方法区中存放的数据结构
    类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM规范并没有指定。

  • JVM规范并没有指定Class对象存放的位置
    在二进制字节流以特定格式存储在方法区以后,JVM会创建一个java.lang.Class类型的对象,作为本类的外部接口。既然是对象就应该存放在堆内存中,不过JVM规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot将Class对象存放在方法区。

  • 加载阶段和连接阶段是交叉的
    通过之前的介绍可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照以下顺序开始:加载、验证、准备、解析、初始化,但结束顺序无所谓。因此由于每个步骤处理时间的长短不一,就会导致有些步骤会出现交叉。


【4】类加载的过程2—验证

验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none参数关闭,以缩短类加载时间。

① 验证的目的

验证是为了确保二进制字节流中信息符合虚拟机规范,并没有安全问题。

② 为什么需要验证

虽然Java语言是一门安全语言,它能确保程序员无法访问数组边界以外的内存、避免让一个对象转换成任意类型,避免跳转到不存在的代码行;如果出现这些情况,编译无法通过。也就是说,Java语言的安全性是通过编译器来保证的。

但是我们知道,编译器(如javac)和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的。当然,如果是编译器给它的,那就相对安全。但如果是从其他途径获得的,那么无法确保该二进制字节流是安全的。

通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证。


③ 验证的过程

  • 文件格式验证

这个阶段主要验证输入的二进制字节流是否符合class文件的结构规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。

本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。

通过上文可知,加载开始前,二进制字节流还没有进入方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。

也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储在方法区中,继而开始下阶段的验证和创建Class对象等操作。这个过程印证了:加载和验证是交叉进行的。


  • 元数据验证

本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。

  • 字节码验证

本阶段是验证过程的最复杂的一个阶段。本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。

  • 符号引用验证

本阶段验证发生在解析阶段,确保解析能正常执行。


【5】类加载的过程3—准备和解析

① 准备

准备阶段完成两件事情:

  • 为已经在方法区中的类中的静态成员变量分配内存
    类的静态成员变量也存储在方法区中(如果是常量,则编译时存放进引用其的class的常量池中,加载时存入方法区的运行时常量池)。

  • 为静态成员变量设置初始值
    初始值为0、false、null等。

示例一:

 public static String name="柴毛毛";

在准备阶段,JVM会在方法区中为name分配内存空间,并赋上初始值null。给name赋上“柴毛毛”是在初始化阶段完成的。

示例二:

 public static final String name="柴毛毛";

被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantValue属性(class文件的常量池)中,在准备阶段就将constantValue的值赋给该字段。


② 解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。


【6】类加载的过程4—初始化

初始化阶段就是执行类构造器clinit()的过程(不是init())。

clinit()方法由编译器自动产生,收集类中static{}代码块中类变量赋值语句和类中静态成员变量的赋值语句。此时将会执行静态代码块和静态方法。

在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

初始化过程的注意点

  • clinit()方法中静态成员变量的赋值顺序是根据Java代码中静态成员变量的出现的顺序决定的。

  • 静态代码块能访问出现在静态代码块之前的静态成员变量,无法访问出现在静态代码块之后的成员变量。

  • 静态代码块能给出现在静态代码块之后的静态成员变量赋值。
    因为在准备阶段已经给静态成员变量进行了默认初始化。

public class Test{
    static{
        i=0;//给变量赋值可以正常编译通过
        System.out.print(i);//这句编译器会提示"非法向前引用"
    }
    static int i=1;
}
  • 构造函数init()需要显示调用父类构造函数,而类的构造函数clinit()不需要调用父类的类构造函数,因为虚拟机会确保子类的clinit()方法执行前已经执行了父类的clinit()方法。

    因此在虚拟机中第一个被执行的clinit()方法的类肯定是java.lang.Object。由于父类的clinit()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

  • 如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会生成clinit()方法。

  • 接口也需要通过clinit()方法为接口中定义的静态成员变量显示初始化。

  • 接口中不能使用静态代码块。

  • 接口在执行clinit()方法前,虚拟机不会确保其父接口的clinit()方法被执行,只有当父接口中的静态成员变量被使用到时才会执行父接口的clinit()方法。

  • 虚拟机会给clinit()方法加锁,因此当多条线程同时执行某一个类的clinit()方法时,只有一个方法会被执行,其他的方法都被阻塞。并且,只要有一个clinit()方法执行完,其它的clinit()方法就不会再被执行。因此,在同一个类加载器下,同一个类只会被初始化一次。


【7】类加载器

① 类与类加载器

  • 类加载器的作用

将class文件加载进JVM的方法区,并在方法区中创建一个java.lang.Class对象作为外界访问这个类的接口。

java.lang.Class对象存放在哪里,JVM规范并未严格研究,具体看厂商实现。HotSpot将java.lang.Class对象存放在方法区。

  • 类与加载器的联系

比较两个类是否相等,只有当这两个类由同一个加载器加载才有意义;否则,即使同一个class文件被不同的类加载器加载,那这两个类必定不同,即通过类的Class对象的equals执行的结果必定为false。


② 类加载器种类

JVM 提供如下三种类加载器:

  • 启动类加载器(BootStrap ClassLoader,又称根加载器)
    负责加载Java_Home\lib中的class文件(这里的Java_Home指的是jre)。

  • 扩展类加载器(Extension ClassLoader)
    负责加载Java_Home\lib\ext目录下的class文件,它的父加载器是Bootstra。

  • 应用程序类加载器(System ClassLoader)
    负责加载用户classpath下的class文件。又叫系统加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。

此外还有用户自定义类加载器,继承自System ClassLoader。

类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。


③ 类加载图示

这里写图片描述

连接过程包括验证、准备和解析。


④ 双亲委派模型

  • 工作过程

如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。


  • 作用

像java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的Object类都是同一个。


  • 原理

双亲委派模型的代码在java.lang.ClassLoader类中的loadClass函数中实现,其逻辑如下:

  • 首先检查类是否被加载;
  • 若未加载,则调用父类加载器的loadClass方法;
  • 若该方法抛出ClassNotFoundException异常,表示父类加载器无法加载,则当前类加载器调用findClass加载类;
  • 若父类加载器可以加载,则直接返回Class对象。

源码示例如下:

 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;
        }
    }

【8】类/对象的静态成员变量与非静态成员变量

① 静态成员变量

通过上面可知,类的静态成员变量在准备阶段进行内存分配并默认初始化,在初始化阶段进行显示初始化。

静态变量在以后的创建对象的时候不再初始化,但是对象可以再次对静态变量进行赋值,该赋值操作将会反映到方法区中类的数据结构中。即,方法区中存储静态变量的值也会随之发生改变。

同样,修改类中静态变量的值会反映到已经创建好的对象上面。

示例如下:

public class ClassA {

    private int a =10;

    private static int b=20;

    public int getA() {
        return a;
    }

    public void setA(int a) {
        this.a = a;
    }

    public static int getB() {
        return b;
    }

    public static void setB(int b) {
        ClassA.b = b;
    }
}

测试类如下

public class TestClassA {

    public static void main(String[] args) throws InterruptedException {
        // 先实例化一个对象
        ClassA classA = new ClassA();
        // 另起一个县城
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                //获取实例化对象的静态变量值
                System.out.println("thread:"+classA.getB());
                //重新设置实例化对象的静态变量值并获取
                classA.setB(30);
                System.out.println("thread:"+classA.getB());
                // 直接通过类设置类的静态变量值
                ClassA.setB(50);
                // 这里,获取实例化对象的静态变量值
                System.out.println("thread:"+classA.getB());
            }
        });
        thread.start();
        //再来一个对象
        ClassA classA2 = new ClassA();
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 首先打印对象的静态变量值
                System.out.println("thread2:"+classA2.getB());
                // 设置头一个对象的静态变量值
                classA.setB(40);
                // 打印类中的静态变量值
                System.out.println("thread2:"+ClassA.getB());
            }
        });
        thread2.start();

    }
}

输出结果:

thread:20
thread:30
thread:50
thread2:50
thread2:40

② 非静态成员变量

非静态成员变量只有在实例化对象的时候才会分配内存并赋值,非静态成员变量随对象一起保存在堆中。

每个实例化对象的非静态成员变量只属于自己,值修改也只 针对自己,并不会影响到其他对象或者反映到方法区中类的数据结构上面。

每次实例化对象的时候,非静态变量的值都为最初存放在方法区中的值,如这里private int a =10;。每次新建对象,对象的a都为10。

测试类如下:

public class TestClassA {

    public static void main(String[] args) throws InterruptedException {

        ClassA classA = new ClassA();

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread:"+classA.getA());
                classA.setA(30);
                System.out.println("thread:"+classA.getA());
            }
        });
        thread.start();
        ClassA classA2 = new ClassA();
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread2:"+classA2.getA());
                classA2.setA(40);
                System.out.println("thread2:"+classA2.getA());
            }
        });
        thread2.start();

    }
}

输出如下:

thread:10
thread:30
thread2:10
thread2:40

另外,需要注意的是类的非静态成员变量中,其中基本类型(非包装类型)变量的值,即字面量是保存在class文件的常量池中的,在加载的时候会被存放在方法区的运行时常量池。

public class A{
    private int a=10;
    private int b=1000;
    private Integer c=20;
    private Integer d=2000;
    //...
}

其中,10,1000都会被放在常量池中;而包装类型20和2000,其中20也放在常量池中,而且是一个Integer(20)对象。2000不在-128-127范围内,是一个新建对象!!!

猜你喜欢

转载自blog.csdn.net/J080624/article/details/82284072
今日推荐