关于java:<JVM中篇字节码与类的加载篇>03类的加载过程类的生命周期详解

37次阅读

共计 10662 个字符,预计需要花费 27 分钟才能阅读完成。

https://gitee.com/vectorx/NOT…

https://codechina.csdn.net/qq…

https://github.com/uxiahnan/N…

[toc]

1. 概述

在 Java 中数据类型分为根本数据类型和援用数据类型。<mark> 根本数据类型由虚拟机事后定义,援用数据类型则须要进行类的加载。</mark>

依照 Java 虚拟机标准,从 class 文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包含如下 7 个阶段:

其中,验证、筹备、解析 3 个局部统称为链接(Linking)

从程序中类的应用过程看

大厂面试题

<mark> 蚂蚁金服:</mark>

形容一下 JVM 加载 Class 文件的原理机制?

一面:类加载过程

<mark> 百度:</mark>

类加载的机会

java 类加载过程?

简述 java 类加载机制?

<mark> 腾讯:</mark>

JVM 中类加载机制,类加载过程?

<mark> 滴滴:</mark>

JVM 类加载机制

<mark> 美团:</mark>

Java 类加载过程

形容一下 jvm 加载 class 文件的原理机制

<mark> 京东:</mark>

什么是类的加载?

哪些状况会触发类的加载?

讲一下 JVM 加载一个类的过程 JVM 的类加载机制是什么?

<hr/>

2. 过程一:Loading(加载)阶段

2.1. 加载实现的操作

加载的了解

$\color{red}{所谓加载,简而言之就是将 Java 类的字节码文件加载到机器内存中,并在内存中构建出 Java 类的原型——类模板对象。}$ 所谓类模板对象,其实就是 Java 类在]VM 内存中的一个快照,JVM 将从字节码文件中解析出的常量池、类字段、类办法等信息存储到类模板中,这样]VM 在运行期便能通过类模板而获取 Java 类中的任意信息,可能对 Java 类的成员变量进行遍历,也能进行 Java 办法的调用。

反射的机制即基于这一根底。如果 JVM 没有将 Java 类的申明信息存储起来,则 JVM 在运行期也无奈反射。

加载实现的操作

$\color{red}{加载阶段,简言之,查找并加载类的二进制数据,生成 Class 的实例。}$

在加载类时,Java 虚拟机必须实现以下 3 件事件:

  • 通过类的全名,获取类的二进制数据流。
  • 解析类的二进制数据流为办法区内的数据结构(Java 类模型)
  • 创立 java.lang.Class 类的实例,示意该类型。作为办法区这个类的各种数据的拜访入口

2.2. 二进制流的获取形式

对于类的二进制数据流,虚拟机能够通过多种路径产生或取得。<mark>(只有所读取的字节码合乎 JVM 标准即可)</mark>

  • 虚拟机可能通过文件系统读入一个 class 后缀的文件 $\color{red}{(最常见)}$
  • 读入 jar、zip 等归档数据包,提取类文件。
  • 当时寄存在数据库中的类的二进制数据
  • 应用相似于 HTTP 之类的协定通过网络进行加载
  • 在运行时生成一段 class 的二进制信息等
  • 在获取到类的二进制信息后,Java 虚拟机就会解决这些数据,并最终转为一个 java.lang.Class 的实例。

如果输出数据不是 ClassFile 的构造,则会抛出 ClassFormatError。

2.3. 类模型与 Class 实例的地位

类模型的地位

加载的类在 JVM 中创立相应的类构造,类构造会存储在办法区(JDKl.8 之前:永恒代;J0Kl.8 及之后:元空间)。

Class 实例的地位

类将.class 文件加载至元空间后,会在堆中创立一个 Java.lang.Class 对象,用来封装类位于办法区内的数据结构,该 Class 对象是在加载类的过程中创立的,每个类都对应有一个 Class 类型的对象。

Class clazz = Class.forName("java.lang.String");
// 获取以后运行时类申明的所有办法
Method[] ms = clazz.getDecla#FF0000Methods();
for (Method m : ms) {
    // 获取办法的修饰符
    String mod = Modifier.toString(m.getModifiers());
    System.out.print(mod + "");
    // 获取办法的返回值类型
    String returnType = (m.getReturnType()).getSimpleName();
    System.out.print(returnType + "");
    // 获取办法名
    System.out.print(m.getName() + "(");
    // 获取办法的参数列表
    Class<?>[] ps = m.getParameterTypes();
    if (ps.length == 0) {System.out.print(')');
    }
    for (int i = 0; i < ps.length; i++) {char end = (i == ps.length - 1) ? ')' : ',';
        // 获取参教的类型
        System.out.print(ps[i].getSimpleName() + end);
    }
}

2.4. 数组类的加载

创立数组类的状况略微有些非凡,因为 <mark> 数组类自身并不是由类加载器负责创立 </mark>,而是由 JVM 在运行时依据须要而间接创立的,但数组的元素类型依然须要依附类加载器去创立。创立数组类(下述简称 A)的过程:

  • 如果数组的元素类型是援用类型,那么就遵循定义的加载过程递归加载和创立数组 A 的元素类型;
  • JVM 应用指定的元素类型和数组维度来创立新的数组类。

如果数组的元素类型是援用类型,数组类的可拜访性就由元素类型的可拜访性决定。否则数组类的可拜访性将被缺省定义为 public。

<hr/>

3. 过程二:Linking(链接)阶段

3.1. 环节 1:链接阶段之 Verification(验证)

当类加载到零碎后,就开始链接操作,验证是链接操作的第一步。

$\color{red}{它的目标是保障加载的字节码是非法、正当并符合规范的。}$

验证的步骤比较复杂,理论要验证的我的项目也很繁多,大体上 Java 虚拟机须要做以下查看,如图所示。

整体阐明:

验证的内容则涵盖了类数据信息的格局验证、语义查看、字节码验证,以及符号援用验证等。

  • $\color{red}{其中格局验证会和加载阶段一起执行}$。验证通过之后,类加载器才会胜利将类的二进制数据信息加载到办法区中。
  • $\color{red}{格局验证之外的验证操作将会在办法区中进行}$。

链接阶段的验证尽管拖慢了加载速度,然而它防止了在字节码运行时还须要进行各种查看。(磨刀不误砍柴工)

具体阐明:

  1. <mark> 格局验证 </mark>:是否以魔数 0XCAFEBABE 结尾,主版本和副版本号是否在以后 Java 虚拟机的反对范畴内,数据中每一个项是否都领有正确的长度等。
  2. <mark> 语义查看 </mark>:Java 虚构机会进行字节码的语义查看,凡是在语义上不符合规范的,虚拟机也不会给予验证通过。比方:

    • 是否所有的类都有父类的存在(在 Java 里,除了 object 外,其余类都应该有父类)
    • 是否一些被定义为 final 的办法或者类被重写或继承了
    • 非抽象类是否实现了所有形象办法或者接口办法
  3. <mark> 字节码验证 </mark>:Java 虚拟机还会进行字节码验证,$\color{red}{字节码验证也是验证过程中最为简单的一个过程}$。它试图通过对字节码流的剖析,判断字节码是否能够被正确地执行。比方:

    • 在字节码的执行过程中,是否会跳转到一条不存在的指令
    • 函数的调用是否传递了正确类型的参数
    • 变量的赋值是不是给了正确的数据类型等

    栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100% 精确地判断一段字节码是否能够被平安执行是无奈实现的,因而,该过程只是尽可能地查看出能够预知的显著的问题。如果在这个阶段无奈通过查看,虚拟机也不会正确装载这个类。然而,如果通过了这个阶段的查看,也不能阐明这个类是齐全没有问题的。

    $\color{red}{在后面 3 次查看中,曾经排除了文件格式谬误、语义谬误以及字节码的不正确性。然而仍然不能确保类是没有问题的。}$

  4. <mark> 符号援用的验证 </mark>:校验器还将进符号援用的验证。Class 文件在其常量池会通过字符串记录本人将要应用的其余类或者办法。因而,在验证阶段,$\color{red}{虚拟机就会查看这些类或者办法的确是存在的}$,并且以后类有权限拜访这些数据,如果一个须要应用类无奈在零碎中找到,则会抛出 NoClassDefFoundError,如果一个办法无奈被找到,则会抛出 NoSuchMethodError。此阶段在解析环节才会执行。

3.2. 环节 2:链接阶段之 Preparation(筹备)

$\color{red}{筹备阶段(Preparation),简言之,为类的动态变分配内存,并将其初始化为默认值。}$

当一个类验证通过时,虚拟机就会进入筹备阶段。在这个阶段,虚拟机就会为这个类调配相应的内存空间,并设置默认初始值。Java 虚拟机为各类型变量默认的初始值如表所示。

类型 默认初始值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean false
reference null

Java 并不反对 boolean 类型,对于 boolean 类型,外部实现是 int,因为 int 的默认值是 0,故对应的,boolean 的默认值就是 false。

留神

  • $\color{red}{这里不蕴含根本数据类型的字段用 static final 润饰的状况,因为 final 在编译的时候就会调配了,筹备阶段会显式赋值。}$

    // 个别状况:static final 润饰的根本数据类型、字符串类型字面量会在筹备阶段赋值
    private static final String str = "Hello world";
    // 非凡状况:static final 润饰的援用类型不会在筹备阶段赋值,而是在初始化阶段赋值
    private static final String str = new String("Hello world");
  • 留神这里不会为实例变量调配初始化,类变量会调配在办法区中,而实例变量是会随着对象一起调配到 Java 堆中。
  • 在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。

3.3. 环节 3:链接阶段之 Resolution(解析)

在筹备阶段实现后,就进入了解析阶段。解析阶段(Resolution),简言之,将类、接口、字段和办法的符号援用转为间接援用。

具体形容

符号援用就是一些字面量的援用,和虚拟机的外部数据结构和和内存布局无关。比拟容易了解的就是在 Class 类文件中,通过常量池进行了大量的符号援用。然而在程序理论运行时,只有符号援用是不够的,比方当如下 println()办法被调用时,零碎须要明确晓得该办法的地位。

举例

输入操作 System.out.println()对应的字节码:

invokevirtual #24 <java/io/PrintStream.println>

以办法为例,Java 虚拟机为每个类都筹备了一张办法表,将其所有的办法都列在表中,当须要调用一个类的办法的时候,只有晓得这个办法在办法表中的偏移量就能够间接调用该办法。$\color{red}{通过解析操作,符号援用就能够转变为指标办法在类中办法表中的地位,从而使得办法被胜利调用。}$

<hr/>

4. 过程三:Initialization(初始化)阶段

4.1. static 与 final 的搭配问题

阐明:应用 static+ final 润饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?

  • 状况 1:在链接阶段的筹备环节赋值
  • 状况 2:在初始化阶段 <clinit>()中赋值

论断:在链接阶段的筹备环节赋值的状况:

  • 对于根本数据类型的字段来说,如果应用 static final 润饰,则显式赋值(间接赋值常量,而非调用办法通常是在链接阶段的筹备环节进行
  • 对于 String 来说,如果应用字面量的形式赋值,应用 static final 润饰的话,则显式赋值通常是在链接阶段的筹备环节进行
  • 在初始化阶段 <clinit>()中赋值的状况:排除上述的在筹备环节赋值的状况之外的状况。

最终论断:应用 static+final 润饰,且显示赋值中不波及到办法或结构器调用的根本数据类到或 String 类型的显式财值,是在链接阶段的筹备环节进行。

public static final int INT_CONSTANT = 10;                                // 在链接阶段的筹备环节赋值
public static final int NUM1 = new Random().nextInt(10);                  // 在初始化阶段 clinit>()中赋值
public static int a = 1;                                                  // 在初始化阶段 <clinit>()中赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);     // 在初始化阶段 <clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(100);           // 在初始化阶段 <clinit>()中概值

public static final String s0 = "helloworld0";                            // 在链接阶段的筹备环节赋值
public static final String s1 = new String("helloworld1");                // 在初始化阶段 <clinit>()中赋值
public static String s2 = "hellowrold2";                                  // 在初始化阶段 <clinit>()中赋值

4.2. <clinit>()的线程安全性

对于 <clinit>()办法的调用,也就是类的初始化,虚构机会在外部确保其多线程环境中的安全性。

虚构机会保障一个类的 () 办法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>()办法,其余线程都须要阻塞期待,直到流动线程执行 <clinit>()办法结束。

正是因为 $\color{red}{函数 <clinit>()带锁线程平安的}$,因而,如果在一个类的 <clinit>()办法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。

如果之前的线程胜利加载了类,则等在队列中的线程就没有机会再执行 <clinit>()办法了。那么,当须要应用这个类时,虚构机会间接返回给它曾经筹备好的信息。

4.3. 类的初始化状况:被动应用 vs 被动应用

Java 程序对类的应用分为两种:被动应用和被动应用。

被动应用

Class 只有在必须要首次应用的时候才会被装载,Java 虚拟机不会无条件地装载 Class 类型。Java 虚拟机规定,一个类或接口在首次应用前,必须要进行初始化。这里指的“应用”,是指被动应用,被动应用只有下列几种状况:(即:如果呈现如下的状况,则会对类进行初始化操作。而初始化操作之前的加载、验证、筹备曾经实现。

  1. <mark> 实例化 </mark>:当创立一个类的实例时,比方应用 new 关键字,或者通过反射、克隆、反序列化。

    /**
     * 反序列化
     */
    Class Order implements Serializable {
        static {System.out.println("Order 类的初始化");
        }
    }
    
    public void test() {
        ObjectOutputStream oos = null;
        ObjectInputStream ois = null;
        try {
            // 序列化
            oos = new ObjectOutputStream(new FileOutputStream("order.dat"));
            oos.writeObject(new Order());
            // 反序列化
            ois = new ObjectInputStream(new FileOutputStream("order.dat"));
            Order order = ois.readObject();}
        catch (IOException e){e.printStackTrace();
        }
        catch (ClassNotFoundException e){e.printStackTrace();
        }
        finally {
            try {if (oos != null) {oos.close();
                }
                if (ois != null) {ois.close();
                }
            }
            catch (IOException e){e.printStackTrace();
            }
        }
    }
  2. <mark> 静态方法 </mark>:当调用类的静态方法时,即当应用了字节码 invokestatic 指令。
  3. <mark> 动态字段 </mark>:当应用类、接口的动态字段时(final 润饰非凡思考),比方,应用 getstatic 或者 putstatic 指令。(对应拜访变量、赋值变量操作)

    public class ActiveUse {
        @Test
        public void test() {System.out.println(User.num);
        }
    }
    
    class User {
        static {System.out.println("User 类的初始化");
        }
        public static final int num = 1;
    }
  4. <mark> 反射 </mark>:当应用 java.lang.reflect 包中的办法反射类的办法时。比方:Class.forName(“com.atguigu.java.Test”)
  5. <mark> 继承 </mark>:当初始化子类时,如果发现其父类还没有进行过初始化,则须要先触发其父类的初始化。

    当 Java 虚拟机初始化一个类时,要求它的所有父类都曾经被初始化,然而这条规定并不适用于接口。

    • 在初始化一个类时,并不会先初始化它所实现的接口
    • 在初始化一个接口时,并不会先初始化它的父接口
    • 因而,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次应用特定接口的动态字段时,才会导致该接口的初始化。
  6. <mark>default 办法 </mark>:如果一个接口定义了 default 办法,那么间接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。

    interface Compare {public static final Thread t = new Thread() {
            {System.out.println("Compare 接口的初始化");
            }
        }   
    }
  7. <mark>main 办法 </mark>:当虚拟机启动时,用户须要指定一个要执行的主类(蕴含 main()办法的那个类),虚构机会先初始化这个主类。

    VM 启动的时候通过疏导类加载器加载一个初始类。这个类在调用 public static void main(String[])办法之前被链接和初始化。这个办法的执行将顺次导致所需的类的加载,链接和初始化。

  8. <mark>MethodHandle<mark>:当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的办法所在的类。(波及解析 REF getStatic、REF_putStatic、REF invokeStatic 办法句柄对应的类)

被动应用

除了以上的状况属于被动应用,其余的状况均属于被动应用。$\color{red}{被动应用不会引起类的初始化。}$

也就是说:$\color{red}{并不是在代码中呈现的类,就肯定会被加载或者初始化。}$ 如果不合乎被动应用的条件,类就不会初始化。

  1. <mark> 动态字段 </mark>:当通过子类援用父类的动态变量,不会导致子类初始化,只有真正申明这个字段的类才会被初始化。

    public class PassiveUse {
         @Test
        public void test() {System.out.println(Child.num);
        }
    }
    
    class Child extends Parent {
        static {System.out.println("Child 类的初始化");
        }
    }
    
    class Parent {
        static {System.out.println("Parent 类的初始化");
        }
        
        public static int num = 1;
    }
  2. <mark> 数组定义 </mark>:通过数组定义类援用,不会触发此类的初始化

    Parent[] parents= new Parent[10];
    System.out.println(parents.getClass()); 
    // new 的话才会初始化
    parents[0] = new Parent();
  3. <mark> 援用常量 </mark>:援用常量不会触发此类或接口的初始化。因为常量在链接阶段就曾经被显式赋值了。

    public class PassiveUse {public static void main(String[] args) {System.out.println(Serival.num);
            // 但援用其余类的话还是会初始化
            System.out.println(Serival.num2);
        }
    }
    
    interface Serival {public static final Thread t = new Thread() {
            {System.out.println("Serival 初始化");
            }
        };
    
        public static int num = 10; 
        public static final int num2 = new Random().nextInt(10);
    }
  4. <mark>loadClass 办法 </mark>:调用 ClassLoader 类的 loadClass()办法加载一个类,并不是对类的被动应用,不会导致类的初始化。

    Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.test.java.Person");

扩大

-XX:+TraceClassLoading:追踪打印类的加载信息

<hr/>

5. 过程四:类的 Using(应用)

任何一个类型在应用之前都必须经验过残缺的加载、链接和初始化 3 个类加载步骤。一旦一个类型胜利经验过这 3 个步骤之后,便“厉事俱备只欠东风”,就等着开发者应用了。

开发人员能够在程序中拜访和调用它的动态类成员信息(比方:动态字段、静态方法),或者应用 new 关键字为其创建对象实例。

<hr/>

6. 过程五:类的 Unloading(卸载)

6.1. 类、类的加载器、类的实例之间的援用关系

在类加载器的外部实现中,用一个 Java 汇合来寄存所加载类的援用。另一方面,一个 Class 对象总是会援用它的类加载器,调用 Class 对象的 getClassLoader()办法,就能取得它的类加载器。由此可见,代表某个类的 Class 实例与其类的加载器之间为双向关联关系。

一个类的实例总是援用代表这个类的 Class 对象。在 Object 类中定义了 getClass()办法,这个办法返回代表对象所属类的 Class 对象的援用。此外,所有的 java 类都有一个动态属性 class,它援用代表这个类的 Class 对象。

6.2. 类的生命周期

当 Sample 类被加载、链接和初始化后,它的生命周期就开始了。当代表 Sample 类的 Class 对象不再被援用,即不可涉及时,Class 对象就会完结生命周期,Sample 类在办法区内的数据也会被卸载,从而完结 Sample 类的生命周期。

$\color{red}{一个类何时完结生命周期,取决于代表它的 Class 对象何时完结生命周期。}$

6.3. 具体例子

loader1 变量和 obj 变量间接利用代表 Sample 类的 Class 对象,而 objClass 变量则间接援用它。

如果程序运行过程中,将上图左侧三个援用变量都置为 null,此时 Sample 对象完结生命周期,MyClassLoader 对象完结生命周期,代表 Sample 类的 Class 对象也完结生命周期,Sample 类在办法区内的二进制数据被卸载。

当再次有须要时,会查看 Sample 类的 Class 对象是否存在,如果存在会间接应用,不再从新加载;如果不存在 Sample 类会被从新加载,在 Java 虚拟机的堆区会生成一个新的代表 Sample 类的 Class 实例(能够通过哈希码查看是否是同一个实例)

6.4. 类的卸载

(1)启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm 和 jls 标准)

(2)被零碎类加载器和扩大类加载器加载的类型在运行期间不太可能被卸载,因为零碎类加载器实例或者扩大类的实例基本上在整个运行期间总能间接或者间接的拜访的到,其达到 unreachable 的可能性极小。

(3)被开发者自定义的类加载器实例加载的类型只有在很简略的上下文环境中能力被卸载,而且个别还要借助于强制调用虚拟机的垃圾收集性能才能够做到。能够料想,略微简单点的利用场景中(比方:很多时候用户在开发自定义类加载器实例的时候采纳缓存的策略以进步零碎性能),被加载的类型在运行期间也是简直不太可能被卸载的(至多卸载的工夫是不确定的)。

综合以上三点,一个曾经加载的类型被卸载的几率很小至多被卸载的工夫是不确定的。同时咱们能够看的进去,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假如的前提下,来实现零碎中的特定性能。

回顾:办法区的垃圾回收

办法区的垃圾收集次要回收两局部内容:常量池中废除的常量和不再应用的类型。

HotSpot 虚拟机对常量池的回收策略是很明确的,只有常量池中的常量没有被任何中央援用,就能够被回收。

断定一个常量是否“废除”还是绝对简略,而要断定一个类型是否属于“不再应用的类”的条件就比拟刻薄了。须要同时满足上面三个条件:

  • $\color{blue}{该类所有的实例都曾经被回收。也就是 Java 堆中不存在该类及其任何派生子类的实例。}$
  • $\color{blue}{加载该类的类加载器曾经被回收。这个条件除非是通过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。}$
  • $\color{blue}{该类对应的 java.lang.Class 对象没有在任何中央被援用,无奈在任何中央通过反射拜访该类的办法。}$

Java 虚拟机被容许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被容许”,而并不是和对象一样,没有援用了就必然会回收。

正文完
 0