目录
简介
你们肯定听说过, 什么叫做类加载机制吧, 还有JVM的内存模型, 如果我拿出下面这张图, 你肯定就会想起来了:
从java的源文件说起, 下面是你所编译的代码:
public class TestMain {
public static void main(String[] args) {
System.out.println("hello world");
}
}
这一串代码最终会被 编译为 字节码文件, 也就是一个个的.class文件 :
然后就需要将这个class文件加载到JVM中去, 这个任务就是由classLoader完成, classLoader这个类尝试将class文件加载到jvm中并在内存中, 生成构成这个类的各个的数据结构, 并分布在jvm内存的各个区域.
加载过程
分为三个大阶段, 其中连接阶段又可以有细的拆分
简单说明一下,
- 加载阶段: 也就是查找并加载类的二进制数据 (class文件)
- 连接阶段 :
- 验证: 首先你得确保验证类文件的正确性, 比如class的版本, jdk版本是否兼容等等
- 准备: 为类的静态变量分配内存, 并且为其初始化默认值
- 解析: 将类的符号引用替换为直接引用
- 初始化阶段: 为类的静态变量赋予正确的初始化值
这里有人就要问了, 我都有构造方法了, 我直接让JVM调用构造方法就可以了呀, 那为什么还要一个初始化阶段?
- 类的初始化指的是, 类被加载到JVM中, 并且其静态成员被初始化的过程, 这个过程在任何类的任何对象被初始化之前, 只会触发一次, 无论创建多少个实例.
- 构造方法是一种特殊的方法, 用于创建对象时后初始化对象, 每个类都可以拥有一个或者多个构造方法.
- 构造方法的目的就是为了初始化新创建的对象, 对象创建的时候, 会自动调用响应的构造方法初始化对象的状态
- 用于在创建对象时初始化对象的实例字段
对于初始化需要注意的是 :
当一个JVM 在我们通过执行 Java命令启动之后,其中可能包含的类非常多,是不是
每一个类都会被初始化呢?答案是否定的,JVM对类的初始化是一个延迟的机制,即使用
的是 lazy的方式,当一个类在首次使用的时候才会被初始化,在同一个运行时包下,一个
Class 只会被初始化一次(运行时包和类的包是有区别的
类的主动使用和被动使用
上面提到过, 类是使用的延迟加载的模式, 那么什么时候才会加载呢? 延迟到什么时候呢? 这就要看JVM的规范了, JVM规定, 每个类或者接口, 被java程序首次主动使用的时候, 才会对其进行初始化. 下面我们就对这个首次主动使用来进行分析:
主动使用
- 什么是主动使用?无序列表 主动使用包括以下几种:
- 通过new关键字会导致初始化
public class MyClass {
static {
System.out.println("MyClass is initialized");
}
public static void main(String[] args) {
new MyClass(); // 触发MyClass的初始化
}
}
- 访问类的静态变量: 访问某个类或者接口的非final 类型的静态变量, 或者对这些静态变量进行赋值的时候, 都会触发初始化 (final static的静态常量是在编译的时候就已经确定其中的值, 属于字面常量, 不会触发类的初始化)
public class MyStaticClass {
static int staticVar = 10; // 静态变量初始化也会触发类初始化
static {
System.out.println("MyStaticClass is initialized");
}
public static void main(String[] args) {
System.out.println(MyStaticClass.staticVar); // 访问静态变量,触发MyStaticClass的初始化
}
}
- 访问静态方法, 会触发类的初始化, 例如:
public class MyStaticMethodClass {
static {
System.out.println("MyStaticMethodClass is initialized");
}
public static void myStaticMethod() {
System.out.println("Static method called");
}
public static void main(String[] args) {
MyStaticMethodClass.myStaticMethod(); // 调用静态方法,触发MyStaticMethodClass的初始化
}
}
- 对某个类进行反射, 会导致类的初始化
public class MyReflectionClass {
static {
System.out.println("MyReflectionClass is initialized");
}
public static void main(String[] args) throws ClassNotFoundException {
Class.forName("com.test.testClass.MyReflectionClass"); // 通过反射加载类,触发MyReflectionClass的初始化
}
}
- 初始化子类的时候 会先初始化父类
class Parent {
static {
System.out.println("Parent is initialized");
}
}
class Child extends Parent {
static {
System.out.println("Child is initialized");
}
public static void main(String[] args) {
new Child(); // 实例化Child时,先初始化Parent
}
}
- 最容易忽视的一点就是, 启动main方法, 所在的类会导致该类的初始化, 上面的案例都可以拿来作为这一点的经典案例.
被动使用
除了上述的几种情况, 其余的都称为被动使用, 不会造成类的加载和初始化
容易混淆的例子
- 构造某个类的数组时, 并不会导致该类的初始化
上述例子中, new了一个Simple的数组, 但是它并不会导致Simple类的初始化, 因此它是被动使用, 事实上这个new 只是在内存里面开辟了10个指针所占用的内存空间 .
- 引用类的静态常量, 不会导致类的初始化
类的静态常量是在编译的时候就被确认的, 直接因此引用是不需要进行初始化操作的, 下面是引用静态常量的例子.
class NonInitializingClass {
// 静态常量
static final int MY_CONSTANT = 42;
// 静态初始化块,会打印信息表明类被初始化
static {
System.out.println("NonInitializingClass is being initialized");
}
}
public class Test {
public static void main(String[] args) {
// 直接使用静态常量,不会触发NonInitializingClass的初始化
System.out.println(NonInitializingClass.MY_CONSTANT);
// 假设还有其他静态成员或常量(非基本数据类型/String)的引用,则可能触发初始化
// 但这里只使用了基本数据类型的静态常量,所以不会
}
}
但是需要注意的是, 这里只有对应的基础类型和String类的静态常量才会在编译的时候被直接替换.
那如果不是基础类型和String类呢?
如果静态常量不是基础类型或String类,而是其他对象类型(包括自定义类或Java库中的类),情况就有所不同了:
- 对象常量:对于对象类型的静态常量(即对象的引用是static final的),这个引用本身在编译时是确定的,但是对象的内容(即对象的字段和状态)并不会在编译时被替换或内联。每次程序运行时,都会根据常量的定义来创建或引用相应的对象实例(除非该对象在编译时就已经以某种方式被优化掉了,比如通过逃逸分析等技术,但这通常不是由开发者直接控制的)。
- 不可变对象:如果静态常量引用的是一个不可变对象(如java.lang.String、java.math.BigInteger的某些实例,或者任何你定义为不可变的自定义类),那么这个对象的状态在创建后就不会改变。尽管对象的内容本身不会在编译时被替换,但由于它的不可变性,你可以安全地在多个地方重用这个对象的引用,而不必担心它的状态会发生变化。
- 可变对象:如果静态常量引用的是一个可变对象(即对象的字段可以在创建后被修改),那么你需要格外小心,因为任何对这个对象状态的修改都会影响到所有持有这个对象引用的地方。在这种情况下,即使常量引用本身在编译时是确定的,它的行为也可能与你的预期不符。
- 复杂初始化:如果静态常量的值是通过复杂的方法调用或计算得出的,那么这些计算或方法调用通常需要在运行时进行,而不是在编译时。这意味着,即使常量是static final的,它的值也可能不是编译时常量。
- 编译器优化:值得注意的是,现代Java编译器(如HotSpot JVM中的javac)可能会进行各种优化,包括但不限于逃逸分析、常量传播等。这些优化可能会改变代码的行为,包括静态常量的处理方式。然而,这些优化通常是透明的,对开发者来说是不可见的,并且它们的具体行为可能会随着JVM版本和编译器的不同而有所不同。
类加载全过程
类加载阶段
简单来说, 就是将已经编译好的class文件中的二进制数据读取到内存中, 然后将该字节流所代表的静态的存储结构, 转化为JVM内存中的运行时的数据结构, 并且在JVM堆内存中生成一个java的Class对象, 以便在运行时支持反射、动态加载、类型检查等高级功能.
正如下图所示:
这一个个的从java源代码编译而成的.class字节码文件, 最后进行类加载, 最终的结果就是成为了JVM堆内存中的class对象
对于同一个ClassLoader来讲, 不管某个类被加载了多少次, 对应到堆内存中的class对象, 就只有一个. 虚拟机规范中指出了类的加载是一个通过全限定名 来获取二进制数据流, 但是也没有限定死说, 只能通过这种形式来获取二进制流 并加载为class对象.
例如 :
- 运行时动态生成,比如通过开源的ASM包可以生成一些class,或者通过动态代理
java.lang.Proxy也可以生成代理类的二进制字节流。 - 通过网络获取,比如很早之前的 Applet小程序,以及 RMI动态发布等。
- 通过读取 zip 文件获得类的二进制字节流,比如jar、war(其实,jar 和 war 使用的
是和 zip 同样的压缩算法)。 - 将类的二进制数据存储在数据库的 BLOB 字段类型中。
- 运行时生成 class 文件,并且动态加载,比如使用Thrif、AVRO 等都是可以在运行
时将某个 Schema 文件生成对应的若干个class 文件,然后再进行加载。
以上所述只是加载的第一个阶段, 也就是类加载的阶段, 并不代表整个类加载的全过程. 虚拟机讲这些类进行加载完成之后, 虚拟机就会将这些二进制数据 按照虚拟机内部的存储数据结构给存储下来, 形成特定的组织结构(首先将格式存储在方法区, 随后在堆中实例化一个Class对象).
在类加载的全过程中,连接工作是可以交叉工作的.
类连接阶段
学过C语言的肯定听说过C语言的链接吧, 其实这个过程和java的连接过程差不多.
连接被分为三个小阶段:
验证
首先不是任何被编译而成的字节码都是符合要求的. 你得确保你加载的字节码文件的字节流是符合当前JVM规范的, 并且不会危害到JVM自身的安全. 当字节流的信息在不符合规范的时候, 会抛出VerifyError(检验误差) 这样的异常, 或者是其子异常.
所以这个验证过程做了哪些验证?
- 验证文件格式
- java的版本是不断的升级的, JVM的规范同时也在不断升级, 因此就会检查验证你的JDK编译的class文件版本是否符合当前JDK的所能处理的范围
- 一个class文件在经过网络传输或者是别的方式传过来, 存在数据缺失的情况, 那么它就不是一个完整的class文件, 因此必须检查验证是否存在缺失, 也或者会附带其他额外的信息.
- 其他信息等等
- 文件格式的验证可以将那些恶意的字节流拒之门外.
- 元数据验证
- 文件本身的格式验证好了没问题了, 但是并不代表文件本身的内容是正确的, 元数据的验证其实是对 class的字节流进行语义分析的过程,整个语义分析就是为了确保class字节流符合 JVM规范的要求
- 检查这个类是否存在父类,是否继承了某个接口,这些父类和接口是否合法,或者
是否真实存在 - 口检查该类是否继承了被final修饰的类,被final修饰的类是不允许被继承并且其中的
方法是不允许被override的 - 检查该类是否为抽象类,如果不是抽象类,那么它是否实现了父类的抽象方法或者
接口中的所有方法 - 口检查方法重载的合法性,比如相同的方法名称、相同的参数但是返回类型不相同
这都是不被允许的 - .....
- 这些还不够, 还需要对字节码进行进一步验证, 该部分的验证是验证程序的控制流程, 比如分支和循环等. ..
- 字节码验证:
- 保证当前线程在程序计数器中的指令不会跳转到不合法的字节码指令中去。
- 保证类型的转换是合法的,比如用A声明的引用,不能用B进行强制类型转换
- 保证任意时刻,虚拟机栈中的操作栈类型与指令代码都能正确地被执行,比如在压栈的时候传人的是一个A类型的引用,在使用的时候却将B类型载入了本地变量表。
- ..... (其他验证)
- 符号引用验证
- 主要验证符号引用转换为直接引用时候的合法性
- 通过符号引用描述的字符串全限定名称是否能够顺利地找到相关的类。
- 符号引用中的类、字段、方法,是否对当前类可见,比如不能访问引用类的私有
方法。 - ..... ... .
- 符号引用的验证目的是为了保证解析动作的顺利执行 和 程序的安全性,比如,如果某个类的字段不存在,则会抛出 NoSuchFieldError,若方法不存在时则抛出NoSuchMethodError等,我们在使用反射的时候会遇到这样的异常信息
准备
当一个字节码文件通过验证之后, 想想, 下一步要干什么? 首先我们只是验证字节码的JVM规范, 字符引用和直接引用等等问题, 但是我们还缺少一个东西, 就是这个类它本身的变量的值.
我们程序要用的, 本身也是类里面变量的值, 此时我们在使用之前首先需要初始化它.
为什么说是变量, 而不是常量?
这是因为我们上面提到过, 如果一个常量本身是基础数据类型或者是String类型, 那么其字符引用就会在编译的时候直接被替换为直接引用.
当一个class字节流通过了所有的验证过程之后 , 就开始为该对象的类变量, 也就是静态变量分配内存并且设置初始值, 此时的初始值, 也可以理解为在你编写代码的时候, 一个类的静态变量的缺省值:
例如你有如下代码:
public class LinkedPrepare {
private static inta=10;
private final static intb=10;
}
- 首先intb = 10 , 由于intb是静态常量, 实在编译的时候就直接将符号引用替换为了直接引用, 所以此处的intb还是10保持不变
- inta在准备阶段不是10, 而是初始值0.
解析
在准备和验证之后, 就可以进入解析流程了, 同样的, 解析流程也穿插着验证的事情~
例如你有如下代码:
上述的ClassResolve类使用到了Simple类, 我们可以直接可以使用这个类去访问simple中的属性和方法, 但是字节码中可不是这么简单, 它会被编译成助记符, 这个助记符还会再类解析的过程中进一步解析, 才能正在找到堆内存中的simple的数据结构.
- 类接口解析
- 具体来说,就是将这些符号引用转换为对应的直接引用,以便在运行时能够直接通过这些引用访问到目标对象或方法
- 假设前文代码中的 Simple,不是一个数组类型,则在加载的过程中,需要先完成对
Simple 类的加载,同样需要经历所有的类加载阶段。 - 如果 Simple 是一个数组类型,则虚拟机不需要完成对 Simple 的加载,只需要在虚拟机中生成一个能够代表该类型的数组对象,并且在堆内存中开辟一片连续的地址空间即可。
- 在类接口的解析完成之后,还需要进行符号引用的验证。
- 字段解析
- 也就是解析你所访问的类或者接口中的字段(将字段的符号引用解析为字段的直接引用,即解析出字段的内存地址,以便在运行时能够直接访问该字段的值), 在解析的时候如果字段不存在, 就不会执行下面的解析
- 如果 Simple 类本身就包含某个字段,则直接返回这个字段的引用,当然也要对该字
段所属的类提前进行类加载 - 如果 Simple类中不存在该字段,则会根据继承关系自下而上,查找父类或者接口的
字段,找到即可返回,同样需要提前对找到的字段进行类的加载过程 - 如果 Simple 类中没有字段,一直找到了最上层的java.lang.Object还是没有,则表示
查找失败,也就不再进行任何解析,直接抛出NoSuchFieldError异常
- 类解析
- 将类的符号引用解析为类的直接引用,即解析出类的元数据信息,并存储在方法区中
- 方法解析
- 将方法的符号引用解析为方法的直接引用,即解析出方法的入口点(通常是方法体中的第一条指令的地址),以便在调用该方法时能够跳转到该入口点执行
类的初始化
加载后之后, 进行验证, 然后替换符号引用, 然后进行初始化操作, 在初始化阶段做的最主要的一件事情就是执行<clinit>()方法的过程
什么是<clinit>() 方法?
这个方法不是由程序员直接编写的,而是由Java虚拟机(JVM)自动生成和调用的。<clinit>() 方法在类被加载到JVM并且被初始化时执行,且仅执行一次
<clinit>() 方法的主要目的是执行以下操作:
- 静态块执行:它执行类定义中所有的静态初始化块(static blocks)以及静态字段的初始化器(如果有的话)。静态初始化块是在类声明中,用static关键字修饰的代码块, 如果有多个就按声明的顺序执行
- 静态字段的初始化:如果类中有静态字段,并且这些字段在声明时没有显式初始化(即没有使用赋值语句或在静态初始化块中设置, 非显式的初始化其实就是缺省状态),那么JVM会为它们赋予默认值(如int的默认值为0,对象的默认值为null等)。但是,如果静态字段在声明时就已经被初始化(或者在静态初始化块中被初始化),那么这些初始化操作会作为<clinit>()方法的一部分被执行。
<clinit> 方法是在编译阶段生成的, 也就是说它本身已经被包含在了class文件中了, 它包含了所有的类变量赋值操作 和 静态语句块的执行代码
同时 初始化 方法 会首先去初始化其父类的方法, 然后再初始化子类.
同时它保证多线程下的安全性.