synchronized 关键字深入解析
对象的内存空间分配
在 JVM 中,对象在内存中分为三块区域:
对象头
- Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
- 这部分主要是存放类的数据信息,父类的信息。
对其填充
由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
标记字段
这段话描述的是 Java 对象头中 Mark Word 的核心设计思想:动态复用(空间换时间/灵活性)。
Mark Word 只有很少的空间(32位系统是 4 字节,64位系统是 8 字节),但它需要记录的信息非常多(HashCode、GC 年龄、偏向锁 ID、各种锁状态等)。如果固定每个信息占几个 bit,这点空间根本不够用。
所以 JVM 的设计者采用了一种聪明的做法:根据最后几位(锁标志位)的值,决定前面的 bit 存什么内容。 也就是像"变色龙"一样,在不同状态下,Mark Word 的结构是完全不同的。
假设我们是在 32 位 JVM 下(Mark Word 总共 32 bit),它的结构变化如下:
1. 正常状态 (无锁)
标志位:最后 2 位是 01。
前面存什么:
25 bit: 对象的 HashCode (只有调用了 hashCode() 才会存)
4 bit: GC 分代年龄 (这就是为什么对象年龄最大是 15,因为 4 bit 最大是 1111 = 15)
1 bit: 0 (偏向锁标志)
含义:对象很干净,没被锁,谁都可以访问。
2. 偏向锁状态
标志位:最后 2 位依然是 01,但倒数第 3 位(偏向标志)变成了 1。
前面存什么:
线程 ID (23 bit):记录上次是谁拿了这个锁。
Epoch (2 bit):时间戳。
GC 年龄 (4 bit)。
重要变化:为了存线程 ID,HashCode 就没地儿存了(这会导致一些特殊的锁撤销问题)。
3. 轻量级锁状态
标志位:最后 2 位变成了 00。
前面存什么:
30 bit: 指向栈中锁记录 (Lock Record) 的指针。
含义:因为变成了指针,原来的 HashCode、分代年龄全都被移到了线程栈的 Lock Record 里暂存。Mark Word 腾出空间来存指针。
4. 重量级锁状态
标志位:最后 2 位变成了 10。
前面存什么:
30 bit: 指向堆中 Monitor (互斥量) 对象的指针。
含义:这时候竞争激烈,对象头指向了重量级的 Monitor 对象,所有的等待队列、阻塞信息都由 Monitor 来管理。
5. GC 标记状态
标志位:最后 2 位是 11。
含义:对象要被回收了,前面的内容用于 GC 算法。
总结
这就好比一块只有 4 个字的黑板:
平时:写"家庭住址"(HashCode)。
只有一个人用时:擦掉住址,写"张三专用"(偏向锁)。
几个人抢时:擦掉名字,写"请去 101 房间排队"(轻量级锁/指针)。
大家打起来时:写"去找保安大队处理"(重量级锁/Monitor 指针)。
黑板大小没变,但根据情况(锁状态),上面的内容一直在变。这就是 "复用存储空间"。
JMM
这段话的核心在于区分"物理硬件架构"和"软件抽象模型"。它想告诉你:JMM (Java Memory Model) 并不是你拆开电脑能看到的内存条,也不是 JVM 里的堆栈划分,而是一本"交通法规"。
我们可以分三点来深度解读这段话:
1. "JMM 并不是实际存在的"
误区:很多人以为 JMM 就是堆(Heap)、栈(Stack)、方法区。
真相:JMM 是一个抽象概念。
物理上:数据存在 CPU 寄存器、L1/L2/L3 缓存、RAM 内存条里。
JMM 上:它把所有物理存贮抽象为两个概念——主内存 (Main Memory) 和 工作内存 (Working Memory)。
这个划分是为了屏蔽掉各种硬件(Intel, ARM)和操作系统(Linux, Windows)在内存访问上的差异。就像 Java 虚拟机屏蔽了操作系统差异一样,JMM 屏蔽了硬件内存架构的差异。
2. "描述了变量的访问规则...底层细节"
这部分是在说 JMM 的职能。既然物理硬件(缓存、总线)很复杂,JMM 就制定了一套通用的操作协议:
所有的共享变量(实例字段、静态字段)都必须存在主内存中。
每个线程都有自己的工作内存(对应 CPU 缓存/寄存器)。
规则:
线程不能直接修改主内存里的变量。
线程必须先把变量从主内存 拷贝 (Read/Load) 到自己的工作内存。
在工作内存里修改完后,再 同步回 (Store/Write) 主内存。
线程之间不能直接传数据,必须通过主内存中转。
这就是所谓的"底层细节":它规定了数据如何在"公共区域"和"私有区域"之间流动。
3. "可见性、有序性、原子性的规则和保障"
这是 JMM 存在的终极目的。并发编程的三大恶魔就是:
看不见(可见性问题):A 线程改了 flag,B 线程还在读旧缓存。
乱序(有序性问题):编译器为了快,把代码顺序打乱了(指令重排)。
操作被打算(原子性问题):i++ 实际上是读、改、写三步,中间可能被切走。
JMM 这本"法规"规定了:
volatile 关键字可以让变量修改后立刻刷新回主内存(保障可见性),并禁止指令重排(保障有序性)。
synchronized 关键字可以锁住一段代码,保证同一时刻只有一个线程执行(保障原子性、可见性、有序性)。
Happens-Before 原则:这是 JMM 的核心判定规则,用来判断两个操作之间是否存在合法的先后顺序。
总结
这段话其实是在说:
物理计算机的内存架构太乱太复杂了(各种缓存、乱序执行),Java 为了保证写出来的代码在任何机器上跑都有一样的结果,于是凭空定义了一套标准(JMM)。这套标准规定了数据怎么拷贝、怎么同步,以及提供了 volatile、synchronized 这样的工具,让我们能写出线程安全的代码。
synchornized如何保证可见性,原子性,有序性
synchronized 是 Java 并发编程中的"万能钥匙",因为它能同时保证并发编程的三大特性。以下是其底层实现原理:
1. 保证原子性 (Atomicity)
原子性是指一个操作要么全部执行,要么全不执行,中间不能被打断。
- 原理:
synchronized 对应的底层字节码指令是 monitorenter 和 monitorexit。
加锁 (monitorenter):当线程执行到这里时,尝试获取对象的 Monitor(监视器锁)。如果获取成功,该线程就成为了 Monitor 的拥有者,其他线程如果也想进,就只能在外面排队阻塞(Blocked)。
执行:在锁释放之前,在这个临界区(Critical Section)内执行的代码,对于外界来说是不可分割的。因为别人进不来,也不会有线程切换导致的数据不一致(即使发生了线程切换,其他线程没锁也进不来)。
解锁 (monitorexit):执行完或抛异常时释放锁。
结果:就像上厕所锁门一样,只要我锁了门,无论我在里面待多久,中间都不会有人插队进来打断我,这就保证了原子性。
2. 保证可见性 (Visibility)
可见性是指当一个线程修改了共享变量,其他线程能立即看到修改后的值。
- 原理:
JMM(Java 内存模型)关于 synchronized 有两条明确的规定:
加锁前:线程在获取锁(Lock)那一刻,必须清空工作内存中共享变量的值,从而强制从主内存中重新读取最新的值。
解锁后:线程在释放锁(Unlock)之前,必须把工作内存中修改过的数据同步回主内存。
- 结果:
线程 A 释放锁 -> 主内存更新 -> 线程 B 获取锁 -> 强制从主内存读。这一连串动作形成了 Happens-Before 关系,确保了线程 B 一定能看到线程 A 的修改。
3. 保证有序性 (Ordering)
有序性是指程序执行的顺序按照代码的先后顺序执行。
- 原理:
这里有一个误区:synchronized 并不能禁止指令重排序。
真相:在 synchronized 代码块内部,编译器和 CPU 依然可以进行指令重排(只要不影响单线程执行结果)。
那为什么说它保证了有序性?
因为加锁使得代码块内部变成了单线程串行执行。
根据 As-If-Serial 语义:不管怎么重排序,单线程程序的执行结果不能改变。
对于外部观察者(其他线程)来说,既然我是排队进来的,我看到的就像是你按顺序做完了一样。即便是内部乱序了,因为我根本无法在中间时刻切进去观察,所以我看到的最终结果依然是正确的。
特例:这也是为什么双重检查锁(DCL)单例模式中,除了 synchronized 还需要 volatile。因为 new Instance() 分为三步(分配内存、初始化、引用赋值),如果发生重排(先赋值后初始化),另一个线程在 synchronized 外部(还没加锁时)检查 instance != null,可能会拿到一个没初始化的半成品对象。
总结
原子性:通过 Monitor 互斥锁,让代码串行执行,谁也别想插队。
可见性:通过 JMM 的 Lock/Unlock 内存语义,强制刷新主内存。
有序性:通过 "排队",让内部的重排序对外部不可见(除了 DCL 这种极端情况)。
monitor
你非常敏锐!"对象头里明明只有标记字段和类型指针,Monitor 到底藏在哪里?"这是一个困扰很多人的问题。
答案是:Monitor 不是对象本身的一部分,它是一个独立存在的"管理者",是 JVM 内部创建的一个 C++ 对象。
Java 对象和 Monitor 的关系,就像是 "房卡" 和 "酒店房间" 的关系。
1. 它们是如何关联的?
当一个 Java 对象升级为重量级锁时,它的对象头(Mark Word)会发生变化:
Mark Word 的内容会被清空。
Mark Word 中会存储一个指针(指向 Monitor 的地址)。
JVM 会在堆中创建一个 Monitor 对象(在 HotSpot 源码中叫 ObjectMonitor)。
Java 对象头里的指针,就指向这个 ObjectMonitor。
图解:
Java对象 (Stack/Heap) ObjectMonitor (JVM 内部 C++ 对象)
+------------------+ +---------------------------+
| Mark Word |-------------->| _owner (谁拿着锁) |
| (存的是指针) | | _EntryList (排队等待的线程) |
+------------------+ | _WaitSet (调用wait的线程) |
| Klass Pointer | | _count (重入次数) |
+------------------+ +---------------------------+
| 实例数据... |2. Monitor 到底是什么?
Monitor(监视器锁)本质上是操作系统的互斥量 (Mutex) 在 JVM 里的封装。
在 HotSpot 虚拟机中,它是由 C++ 类 ObjectMonitor 实现的。它主要包含三个关键区域:
_owner:记录当前持有锁的线程是谁。(相当于房卡,只能有一个人拿)。
_EntryList:阻塞队列。如果你想抢锁但没抢到,就会被扔进这里排队(状态变为 BLOCKED)。
_WaitSet:等待队列。如果你拿到了锁,但调用了 Object.wait(),你就会释放锁,并乖乖去这里等着(状态变为 WAITING),直到有人叫你(notify)。
3. 总结
Java 对象:是你 new 出来的那个东西,有数据,有对象头。
Monitor:是 JVM 为了管理并发,临时给这个对象配的一个"管家"(C++ 对象)。
关系:
无锁/偏向锁/轻量级锁时:Java 对象头里没有 Monitor 的指针(为了省资源,尽量不创建 Monitor)。
重量级锁时:Java 对象头里存了 Monitor 的指针,所有关于锁的复杂逻辑(排队、唤醒)都移交给 Monitor 这个"管家"去处理。
互斥量
互斥量 (Mutex, Mutual Exclusion),你可以把它理解为操作系统提供的一把"系统级的锁"。
1. 为什么需要它?
在多线程编程中,Java 线程最终是映射到操作系统的原生线程(OS Thread)上的。
当 Java 里的 synchronized 升级为重量级锁时,JVM 自己搞不定了(因为需要让线程"真的"停下来,挂起,不消耗 CPU),这时它必须向操作系统求助。
操作系统说:"想让线程排队、挂起、唤醒?用我的 Mutex 吧。"
2. 互斥量的工作原理
Mutex 就像是公共厕所门上的那个红绿指示锁:
状态:它只有两个状态——0 (开锁/空闲) 和 1 (上锁/占用)。
原子性:操作系统通过硬件指令(如 CAS, Test-and-Set)保证,无论多少个线程同时去抢这个锁,只有一个人能成功把 0 变成 1。
阻塞机制:
抢到了 (0 -> 1):线程继续跑。
没抢到:操作系统会把这个线程挂起 (Suspend),让它进入睡眠状态,从 CPU 的调度队列里移除(不再分给它时间片)。
解锁了 (1 -> 0):操作系统会唤醒等待队列里的线程,让它们重新去抢。
3. 为什么说它"重"? (重量级锁的由来)
使用 Mutex 的成本非常高,因为涉及到用户态 (User Mode) 和 内核态 (Kernel Mode) 的切换。
Java 程序运行在用户态。
当你要申请 Mutex 时,必须发起系统调用,进入内核态(因为只有操作系统内核才有权挂起线程)。
内核处理完锁逻辑,挂起线程,再切换回用户态执行其他线程。
这种切换非常耗时。有时候,线程切换的时间甚至比执行那段代码的时间还要长!这就是为什么 JVM 会发明"偏向锁"和"轻量级锁"(自旋锁),就是为了尽量别去麻烦操作系统,别用 Mutex。
总结
Mutex 是操作系统内核提供的一种底层同步机制。
它是重量级锁的基石。
它的核心能力是:原子性地抢占资源 + 挂起/唤醒线程。
synchornized的字节码底层
monitorenter 和 monitorexit 是 JVM 字节码指令集中的两个指令,它们是 Java 中 synchronized 代码块(synchronized statement)在底层的具体实现机制。
简单来说,它们负责管理对象的锁(Monitor)的获取与释放。
以下是详细的深度解析:
1. 核心概念:Monitor(监视器锁)
在 Java 虚拟机中,每一个对象都天生关联着一个 Monitor(监视器)。
Monitor 可以理解为一个同步工具,或者一种"互斥量"。
当一个 Monitor 被某个线程持有后,它就处于"锁定"状态。
2. monitorenter (进入/加锁)
指令作用:
尝试获取对象关联的 Monitor 的所有权(即尝试加锁)。
执行逻辑:
当 JVM 执行到 monitorenter 指令时,会尝试获取栈顶对象所对应的 Monitor 所有权,过程如下:
- 计数器检查:
如果 Monitor 的进入计数器(Entry Count)为 0,说明该锁没有被占用。
当前线程将其设为 1,并成为该 Monitor 的拥有者(Owner)。
- 重入(Reentrancy):
如果当前线程已经是该 Monitor 的拥有者,又重新进入(比如递归调用或嵌套 synchronized 块),它依然可以进入。
此时,计数器会自增(+1)。
- 阻塞(Blocking):
如果 Monitor 已经被其他线程占用,当前线程就会被阻塞(Blocked)。
它会一直等待,直到 Monitor 的计数器变为 0,再次尝试获取锁。
3. monitorexit (退出/解锁)
指令作用:
释放对象关联的 Monitor 的所有权。
执行逻辑:
执行前提:执行该指令的线程,必须是该 Monitor 的拥有者。如果不是,JVM 会抛出 IllegalMonitorStateException。
计数器自减:
- 指令执行时,Monitor 的计数器会减 1。
- 彻底释放:
如果减完后计数器变为 0,说明当前线程不再持有该锁。
Monitor 被彻底释放,其他正在等待的线程可以尝试去争抢这个锁。
注意:如果减完后不为 0(说明是重入锁的内层退出),当前线程依然持有锁,只是退出了一层嵌套。
4. 经典面试题:为什么通常是一个 enter 对应两个 exit?
如果你反编译一个带有 synchronized 代码块的方法,你经常会看到类似这样的结构:
// Java源码
synchronized(lock) {
doSomething();
}对应的字节码逻辑(伪代码):
0: aload_1 // 加载 lock 对象
1: monitorenter // 【加锁】
2: ...执行业务逻辑...
3: aload_1
4: monitorexit // 【正常路径解锁】
5: goto 10 // 跳转到结束
...
6: astore_2 // 捕获异常
7: aload_1
8: monitorexit // 【异常路径解锁】!!!
9: aload_2
10: athrow // 抛出异常解释:
为了保证在任何情况下锁都能被释放(防止死锁),编译器会自动生成一个异常处理表。
第 1 个 monitorexit:用于代码正常执行结束时释放锁。
第 2 个 monitorexit:编译器会自动添加一个隐式的 try-finally 效果。如果业务逻辑(第 2 步)抛出了异常,程序会跳转到异常处理路径,执行第 2 个 monitorexit 来释放锁,确保不会因为程序报错而导致锁永远不释放。
总结
monitorenter:抢锁。抢到了,计数器+1;抢不到,排队睡觉。如果是自己抢过的,计数器再+1。
monitorexit:还锁。计数器-1。减到0了,才算真正把锁还回去,别人才能抢。
关系:它们成对出现,且为了兜底异常情况,字节码中往往会有多余的 monitorexit 指令。
synchornized可重入原理
synchronized 是 Java 语言层面的可重入锁。它的可重入特性主要是通过 JVM 底层的 对象头 (Object Monitor) 和 线程计数器 来实现的。
原理可以概括为:"记账法"。
以下是详细的原理解析:
1. 核心组件:Monitor 对象
在 JVM (HotSpot) 中,每个 Java 对象都可以关联一个 Monitor (监视器锁) 对象(底层 C++ 实现通常是 ObjectMonitor)。 当线程试图执行 synchronized 代码块时,它需要先获取这个 Monitor 的所有权。
Monitor 内部主要维护了两个关键字段来支持可重入:
_owner:记录当前持有锁的线程(Thread ID)。_recursions:记录锁的重入次数(计数器)。
2. 抢锁逻辑 (MonitorEnter)
当一个线程 T 尝试进入 synchronized 代码块时(执行 monitorenter 指令),JVM 会执行以下判断逻辑:
检查锁是否空闲:
- 看
_owner是不是null。 - 如果是 null,说明锁没人用。线程 T 将
_owner设置为自己,并将_recursions设置为1。抢锁成功。
- 看
检查是不是自己 (重入判断):
- 如果
_owner不为空,但_owner指向的正是线程 T 自己。 - 这就是重入发生的地方!
- JVM 仅仅是将计数器
_recursions加 1 (_recursions++)。 - 抢锁成功。不需要再次竞争,也不需要阻塞。
- 如果
锁被别人占了:
- 如果
_owner不是自己。 - 线程 T 进入阻塞等待状态。
- 如果
3. 释放逻辑 (MonitorExit)
当线程退出 synchronized 代码块时(执行 monitorexit 指令):
- JVM 将计数器
_recursions减 1 (_recursions--)。 - 判断计数器是否归零:
- 如果不为 0:说明还在内层的
synchronized块里,当前线程依然持有锁,只是退出了一层嵌套。锁不释放。 - 如果为 0:说明退出了最外层的
synchronized块。
- 如果不为 0:说明还在内层的
- 彻底释放:将
_owner设置为null,正式释放锁,并唤醒其他等待的线程。
4. 示例说明
synchronized void methodA() { // 1. 第一次拿锁:_owner=Me, _recursions=1
methodB();
}
synchronized void methodB() { // 2. 重入:发现 _owner是Me,_recursions=2
// do something
} // 3. 退出 methodB:_recursions=1,锁没释放
// 4. 退出 methodA:_recursions=0,_owner=null,锁彻底释放总结
synchronized 的可重入原理非常直观: 它是基于"线程持有者标识"和"递归计数器"实现的。
- 进门:是自己人?计数器 +1。
- 出门:计数器 -1。
- 清零:计数器变 0 了?把钥匙交出来。