脚本宝典收集整理的这篇文章主要介绍了20220605 JVM中篇:字节码与类的加载篇 3. 类的加载过程(类的生命周期)详解,脚本宝典觉得挺不错的,现在分享给大家,也给大家做个参考。
在 Java 中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。
按照 Java 虚拟机规范,从 class 文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下 7 个阶段:
其中,验证、准备、解析 3 个部分统称为 链接(Linking)
从程序中类的使用过程看
蚂蚁金服:
描述一下JVM加载Class文件的原理机制?
一面:类加载过程
百度:
类加载的时机
java类加载过程?
简述java类加载机制?
腾讯:
JVM中类加载机制,类加载过程?
滴滴:
JVM类加载机制
美团:
Java类加载过程
描述一下jvm加载class文件的原理机制
京东:
什么是类的加载?
哪些情况会触发类的加载?
讲一下JVM加载一个类的过程JVM的类加载机制@R_126_2553@?
所谓加载,简而言之就是将 Java 类的字节码文件加载到机器内存中,并在内存中构建出 Java 类的原型——类模板对象。所谓类模板对象,其实就是 Java 类在 JVM 内存中的一个快照, JVM 将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样 JVM 在运行期便能通过类模板而获取 Java 类中的任意信息,能够对 Java 类的成员变量进行遍历,也能进行 Java 方法的调用。
反射的机制即基于这一基础。如果 JVM 没有将 Java 类的声明信息存储起来,则 JVM 在运行期也无法反射。
加载阶段,简言之,查找并加载类的二进制数据,生成 Class 实例。
在加载类时,Java 虚拟机必须完成以下 3 件事情:
通过类的全名,获取类的二进制数据流
解析类的二进制数据流为方法区内的数据结构( Java 类模型)
创建 java.lang.Class
类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
对于类的二进制数据流,虚拟机可以通过多种途径产生或获得(只要所读取的字节码符合 JVM 规范即可)
虚拟机可能通过文件系统读入一个 class 后缀的文件(最常见)
读入 jar、zip 等归档数据包,提取类文件
使用类似于 HTTP 之类的协议通过网络进行加载
在运行时生成一段 Class 的二进制信息等
在获取到类的二进制信息后,Java 虚拟机就会处理这些数据,并最终转为一个 java.lang.Class
的实例。
如果输入数据不是 ClassFile 的结构,则会抛出 ClassForMATError
加载的类在 JVM 中创建相应的类结构,类结构会存储在方法区( JDK 8 之前:永久代; JDK 8 及之后:元空间)。
类将 .class 文件加载至元空间后,会在堆中创建一个 java.lang.Class
对象,用来封装类位于方法区内的数据结构,该 Class
对象是在加载类的过程中创建的,每个类都对应有一个 Class
类型的对象。
外部可以通过访问代表 Order
类的 Class
对象来获取 Order
的类数据结构
创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由 JVM 在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。创建数组类(下述简称 A )的过程:
如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为 public
当类加载到系统后,就开始链接操作,验证是链接操作的第一步。
目的是保证加载的字节码是合法、合理并符合规范的
验证的步骤比较复杂,实际要验证的项目也很繁多,大体上 Java 虚拟机需要做以下检查,如图所示。
整体说明:
验证的内容涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等。
其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。
格式验证之外的验证操作将会在方法区进行。
链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查。(磨刀不误砍柴工)
具体说明:
0XCAFEBABE
开头,主版本和副版本号是否在当前 Java 虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等。栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100% 准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。
在前面 3 次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的
NoClaSSDefFoundError
,如果一个方法无法被找到,则会抛出 NoSuchMethodError
。此阶段在解析环节才会执行。准备阶段(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
。
注意:
static final
修饰的情况,因为 final
在编译的时候就会分配了,准备阶段会显示赋值在准备阶段完成后,就进入了解析阶段。
解析阶段(Resolution),简言之,将类、接口、字段和方法的符号引用转为直接引用。
符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在 Class 类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下 println()
方法被调用时,系统需要明确知道该方法的位置。
举例:
输出操作 System.out.println()
对应的字节码:
invokevirtual #24 <java/io/PrintStream.println>
以方法为例,Java 虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转化为目标方法在类表中的位置,从而使得方法被成功调用。
所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。
不过 Java 虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在 HotSpot VM 中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
最后,再来看一下 CONSTANT_String
的解析。由于字符串在程序开发中有着重要的作用,因此,读者有必要了解一下 String
在 Java 虚拟机中的处理。当在 Java 代码中直接使用字符串常量时,就会在类中出现 CONSTANT_String ,它表示字符串常量,并且会引用一个 CONSTANT_UTF8
的常量项。在 Java 虚拟机内部运行中的常量池中,会维护一张字符串拘留表( intern )(即字符串常量池),它会保存所有出现过的字符串常量,并且没有重复项。只要以 CONSTANT_String
形式出现的字符串也都会在这张表中。使用 String.intern()
方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的 String.intern()
方法返回总是相等的。
初始化阶段,简言之,为类的静态变量赋予正确的初始值。
类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行 Java 字节码。(即:到了初始化阶段,才真正开始执行类中定义的 Java 程序代码)
初始化阶段的重要工作是执行类的初始化方法:<clinIT>()
方法。
在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的 <clinit>
总是在子类 <clinit>
之前被调用。也就是说,父类的 static
块优先级高于子类。
Java 编译器并不会为所有的类都产生 <clinit>()
初始化方法。哪些类在编译为字节码后,字节码文件中将不会包含 <clinit>()
方法?
static final
修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式不会生成 <clinit>()
方法的场景
public class Initializationtest1 {
//场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public int num = 1;
//场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
public static int num1;
//场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public static final int num2 = 1;
}
static + final 修饰的字段在哪个阶段赋值
/**
*
* 说明:使用static + final修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?
* 情况1:在链接阶段的准备环节赋值
* 情况2:在初始化阶段<clinit>()中赋值
*
* 结论:
* 在链接阶段的准备环节赋值的情况:
* 1. 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行
* 2. 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行
*
* 在初始化阶段<clinit>()中赋值的情况:
* 排除上述的在准备环节赋值的情况之外的情况。
*
* 最终结论:使用static + final修饰,且显示赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行。
* */
public class Initializationtest2 {
public static int a = 1;//在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值
public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值
public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值
public static String s2 = "helloworld2";
public static final int NUM1 = new Random().nextInt(10);//在初始化阶段<clinit>()中赋值
}
<clinit>()
的线程安全性<clinit>()
方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。<clinit>()
方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>()
方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>()
方法完毕。<clinit>()
带锁线程安全的,因此,如果在一个类的 <clinit>()
方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。<clinit>()
方法了。那么,当需要使用这个类时,虚拟机会直接返回给它已经准备好的信息。死锁示例:
class StaticA {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticB");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticA init OK");
}
}
class StaticB {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticA");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticB init OK");
}
}
public class StaticDeadLockMain extends Thread {
private char flag;
public StaticDeadLockMain(char flag) {
this.flag = flag;
this.setName("Thread" + flag);
}
@override
public void run() {
try {
Class.forName("com.atguigu.java1.Static" + flag);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println(getName() + " over");
}
public static void main(String[] args) throws InterruptedException {
StaticDeadLockMain loadA = new StaticDeadLockMain('A');
loadA.start();
StaticDeadLockMain loadB = new StaticDeadLockMain('B');
loadB.start();
}
}
Java 程序对类的使用分为两种:主动使用和被动使用。主动使用才会调用 <clinit>()
,被动使用不会引起类的初始化
通过 -XX:+TraceClassLoading
,可以查看类加载信息
Class
只有在必须要首次使用的时候才会被装载,Java 虚拟机不会无条件地装载 Class
类型。Java 虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即:如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成)
new
关键字,或者通过反射、克隆、反序列化。invokestatic
指令。getstatic
或者 putstatic
指令。(对应访问变量、赋值变量操作)java.lang.reflect
包中的方法反射类的方法时。比如: Class.forName("com.atguigu.java.test")
default
方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。main
方法的那个类),虚拟机会先初始化这个主类。MethodHandle
实例(反射包下的一个类)时,初始化该 MethodHandle
指向的方法所在的类。(涉及解析 REF_getStatic
、REF_putStatic
、REF_invokeStatic
方法句柄对应的类)针对 5,补充说明:
当 Java 虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。
因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化。
针对 7 ,说明:
JVM 启动的时候通过引导类加载器加载一个初始类。这个类在调用 public static void main(String[])
方法之前被链接和初始化。这个方法的执行将依次导致所需的类的加载,链接和初始化。
除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化。
也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化。
没有初始化的类,不意味着没有加载
ClassLoader
类的 loadClass
方法加载一个类,并不是对类的主动使用,不会导致类的初始化。任何一个类型在使用之前都必须经历过完整的加载、链接和初始化 3 个类加载步骤。一旦一个类型成功经历过这 3 个步骤之后,便“万事俱备只欠东风”,就等着开发者使用了。
开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用 new
关键字为其创建对象实例。
在类加载器的内部实现中,用一个 Java 集合来存放所加载类的引用。另一方面,一个 Class
对象总是会引用它的类加载器,调用 Class
对象的 getclassLoader()
方法,就能获得它的类加载器。由此可见,代表某个类的 Class
实例与其类的加载器之间为双向关联关系。
一个类的实例总是引用代表这个类的 Class
对象。在 Object
类中定义了 getClass()
方法,这个方法返回代表对象所属类的 Class
对象的引用。此外,所有的 Java 类都有一个静态属性 class
,它引用代表这个类的 Class
对象。
当 Sample
类被加载、链接和初始化后,它的生命周期就开始了。当代表 Sample
类的 Class
对象不再被引用,即不可触及时, Class
对象就会结束生命周期, Sample
类在方法区内的数据也会被卸载,从而结束 Sample
类的生命周期。
一个类何时结束生命周期,取决于代表它的 Class
对象何时结束生命周期。
loader1
变量和 obj
变量间接应用代表 Sample
类的 Class
对象,而 objClass
变量则直接引用它null
,此时 Sample
对象结束生命周期, MyClassLoader
对象结束生命周期,代表 Sample
类的 Class
对象也结束生命周期, Sample
类在方法区内的二进制数据被卸载。Sample
类的 Class
对象是否存在,如果存在会直接使用,不再重新加载;如果不存在 Sample
类会被重新加载,在 Java 虚拟机的堆区会生成一个新的代表 Sample
类的 Class
实例(可以通过哈希码查看是否是同一个实例)综合以上三点,一个已经加载的类型被卸载的几率很小,至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
以上是脚本宝典为你收集整理的20220605 JVM中篇:字节码与类的加载篇 3. 类的加载过程(类的生命周期)详解全部内容,希望文章能够帮你解决20220605 JVM中篇:字节码与类的加载篇 3. 类的加载过程(类的生命周期)详解所遇到的问题。
本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
如您有任何意见或建议可联系处理。小编QQ:384754419,请注明来意。