类的文件结构

什么是字节码文件

在 Java 中,JVM 可以理解的代码就叫做字节码,也就是 .java文件经过 Java 编译器编译后产生的扩展名为 .class 的文件。

字节码不是机器码,也不是人类可读的源代码,而是一种平台无关的二进制指令集,它不面向任何特定的处理器,只面向虚拟机,是一种中间语言

这是 Java 跨平台的核心实现。因为Java 程序编译成字节码后,可在任何安装了兼容 JVM 的平台上运行,无需重新编译。这是通过“虚拟机抽象层”实现的。

Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。

Clojure(Lisp 语言的一种方言)、Groovy、Scala、JRuby、Kotlin 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。

运行在 Java 虚拟机之上的编程语言

Class 文件结构

根据 Java 虚拟机规范,Class 文件通过 ClassFile 定义,有点类似 C 语言的结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic; // 魔数
u2 minor_version; // 次版本号
u2 major_version; // 主版本号
u2 constant_pool_count; // 常量池项数量
cp_info constant_pool[constant_pool_count-1]; // 常量池
u2 access_flags; // 类访问标志
u2 this_class; // 当前类索引
u2 super_class; // 父类索引
u2 interfaces_count; // 接口数量
u2 interfaces[interfaces_count]; // 实现的接口列表
u2 fields_count; // 字段数量
field_info fields[fields_count]; // 字段信息
u2 methods_count; // 方法数量
method_info methods[methods_count]; // 方法信息
u2 attributes_count; // 属性数量
attribute_info attributes[attributes_count]; // 类级属性
}

注意

  • u1, u2, u4 分别表示无符号的 1 字节、2 字节、4 字节整数。
  • 所有数值均以 大端序(Big-Endian) 存储。
  • 常量池从索引 1 开始(索引 0 不使用),因此常量池大小为 constant_pool_count - 1

通过分析 ClassFile 的内容,我们便可以知道 class 文件的组成,它的二进制格式很严格的遵循 《Java 虚拟机规范》,按顺序其整体结构如下

结构项 说明
Magic Number 固定值 0xCAFEBABE,用于标识这是一个有效的 .class 文件
Minor Version 次版本号(通常为 0)
Major Version 主版本号,决定支持的 Java 版本(如 61 表示 Java 17,65 表示 Java 21)
Constant Pool Count 常量池项数量(n+1,索引从 1 开始)
Constant Pool 常量池:存储类中用到的字面量(如字符串 "hello")、符号引用(如类名、方法名、字段名)等
Access Flags 类或接口的访问标志(如 public, final, abstract
This Class 当前类的常量池索引(指向一个 CONSTANT_Class_info
Super Class 父类的常量池索引(Object 类为 0)
Interfaces Count & Interfaces 实现的接口列表
Fields Count & Fields 字段信息(包括名称、类型、访问标志等)
Methods Count & Methods 方法信息(包括名称、描述符、字节码、异常表、属性等)
Attributes Count & Attributes 类级别的属性(如 SourceFile, BootstrapMethods, InnerClasses 等)

注意:所有多字节数据(如版本号、索引)均采用 大端序(Big-Endian) 存储。

下面这张图是通过 IDEA 插件 jclasslib 查看的,你可以更直观看到 Class 文件结构。

image-20260124111252280

下面对 Class 文件结构涉及到的内容的展开描述

魔数

1
u4             magic;                  // 魔数

每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件

Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。

版本号

紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号

字段 类型 说明
minor_version u2 次版本号(通常为 0)
major_version u2 主版本号,决定支持的 Java 版本

主要版本号对应关系:

Major Version Java 版本 发布年份
46 JDK 1.2 1998
49 Java 5 2004
51 Java 7 2011
52 Java 8 2014
55 Java 11 2018
61 Java 17 2021
65 Java 21 2023

你可以使用 javap -v 命令来快速查看 Class 文件的版本号信息。

JVM 在加载 .class 文件时会检查版本号是否兼容,若不兼容则抛出 UnsupportedClassVersionError,这个异常,我只见过一次

JDK是向下兼容的,高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件

常量池

紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count-1

常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”

常量池(Constant Pool)是 .class 文件的核心部分,它是一个表,存储了编译期间生成的所有符号引用字面量

常量池主要存放两大常量:

  • 字面量:字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。

  • 符号引用。符号引用则属于编译原理方面的概念。包括下面三类常量:

    • 类和接口的全限定名

    • 字段的名称和描述符

    • 方法的名称和描述符

常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.

数据结构如下

1
2
3
4
cp_info {
u1 tag; // 标识该条目的类型
u1 info[]; // 具体内容,根据 tag 决定
}
Tag 名称 说明
1 CONSTANT_Utf8_info UTF-8 编码字符串
3 CONSTANT_Integer_info int 型常量
4 CONSTANT_Float_info float 型常量
5 CONSTANT_Long_info long 型常量
6 CONSTANT_Double_info double 型常量
7 CONSTANT_Class_info 类或接口名(指向 Utf8)
8 CONSTANT_String_info 字符串对象(指向 Utf8)
9 CONSTANT_Fieldref_info 字段引用(类 + 字段名 + 描述符)
10 CONSTANT_Methodref_info 方法引用(类 + 方法名 + 描述符)
11 CONSTANT_InterfaceMethodref_info 接口方法引用
12 CONSTANT_NameAndType_info 名称+描述符对
15 CONSTANT_MethodHandle_info 方法句柄(Java 7+)
16 CONSTANT_MethodType_info 方法类型(Java 7+)
18 CONSTANT_Module_info 模块名(Java 9+)
19 CONSTANT_Package_info 包名(Java 9+)

注意:CONSTANT_LongCONSTANT_Double 占用两个 cp_info 项(因为它们是 8 字节),所以常量池中每遇到一个 LongDouble,索引会跳过一个。

.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息,javap -v class类名-> temp.txt,会将结果输出到 temp.txt 文件

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,是一个 u2 的位掩码(bitmask),包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。

标志 含义
ACC_PUBLIC 0x0001 公共类
ACC_FINAL 0x0010 最终类(不能被继承)
ACC_SUPER 0x0020 使用新方法调用规则(Java 1.2+)
ACC_INTERFACE 0x0200 是接口
ACC_ABSTRACT 0x0400 抽象类
ACC_ANNOTATION 0x0020 注解类(Java 5+)
ACC_ENUM 0x0040 枚举类(Java 5+)

通过javap -v class类名 指令来看类的访问标志。

this_classsuper_class

1
2
u2             this_class;//当前类
u2 super_class;//父类
  • this_class:当前类在常量池中的索引,指向一个 CONSTANT_Class_info,表示类名(如 com.example.MyClass)。
  • super_class:父类在常量池中的索引,如果类是 Object 的子类,则此值为 0(因为 Object 没有父类)。

例如,this_class = 1,表示常量池第 1 项是类名;super_class = 2,表示父类是常量池第 2 项。

interfaces_countinterfaces[]

表示当前类实现的接口数量,因为 Java 是单继承多实现,所以interfaces[]是一个数组,每个元素是常量池中 CONSTANT_Class_info 的索引,指向实现的接口。

一个类可以实现多个接口,最多可实现 65535 个(受 u2 限制)。

字段表集合fields_countfields[]

1
2
u2             fields_count;//字段数量
field_info fields[fields_count];//一个类会可以有个字段

字段表(field info)用于描述接口或类中声明的变量。

字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。

  • fields_count:字段的数量。

  • fields[]:每个字段的信息由 field_info 结构组成。

    1
    2
    3
    4
    5
    6
    7
    field_info {
    u2 access_flags; // 字段访问标志(public, private, static 等)
    u2 name_index; // 字段名在常量池中的索引
    u2 descriptor_index; // 字段类型描述符(如 I, Ljava/lang/String;)
    u2 attributes_count; // 字段属性数量
    attribute_info attributes[attributes_count]; // 如 Signature, ConstantValue 等
    }
    • access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
    • name_index: 对常量池的引用,表示的字段的名称;
    • descriptor_index: 对常量池的引用,表示字段和方法的描述符;
    • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
    • attributes[attributes_count]: 存放具体属性具体内容。

各个修饰符都是布尔值,是因为某个修饰符要么有,要么没有,很适合使用标志位来表示。

而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。

方法表集合 methods_countmethods[]

1
2
u2             methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法

methods_count 表示方法的数量,而 method_info 表示方法表。

  • methods_count:方法的数量。
  • methods[]:每个方法由 method_info 定义。

Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。

方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

1
2
3
4
5
6
7
method_info {
u2 access_flags; // 方法访问标志(public, private, static, abstract 等)
u2 name_index; // 方法名在常量池中的索引
u2 descriptor_index; // 方法描述符(如 ()V, (II)I)
u2 attributes_count; // 方法属性数量
attribute_info attributes[attributes_count]; // 如 Code, Exceptions, Synthetic 等
}

方法访问标志表

image-20260124112833217

因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronizednativeabstract等关键字修饰方法,所以也就多了这些关键字对应的标志。

属性表集合 attributes_countattributes[]

1
2
u2             attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合

二编:attribute_info 是一个通用结构,包含:

1
2
3
4
5
attribute_info {
u2 attribute_name_index; // 属性名在常量池中的索引
u4 attribute_length; // 属性长度(字节数)
u1 info[attribute_length]; // 具体内容,取决于属性类型
}

在 Class 文件,字段表,方法表中都可以携带自己的属性表集合

和 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性

  • attributes_count:类级别的属性数量。
  • attributes[]:存储类级别的附加信息,如源文件名、内部类、注解等。

常见的类级属性

属性名 说明
SourceFile 指向源文件名(如 MyClass.java
InnerClasses 内部类信息
EnclosingMethod 外部类的方法(用于嵌套类)
Signature 泛型签名(如 <T>)
BootstrapMethods 支持 invokedynamic(Java 7+)
RuntimeVisibleAnnotations 运行时可见的注解
Deprecated / Synthetic 标记类是否已弃用或合成

类的加载过程

类的生命周期

类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段

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

其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。

一个类的完整生命周期

类的加载过程

加载

在 Java 类的生命周期中,加载是类加载过程的第一步,也是唯一一个开发者可以高度干预的阶段

因为“获取字节流”这一步完全开放,我们可以通过自定义 ClassLoader 控制加载逻辑。

它的核心任务是:

将类的二进制字节流(.class 文件或其他形式)加载到 JVM 中,并在方法区创建对应的运行时数据结构,同时在堆中生成一个 java.lang.Class 对象作为访问入口。

这个过程由 类加载器(ClassLoader) 完成

类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定

根据 JVM 规范,加载阶段主要完成以下三件事:

  1. 通过全限定类名(Fully Qualified Name)获取该类的二进制字节流

    • 全限定类名示例java.lang.Stringcom.example.HelloWorld
    • JVM 不规定字节流必须来自哪里,只关心能否获取到合法的字节流,所以字节流来源的灵活性是 Java 支持热部署和模块化的核心
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(方法区构建元数据

    • .class 文件是静态的二进制格式,JVM 需要将其解析并转换为内部运行时表示,存放在 方法区(Method Area) 中,为后续奠定基础

    此时不会执行任何 Java 代码,也不会为实例变量分配内存。

  3. 在堆中生成一个 java.lang.Class 对象,作为访问入口

    • JVM 会在 Java 堆(Heap) 中创建一个 java.lang.Class 的实例。这个对象是反射(Reflection)的入口

    • Class 对象封装了方法区中该类的所有元数据,提供给 Java 程序使用。

    • 每个类在 JVM 中只有一个 Class 对象,因为它是由类加载器 + 全限定名唯一确定的。

      可以通过 getClassLoader() 获取加载该类的 ClassLoader:

对了,数组类不是由 ClassLoader 创建的,而是由 JVM 在运行时自动创建的。

也就是说,数组类的创建不由 ClassLoader.loadClass() 触发。数组类没有对应的 .class 文件,它的行为完全由 JVM 内建支持。而且数组类的 ClassLoader 与其元素类型的 ClassLoader 一致

那么,如果元素是基本类型(如 int[]),则数组类的 ClassLoadernull,由 Bootstrap 加载

1
2
3
String[] arr = new String[0];
// 与 String.class.getClassLoader() 相同(null)
System.out.println(arr.getClass().getClassLoader());

虽然类加载分为 加载 → 连接(验证/准备/解析)→ 初始化 三个阶段,但它们并非严格串行。这是为了尽早发现错误,避免浪费资源。

验证

验证是连接阶段的第一步

这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行。任何时候,程序安全都是第一位。

不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码都被验证过了,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施来缩短 JVM 类加载的时间。

但是需要注意的是 -Xverify:none-noverify 在 JDK 13 中被标记为 deprecated ,在未来版本的 JDK 中可能会被移除。(目前JDK25还活着)也就是说,这招还是别用了,不差这点功夫和资源来验证一下

验证阶段主要由四个检验阶段组成:

  1. 文件格式验证:验证Class字节码文件格式
  2. 元数据验证:对字节码进行语义检查
  3. 字节码验证:确定程序语义是合法的,没有编译错误
  4. 符号引用验证:验证该类的正确性
验证阶段示意图

文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。

除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候

符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:IllegalAccessErrorNoSuchFieldErrorNoSuchMethodErrorNullPointerException

我已急哭

准备

准备阶段是连接(Linking)过程的第二步,它的核心任务是为类变量(static 变量)分配内存,并设置其初始值

这个初始值是 JVM 定的,不是你程序中定义的初始值

这个阶段不执行任何 Java 代码,仅进行内存分配和默认值初始化,为了提供已分配但未赋值的变量容器。这些内存都将在方法区中分配。对于该阶段:

  1. 分配内存的对象:仅限类变量(静态变量)

    • 类变量就是被 static 修饰的字段,准备阶段只关心 static 字段
    • 实例变量轮不到在这处理,它们在对象创建的时候分配在 Java 堆中
  2. 初始值 ≠ 程序赋值,而是类型的默认零值

    • 非 final 的普通情况:在 准备阶段,该变量被赋予 该类型 的默认零值,这个在前面内存中也说过,在 初始化阶段 也就是执行 clinit方法的时候,才会执行你程序中定义的初始化

      image-20260124115911340
    • 但是对于 static + final 这样属于 编译时常量 的特殊情况而言,JVM 会在 准备阶段直接赋值为你在程序中定义的初始值,因为 final 定义的变量的该值在编译期已确定,并被放入 常量池

      • 注意,只有满足以下条件才是编译时常量:
        • static final
        • 类型为基本类型或 String
        • 初始化表达式是常量表达式

那么,总结一下从 JVM 的角度来解释如何完成准备这一过程的

  1. 解析字段信息:从 .class 文件的 fields[] 中读取所有字段。
  2. 筛选 static 字段:只处理 access_flags 包含 ACC_STATIC 的字段。
  3. 计算内存布局:确定每个 static 字段在类元数据中的偏移量。
  4. 分配内存并清零:
    • 在 JDK 7+:在堆中为 Class 对象分配空间时,预留 static 字段区域,并初始化为零值。
    • 若是 static final 编译时常量,则从常量池直接复制值。
  5. 注册到类结构:使后续初始化阶段能正确访问这些字段。

解析

解析是连接(Linking)阶段的第三步,这步的任务是

将常量池中符号引用(Symbolic Reference) 转换为直接引用(Direct Reference)

这句话很准确,却很神秘

  • 符号引用:一个“名字”或“标识符”,表示某个类、字段、方法等目标。
  • 直接引用:一个内存地址、偏移量或指针,可以直接定位到目标实体。

貌似前面说过

对了,注意,同一个符号引用,在不同 JVM 实例中可能对应不同的直接引用,因为内存布局不同。

为什么需要解析这步,其实按照操作系统的思路也很明显,因为Java 是一种静态编译 + 动态运行的语言。编译器无法知道运行时的最终内存布局,因此:

  • 在编译期只能使用符号引用(如类名、方法名)。
  • 到运行时,JVM 才能确定这些符号的实际位置。
  • 解析阶段就是完成这种“命名 → 地址”的映射,让程序能够真正调用方法、访问字段。

根据《Java 虚拟机规范》,解析动作主要针对以下 7 类符号引用进行转换:

类型 说明 示例
类或接口(Class/Interface) 引用另一个类或接口 java.lang.Object
字段(Field) 引用类中的静态或实例字段 String.length
类方法(Method) 引用类的非虚方法(static 方法) Math.sqrt()
接口方法(Interface Method) 引用接口中的方法 List.add()
方法类型(Method Type) 描述方法签名的类型(Java 7+) ()V, (II)I
方法句柄(Method Handle) 对方法的引用(Java 7+) MethodHandle.invoke()
调用限定符(InvokeDynamic) 支持动态语言调用(Java 7+) invokedynamic 指令

其中前四类是最常见的,后三类主要用于高级特性

解析不是一次性完成的,而是按需进行,通常在以下情况发生:

触发条件 说明
首次访问类或接口 当第一次使用某个类时,JVM 会解析其符号引用
首次访问字段或方法 使用 getfield, putfield, invokevirtual 等指令时
类初始化完成 一般在类初始化完成后才开始解析
延迟解析(Lazy Resolution) JVM 采用懒加载策略,只在真正需要时才解析

那么,如果一个类中有 private static final int VALUE = 100;,但从未被访问,则该字段的符号引用不会被解析。

那么,解析阶段与方法调用的绑定方式密切相关:

  • 静态绑定:会在解析阶段完成,因为他们在编译期就能确定目标
  • 动态绑定:不在这步完成解析,例如虚方法和接口方法,它们编译期确定不了目标,实例化的时候才行,所以需要在运行时根据对象类型决定

因为解析阶段只负责“找到目标”,但多态性(如父类引用指向子类对象)是在运行时通过方法表动态查找实现的。

以 JVM 的视角来看是如何实现解析的

  1. 读取常量池:从 .class 文件中加载所有 cp_info 条目。
  2. 识别符号引用:判断哪些是 CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info 等。
  3. 解析类名:通过 this_classsuper_class 构建类继承关系树。
  4. 解析字段/方法:
    • 查找目标类的方法表(Method Table)
    • 计算方法在表中的偏移量(index)
    • 将符号引用替换为该偏移量
  5. 建立直接引用:将解析结果缓存,避免重复解析。

在 HotSpot JVM 中,方法表(vtable / itable)是实现动态调用的核心数据结构。

通过一个很具体的例子来说明解析是如何工作的

1
2
3
4
5
public class Main {
public static void main(String[] args) {
System.out.println("Hello");
}
}

这个玩意编译只会主要是这个东西

1
invokevirtual java/io/PrintStream println(Ljava/lang/String;)V

这个指令中:

  • println 是一个方法名
  • Ljava/lang/String; 是参数类型
  • V 表示返回 void

但在 .class 文件的常量池中,它是一个符号引用,结构如下:

1
2
3
4
5
CONSTANT_Methodref_info {
u1 tag = 10;
u2 class_index = 1; // 指向 "java/io/PrintStream"
u2 name_and_type_index = 2; // 指向 "println" 和 "(Ljava/lang/String;)V"
}
  1. 查找目标类:JVM 根据 class_index=1 找到 PrintStream 类。
  2. 查找方法:根据 name_and_type_index=2 找到 println 方法。
  3. 确认方法位置:在 PrintStream 的方法表中找到该方法对应的偏移量(offset)。
  4. 替换为直接引用:将原来的符号引用替换为一个指向该方法的直接引用(如方法表索引或内存地址)。

此后,每次调用 println 时,JVM 不再需要查表,而是直接跳转到指定位置执行。

初始化

初始化是类加载过程的最后一步,也是 JVM 首次真正执行 Java 字节码 的阶段。

在此之前(加载、验证、准备、解析),JVM 只是读取、验证、分配内存、建立引用关系,但从未执行任何用户定义的代码

而初始化阶段会执行类中定义的静态初始化逻辑,包括:

  • static 字段的显式赋值(非编译时常量)
  • static {} 静态代码块

这些逻辑被编译器收集并生成一个特殊的 类构造器方法:<clinit>()

对于<clinit>()

  • <clinit>()方法是编译之后自动生成的。全称 Class Initialization Method,它在字节码中表现为一个名为 <clinit>、无参数、无返回值、访问标志为 static 的方法。每个类最多只有一个 <clinit> 方法,如果你没写静态初始化逻辑,则大概率没有
  • <clinit>()中,编译器会按源码中出现的顺序,将以下内容合并到 <clinit>
    • 静态字段的显式赋值(别忘了编译时常量在准备阶段就赋值了,所以这里肯定不赋值编译时常量)
    • 静态代码块
  • 而实例变量和构造函数不会进入 <clinit>,它们属于对象初始化<init>

JVM 保证 <clinit> 方法在多线程环境下是线程安全的。

  • 当一个线程开始初始化一个类时,JVM 会对该类加锁。
  • 其他线程若同时尝试初始化该类,会被阻塞,直到第一个线程完成 <clinit>
  • 初始化完成后,其他线程不再执行 <clinit>,那么直接使用已初始化的类。

如果你的<clinit> 方法中有耗时操作,会导致多个线程长时间阻塞,进而很有可能引发死锁,而且一旦出现,极其难被发现(哈j码难被录都)

所以,避免在静态初始化中做复杂或 I/O 操作。

而且JVM 规范明确规定,只有当程序“主动使用”一个类时,才会触发其初始化。有且只有 6 种情况:

  1. 遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时:
    • new: 创建一个类的实例对象。
    • getstaticputstatic: 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)。
    • invokestatic: 调用类的静态方法
  2. 使用反射(使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName("..."), newInstance() 等等)
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户定义了一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

针对数组,根据我们对上面的研究,其实也不难发现,创建数组根本不会引发初始化,static除外啊

使用 javap -c -v 查看字节码

1
javap -c -v YourClass.class

类的剩下的生命周期详解

使用

使用就是使用,没啥好多说的,指应用程序在运行时对已加载类的各种操作

这一步其实不算类的加载过程,但是属于类的生命周期,它是类加载的最终目的

因为

  1. 加载(Loading)
  2. 连接(Linking) → 验证 + 准备 + 解析
  3. 初始化(Initialization)

完成这三步后,类就“准备好了”,可以被程序正常使用

一旦类初始化完成,其静态字段和方法通常是线程安全,但如果静态字段是可变的,多个线程同时修改仍需同步。

类一旦被加载,通常不会被卸载,但是也存在类卸载

卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求,和前面提到的一样

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 GC

正因如此,垃圾回收器没事不会把类 GC 掉,所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类通常是不会被卸载的

只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们类加载器的实例肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。