enyang
enyang
Published on 2025-12-06 / 0 Visits
0
0

Java 多线程<4>——锁机制与 synchronized

1 synchronized 的三种使用方式

synchronized 是 Java 最基础、最安全的同步方案,它使用 对象内置锁(Monitor Lock) 来保证多个线程访问临界区时的互斥性。

1.1 修饰实例方法

写法:

public synchronized void m() {
    count++;
}

锁对象是 this(当前实例),对应对象头的 Monitor。
特性如下:

  1. 多线程访问同一个对象实例时会阻塞。

  2. 如果是不同对象实例,则不互斥,因为每个对象头对应不同的 Monitor。

  3. 锁粒度较大,容易导致整个对象方法被锁住而降低并发性。

  4. 适合对象本身内部状态一致性很重要的场景(如银行账户类)。

示例场景:
两个 ATM 操作同一个账户对象时需要锁,但操作不同账户对象不需要锁。

1.2 修饰静态方法

public static synchronized void log() {
    ...
}

锁对象是类对象,即 XXX.class,它在 JVM 中是单例的。
因此:

  1. 所有实例调用该静态方法时都要争同一把锁。

  2. 适用于控制全局资源(文件日志、单例变量等)。

实际上:

  • synchronized 修饰实例方法 → 锁的是 对象监视器锁

  • synchronized 修饰静态方法 → 锁的是 Class 监视器锁

它们之间互不干扰。

1.3 修饰代码块(最灵活)

synchronized (lockObj) {
    // 临界区
}

锁对象是 lockObj 引用指向的对象。

为什么推荐使用代码块?

  1. 锁粒度可控,只锁住关键代码,不锁整个方法。

  2. 可以独立构造专门的锁对象,避免与其他 synchronized 冲突。

  3. 避免锁 this(因为某些外部代码可能也锁 this,导致意外形成死锁)。

示例推荐写法:

private final Object lock = new Object(); // 永远不要用 new Object() 每次创建

public void update() {
    synchronized (lock) {
        ...
    }
}

锁对象一定要保证:

  • 是 final 的

  • 不暴露在外部(否则外部也能锁这个对象)

2 synchronized 的底层原理(字节码与 Monitor)

理解 synchronized 的执行过程,需要从字节码和 JVM 内部结构来看。

2.1 monitorenter 与 monitorexit 字节码

示例:

synchronized (obj) {
    x++;
}

编译后:

monitorenter
...  // 临界区代码
monitorexit

特点:

  1. 每个 synchronized 代码块有且必须有一条 monitorenter,与至少一条 monitorexit。

  2. 编译器会自动生成 try-finally,保证 monitorexit 总会执行。

  3. 如果遗漏 monitorexit,会导致锁永远不释放(因此 Java 语法禁止开发者绕过)。

为什么两条 monitorexit?
因为 finally 中也会插入一条,以保证异常时一样释放。

2.2 Monitor 的内部机制(HotSpot 实现)

Monitor 是 JVM 层面的锁,与 synchronized 直接关联。

Monitor 内部包含:

  1. owner:当前占有锁的线程(为空表示锁空闲)

  2. entryList:尝试竞争锁但竞争失败的线程队列

  3. cxq:新来的竞争线程队列,后来加入时通常先入 cxq

  4. waitSet:执行了 wait() 的线程队列(放弃锁)

  5. 递归计数器:用来支持同一线程的可重入

当 monitorenter 发生时:

  • 若 owner == null:获得锁,owner = 当前线程

  • 若 owner == 当前线程:递归获取(计数 +1)

  • 若 owner == 其他线程:进入 EntryList 或 cxq 阻塞

这个机制也能解释:

  • 为什么 synchronized 是可重入的

  • 为什么 wait() 必须在 synchronized 内部调用(要进入 WaitSet 前必须先获得锁)

3 JVM 对象头与 Lock Record、Monitor 的交互

synchronized 的锁状态完全记录在对象头(Object Header)与线程栈帧的 Lock Record 中。

3.1 对象头结构(HotSpot MarkWord)

Mark Word 用来记录锁状态、hashCode、GC Age、偏向线程 ID 等信息。

不同锁状态下,Mark Word 内容不同。

例如 64 位 JVM 中典型结构如下(示意):

无锁:

hashcode(31bit) | age(4bit) | biased_flag(1) | lock_state(2bits=01)

偏向锁:

threadId(54bits) | epoch(2) | age(4) | biased_flag | lock_state(01)

轻量级锁:

ptr to LockRecord | lock_state(00)

重量级锁:

ptr to Monitor | lock_state(10)

Mark Word 的变化就是锁升级的真实来源。

3.2 Lock Record(轻量级锁用)

轻量级锁依赖线程栈帧中专门的 Lock Record 每个 synchronized 块进入时都会在当前线程栈中创建。

Lock Record 内保存:

  1. 指向 Mark Word 的原始值

  2. 指向锁对象

  3. 锁状态记录

轻量级锁过程:

  1. 线程进入同步区 → 在当前线程栈中创建 Lock Record

  2. CAS 尝试把对象头 Mark Word 指向 Lock Record

如果成功 → 获得轻量级锁
如果失败 → 说明竞争 → 进入自旋或升级为重量级锁

4 JVM 锁优化与升级流程(核心)

HotSpot 对 synchronized 做了大量优化,使其性能远比 JDK 1.4 时代好。

锁的升级流程如下:

无锁
↓
偏向锁
↓
轻量级锁(自旋锁)
↓
重量级锁(OS Mutex)

注意:不会降级,只会升级。

以下详细解释每一种状态。

4.1 偏向锁(Biased Lock)

偏向锁的目的:尽可能让无竞争的 synchronized 更快。

适用于某个对象总是由同一个线程访问的情况。

偏向锁流程:

  1. 对象第一次被线程 T1 进入 synchronized
    JVM 会直接把线程 ID 写入 Mark Word

  2. T1 再次进入 synchronized
    JVM 发现线程 ID 相同 → 不做任何锁操作
    连 CAS 都不执行,几乎零成本

  3. 当 T2 第一次竞争这个锁 → 撤销偏向锁

偏向锁撤销过程比较复杂:

  1. JVM 到达 safepoint(暂停所有线程)

  2. 检查所有线程的栈帧,看锁对象是否被其他线程持有

  3. 恢复 Mark Word(取消偏向信息)

  4. 升级为轻量级锁

偏向锁适合:

  • 同步块短

  • 几乎无竞争

  • 单线程频繁进入

这是目前 JVM 加速 synchronized 最重要的一环。

4.2 轻量级锁(自旋锁)

轻量级锁通过 CAS 与自旋来避免线程阻塞。

为什么自旋而不是直接阻塞?
阻塞涉及 OS 线程调度,开销非常大(几十倍于 CAS)。

轻量级锁流程如下:

  1. 线程 T1 在栈中创建 Lock Record

  2. 使用 CAS 将对象头指向 Lock Record 的地址

  3. CAS 成功 → 获得轻量级锁

  4. T2 也来获取锁 → CAS 失败 → T2 进行自旋

  5. 自旋一定次数(一般几十次,依 JVM 配置)

  6. 若 T1 释放锁前 T2 自旋成功 → 获取轻量级锁

  7. 否则 → 锁膨胀为重量级锁

轻量级锁的核心:
尽可能不用 OS 的阻塞机制,因此在短时间的锁竞争下效率高。

4.3 重量级锁(OS Mutex)

轻量级锁无法解决长时间竞争,因此会升级为重量级锁。

重量级锁特点:

  1. 依赖 OS 层面的 mutex 或 futex

  2. 获取不到锁的线程会真正阻塞(挂起)

  3. 唤醒线程需要 OS 调度(开销非常大)

  4. 稳定但速度最慢

Monitor 的 EntryList 会阻塞线程,而不是让它们忙等待。

重量级锁适合:

  • 高并发

  • 临界区长

  • 多线程激烈竞争

5 synchronized 的关键特性

5.1 可重入(Reentrant)

可重入意味着同一线程可以重复获得同一把锁而不会死锁。

如:

public synchronized void a() {
    b();
}

public synchronized void b() {}

Monitor 内部记录递归次数:

  1. 第一次获取锁 → count = 1

  2. 调用 b() → count = 2

  3. 方法退出 → count = 1

  4. 再退出 → count = 0 → 释放锁

5.2 内存语义(可见性 + 有序性 + 原子性)

进入 synchronized:

  • 工作内存失效 → 重新从主内存读取共享变量

退出 synchronized:

  • 将修改后的变量刷新到主内存

因此:

  1. 提供可见性(类似 volatile)

  2. 保证原子性(临界区内不会被打断)

  3. 保证有序性(同步块内指令不被重排)

5.3 锁自动释放

当同步块退出(正常或异常)时,JVM 自动执行 monitorexit。

不会出现 ReentrantLock 忘记 unlock 的问题。

6 wait/notify 与 Monitor 的结合(更细节)

Monitor 有两个队列:

  1. EntryList:等待获取锁的线程(阻塞)

  2. WaitSet:调用了 wait() 的线程(放弃锁)

wait/notify 规则:

  1. wait() 必须在 synchronized 内调用

  2. wait() 会释放锁,并进入 WaitSet

  3. notify() 将 WaitSet 的某个线程移动到 EntryList

  4. 只有当调用 notify 的线程退出 synchronized 后,唤醒的线程才可能获得锁

注意:notify 不一定唤醒最早的线程,其选择通常是不确定的。

如果你需要有序唤醒,可以使用 Condition(ReentrantLock)替代。

7 synchronized 与 ReentrantLock 对比(更全面)

能力

synchronized

ReentrantLock

可重入

可中断锁

公平锁

条件队列数量

1(用 notifyAll 控制不精准)

任意多个 Condition

尝试获取锁 tryLock

自动释放锁

性能

在简单场景下极高

在复杂锁场景中更灵活

底层

JVM(monitor)

Java(AQS)

总结使用建议:

如果只是简单同步:
推荐 synchronized(JVM 针对它做了大量优化)。

如果需要复杂锁机制:
使用 ReentrantLock。


Comment