JVM 加载类的详尽过程

在 HotSpot 虚拟机中,创建一个 Java 对象是一个非常严密的过程,涉及类加载检查、内存分配、状态初始化、对像头设置、执行init等多个环节,这些过程中会涉及诸如类加载器、对象头信息、并发处理、GC回收等多个概念或问题,下面我们进行一一拆解。首先我们来看对象创建的五大核心步骤。


对象创建的五大核心步骤

JVM 对象创建核心五步法
1.类加载检查 (Loading Check)

检查常量池是否能定位类引用,判断类是否已加载、解析和初始化。若无则触发 ClassLoader 加载。

2.分配内存 (Memory Allocation)

在堆中划分固定大小空间。涉及:
- 指针碰撞/空闲列表 (取决于堆规整度)
- TLAB (本地线程分配缓冲,解决并发安全)

3.初始化零值 (Zeroing)

将分配到的内存(不含对象头)全部设为零值(0, false, null)。确保实例变量不赋初值即可使用。

4.设置对象头 (Header Setup)

写入 Mark Word (哈希码、GC年龄、锁状态)
写入 Klass Pointer (元数据指针)。如果是数组还要记录长度。

5.执行 <init> 方法 (Initialization)

程序员眼中的构造开始。执行构造代码块、显式初始化成员变量,最后执行构造函数。

💡 底层贴士: 在第 2 步分配内存时,HotSpot 会优先尝试在 TLAB (Thread Local Allocation Buffer) 中分配,这样可以避免多线程竞争锁,这也是 Java 创建对象极快的原因。

TLAB 是性能关键:大多数对象在 TLAB 就能分配完成,避免了全局堆加锁。
对象头是个 “多面手”:它既负责记录 GC 年龄,又负责实现 Java 的 synchronized 锁升级。
对齐填充:HotSpot 要求对象起始地址必须是 8 字节的整数倍。如果对象数据不满,会用 Padding 补齐,这主要是为了让 CPU 访问内存更高效(内存对齐)。

如果你去翻阅 HotSpot 源码(如 bytecodeInterpreter.cpp),你会发现 new 指令的底层逻辑大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 伪码:HotSpot new 指令部分逻辑
CASE(_new): {
u2 index = Bytes::get_Java_u2(pc+1);
// 1. 尝试快速分配:检查类是否已解析且在 TLAB 中
Klass* k = constants->klass_at(index, CHECK);
if (k->is_initialized() && instanceKlass::cast(k)->can_be_fastpath_allocated()) {
size_t size = instanceKlass::cast(k)->size_helper();
// 2. 尝试从 TLAB 分配
HeapWord* obj = thread->tlab().allocate(size);
if (obj == NULL) {
// TLAB 不够,去堆里找
obj = Universe::heap()->mem_allocate(size, &gc_overhead_limit_was_exceeded);
}
if (obj != NULL) {
// 3. 初始化内存空间为 0
memset(obj, 0, size * HeapWordSize);
// 4. 设置对象头 (Mark Word)
obj->set_mark(markWord::prototype());
// 5. 设置类型指针 (Klass Pointer)
obj->set_klass(k);
SET_STACK_OBJECT(obj, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
// 6. 如果快速路径失败,进入慢速路径(完整加载和分配)
InterpreterRuntime::_new(thread, pool, index);
}


第一步:类加载

在 Java 对象诞生的流水线上,类加载是第一道工序。如果说 “对象”是一座房子,那么“类” 就是图纸。JVM 在搬砖(分配内存)之前,必须先确保图纸已经通过审核并存放在档案库里。


类加载的机制

JVM 并不是简单地把 ”.class“ 文件读入内存,而是通过一套层级结构来确保核心库的安全——这就是双亲委派模型 (Parents Delegation Model) 。简单来说,双亲委派模型是 JVM 在加载类时的一种 “层级递交、优先上报” 的机制。它规定当一个类加载器收到加载类的请求时,它自己先不试着加载,而是把这个请求 “委派” 给父类加载器去完成。只有当父类加载器反馈自己无法完成这个加载请求时,子类加载器才会尝试自己去加载。在 Java 中,主要存在三种默认的类加载器,它们形成了如下的层级:

启动类加载器 (Bootstrap ClassLoader):

  • 地位:最顶层的 “老祖宗”。
  • 职责:由 C++ 实现,负责加载 Java 的核心库(如 “rt.jar”、”java.lang.*” 等)。

扩展类加载器 (Extension ClassLoader):

  • 职责:负责加载 Java 的扩展库(”lib/ext” 目录下的 Jar 包)。

应用程序类加载器 (App ClassLoader):

  • 职责:负责加载用户路径(Classpath)上指定的类库,也就是我们平时写的代码。


双亲委派的工作流程

当你的代码里执行 new MyClass() 时,加载过程如下:

  • 第一步(向上委派):App ClassLoader 收到请求,不查自己,先问 Extension ClassLoader。
  • 第二步(继续委派):Extension ClassLoader 也不查,继续问顶层的 Bootstrap ClassLoader。
  • 第三步(尝试加载):Bootstrap 开始搜寻自己的领地(核心库)。如果找到了,就加载返回;如果没找到,告诉子类:“我这没有”。
  • 第四步(向下传递):Extension 收到回复,开始搜寻自己的领地。如果还没有,再告诉 App ClassLoader。
  • 第五步(最后保底):App ClassLoader 只能在自己的 Classpath 里找,找到了就大功告成,找不到就抛出我们熟悉的 ClassNotFoundException。

这种看起来“推卸责任”的设计,其实是为了解决两个关键问题:

  • A. 安全保障(防止核心 API 被篡改):假设没有双亲委派,一个坏蛋写了一个黑客版的 java.lang.Object 并放在代码库里。如果没有委派机制,JVM 可能会加载这个黑客版的 Object。有了双亲委派模型,请求最终会传给 Bootstrap,它发现要加载的是 java.lang.Object,会直接从核心库加载官方正版,从而保证了 Java 最基础的行为不会被随意替换。

  • B. 避免重复加载:通过委派,父类加载器加载过的类,子类就不会再加载一遍。这保证了在整个 JVM 程序中,同一个全限定名(如 java.util.List)对应的类是唯一的。


自定义类加载器

要实现自定义类加载器,通常需要继承 java.lang.ClassLoader 并重写 findClass 方法。原理是:

  • 标准委派:调用 loadClass(),它会先问父亲,父亲不行再调用自己的 findClass()。
  • 打破委派:如果你重写 loadClass(),直接让自己先找,找不到了再问父亲,这就打破了双亲委派。

Tomcat 就是最典型的例子。一个 Tomcat 容器可以跑两个 Web 应用(WebApp1 和 WebApp2),它们可能都用了 Spring,但版本不同(一个是 4.0,一个是 5.0)。在实际中,Tomcat 为每个 WebApp 创建了一个独立的 WebappClassLoader,它们互不委派,各自加载自己目录下的 Jar 包,从而实现了同一个 JVM 里的类版本隔离。

另外,自定义类加载器还可以实现代码的加密。你可以把 “.class” 文件先进行异或运算加密,标准的加载器无法识别。而你在自定义的 findClass 里读取字节流后,先进行解密还原,再交给 defineClass。这样别人就算拿到了你的 Jar 包也无法反编译。


手搓一个自定义类加载器

第一步:准备一个待加载的类

1
2
3
4
5
6
7
8
package mytest;

// 保存为 mytest.Hello.java 并执行 javac mytest.Hello.java 生成 mytest.Hello.class
public class Hello {
public void say() {
System.out.println("我是由 " + this.getClass().getClassLoader() + " 加载的!"); // 我是由 MyClassLoader@7ad041f3 加载的!
}
}

第二步:编写自定义类加载器 MyClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

/**
* @author KJ
*/
public class MyClassLoader extends ClassLoader {

private String rootPath;
public MyClassLoader(String rootPath) {
this.rootPath = rootPath;
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 如果是系统核心类(如 java.lang.*),必须委派给父类,否则会报安全异常
if (name.startsWith("java.")) {
return super.loadClass(name);
}

// 2. 对于我们自己的类,直接绕过委派,先尝试自己加载
try {
return findClass(name);
} catch (ClassNotFoundException e) {
// 3. 如果自己加载不了,再交给老爸(保底机制)
return super.loadClass(name);
}
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 根据类名拼接文件路径
String filePath = rootPath + name.replace(".", "/") + ".class";

try (InputStream is = new FileInputStream(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

// 2. 将文件流读入字节数组
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
byte[] data = baos.toByteArray();

// 3. 调用父类的 defineClass 将字节数组转化为 Class 对象
return super.defineClass(name, data, 0, data.length);
} catch (IOException e) {
throw new ClassNotFoundException("找不到类: " + name, e);
}
}
}

测试:我们要观察同一个类被不同的加载器加载后,JVM 是如何看待它们的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) throws Exception {
// 指定 class 文件所在的根目录
String path = "~/Desktop/zdemo/xxx/target/classes/";

// 创建两个不同的加载器实例
MyClassLoader loader1 = new MyClassLoader(path);
MyClassLoader loader2 = new MyClassLoader(path);

// 加载同一个类
Class<?> clazz1 = loader1.loadClass("mytest.Hello");
Class<?> clazz2 = loader2.loadClass("mytest.Hello");

// 打印结果
System.out.println("clazz1 的加载器: " + clazz1.getClassLoader()); // MyClassLoader@7ad041f3
System.out.println("clazz1 == clazz2 ? " + (clazz1 == clazz2)); // false

// 实例化并调用方法
Object obj = clazz1.getDeclaredConstructor().newInstance();
clazz1.getMethod("say").invoke(obj);
}

虽然它们加载的是磁盘上同一个 Hello.class 文件,但在 JVM 看来,“全限定类名 + 类加载器实例” 才构成的唯一标识。不同的加载器实例加载的类是完全隔离的。

注意,如果你的 Hello.class 同时也存在于项目的 Classpath 下。根据双亲委派,AppClassLoader 会抢先找到并加载它,结果是 clazz1.getClassLoader() 将会是 AppClassLoader,而不是你的 MyClassLoader。这就是为什么在做类隔离(如 Tomcat)时,我们需要重写 loadClass 而不仅仅是 findClass 来主动拦截委派过程。


热加载的实现原理

热加载的本质是:监测到 “.class” 文件变化后,丢弃旧的类加载器,创建新的类加载器重新加载。

实现步骤:

  1. 文件监听:启动一个后台线程,定期检查特定目录下的 “.class” 文件修改时间。
  2. 销毁旧引用:当文件改变时,将原来负责加载该类的 ClassLoader 实例置为 “null”。
  3. 重新加载:创建一个新的 ClassLoader 实例,重新读取新的字节码文件。

注意,由于 Java 中 “类” 的唯一性是由 (全类名 + 类加载器实例) 共同决定的。所以即使类名没变,换了加载器,JVM 也会认为这是一个全新的类。


第二步:分配内存

当类加载检查通过后,JVM 已经确切知道要为这个新生命申请多大的空间。接下来,它会在 Java 堆(Heap)这个“大社区” 里开始划分地盘。这个过程看似简单,实则涉及极其复杂的空间布局算法和并发同步机制。

分配过程的完整时序

  • 计算大小:根据类信息确定对象所需的字节数。
  • 栈上分配尝试:若开启逃逸分析且符合条件,直接入栈,流程结束。
  • TLAB 优先:若栈上分不了,看线程私有的 TLAB 够不够。够则指针碰撞,结束。
  • 全局分配:TLAB 不够了,尝试在公共 Eden 区申请。如果内存绝对规整则 CAS 锁住全局指针,指针碰撞。如果不规整,那么 CAS 锁住空闲列表,查找并分配。
  • 触发 GC:如果公共 Eden 区也满了,JVM 只能放下手头的活,发起一次 Minor GC。


逃逸分析与栈上分配

并不是所有对象都必须进堆!在分配内存之前,JVM 还有一个隐藏的大招——逃逸分析

JVM 会观察对象的作用域。如果发现一个对象只在某个方法内部使用,绝对不会被外部引用(没有“逃逸”),那么 JVM 可能会直接把这个对象分配在 线程栈(Stack) 上。这时候栈上分配的对象会随着方法调用结束直接销毁,完全不需要 GC 介入。这是 Java 性能优化的顶级黑科技。


堆上分配与内存划分

JVM 会根据 Java 堆是否整齐,选择不同的分配算法(主要有两种不同的 “拿地” 方式)。而堆是否规整,取决于你使用的 GC 垃圾回收器 是否具有 “压缩/整理” 功能。

A. 指针碰撞 (Bump the Pointer) —— 规整的 “排队机制”

  • 适用场景:内存绝对规整。所有用过的内存都在一边,空闲的在另一边,中间由一个指针作为分界点。
  • 动作:分配内存仅仅是把分界点指针向空闲方向挪动一段与对象大小相等的距离。
  • 对应 GC:Serial, ParNew, G1 等带整理功能的回收器。

B. 空闲列表 (Free List) —— 散乱的 “查表机制”

  • 适用场景:内存不规整,已用和空闲内存交错分布。
  • 动作:JVM 必须维护一张列表,记录哪些内存块是空的。分配时从列表中找出一块足够大的空间划分给对象,并更新列表。
  • 对应 GC:CMS 等基于 “标记-清除” 算法的回收器。


内存分配中的内存对齐

在分配时,你会发现对象的大小通常是 8字节的整数倍,这叫 对齐填充 (Padding)。现代 CPU 访问 8 字节对齐的内存地址效率最高。如果对象跨越了缓存行边界,性能会大打折扣。所以即使你的对象只需要 13 字节,JVM 也会分给你 16 字节。


TLAB 解决并发争抢

在高性能应用中,成千上万个线程同时申请内存。如果大家都去抢那个 “全局指针”,就会产生锁竞争,导致性能大幅下降。为了解决这个问题,JVM 引入了 TLAB (Thread Local Allocation Buffer)

  • TLAB 原理:JVM 在每个线程启动时,预先在堆的 Eden 区 为其分配一小块私有内存(默认通常只有 Eden 的 1%)。
  • 分配逻辑:线程要创建对象时,优先在自己的那块 TLAB 里分配。因为这块地是私有的,分配时完全不需要加锁,速度极快。只有当 TLAB 用完了,线程才去申请新的 TLAB,此时才需要进行全局同步(加锁)。
  • 参数控制:-XX:+UseTLAB(默认开启)。


第三步:初始化零值

在内存地盘划好之后,这块空间还残留着上一个对象留下的“历史遗迹”(随机的二进制位)。为了保证新对象的洁净,JVM 会立即执行初始化零值 (Zeroing)。这是对象诞生过程中最 “沉默” 却又最 “慷慨” 的一步。

什么是初始化零值?

JVM 会将分配到的内存空间(不包括对象头)中的所有字节全部设置为零。

  • 数值类型(byte, short, int, long):变为 0。
  • 浮点类型(float, double):变为 0.0。
  • 布尔类型(boolean):变为 false。
  • 引用类型(reference):变为 null。

这一步的核心意义在于:保证 Java 程序中对象的实例变量在不赋初值的情况下就可以直接使用。在 Java 中,你会发现以下有趣的现象:

1
2
3
4
5
6
7
8
9
10
public class User {
int age; // 没给初值
boolean isActive; // 没给初值
String name; // 没给初值

public void print() {
// 直接打印,不会报错,结果是 0, false, null
System.out.println(age + ", " + isActive + ", " + name);
}
}

这背后的功劳全在 Step 3。如果 JVM 不做这一步,这些变量就会指向内存中残留的乱码,导致程序运行结果不可预测,甚至引发崩溃。

注意:局部变量(方法内的变量)是不享受这个待遇的。局部变量必须手动初始化,否则编译不通过。这是因为局部变量存在于栈帧中,为了极致的性能,编译器强制要求开发者赋值,而不是靠 JVM 自动刷零。


在底层是如何实现的?

在 HotSpot 源码中,这一步通常发生在分配内存的逻辑内部。

  • TLAB 场景:如果内存是从线程私有的 TLAB 中分配的,JVM 会利用底层的 memset 或类似的指令快速清零。
  • 逃逸分析优化:如果对象被优化为 “栈上分配”,这个清零动作可能直接通过操作 CPU 寄存器或栈指针移动来完成。

注意,虽然 memset 很快,但如果你一次性分配一个巨大的数组(比如 new byte[1024 x 1024 x 100] 即 100MB),初始化零值确实会产生一定的耗时。

在某些高性能场景下,JVM 可能会利用操作系统的 “零页机制。当 JVM 向 OS 申请新内存时,OS 返回的页面通常已经是清理过的零页,这样 JVM 就可以偷懒,跳过部分清零动作。


为什么不包括对象头?

这是一个非常精妙的设计。

  • 原因:对象头(Mark Word 和 Klass Pointer)在下一步(Step 4)中会有专门的赋值逻辑。如果在 Step 3 把对象头也刷成全 0,那么下一步还要重新写入,这属于无效的二次操作。
  • 顺序:JVM 遵循 先刷地盘,再立门户 的原则。

经过第三步的初始化零值,内存已经变成了一张白纸。虽然变量都有了默认值,但这个对象目前还是个 “无名氏”——它不知道自己是谁(属于哪个类),GC 也不知道它几岁了。


第四步:设置对象头

在完成内存清零后,对象还只是堆中一块 “无主的荒地”。设置对象头(Header Setup)就是为这块内存刻上 “身份证” 和 “通行证”,让 JVM 能够识别它、管理它。在 HotSpot 虚拟机中,对象头是实现垃圾回收(GC)多线程同步(Synchronized)和 动态分派(反射/类型检查)的关键。


对象头的三大组成部分

对象头在 64 位 JVM 开启压缩指针(默认开启)时,通常占用 12 字节。

Java 对象头 (64-bit) 内存布局
Mark Word
8 Bytes / 64 bits
Klass Pointer
4 Bytes (Compressed)
Array Length
4 Bytes (Optional)

🛡️ Mark Word 动态布局 (随锁状态变化)

状态 内容 (62 bits) 年龄 偏向 标志位
无锁 unused(25b) + HashCode(31b) 4b 0 01
偏向锁 Thread ID(54b) + Epoch(2b) 4b 1 01
轻量锁 指向栈中 Lock Record 指针 00
重量锁 指向堆中 Monitor 指针 10
💡 分代年龄 (4 bits)

最大值 15。由于空间仅 4 位,超过 15 次回收的对象必须进入老年代。

🏷️ 指针压缩 (Compressed Oops)

开启后 Klass Pointer 减半为 4 字节,大幅提升大内存环境下的缓存命中率。

🔑 Identity HashCode

一旦对象计算过哈希值,它将无法进入偏向锁状态,因为 HashCode 占用了原有的 ThreadID 空间。

Mark Word (标记字段) —— 8 字节:

这是对象头的核心,它是一个动态、可复用的位结构。

  • 哈希码 (HashCode):延迟加载,存放对象的 Identity HashCode。
  • 分代年龄 (Age):4 位,记录对象在 Survivor 区被复制的次数,最大值为 15。
  • 锁状态标志 (Lock Tag):记录当前对象是被哪个线程持有,是偏向锁、轻量级锁还是重量级锁。
  • GC 标记:标记对象是否需要被回收。

在 64 位 JVM 的 Mark Word 中,最后 3 位(1 bit 偏向位 + 2 bit 标志位)共同构成了一个状态机。之所以不直接合并成一个 3 bit 的 “等级字段”,是因为 “偏向状态” 是一个独立的属性,它决定了前 54 bit 数据的解释方式。如果只用 2 bit 标志位(只能表示 00, 01, 10, 11),我们只能区分四种状态。但实际中,“无锁” 状态有两种完全不同的子状态:

  • 真正的无锁(不可偏向):此时 Mark Word 存的是对象的 HashCode。
  • 可偏向状态(匿名偏向):此时对象刚出生,还没人认领,前 54 bit 是空的(全 0),准备记录 Thread ID。

分层设计的逻辑:

  • 2 bit 标志位:定义了锁的“物理形态”(是记录在对象头里,还是记录在栈里,还是记录在外部 Monitor 里)。
  • 1 bit 偏向位:是一个 “开关”。它告诉 JVM:“当标志位是 01 时,请看这一位。如果我是 1,前面的数据就不是 HashCode,而是线程 ID。”

Klass Pointer (类型指针) —— 4 字节 (压缩后)

指向该对象所属类在元空间(Metaspace)中的 InstanceKlass 对象。JVM 通过这个指针才知道这个对象到底是 User 类还是 Order 类。没有它,instanceof 和反射都无法工作。

Array Length (数组长度) —— 4 字节

仅当对象是数组时才会存在,因为 JVM 无法通过元数据计算出动态数组的大小。


对象头设置过程

设置对象头并不是一次性写死,而是一个分层赋值的过程。

  • 第一步:建立类关联 (Klass Pointer):JVM 将第一步类加载检查时获取到的 InstanceKlass 地址,经过压缩算法处理后,写入对象的类型指针位置。
  • 第二步:写入原型 Mark Word (Mark Word):JVM 每一个类都会维护一个 “原型 Mark Word”。
    • 如果类开启了偏向锁,原型 Mark Word 的最后三位通常是 101
    • JVM 会将这个原型直接 Copy 到新对象的 Mark Word 位置。
  • 第三步:初始化状态位
    • 分代年龄:初始化为 0。
    • 哈希码:初始为 0(只有当程序第一次调用 hashCode() 方法时,才会通过随机数算法生成并回写到对象头)。


Mark Word “变脸” 过程

Mark Word 设计最精妙的地方在于其极高的空间利用率。为了节省内存,它会根据锁的状态 “变脸”:

注意:在 64 位机器上,指针原本该占 8 字节。但 JVM 发现 32GB 内存以下的机器,可以用 4 字节的偏移量来表示地址(类似于缩尺比例)。这样 Klass Pointer 从 8 字节压缩到 4 字节。这省下的 4 字节,在海量对象的应用中能节省几 GB 的堆内存!

1、实现 synchronized 锁升级:当多个线程竞争同一个对象时,JVM 不需要额外创建一个 “锁对象”,直接修改这个对象的 Mark Word 指针,将其指向操作系统级别的互斥量(Monitor)。

2、决定对象的生死:GC 扫描时,会查看对象头里的 “分代年龄”。每熬过一次 Minor GC,年龄就加 1。当达到 15 时,对象头的信息会告诉 JVM:“这个对象很老了,把它挪到老年代去”。

3、支持虚函数调用:在执行 user.sayHello() 时,JVM 先看对象头的 Klass Pointer 找到类,再在类的方法表(vtable)里找具体的实现代码。


锁状态升级逻辑拆解

简单来说,Java 的 synchronized 锁并不是一步到位的。为了提升性能,JVM 设计了一套锁升级(Lock Inflation)机制,利用对象头里的 Mark Word 记录锁的状态。我们可以把这四种状态想象成公司进门的安全级别。

四种锁状态的形象演进

🔓 无锁 (Unlocked)

  • 场景:没有线程竞争。
  • 状态:偏向位 0,标志位 01。对象头里存的是 HashCode。

💘 偏向锁 (Biased Lock) 【Java15之后被弃用】

  • 核心思想:“锁只属于初恋”。研究发现,多数情况下锁总是由同一个线程多次获得。
  • 机制:线程第一次访问时,CAS 修改 Mark Word,把自己的 Thread ID 刻在对象头上。下次再来时,只需比对 ID,无需任何同步操作。
  • 代价:一旦有第二个线程尝试竞争,偏向锁就会失效,升级为轻量级锁。

⚡ 轻量级锁 (Lightweight Lock)

  • 核心思想:“先等一会儿,说不定马上就开了”。
  • 机制:竞争线程不挂起,而是进行自旋(循环尝试)。它会在自己的栈帧里创建一个空间,尝试把对象头的 Mark Word 拷贝过来(CAS 指针交换)。
  • 优点:避免了操作系统内核态切换的昂贵代价。
  • 缺点:如果对方持锁时间很长,自旋会白白消耗 CPU。

⛓️ 重量级锁 (Heavyweight Lock)

  • 核心思想:“别转了,去后边排队!”。
  • 机制:标志位变为 10。Mark Word 指向堆中的 Monitor(监视器)对象。没抢到锁的线程直接进入阻塞状态(Blocked),交给操作系统管理。
  • 性能:涉及用户态和内核态的切换,最重,但不会浪费 CPU。

作为 Java 开发者,你不需要(也无法)手动指定使用哪种锁,这是 JVM 自动管理的。但你的代码逻辑决定了锁的性能:

  • A.减少锁的粒度,原则就是不要锁住整个方法,只锁必要的代码块。这样就可以减少线程在同步块里的停留时间,增加轻量级锁成功的概率,避免升级到重量级锁。
  • B.锁消除 (Lock Elision):如果在局部方法里 new 了一个 StringBuffer(内部带锁)。JVM 经过逃逸分析发现这个对象不会跑出方法,会直接在编译时删掉这个锁。
  • C.注意 HashCode 的副作用:如果一个对象计算过 hashCode(),它就再也无法进入偏向锁状态了!因为 Mark Word 空间有限,偏向锁的 Thread ID 和 HashCode 占的是同一个位置。如果 HashCode 占了坑,Thread ID 就再也没地方写了。所以实际中,对于频繁加锁的对象,尽量避免在同步逻辑前调用其 hashCode()。

在 Java 8 及以后的版本中,synchronized 的性能已经由于这些优化而变得非常出色。

  • 如果你的场景是单线程循环加锁:偏向锁近乎零开销。
  • 如果是短平快的并发:轻量级锁自旋效率极高。
  • 如果是高耗时的任务(如 IO):重量级锁让出 CPU 是最合理的。


偏向锁在15后被弃用原因

偏向锁在 Java 15 中被正式弃用(Deprecated),并最终在后续版本中被彻底移除。在这种情况下,对象创建后直接就是“无锁状态”(001),它不再经历 “可偏向(101)” 阶段。这意味着在现代 JDK 环境下,偏向锁的作用正在弱化,而轻量级锁和自旋优化成为了并发的支柱。如果如果你想在最新的 JDK 上强制看到 101 状态,请在启动时添加这个 VM 参数: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0(必须放在 -classpath 的前面)

这个曾被视为 “神级优化” 的机制之所以退出历史舞台,本质上是因为硬件的进步和系统复杂度的博弈。以下是核心的几个原因:

  • 复杂性与维护成本:偏向锁的实现逻辑极其复杂。为了在不竞争时提升性能,它必须在对象头中记录 Thread ID。当第二个线程尝试竞争锁时,偏向锁必须被“撤销”。撤销过程需要进入全局安全点(SafePoint),暂停所有正在运行的线程(Stop The World),遍历栈帧,修改对象头。偏向锁的代码散落在 JVM 的各个角落(如同步块、解构、JIT 优化等),导致 JVM 代码难以维护和重构,阻碍了其他新特性的开发。
  • 硬件性能的提升:偏向锁最初设计于 2000 年代初期,当时的 CAS(Compare-And-Swap)原子指令开销非常昂贵。现在的处理器处理 CAS 指令的性能已经大幅提升。轻量级锁所使用的 CAS 操作已经足够快,偏向锁所节省的那一点点开销,在复杂的撤销成本面前显得得不偿失。
  • 应用模式的改变:早期的 Java 应用大量使用 Vector、Hashtable 或 StringBuffer 等内部带锁的旧集合类。在单线程环境下,这些锁会造成不必要的浪费。现代 Java 开发者更倾向于使用非同步的集合类(如 ArrayList、HashMap),或者使用专门为并发设计的 java.util.concurrent 包。偏向锁能优化的 “单线程锁竞争” 场景在高质量代码中已经越来越少见。

现在的 Java(如 JDK 17/21)默认采用的是轻量级锁作为起始优化方案。它不再维护 “初恋关系”(Thread ID),而是直接通过 CAS 尝试修改对象头。

  • 如果没竞争,CAS 一次就成功,性能极佳。
  • 如果有轻度竞争,自旋几次,依然保持在用户态。


锁升级和分代年龄观察

利用 JOL (Java Object Layout) 工具捕捉对象在不同状态下的 Mark Word 原始位信息,观察对象从 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 的完整过程。

1
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import org.openjdk.jol.info.ClassLayout;

/**
* @author KJ
*/
public class LockUpgradeDemo {

public static void main(String[] args) throws InterruptedException {
// 关键点:JVM 启动前几秒不开启偏向锁,我们要么等,要么加参数:
// -XX:BiasedLockingStartupDelay=0
Thread.sleep(5000);

Object obj = new Object();
System.out.println("1. 新建对象 (此时应为可偏向状态 101):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());

synchronized (obj) {
System.out.println("2. 主线程首次进入 (此时应为偏向锁,记录了 Thread ID):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

new Thread(() -> {
synchronized (obj) {
System.out.println("3. 另一个线程竞争 (此时应升级为轻量级锁 00):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());

try { Thread.sleep(1000); } catch (InterruptedException e) {}
}
}).start();

Thread.sleep(100); // 确保第二个线程已持锁
synchronized (obj) {
System.out.println("4. 多个线程激烈竞争 (此时应升级为重量级锁 10):");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
1. 新建对象 (此时应为可偏向状态 101): # 这个对象现在是 “单身”,已经开启了偏向锁模式,等待第一个线程来 “领证”。
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000005 (biasable; age: 0) # 末尾8位二进制: 00000101
8 4 (object header: class) 0x00000d68
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

2. 主线程首次进入 (此时应为偏向锁,记录了 Thread ID): # 此时 Mark Word 已经刻上了主线程的 Java Thread ID。从此该线程进出此同步块只需比对 ID,无需 CAS 操作,性能近乎无损。
java.lang.Object object internals: # 末尾依然是 101(偏向模式),但高位不再是全 0,JOL 已经识别出其中包含了 biased: 0x0000001ff359403e。
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00007fcd6500f805 (biased: 0x0000001ff359403e; epoch: 0; age: 0)
8 4 (object header: class) 0x00000d68
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

3. 另一个线程竞争 (此时应升级为轻量级锁 00): # 另一个线程来敲门了,偏向锁被撤销。JVM 在竞争线程的栈帧中划出一块空间(Lock Record),并将对象的 Mark Word 拷贝过去。此时 VALUE 指向的是栈中的 Lock Record 地址。
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000700002f6fa38 (thin lock: 0x0000700002f6fa38) # 末尾8位二进制: 00111000,最后两位跳变成了 00。
8 4 (object header: class) 0x00000d68
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

4. 多个线程激烈竞争 (此时应升级为重量级锁 10): # 竞争白热化。自旋失败的线程不再浪费 CPU,JVM 向操作系统申请了重量级锁(Monitor)。此时 VALUE 指向的是堆中 ObjectMonitor 的内存地址。
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000600003ca05b2 (fat lock: 0x0000600003ca05b2) # 末尾8位二进制 10110010,最后两位跳变成了10。
8 4 (object header: class) 0x00000d68
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

# 在大多数 JVM 实现中,锁只能升级不能降级(重量级锁降级非常罕见且条件苛刻)。

注意:在这个实验中,如果我们不手动开启参数,对象一加锁就会直接显示为 “thin lock”(轻量级锁)。


分代年龄实验观察:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// java -classpath xxx/target/classes:xxx-01.jar:xxx-02.jar GCAgeRealDemo -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:+UseSerialGC GCAgeRealShow

public class GCAgeRealDemo {

// 保持引用,防止被回收
static List<Object> holder = new ArrayList<>();

public static void main(String[] args) {
Object target = new Object();
holder.add(target);

System.out.println("--- 初始状态 ---");
System.out.println(ClassLayout.parseInstance(target).toPrintable());

// 持续分配内存,诱发多次 Minor GC
for (int i = 0; i < 15; i++) {
// 分配大量 1KB 的数组,填满 10MB 的 Eden 区非常快
for (int j = 0; j < 8000; j++) {
byte[] junk = new byte[1024];
}
// 此时 Eden 满了,JVM 会自动进行 Minor GC,target 会被移动到 Survivor
System.out.println("--- 循环第 " + (i + 1) + " 次后 ---");
System.out.println(ClassLayout.parseInstance(target).toPrintable());
}
}
}


第五步:执行 init 方法

经过第四步,对象已经 “名花有主”,且有了“身份证”。但在 Java 程序的视角里,它还没被初始化——构造函数还没跑。在 Java 源码里,你看到的是构造方法(Constructor);但在字节码层面,编译器会把所有的初始化逻辑收集起来,编译成一个名为 init 的特殊实例初始化方法。它包含了三部分内容的合体:

  1. 所有非静态变量的显式赋值语句(例如 int age = 18)。
  2. 构造代码块(即类中直接用 {} 包起来的代码)。
  3. 构造函数本体的代码。

内存层面的 “填坑” 过程:

  • Step 2 (内存分配) 后:你得到了一块全 0 的空间。
  • Step 4 (设置对象头) 后:对象头里的 12 字节(8 字节 Mark Word + 4 字节 Klass Pointer)已经填好了。
  • Step 5 init 执行时:CPU 执行指令,把业务数据(比如 name 的引用地址、age 的数值)精准地填入对象头之后的实例数据(Instance Data)区域。


初始化执行的顺序

JVM 在执行 init 时有非常严格的先后顺序,这保证了对象状态的正确性:

  • 父类 init 调用:首先调用父类的构造方法 super(),确保祖先们的属性先初始化好。
  • 实例变量初始化与属性块:按照它们在源码中出现的先后顺序从上到下执行。
  • 子类构造函数本体:最后才执行你写在子类构造器里的逻辑。

⚠️ 避坑——在构造函数里调用 “可重写方法”:这是一个经典的 Bug 来源。如果在父类的构造函数里调用了一个被子类重写的方法:

  1. 父类构造器开始跑。
  2. 调用该方法,由于 Java 的多态性,它会去执行子类重写后的方法。
  3. 但此时子类的属性还没开始执行 init 赋值(还是默认零值)。
  4. 结果你的程序可能会读到一个诡异的 null 或 0。


注意区分 clinit 和 init

特性 Class Init Instance Init
触发时机 类加载的初始化阶段(整个类生命周期仅一次) 创建实例时(每次 new 都会触发)
初始化对象 静态变量、静态代码块 实例变量、构造代码块、构造函数
线程安全 JVM 保证多线程下只有一个线程能执行 随业务逻辑,可能存在并发问题


对象的寿终正寝

在上面我们已经看过了对象的 “诞生” 和 “奋斗史”,我们再来看看它是如何 “寿终正寝 ”的。在 JVM 的世界里,判定一个对象是否存活,靠的不是 “引用计数”,而是可达性分析算法(Reachability Analysis)。

为什么 “引用计数法” 会失灵?

在早期的垃圾回收思路中,人们给对象放一个计数器:有人引用就 +1​,引用失效就 -1。假设对象 A 引用了对象 B,对象 B 也引用了对象 A,但外界已经没有任何人引用它们了。结果它们的计数器永远是 1​,但在程序里它们已经是彻彻底底的 “孤岛”,这种逻辑下它们永远不会被回收,最终导致内存泄漏


可达性分析:寻找 “根” 的力量

JVM 采用的方法更像是 “顺藤摸瓜”。它会定义一系列被称为 GC Roots 的 “根” 对象,从这些根开始向下搜索。

  • 搜索路径:搜索走过的路径称为 “引用链”。
  • 判定标准:如果一个对象到 GC Roots 没有任何引用链相连(用图论的话说,就是从 GC Roots 到这个对象不可达),哪怕它和别的对象互相引用得再紧密,也会被判定为不可达。


谁有资格当 GC Roots?(大权在握的对象)

在 JVM 中,能作为 “根” 的对象通常是那些绝对不会轻易消失的:

  • 虚拟机栈中引用的对象:比如你正在执行的方法里的局部变量、参数。
  • 方法区中类静态属性引用的对象:比如你定义的 public static Object cache。
  • 方法区中常量引用的对象:比如 String 字符串常量池里的引用。
  • 本地方法栈中 JNI(Native 方法)引用的对象。
  • 所有被 synchronized 持有的对象(这也解释了为什么锁对象不能随便回收)。


脑死亡的 “最后通牒”:两次标记

被判定为不可达的对象,并不代表立刻就会被处决。它还有一次 “垂死挣扎” 的机会:

  • 第一次标记:筛选出不可达的对象。
  • 第二次标记(救赎时刻):
    • JVM 会检查该对象是否覆盖了 finalize() 方法。
    • 如果有,且还没执行过,这个对象会被塞进一个叫 F-Queue 的队列里。
    • 稍后由一个低优先级的 Finalizer 线程去触发该方法。
    • 救赎:如果对象在 finalize() 里重新把自己关联到了任何一个 GC Roots 上(比如 holder = this),那么在第二次标记时它会被移出 “即将回收” 名单,宣告复活。


循环引用的结局

当两个对象互相引用,但它们到 GC Roots 的路径断了时:

  • GC 开始扫描,发现从栈帧、静态变量等 “根” 位置出发,找不到路径通往这两个对象。
  • 尽管它们彼此 “深情对望”(互相引用),但在 GC 眼里,它们已经是脱离了主程序的孤魂野鬼。
  • 在下一次垃圾回收中,它们会被标记并一起清理掉。