《并发编程》那篇讲了因为线程内存共享会导致并发问题,要解决并发问题可以保证对共享资源修改的互斥性和对共享资源修改后的内存可见性。
如果要求更严格,需要保证任何对共享资源访问的互斥性。
今天介绍的Java关键字 synchronized 就是一种保证线程安全的同步机制,synchronized 的工作原理如下:
synchronized 会给被修饰的代码快加一把锁,当一个线程执行到这部分代码时,会先获取该锁,获取成功才能继续执行,否则只能等待这个锁被释放。
lock;
{
//do something
}
unlock;
一个线程获得同步锁后,其他线程必须等待,直到该线程释放锁。这样可以防止多个线程同时修改同一个共享资源,确保互斥性。
基本使用
关键字 synchronized 的使用方法很简单,随便找一个对象作为锁就行。
public class SynchronizedDemo {
private final Object lock = new Object(); // 锁
private int count = 0; // 共享资源
void increment(){
synchronized (lock){
count++;
}
}
}
上述 synchronized (lock) 表示线程进入这个代码块时,必须首先获得 lock 对象的锁。只有持有该锁的线程才能执行 count++ 操作。
当然这个 lock 对象是随便构造的,也就是任意对象都能当做锁,this对象,Class对象都行。
public class SynchronizedDemo {
private final Object lock = new Object(); // 锁
private int count = 0; // 共享资源
void increment(){
synchronized (lock){
count++;
}
}
synchronized void decrement(){
count--;
}
int getCount(){
synchronized (SynchronizedDemo.class){
return count;
}
}
}
decrement() 方法使用synchronized 关键字修饰,就是使用 this 对象作为实例锁,而 getCount() 方法则是使用 SynchronizedDemo 的 Class 实例作为类锁。
那分析一下,上述对共享资源的修改和访问都使用对象锁进行保护了,那多线程调用这些方法会有并发问题吗?
increment() 方法使用的是自定义的 lock 对象,decrement() 方法使用的是实例锁(this),getCount() 方法使用的是类锁(SynchronizedDemo.class),三个方法三把锁。
也就是一份共享资源(count)有三种访问途径,这三条路各有一把锁锁定,
完全有可能三个线程各获取一把对应的锁,同时访问共享资源 count。
所以使用关键字 synchronized 的最佳实践就是使用一把锁,保护共享资源:
⨳ 实例锁(this) :适用于 SynchronizedDemo 是单例的情况,它将锁定整个实例,相同实例不同方法之间会共享这把锁。
⨳ 类锁(SynchronizedDemo.class):适用于 SynchronizedDemo 是多例的情况,它将锁定整个类,不同实例之间会共享这把锁。
其实不管是实例锁(this) 还是 类锁(SynchronizedDemo.class),本质都是将某一对象作为锁,都是对象锁。
那一个普通的对象怎么能作为锁呢?底层细节又是什么呢?
锁的本质
无锁 Mark Word
对象头是每个 Java 对象的内存结构的一部分,包含了与对象相关的元数据信息。在 HotSpot JVM 中,Java 对象的对象头通常分为两部分:
⨳ Class Pointer:指向该对象的类元数据(Class Metadata),用于标识对象属于哪个类。
⨳ Mark Word:用于存储对象的状态和一些元数据,包括哈希码、GC 信息、锁信息等。
| 锁状态 | 最后2位标志 | 存储内容(高位→低位) |
|---|---|---|
| 无锁 | 01 | unused(25) + hashcode(31) + unused(1) + age(4) + biased_lock(1) + lock(2) |
- hashcode(31位) :对象的 identity hash code(通过
System.identityHashCode()获取)。一旦计算并写入,就不可更改。 - age(4位) :GC 分代年龄,记录对象在 Young GC 中存活的次数。最大值为 15,这就是
-XX:MaxTenuringThreshold最大只能设为 15 的原因。 - biased_lock(1位) :是否启用偏向锁。0 表示未启用,1 表示已启用。
这个Mark Word 就是是 JVM 实现锁机制(偏向锁 → 轻量级锁 → 重量级锁)和 GC 分代管理的物理基础。
使用查看对象的内存布局的工具 JOL (Java Object Layout) 工具,就可以看到对象头中的详细信息。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
普通对象 lock 作为锁之前的内存布局如下:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
那为什么对象头中的 Mark Word 最初是 0x0000000000000001 呢?怎么感觉没有对象的哈希码、GC信息呀。
0x0000000000000001 的含义是:
- ock = 01:无锁状态
- biased_lock = 0:偏向锁未启用(JDK 15+ 默认关闭)
- age = 0:刚创建的对象,还没经历过任何一次 Young GC
- hashcode = 0:尚未调用过
hashCode(),未计算 identity hash code
对象的 identity hash code 是惰性计算的。只有在第一次调用 System.identityHashCode(obj) 或 obj.hashCode()(且未被重写)时,JVM 才会真正计算并写入 Mark Word。在此之前,hashcode 字段就是 0。
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000005b6f741201 (hash: 0x5b6f7412; age: 0)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
轻量级锁 Lightweight
| 锁状态 | 最后2位标志 | 存储内容(高位→低位) |
|---|---|---|
| 无锁 | 01 | unused(25) + hashcode(31) + unused(1) + age(4) + biased_lock(1) + lock(2) |
| 轻量级锁 | 00 | 指向栈中 Lock Record 的指针(62) + lock(2) |
轻量级锁(Lightweight Locking)的核心是在线程的栈帧中创建一个锁记录 Lock Record,它包含两个关键字段:
- Displaced Mark Word:存储对象头 Mark Word 的原始副本
- Object Reference:指向被加锁的对象
JVM 规范定义的栈帧结构(局部变量表、操作数栈、常量池引用、返回地址)是逻辑上的抽象,没有禁止 JVM 实现在栈帧中分配额外的空间存放 Lock Record。
假设线程 A 执行 synchronized(obj):
- 在栈帧中创建 Lock Record
- 将
obj对象头中的 Mark Word 复制到 Lock Record 的 Displaced Mark Word 字段中 - 将 Lock Record 的 Object Reference 指向
obj
- CAS 尝试获取锁
- 使用 CAS 指令,尝试将
obj对象头的 Mark Word 替换为指向当前线程 Lock Record 的指针 - 同时将锁标志位设为
00(轻量级锁标志)
- CAS 成功 → 获取锁
- 线程 A 成功获取轻量级锁,进入同步块执行
- CAS 失败 → 处理竞争
- 说明有其他线程已经持有锁,线程 A 进入自旋等待
- 自旋一定次数后仍未获取到锁,则膨胀为重量级锁
CAS(Compare-And-Swap)是一种硬件支持的原子操作,用来保证线程在并发访问共享资源时的安全性。
解锁的过程也很简单:
当线程退出 synchronized 代码块时,JVM 会从线程栈中的锁记录中恢复对象头的 Mark Word,将锁对象的状态恢复到解锁状态(即无锁状态),这也是复制 Mark Word 的原因。
▪ 无线程竞争
import org.openjdk.jol.info.ClassLayout;
public class ThinLockDemo {
public static void main(String[] args) {
Object lock = new Object();
System.out.println("====加锁前====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
System.out.println("====加锁后====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
普通对象 lock 作为锁之前的内存布局如下:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
普通对象 lock 作为锁之后的内存布局如下:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000da729ff570 (thin lock: 0x000000da729ff570)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
注意看,对象头中的 Mark Word 从 0x0000000000000001 变成了 0x000000da729ff570,很自然的就能想到,这个 0x000000da729ff570 就是指向当前线程的锁记录(Lock Record)的指针。
轻量级锁也支持重入。当同一个线程再次进入同步块时:
-
CAS 会失败(因为 Mark Word 已经指向自己的 Lock Record)
-
JVM 检测到 Mark Word 指向的就是当前线程自己的 Lock Record
-
此时将新的 Lock Record 的 Displaced Mark Word 设为
null,作为重入计数器 -
解锁时,遇到 Displaced Mark Word 为
null的 Lock Record,就知道这是一次重入,只需将 Object Reference 设为null,不需要恢复 Mark Word
偏向锁 Biased
偏向锁(Biased Locking) 是 JDK 1.6 引入的锁优化机制,核心思想是:绝大多数锁在整个生命周期中只会被一个线程访问,那为什么每次都要做 CAS 操作呢?干脆直接"偏向"第一个获取它的线程,后续该线程再进入时连 CAS 都不做。
| 锁状态 | 最后2位标志 | 存储内容(高位→低位) |
|---|---|---|
| 无锁 | 01 | unused(25) + hashcode(31) + unused(1) + age(4) + biased_lock(1) + lock(2) |
| 偏向锁 | 01 | threadID(54) + epoch(2) + unused(1) + age(4) + biased_lock(1) + lock(2) |
-
threadID(54位) :持有偏向锁的线程 ID。
-
biased_lock(1位) :是否启用偏向锁。0 表示未启用,1 表示已启用。
偏向锁和无锁状态的 lock 标志位都是 01,通过 biased_lock 位来区分。biased_lock 位的值取决于 JVM 启动参数 -XX:+UseBiasedLocking 是否开启,而且偏向锁默认延迟 4 秒启动(参数 -XX:BiasedLockingStartupDelay=4000),在这 4 秒内,即使开启了偏向锁,新创建的对象也不会设置 biased_lock 位为 1,而是保持无锁状态。
- epoch(2位) :偏向锁的时间戳,用于批量重偏向。
Epoch(纪元)本质上是一个 版本号/时间戳,在Class 元数据中,每个类维护一个全局的 epoch 计数器,对象创建时,会将自己的 epoch 字段初始化为所属 Class 当前的 epoch 值,两者保持一致。当某个类的偏向锁撤销次数达到阈值(默认 20 次)时,Class 的 epoch 值会加 1。
假设线程 A 第一次执行 synchronized(obj):
- 检查对象是否可偏向
- 检查 Mark Word 的
biased_lock位是否为 1 - 检查
epoch是否未过期 - 如果不可偏向,直接进入轻量级锁流程
- 检查是否已偏向当前线程
- 如果 Mark Word 中的
threadID == 线程A的ID就直接获取锁,无需任何 CAS 操作 - 如果
threadID == 0,表示未偏向任何线程,就进入步骤 3 - 如果
threadID != 线程A的ID,表示已偏向其他线程,就进入偏向锁撤销流程。
- 使用 CAS 将 threadID 设置为线程 A 的 ID
- CAS 成功:偏向锁获取成功
- CAS 失败:说明有其他线程在竞争,进入偏向锁撤销流程
偏向锁的解锁几乎是"什么都不做",不需要修改 Mark Word,对象头 Mark Word 中还记录着当前线程ID,不需要 CAS 操作,这也是偏向锁性能极高的原因——加锁只需一次 CAS(首次),后续连 CAS 都不需要;解锁什么都不用做。
可以看到,偏向锁并没有在栈帧里面创建锁记录存放Mark Word 中的 hashcode,也就是说偏向锁状态下根本没有 hashcode 可以恢复,那就意味着hashcode 和偏向锁是互斥的,不可能同时存在。一旦对象计算了 identity hashcode,偏向锁必须撤销,Mark Word 重新布局以腾出 31 位存储 hashcode。
下面简单介绍一下偏向锁撤销过程。
当另一个线程(线程 B)尝试获取已被线程 A 偏向的锁时,需要撤销偏向锁。这是一个重量级操作,需要 STW(Stop-The-World) 暂停所有线程(全局安全点),检查线程 A 是否还活着,是否仍在同步块中,STW开销太大了,这也是废弃偏向锁的原因。在 JDK15 中,偏向锁被默认关闭。在 JDK18 中,更被标记为废弃,并不再允许通过命令行手动开启。
OpenJDK 开发团队在 JEP 374 中解释:
偏向锁定的代价是在发生锁争用时需要执行昂贵的撤销操作。因此,受益于它的程序只是那些无竞争同步操作的程序。偏向锁的高效是假定在执行简单的锁检查加上偶尔昂贵的撤销成本,仍然低于执行 CAS 指令的成本。但 HotSpot 已经发生了很大的变化,原子指令成本的变化也改变了保持该关系所需的无竞争操作的数量。
也就是说,随着硬件和 JVM 的改进,轻量锁的 CAS 指令的效率得到了提升,偏向锁已经没这么香了,而且OpenJDK 开发者统计到同步操作在程序实际运行中消耗的资源较少,即使偏向锁有一定提升,但对总体性能影响不大,食之无味,不如废弃。
重量级锁 Heavyweight
重量级锁(Heavyweight Locking) 是 synchronized 锁升级的最终形态,底层依赖一个 C++ 对象——ObjectMonitor,通过操作系统的互斥量(Mutex)实现线程阻塞和唤醒。
锁膨胀为重量级锁通常发生在以下场景:
- 轻量级锁自旋失败:线程 CAS 自旋超过阈值(默认约 10 次)仍未获取到锁
- 多线程激烈竞争:多个线程同时竞争同一个锁,轻量级锁的 CAS 反复失败
- 调用
wait()/notify():轻量级锁不支持等待队列,必须升级为重量级锁才能支持这些语义
轻量级锁是没有线程竞争,一次CAS操作就成功的情况,如果多个线程竞争同一个锁,轻量级锁将升级为重量级锁,在重量级锁状态下,对象头的 Mark Word 不再指向线程栈中的锁记录,而是指向一个 ObjectMonitor 对象,毕竟得给竞争失败的线程一个记录的地方。
ObjectMonitor 的关键字段:
_owner:指向当前持有锁的线程_count:该 monitor 被获取的总次数_recursions:同一线程重复加锁的次数_EntryList: 阻塞队列:等待获取锁的线程在此排队_WaitSet:等待队列:调用了wait()的线程在此等待被唤醒_cxq(Contention Queue):竞争队列:新来的竞争线程先入此队列,后续由 JVM 迁移到_EntryList
import org.openjdk.jol.info.ClassLayout;
public class HeavyLockDemo {
synchronized void work() {
try {
System.out.println("Thread " + Thread.currentThread().getName() + " is working...");
Thread.sleep(5*1000); // 模拟工作负载
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
HeavyLockDemo heavyLockDemo = new HeavyLockDemo();
System.out.println("====无锁状态====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
new Thread(()->heavyLockDemo.work()).start();
Thread.sleep(1000);
System.out.println("====轻量锁====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
Thread.sleep(2000);
new Thread(()->heavyLockDemo.work()).start();
System.out.println("====重量锁====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
}
}
输出结果如下:
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Thread Thread-0 is working...
====轻量锁====
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000065298ff070 (thin lock: 0x00000065298ff070)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
====重量锁====
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000018b656024e2 (fat lock: 0x0000018b656024e2)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Thread Thread-1 is working...
从上述结果可知,当一个线程持有锁,另一个线程想要竞争锁时,会进入重量级锁。
线程尝试获取重量级锁时:
- 检查
_owner是否为空
- 为空 :通过 CAS 将
_owner设置为当前线程,_count++,获取锁成功 - 不为空:进入步骤 2
- 检查是否为重入
_owner == 当前线程表示可重入,_count++,_recursions++,直接获取锁- 否则进入步骤 3
- 竞争失败,进入阻塞
- 将当前线程封装为
ObjectWaiter节点,插入_cxq队列头部 - 调用
pthread_mutex_lock或LockSupport.park()将线程阻塞(用户态 → 内核态切换) - 线程进入休眠,等待被唤醒
线程释放重量级锁时:
-
先检查重入计数
_recursions--,如果_recursions > 0,表示锁未完全释放,如果_recursions = 0,执行步骤2。 -
_owner = NULL,清空持有者,_count--取计数减1 -
唤醒等待线程,检查
_EntryList是否为空:
- 不为空:直接从
_EntryList队首取出一个线程,LockSupport.unpark()唤醒 - 为空:将
_cxq中的所有节点迁移到_EntryList(默认策略下会反转顺序,使后进的先出),然后唤醒_EntryList队首线程
ObjectMonitor 还有一个等待队列 _waitSet,用于存储调用 wait() 后进入等待状态的线程。
锁相关方法
在 Java 中,锁就是普通对象本身,而所有对象都继承自 java.lang.Object 类。因此,只有 Object 类中定义的方法才能直接操作锁(监视器)。以下是与锁直接相关的核心方法及其原理:
| 方法 | 作用 | 必须条件 | JVM 内部行为 |
|---|---|---|---|
final void wait() | 使当前线程释放锁并等待 | 必须持有该对象的锁 | 1. 将线程加入 _WaitSet 2. _count--(但 _recursions 不变) 3. 释放锁(_owner = NULL) |
final void wait(long timeout) | 带超时的等待 | 同上 | 超时后自动从 _WaitSet 移除 |
final void notify() | 唤醒一个等待线程 | 必须持有该对象的锁 | 1. 从 _WaitSet 取出一个线程 2. 迁移到 _EntryList 或 _cxq 3. 不释放锁(需等待当前 synchronized 块退出) |
final void notifyAll() | 唤醒所有等待线程 | 同上 | 将 _WaitSet 中所有线程迁移到等待队列 |
我们已经知道锁就是普通的对象,那锁的方法就是对象的方法,那什么方法是所有对象都有的呢?Java 的所有类都直接或间接继承自 java.lang.Object 类,因此,Object 类提供的部分方法就是与锁相关的方法。
这些方法必须在 synchronized 块中调用,否则会抛出 IllegalMonitorStateException。
需要注意的是无论是 notify() 还是 notifyAll(),被唤醒的线程会进入entryList 队列后,需要等到调用 notify 的线程释放锁后,再一起进行锁竞争(并不是FIFO),竞争完成后,只有一个线程能够成功获取锁,其余线程会继续在entryList 队列中等待锁的释放。
下一篇《生产者-消费者模式》将会详细讲解 wait 和 notify 的用法,敬请期待。
总结
| 锁状态 | 最后2位标志 | 存储内容(高位→低位) |
|---|---|---|
| 无锁 | 01 | unused(25) + hashcode(31) + unused(1) + age(4) + biased_lock(1) + lock(2) |
| 轻量级锁 | 00 | 指向栈中 Lock Record 的指针(62) + lock(2) |
| 偏向锁 | 01 | threadID(54) + epoch(2) + unused(1) + age(4) + biased_lock(1) + lock(2) |
| 重量级锁 | 10 | 指向 ObjectMonitor 的指针(62) + lock(2) |
| GC 标记 | 11 | 空(62) + lock(2) |
上面讲了这么多,核心就是普通对象可以作为互斥锁而存在,普通对象的对象头中的Mark Word部分可以存储线程相关信息,这样线程在进入关键字 synchronized 修饰代码(临界区)时就可以查看这个锁有没有被别的线程占用,如果占用就进入 entryList 队列等待锁的释放,从而实现了 对共享资源访问的互斥性。
那对共享资源修改后的内存可见性怎么保证呢?
Java 内存模型(Java Memory Model, JMM)规定,对一个锁的解锁操作 Happens-Before 于后续对该锁的加锁操作。这意味着,当一个线程释放锁时,它在临界区内的所有修改对接下来获取该锁的线程都是可见的。