跳到正文
Kale的数字花园
目录 文章
/22 分钟/13 阅读/0 评论

Java 锁机制全景指南

要理解锁,必须先理解 JMM(Java Memory Model)。锁的本质是解决多线程之间的**可见性**和**有序性**问题。 每个线程有自己的工作内存(CPU 缓存 + 寄存器),变量存储在主内存中。线程操作变量时,先从主内存拷贝到工


一、锁的分类总览

Java 并发控制体系
├── 按语法层面
│   ├── synchronized 关键字(JVM 层面实现,C++ 底层)
│   └── Lock 接口实现(JDK 代码层面实现)
│       ├── ReentrantLock
│       ├── ReentrantReadWriteLock
│       └── StampedLock
│
├── 按锁的状态(synchronized 特有)
│   ├── 偏向锁(JDK 15+ 默认废弃)
│   ├── 轻量级锁
│   └── 重量级锁
│   (这是一个单向升级过程,不是三种独立的锁)
│
├── 按获取策略
│   ├── 悲观锁(认为会发生竞争,直接加锁)
│   │   ├── synchronized
│   │   ├── ReentrantLock
│   │   └── ReadWriteLock 的写锁
│   └── 乐观锁(认为不会竞争,CAS 尝试)
│       ├── AtomicInteger 等 Atomic 类
│       ├── StampedLock 的乐观读
│       ├── volatile(广义上的轻量级同步)
│       └── LongAdder(分段 CAS)
│
├── 按是否可重入
│   ├── 可重入锁(同一线程可多次获取)
│   │   ├── synchronized
│   │   ├── ReentrantLock
│   │   └── ReentrantReadWriteLock
│   └── 不可重入锁(StampedLock)
│
├── 按公平性
│   ├── 公平锁(按等待队列顺序获取)
│   └── 非公平锁(新线程可"插队"尝试获取)
│
├── 按共享性
│   ├── 排他锁(独占锁,一次只有一个线程持有)
│   └── 共享锁(多个线程可同时持有,如读锁)
│
└── 无锁并发
    ├── volatile + CAS
    ├── ThreadLocal(线程封闭)
    ├── 不可变对象(Immutable)
    └── CopyOnWrite(写时复制)

二、Java 内存模型(JMM)—— 锁的底层基础

要理解锁,必须先理解 JMM(Java Memory Model)。锁的本质是解决多线程之间的可见性和有序性问题。

2.1 主内存与工作内存

线程A ── 工作内存(本地缓存) ──┐
                              ├── 主内存(共享内存,堆中的变量)
线程B ── 工作内存(本地缓存) ──┘

每个线程有自己的工作内存(CPU 缓存 + 寄存器),变量存储在主内存中。线程操作变量时,先从主内存拷贝到工作内存,修改后再写回主内存。

这导致两个问题:

  1. 可见性:线程 A 修改了变量,线程 B 不一定看得到(缓存不一致)
  2. 有序性:编译器和 CPU 会对指令重排序,导致执行顺序和代码顺序不一致

2.2 happens-before 原则

JMM 通过 happens-before 原则定义了哪些操作对其他线程是可见的:

  1. 程序次序规则:同一线程内,前面的操作 happens-before 后面的操作
  2. 锁规则:unlock 操作 happens-before 后续的 lock 操作
  3. volatile 规则:对 volatile 变量的写 happens-before 后续对该变量的读
  4. 传递性:A happens-before B,B happens-before C,则 A happens-before C
  5. 线程启动规则:Thread.start() happens-before 该线程内的任何操作
  6. 线程终止规则:线程的所有操作 happens-before Thread.join() 返回

核心记忆:synchronized 和 volatile 都保证了 happens-before 关系,从而保证了可见性。

2.3 内存屏障(Memory Barrier)

CPU 层面的有序性保障。JVM 通过内存屏障禁止指令重排序:

屏障类型 作用
LoadLoad Barrier 确保 load1 的数据先于 load2 被读取
StoreStore Barrier 确保 store1 的数据先于 store2 写入内存
LoadStore Barrier 确保 load1 的数据先于 store2 被写入
StoreLoad Barrier 确保 store1 的数据先于 load2 被读取(最昂贵的屏障)

volatile 的写操作前后会插入 StoreStore 和 StoreLoad 屏障;读操作前后会插入 LoadLoad 和 LoadStore 屏障。


三、volatile 关键字

volatile 是最轻量级的同步机制,和锁密切相关。

3.1 两个核心保证

  1. 可见性:一个线程修改了 volatile 变量,其他线程立即可见(通过内存屏障刷新到主内存)
  2. 有序性:禁止指令重排序(通过内存屏障)

不保证原子性:volatile int i; i++ 不是原子操作(读-改-写三步)。

3.2 使用场景

// 场景一:状态标志位(典型用法)
volatile boolean running = true;

public void stop() {
    running = false;  // 其他线程立即可见
}

public void run() {
    while (running) {
        // do work
    }
}

// 场景二:双重检查锁定(DCL)单例
public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {           // 第一次检查(不加锁)
            synchronized (Singleton.class) {
                if (instance == null) {   // 第二次检查(加锁后)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么 DCL 需要 volatile?

instance = new Singleton() 不是原子操作,分三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向分配的内存地址

如果发生指令重排(2 和 3 交换),线程 A 执行到第 3 步但第 2 步还没完成,线程 B 进入第一次检查发现 instance != null,直接返回了一个未初始化完成的对象。volatile 禁止了这种重排序。

3.3 volatile vs synchronized

维度 volatile synchronized
可见性 ✅ ✅
有序性 ✅(内存屏障) ✅
原子性 ❌ ✅
线程阻塞 ❌ 不阻塞 ✅ 会阻塞
适用场景 状态标志、DCL 单例 复合操作、临界区

四、synchronized 关键字

4.1 使用方式

// 修饰实例方法 —— 锁的是当前实例对象 (this)
public synchronized void method() { ... }

// 修饰静态方法 —— 锁的是 Class 对象
public static synchronized void method() { ... }

// 修饰代码块 —— 锁的是括号里指定的对象
public void method() {
    synchronized (someObject) { ... }
}

4.2 底层原理 —— Monitor 对象

synchronized 的底层依赖的是 对象头中的 Mark Word 和 Monitor 对象。

每个 Java 对象在内存中的布局:

对象 = 对象头(Mark Word + Klass Pointer) + 实例数据 + 对齐填充

Mark Word 是核心。它只有 64 位(8 字节),根据锁的状态不同,这 64 位的含义会动态变化:

无锁状态:  [unused:25][hashCode:31][分代年龄:4][偏向锁标志=0][锁标志位=01]
偏向锁:    [线程ID:54][epoch:2][分代年龄:4][偏向锁标志=1][锁标志位=01]
轻量级锁:  [指向锁记录的指针(62位)][锁标志位=00]
重量级锁:  [指向 Monitor 的指针(62位)][锁标志位=10]
GC 标记:   [锁标志位=11]

锁标志位是最后两位:01=无锁/偏向,00=轻量级,10=重量级,11=GC标记。

Monitor 是什么?

Monitor 是一个 C++ 对象(ObjectMonitor),定义在 HotSpot 源码的 objectMonitor.hpp 中。内部核心字段:

ObjectMonitor {
    _owner       // 指向当前持有锁的线程
    _WaitSet     // 调用了 wait() 的线程等待队列
    _EntryList   // 等待获取锁的线程等待队列
    _count       // 记录该线程获取锁的累计次数
    _recursions  // 锁的重入次数
    _cxq         // 竞争队列(新来的线程先进这里)
}

当多个线程竞争锁时,没抢到的线程进入 _EntryList 或 _cxq 队列阻塞等待。持有锁的线程调用 wait() 会释放锁并进入 _WaitSet。

Monitor 的获取和释放依赖操作系统级别的 Mutex Lock,涉及用户态 ↔ 内核态切换,这是重量级锁性能开销的根源。

4.3 锁升级过程

JDK 6 之前 synchronized 是重量级锁,性能差;JDK 6 之后引入锁优化,有了锁升级的概念。

stateDiagram-v2
    [*] --> 无锁状态: 对象创建
    无锁状态 --> 偏向锁: 第一个线程访问同步块
    偏向锁 --> 轻量级锁: 出现竞争(第二个线程来)
    轻量级锁 --> 重量级锁: 自旋超过阈值 / CAS 持续失败

    note right of 无锁状态
        没有同步需求
        Mark Word 存 hashCode、分代年龄等
    end note

    note right of 偏向锁
        只有一个线程访问
        Mark Word 中记录线程 ID
        后续进入只需比对 ID,无需 CAS
        最轻量的同步状态
        JDK 15+ 默认废弃
    end note

    note right of 轻量级锁
        多线程交替执行,无激烈竞争
        用 CAS + 自旋尝试获取
        线程栈帧中存 Lock Record
        不阻塞,消耗 CPU 忙等待
    end note

    note right of 重量级锁
        竞争激烈,多个线程同时抢
        进入系统内核态,线程阻塞挂起
        依赖 OS 的 Mutex Lock
        开销最大
    end note

偏向锁

当只有一个线程访问同步资源时,JVM 会在对象头的 Mark Word 里"偏向"这个线程,记录其线程 ID。后续这个线程再次进入时,只需检查 Mark Word 中的线程 ID 是否匹配,不需要任何 CAS 或原子操作。

偏向锁的撤销:当其他线程尝试获取偏向锁时,需要在全局安全点(SafePoint)撤销偏向:

  1. 暂停持有偏向锁的线程
  2. 检查该线程是否还活着、是否还在同步块内
  3. 如果已退出同步块,将 Mark Word 恢复为无锁状态
  4. 如果还在同步块内,升级为轻量级锁

JDK 15 之后,偏向锁默认被废弃(Deprecated)。原因是现代高并发场景下,偏向锁的撤销成本(SafePoint 停顿)往往高于它带来的收益。

轻量级锁

当第二个线程来竞争时,偏向锁升级为轻量级锁。核心机制:

  1. 线程在自己的栈帧里创建一个 Lock Record
  2. 把对象的 Mark Word 拷贝到 Lock Record 中
  3. 通过 CAS 尝试把 Mark Word 替换为指向 Lock Record 的指针
  4. CAS 成功 → 获得锁;CAS 失败 → 自旋(循环重试)

自旋不是无限的,JDK 6 默认 10 次,JDK 7 后改为自适应自旋 —— 根据历史表现动态调整自旋次数。超过自旋阈值后,升级为重量级锁。

重量级锁

竞争激烈的最终状态。没抢到锁的线程进入 _EntryList 队列 阻塞挂起(不消耗 CPU)。线程调度依赖操作系统的 Mutex Lock,涉及用户态 ↔ 内核态切换。

锁能降级吗?

不能。 锁升级是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。


五、LockSupport —— 线程阻塞/唤醒的底层原语

LockSupport 是 JDK 并发包的最底层线程阻塞工具,AQS 内部的线程挂起和唤醒就是基于它实现的。

// 阻塞当前线程
LockSupport.park();

// 唤醒指定线程
LockSupport.unpark(thread);

核心特点:

  1. 基于 Permit(许可证)机制:每个线程有一个 Permit(0 或 1)。park() 消耗 Permit(如果为 0 就阻塞),unpark() 发放 Permit(最多 1 个,不累加)
  2. 不需要先获取再释放:不像 wait()/notify() 必须在 synchronized 块中使用,park()/unpark() 可以在任何地方调用
  3. 不会抛出 InterruptedException:响应中断但不抛异常,通过 Thread.interrupted() 判断

wait/notify 和 park/unpark 的区别:

维度 wait/notify park/unpark
所属类 Object LockSupport
使用前提 必须在 synchronized 块中 无限制
唤醒方式 notify(随机唤醒一个)/ notifyAll(全部) unpark(精确指定线程)
顺序依赖 必须先 wait 后 notify,否则信号丢失 先 unpark 再 park 也不会阻塞(Permit 已存在)

AQS 中的线程挂起最终调用的就是 LockSupport.park(),释放锁时通过 LockSupport.unpark() 唤醒后继线程。


六、ReentrantLock —— JDK 层面的锁

6.1 与 synchronized 的对比

维度 synchronized ReentrantLock
实现层面 JVM 层面(C++ 实现) JDK 代码层面(Java 实现)
公平性 只能非公平 可选公平/非公平
获取/释放 自动(进入/退出代码块) 手动(lock/unlock),必须在 finally 中释放
中断响应 不可中断 可中断(lockInterruptibly())
超时获取 不支持 支持(tryLock(timeout))
条件变量 一个内置 wait/notify 支持多个 Condition,更灵活
性能 JDK 6 后差距不大 激烈竞争时略优

6.2 使用示例

ReentrantLock lock = new ReentrantLock();
// ReentrantLock lock = new ReentrantLock(true); // 公平锁

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();  // 必须在 finally 中释放,防止死锁
}

6.3 AQS 深度解析

AQS(AbstractQueuedSynchronizer)是 Doug Lea 写的,是整个 JUC 并发包的基石。ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 都基于它。

核心就两样东西:

1. 一个 state 变量(volatile int)

表示同步状态。ReentrantLock 中:

  • state = 0 → 无锁
  • state > 0 → 有线程持有锁,值就是重入次数

2. 一个 CLH 双向队列(FIFO)

存放等待获取锁的线程节点:

CLH 队列结构:
[head(dummy)] <--> [Node1: thread-A] <--> [Node2: thread-B] <--> [Node3: thread-C] <--> [tail]

每个 Node 包含:

  • thread:封装的线程
  • waitStatus:节点状态
    • CANCELLED = 1:线程被取消/超时/中断
    • SIGNAL = -1:后继线程需要被唤醒
    • CONDITION = -2:线程在 Condition 队列中等待
    • PROPAGATE = -3:共享模式下传播释放信号
  • prev/next:前驱/后继指针

获取锁的完整流程(非公平模式)

public void lock() {
    sync.lock();  // NonfairSync.lock()
}

// NonfairSync.lock()
final void lock() {
    // 第一步:直接尝试 CAS 获取锁("插队")
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        // 第二步:CAS 失败,进入 AQS 的 acquire 流程
        acquire(1);
}

// AbstractQueuedSynchronizer.acquire()
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&           // 再次尝试获取
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  // 失败则入队
        selfInterrupt();
}

acquireQueued 自旋逻辑:

final boolean acquireQueued(final Node node, int arg) {
    for (;;) {
        final Node p = node.predecessor();  // 获取前驱节点
        if (p == head && tryAcquire(arg)) { // 前驱是头节点,尝试获取
            setHead(node);                  // 获取成功,自己成为头节点
            p.next = null;                  // 帮助 GC
            return false;
        }
        if (shouldParkAfterFailedAcquire(p, node))  // 检查是否需要阻塞
            parkAndCheckInterrupt();                // LockSupport.park() 阻塞
    }
}

核心要点:

  • 线程入队后不会立即阻塞,而是先自旋检查前驱状态
  • 只有前驱节点的 waitStatus 是 SIGNAL(-1) 时,当前线程才会被 park
  • 被唤醒后,继续自旋尝试获取锁

释放锁的流程

public void unlock() {
    sync.release(1);
}

// AbstractQueuedSynchronizer.release()
public final boolean release(int arg) {
    if (tryRelease(arg)) {          // state 减 1,如果 state=0 返回 true
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);     // 唤醒后继节点
        return true;
    }
    return false;
}

unparkSuccessor 会从 _EntryList 中找到第一个有效的后继节点,调用 LockSupport.unpark() 唤醒它。

公平锁 vs 非公平锁

// 非公平锁:直接 CAS 尝试获取("插队")
protected final boolean tryAcquire(int acquires) {
    if (compareAndSetState(0, acquires)) { ... }
}

// 公平锁:先检查队列,有等待线程就排队
protected final boolean tryAcquire(int acquires) {
    if (hasQueuedPredecessors()) return false;  // 队列中有人就排队
    if (compareAndSetState(0, acquires)) { ... }
}

公平锁的性能通常比非公平锁差,因为每次获取锁都要检查队列,增加了线程上下文切换。

可重入的实现

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 当前线程已经持有锁,重入!
        int nextc = c + acquires;
        setState(nextc);  // 不需要 CAS,只有持有锁的线程能改
        return true;
    }
    return false;
}

关键点:只有持有锁的线程才能修改 state(重入时),所以重入时不需要 CAS,直接 setState() 即可。


七、Condition 条件变量

ReentrantLock 支持多 Condition,比 synchronized 的单一 wait/notify 灵活得多。

7.1 为什么需要多个 Condition?

synchronized 的 wait/notify 只能有一个等待集合,所有等待条件混在一起。用 notifyAll() 唤醒所有线程,但只有满足条件的线程才能继续执行,其他的再次等待 —— 这就是信号丢失和虚假唤醒问题。

Condition 允许创建多个等待队列,每个条件一个队列,精确唤醒:

ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();   // 队列未满的条件
Condition notEmpty = lock.newCondition();  // 队列非空的条件

// 生产者
lock.lock();
try {
    while (queue.size() == MAX_SIZE) {
        notFull.await();  // 在 notFull 队列中等待
    }
    queue.add(item);
    notEmpty.signal();    // 精确唤醒在 notEmpty 队列中等待的消费者
} finally {
    lock.unlock();
}

// 消费者
lock.lock();
try {
    while (queue.isEmpty()) {
        notEmpty.await();  // 在 notEmpty 队列中等待
    }
    item = queue.remove();
    notFull.signal();      // 精确唤醒在 notFull 队列中等待的生产者
} finally {
    lock.unlock();
}

7.2 Condition 底层实现

每个 Condition 内部维护一个条件队列(单向链表),和 AQS 的同步队列是两个不同的队列。

ConditionObject {
    firstWaiter  -->  [Node: thread-A] --> [Node: thread-B] --> null
    lastWaiter
}

调用 await() 时:

  1. 将当前线程封装为 Node,加入条件队列尾部
  2. 完全释放锁(state 重置为 0)
  3. LockSupport.park() 阻塞

调用 signal() 时:

  1. 将条件队列中的第一个节点转移到 AQS 同步队列尾部
  2. 线程会在后续被 unpark 唤醒
  3. 唤醒后重新获取锁(从 await() 方法返回)

八、ReadWriteLock / ReentrantReadWriteLock

读写分离锁,核心思想:读读不互斥,读写互斥,写写互斥。

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReadWriteLock.ReadLock readLock = rwLock.readLock();
ReadWriteLock.WriteLock writeLock = rwLock.writeLock();

// 读操作
readLock.lock();
try { ... } finally { readLock.unlock(); }

// 写操作
writeLock.lock();
try { ... } finally { writeLock.unlock(); }

底层原理:

AQS 的 state 被一分为二:

  • 高 16 位:记录读锁的获取次数
  • 低 16 位:记录写锁的重入次数
state: [读锁计数(16位)][写锁计数(16位)]

读锁通过 sharedCount() 取高 16 位,写锁通过 exclusiveCount() 取低 16 位。

适用场景:读多写少。典型场景是缓存系统,大量读操作少量更新。

如果写操作频繁,读锁频繁让位给写锁,性能反而不如直接用排他锁。

锁降级:ReentrantReadWriteLock 支持写锁降级为读锁(先获取写锁,再获取读锁,然后释放写锁),但不支持读锁升级为写锁(会导致死锁)。


九、StampedLock(JDK 8 引入)

ReadWriteLock 的升级版,性能更好,但 API 更复杂。

三种模式:

  1. 写锁(Writing Lock):排他锁,类似 ReentrantLock
  2. 读锁(Reading Lock):共享锁,类似 ReadLock
  3. 乐观读锁(Optimistic Read Lock):不上锁,读完后用 validate() 检查期间有没有写操作
StampedLock lock = new StampedLock();

// 写锁
long stamp = lock.writeLock();
try { ... } finally { lock.unlockWrite(stamp); }

// 悲观读锁
long stamp = lock.readLock();
try { ... } finally { lock.unlockRead(stamp); }

// 乐观读锁(最常用,性能最好)
long stamp = lock.tryOptimisticRead();
// 读取数据
if (lock.validate(stamp)) {
    // 数据有效,读取期间没有写操作
} else {
    // 验证失败,升级为悲观读锁重试
    stamp = lock.readLock();
    try {
        // 重新读取数据
    } finally {
        lock.unlockRead(stamp);
    }
}

为什么性能更好?

乐观读锁不需要 CAS,不需要阻塞线程,只是读取一个 stamp 值(volatile long)。如果读取期间没有写操作,就完成了"无锁化"的并发读取。只有在验证失败时才需要升级。

注意:StampedLock 不可重入。同一个线程获取写锁后再次获取,会死锁。


十、乐观锁 —— CAS 机制

10.1 CAS(Compare And Swap)

三个操作数:内存值 V、预期值 A、新值 B。

如果 V == A,则 V = B,返回 true
如果 V != A,则什么都不做,返回 false

底层依赖 CPU 的原子指令:x86 → cmpxchg,ARM → ldrex/strex。

10.2 CAS 的问题

ABA 问题:

线程1 读取值 A
线程2 把 A 改为 B
线程3 把 B 改回 A
线程1 CAS 检查,发现值还是 A,成功 —— 但实际上中间发生过变化

解决:AtomicStampedReference,带上版本号。

AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);
ref.compareAndSet(1, 2, 0, 1);  // 需要同时匹配值和版本号

自旋开销:CAS 失败后会自旋重试,如果竞争激烈,大量线程空转消耗 CPU。

只能保证一个变量的原子操作:多个变量需要原子操作时,CAS 搞不定。


十·五、死锁、活锁与饥饿

十·五·一、死锁(Deadlock)

什么是死锁?

两个或多个线程互相等待对方持有的锁,形成环形依赖,所有线程都永远阻塞。

// 经典死锁场景
Object lockA = new Object();
Object lockB = new Object();

// 线程1:先拿 A,再拿 B
new Thread(() -> {
    synchronized (lockA) {
        sleep(100);  // 让线程2先拿到 lockB
        synchronized (lockB) {
            System.out.println("Thread1 got both locks");
        }
    }
}, "Thread-1").start();

// 线程2:先拿 B,再拿 A
new Thread(() -> {
    synchronized (lockB) {
        sleep(100);
        synchronized (lockA) {
            System.out.println("Thread2 got both locks");
        }
    }
}, "Thread-2").start();

// 结果:两个线程互相等待,永久阻塞
// Thread-1 持有 lockA,等待 lockB
// Thread-2 持有 lockB,等待 lockA

死锁的四个必要条件(Coffman 条件)

四个条件同时满足才会死锁,破坏其中任意一个即可预防死锁:

条件 含义 破坏方式
互斥条件 锁是排他的,一个资源只能被一个线程持有 无法破坏(锁的本质)
占有并等待 线程持有锁的同时请求其他锁 一次性获取所有需要的锁
不可抢占 线程持有的锁不能被其他线程强行剥夺 使用 tryLock(timeout) 超时释放
循环等待 线程之间形成环形等待链 统一加锁顺序

Java 中 JVM 对死锁的态度

synchronized:JVM 不自动检测死锁(运行时不会主动打破)。死锁的线程会永久阻塞,只能通过外部工具发现。

ReentrantLock:提供了打破死锁的手段:

ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();

// 方式一:tryLock + 超时 —— 破坏"不可抢占"条件
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
    try {
        if (lockB.tryLock(1, TimeUnit.SECONDS)) {
            try {
                // 获取了两把锁,执行操作
            } finally {
                lockB.unlock();
            }
        } else {
            // 获取 lockB 超时,放弃 lockA,稍后重试
        }
    } finally {
        lockA.unlock();
    }
}

// 方式二:lockInterruptibly —— 响应中断
lockA.lockInterruptibly();
try {
    lockB.lockInterruptibly();
    try {
        // 执行操作
    } finally {
        lockB.unlock();
    }
} finally {
    lockA.unlock();
}
// 外部线程可以调用 thread.interrupt() 中断等待

死锁检测 —— 如何排查

方式一:jstack(最常用)

jstack <pid>

输出中会明确标记死锁信息:

Found one Java-level deadlock:
=============================
"Thread-2":
  waiting to lock monitor 0x00007f... (object 0x00000007..., java.lang.Object),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x00007f... (object 0x00000007..., java.lang.Object),
  which is held by "Thread-2"

方式二:JConsole / VisualVM

图形化工具的"线程"面板中,死锁线程会标红显示。

方式三:编程方式检测(Java 1.6+)

ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    System.out.println("检测到死锁!涉及线程数:" + deadlockedThreads.length);
    for (long tid : deadlockedThreads) {
        ThreadInfo info = threadBean.getThreadInfo(tid);
        System.out.println("死锁线程:" + info.getThreadName());
    }
}

死锁预防的最佳实践

  1. 统一加锁顺序(最常用,破坏"循环等待"条件):
// 不好的做法:加锁顺序不一致
void transfer(Account from, Account to, int amount) {
    synchronized (from) { synchronized (to) { ... } }
}
// 线程A: transfer(a, b) → 先锁 a 再锁 b
// 线程B: transfer(b, a) → 先锁 b 再锁 a → 死锁!

// 好的做法:按固定顺序加锁(比如按 ID 大小)
void transfer(Account from, Account to, int amount) {
    Account first = from.id < to.id ? from : to;
    Account second = from.id < to.id ? to : from;
    synchronized (first) { synchronized (second) { ... } }
}
// 无论调用顺序如何,加锁顺序始终是 a → b,不会死锁
  1. 减少锁的粒度:用细粒度锁代替粗粒度锁,降低同时持有多个锁的概率
  2. 使用 tryLock 超时:获取锁失败时回退,稍后重试
  3. 缩短持有锁的时间:临界区尽量短,不要在持有锁的时候做 I/O 操作

十·五·二、活锁(Livelock)

什么是活锁?

线程没有阻塞,一直在运行,但一直在重试、回退、让出资源,导致无法取得进展。

// 活锁场景:两个线程互相谦让
while (!task.done()) {
    if (otherThread.isWorking()) {
        Thread.sleep(10);  // 让给对方
        // 对方也在做同样的事,双方都在 sleep → 永远执行不完
    } else {
        task.doWork();
    }
}

和死锁的区别:死锁是线程不干活(永久阻塞),活锁是线程干不完活(一直在忙但没进展)。

解决:引入随机退避时间,打破"对称行为"。

// 加随机等待,打破对称性
if (otherThread.isWorking()) {
    Thread.sleep(new Random().nextInt(100));  // 随机退避
}

十·五·三、饥饿(Starvation)

什么是饥饿?

某个线程长期得不到执行,因为资源总是被其他线程抢占。

常见原因:

  1. 低优先级线程被高优先级线程一直压制
  2. 不公平锁下,某个线程总是被"插队"
  3. 某个线程等待一个永远不释放的锁(本质上是活锁/死锁的变种)

解决:使用公平锁,或者调整线程优先级。

十·五·四、死锁 vs 活锁 vs 饥饿 对比

维度 死锁 活锁 饥饿
线程状态 阻塞(不消耗 CPU) 运行中(消耗 CPU) 等待中(基本不消耗 CPU)
表现 永久卡住 一直在忙但没进展 长期得不到执行
根因 环形等待 对称退让策略 资源分配不均
解决 统一加锁顺序、超时释放 随机退避 公平调度
JVM 是否自动检测 ❌ ❌ ❌

十一、LongAdder vs AtomicLong —— 高并发计数优化

11.1 AtomicLong 的问题

AtomicLong 在高并发场景下,大量线程竞争同一个变量,CAS 反复失败、重试,性能急剧下降。

AtomicLong counter = new AtomicLong(0);
// 1000 个线程同时 incrementAndGet()
// 大量 CAS 失败,自旋重试,CPU 空转

11.2 LongAdder 的设计

LongAdder 采用分段 CAS(类似 ConcurrentHashMap 的分段锁思想):

LongAdder {
    base: long
    cells: Cell[]  // 多个 Cell 数组,每个 Cell 有一个 value
}

Cell {
    @sun.misc.Contended  // 缓存行对齐,防止伪共享
    volatile long value;
}

当多个线程并发累加时:

  1. 先尝试 CAS 更新 base
  2. 如果失败(有竞争),将当前线程 hash 到一个 Cell 上,更新该 Cell 的 value
  3. 最终结果 = base + sum(cells[i].value)
LongAdder adder = new LongAdder();
// 多线程并发
adder.increment();  // 分散到不同 Cell,减少竞争
// 获取结果
long total = adder.sum();

性能对比:高并发场景下 LongAdder 的性能远超 AtomicLong(可达 3-5 倍)。代价是 sum() 不是实时精确的(并发更新时可能有不一致),适合计数统计场景。

11.3 伪共享(False Sharing)与 @Contended

LongAdder 的 Cell 使用了 @sun.misc.Contended 注解,这是为了解决伪共享问题。

什么是伪共享?

CPU 缓存不是按字节加载的,而是按缓存行(Cache Line,通常 64 字节)加载。如果两个独立变量在同一个缓存行中,一个线程修改了变量 A,会导致整个缓存行失效,其他 CPU 核心必须重新从内存加载 —— 即使它们只关心变量 B。

缓存行(64 字节)
[变量 A: 8字节][变量 B: 8字节][填充: 48字节]
  ↑ 线程1 修改 A  →  整个缓存行失效  →  线程2 读 B 也要重新加载

@Contended 通过在变量前后填充 128 字节,确保该变量独占一个缓存行:

[填充: 128字节][变量 A: 8字节][填充: 128字节]
  ↑ 线程1 修改 A,不影响其他变量

JDK 8 起需要加 JVM 参数 -XX:-RestrictContended 才能生效。


十二、AQS 的其他应用

12.1 CountDownLatch —— 倒计时门闩

CountDownLatch latch = new CountDownLatch(3);

// 主线程等待
latch.await();  // 阻塞,直到 count 为 0

// 子线程完成工作后
latch.countDown();  // count 减 1

底层:AQS 的 state 初始化为 3(或其他值)。countDown() 通过 CAS 将 state 减 1,当 state 变为 0 时,唤醒所有在 await() 上等待的线程。CountDownLatch 不可重用,state 到 0 后不能再重置。

典型场景:主线程等待多个子线程完成初始化后再继续执行。

12.2 CyclicBarrier —— 循环屏障

CyclicBarrier barrier = new CyclicBarrier(3, () -> {
    System.out.println("所有线程已到位,开始执行");
});

// 每个线程到达屏障点
barrier.await();  // 阻塞,直到 3 个线程都到达
// 3 个线程同时被唤醒,继续执行

底层:基于 ReentrantLock + Condition 实现。所有线程到达后触发 signalAll()。CyclicBarrier 可重用,一轮完成后可以开始下一轮。

与 CountDownLatch 的区别:

维度 CountDownLatch CyclicBarrier
可重用 ❌ 不可重用 ✅ 可循环使用
等待方 一个线程等待多个线程完成 所有线程互相等待
底层 AQS(共享模式) ReentrantLock + Condition
典型场景 主线程等子线程 多线程分阶段协作

12.3 Semaphore —— 信号量

Semaphore semaphore = new Semaphore(5);  // 最多允许 5 个线程同时执行

semaphore.acquire();  // 获取一个 permit,没有则阻塞
try {
    // 临界区(最多 5 个线程并发)
} finally {
    semaphore.release();  // 释放一个 permit
}

底层:AQS 的共享模式。state 初始化为 permits 数量。acquire() 将 state 减 1(state < 0 时阻塞),release() 将 state 加 1。


十三、并发集合的锁机制

13.1 ConcurrentHashMap 的演进

JDK 7:分段锁(Segment)

ConcurrentHashMap {
    Segment[] segments;  // 16 个段,每个段是一把锁
}

Segment extends ReentrantLock {
    HashEntry[] table;
}

将数据分成 16 个 Segment,每个 Segment 独立加锁。最多支持 16 个线程并发访问不同 Segment。缺点是并发度固定为 16,无法动态调整。

JDK 8:CAS + synchronized

ConcurrentHashMap {
    Node[] table;  // 和 HashMap 一样的数组
}

每个桶(bucket)独立加锁:
- put 时,如果桶为空,CAS 插入
- 如果桶不为空(有冲突),synchronized 锁住桶的头节点(Record Lock)

关键变化:

  • 去掉了 Segment,直接用 synchronized (f) 锁住桶的头节点
  • 锁粒度从 Segment(16 个)细化到每个桶(N 个),并发度大幅提升
  • 利用 JVM 对 synchronized 的优化(锁升级),性能更好
// JDK 8 ConcurrentHashMap.putVal() 简化版
final V putVal(K key, V value, boolean onlyIfAbsent) {
    Node<K,V> f; int n, i, fh;
    // 第一步:数组未初始化,先初始化
    // 第二步:桶为空,CAS 插入头节点
    if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
        if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
            break;
    }
    // 第三步:桶不为空,锁住头节点
    synchronized (f) {
        // 链表插入或红黑树插入
    }
}

13.2 CopyOnWriteArrayList —— 无锁读

核心思想:写时复制(Copy-On-Write)。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();  // 写操作加锁
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);  // 复制一份
        newElements[len] = e;
        setArray(newElements);  // 引用指向新数组
        return true;
    } finally {
        lock.unlock();
    }
}

public E get(int index) {
    return get(getArray(), index);  // 读操作不加锁,直接读旧数组
}

写操作加锁并复制整个数组,读操作不加锁读取旧数组的快照。

适用场景:读多写极少,比如配置列表、白名单。如果写操作频繁,每次都要复制整个数组,性能极差且内存占用大。

13.3 阻塞队列的锁实现

ArrayBlockingQueue:一把锁 + 两个 Condition

final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();

public void put(E e) throws InterruptedException {
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();  // 队列满,等待
        enqueue(e);
        notEmpty.signal();    // 唤醒消费者
    } finally {
        lock.unlock();
    }
}

LinkedBlockingQueue:两把锁(takeLock + putLock),读和写分离

private final ReentrantLock takeLock = new ReentrantLock();  // 取数据的锁
private final ReentrantLock putLock = new ReentrantLock();   // 放数据的锁

put 和 take 分别用独立的锁,允许同时进行 put 和 take 操作,比 ArrayBlockingQueue 的并发度更高。


十四、线程封闭 —— 另一种无锁并发思路

14.1 ThreadLocal

每个线程有自己独立的变量副本,不存在共享,自然就不需要锁。

private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 每个线程用自己的 SimpleDateFormat,线程安全
String formatted = DATE_FORMAT.get().format(new Date());

底层原理:

每个 Thread 对象内部有一个 ThreadLocalMap:

Thread {
    ThreadLocalMap threadLocals;
}

ThreadLocalMap {
    Entry[] table;  // key 是 ThreadLocal 的弱引用,value 是对象
}

ThreadLocal.get() 实际上是从当前线程的 threadLocals 中以自己的 ThreadLocal 对象为 key 取值。

内存泄漏问题:

ThreadLocalMap 的 key 是弱引用,GC 时可能被回收。但 value 是强引用,如果 ThreadLocal 被 GC 回收了,key 变为 null,但 value 还在 —— 如果线程是线程池中的核心线程(长期存活),value 就永远不会被回收。

解决:用完后必须手动 remove()。

try {
    DATE_FORMAT.get().format(...);
} finally {
    DATE_FORMAT.remove();  // 防止内存泄漏
}

十五、JVM 的锁优化

JIT 编译器在运行时会自动做一些锁优化,开发者无需手动处理。

15.1 锁消除(Lock Elimination)

JIT 发现某个同步块只被单线程访问,直接去掉锁。

public void method() {
    StringBuilder sb = new StringBuilder();  // 局部变量,线程私有
    synchronized (sb) { ... }  // JIT 发现 sb 不会被其他线程访问,消除锁
}

JIT 通过逃逸分析(Escape Analysis)判断对象是否会被其他线程访问。

15.2 锁粗化(Lock Coarsening)

频繁对同一对象加锁解锁,JIT 会把多次加锁合并为一次。

// 原始代码:循环内反复加锁
for (int i = 0; i < 100; i++) {
    synchronized (obj) { ... }
}
// JIT 优化后:锁提到循环外面
synchronized (obj) {
    for (int i = 0; i < 100; i++) { ... }
}

15.3 自旋锁与自适应自旋

JDK 6:固定自旋次数(默认 10 次)。
JDK 7 及以后:自适应自旋 —— 根据历史表现动态调整。


十六、分布式锁(Java 生态常见实现)

16.1 基于 Redis

// 核心思路:SET key value NX PX milliseconds
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    // 获取成功
}

// 释放锁:用 Lua 脚本保证原子性
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then "
    + "return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId));

问题:

  • 锁过期但业务没执行完 → 其他线程获取了锁,并发安全问题
  • 主从切换导致锁丢失

解决:Redisson 框架的看门狗(WatchDog)机制自动续期。

16.2 基于 Zookeeper

创建临时顺序节点:/locks/order_0000000001
节点创建成功 → 获取锁
节点已存在 → Watch 上一个节点,等待释放

优势:强一致性,不会丢失锁。劣势:性能不如 Redis。


十七、常见面试追问

Q1:synchronized 和 ReentrantLock 你选哪个?

看场景。大多数场景 synchronized 够用且更安全。需要公平锁、超时获取、可中断、多 Condition 时选 ReentrantLock。

Q2:AQS 为什么用 CLH 队列?

CLH 被改造为双向阻塞队列,每个节点只关注前驱节点的状态,轮询效率高。双向链表方便节点的前后驱操作和取消操作。

Q3:重量级锁和轻量级锁的线程调度有什么区别?

轻量级锁是自旋(忙等待,消耗 CPU 但不阻塞)。重量级锁是阻塞挂起(不消耗 CPU,但涉及用户态/内核态切换)。

Q4:为什么 ReentrantLock 必须在 finally 中释放?

如果临界区抛出异常,unlock() 不会执行,锁永远不会被释放,后续线程全部阻塞。synchronized 由 JVM 自动释放,即使异常也不会泄漏锁。

Q5:锁升级过程中,Mark Word 怎么变的?

无锁:    [hashCode:31][分代年龄:4][偏向=0][锁标志=01]
偏向锁:  [线程ID:54][epoch:2][分代年龄:4][偏向=1][锁标志=01]
轻量级:  [指向 Lock Record 的指针(62位)][锁标志=00]
重量级:  [指向 Monitor 的指针(62位)][锁标志=10]

Q6:CAS 的 ABA 问题怎么解决?

AtomicStampedReference,带上版本号。

Q7:volatile 能保证原子性吗?DCL 单例为什么需要 volatile?

不能。volatile i++ 不是原子操作。DCL 中 instance = new Singleton() 不是原子的(分配内存 → 初始化 → 赋值),volatile 禁止了指令重排序,防止其他线程拿到未初始化完成的对象。

Q8:LockSupport.park() 和 Object.wait() 有什么区别?

park() 不需要在 synchronized 块中使用,可以精确唤醒指定线程,先 unpark 再 park 也不会丢失信号。wait() 必须在 synchronized 块中,notify 是随机唤醒,必须先 wait 后 notify。

Q9:LongAdder 为什么比 AtomicLong 快?

分段 CAS:将竞争分散到多个 Cell 上,每个线程 hash 到一个 Cell 独立更新,减少 CAS 冲突。代价是 sum() 不是实时精确的。

Q10:ConcurrentHashMap JDK 7 和 JDK 8 的区别?

JDK 7 用 Segment 分段锁(16 个段,每段一把 ReentrantLock)。JDK 8 去掉了 Segment,用 CAS + synchronized 锁每个桶(头节点),锁粒度更细,并发度更高。

Q11:CountDownLatch 和 CyclicBarrier 的区别?

CountDownLatch 不可重用,一个线程等待多个线程完成。CyclicBarrier 可循环使用,所有线程互相等待。


十八、速查表

锁/机制 底层实现 可重入 公平性 适用场景
synchronized Monitor(C++) 是 非公平 大多数场景,简单安全
ReentrantLock AQS(Java) 是 可选 需要公平/超时/中断/多条件
ReadWriteLock AQS(Java) 是 可选 读多写少
StampedLock 自定义队列 否 非公平 高性能读,乐观读场景
volatile 内存屏障 N/A N/A 状态标志、DCL 单例
CAS/Atomic CPU 原子指令 N/A N/A 单变量原子操作
LongAdder 分段 CAS + @Contended N/A N/A 高并发计数统计
ThreadLocal 线程隔离 Map N/A N/A 线程私有数据,无锁
CopyOnWrite 写时复制 + ReentrantLock N/A N/A 读多写极少的列表
Redis 分布式锁 Redis SET NX PX N/A N/A 跨 JVM 互斥
Zookeeper 锁 临时顺序节点 N/A 公平 强一致性分布式锁

AQS 应用汇总

工具 AQS 模式 state 含义
ReentrantLock 独占(Exclusive) 锁的重入次数
ReadWriteLock 独占 + 共享 高16位读计数,低16位写计数
CountDownLatch 共享(Shared) 倒计数剩余值
Semaphore 共享(Shared) 剩余 permit 数量
CyclicBarrier ReentrantLock + Condition 基于 Lock 实现,非直接 AQS
相关阅读

13 分钟

MySQL 锁机制全景指南

全局锁作用于**整个数据库实例**,加锁后整个实例处于只读状态。 主要用于**全库备份**。在备份期间加全局读锁,确保备份期间数据不会变化,保证备份的一致性。 全局锁会让整个数据库只读,生产环境慎用。 现代备份通常使用 参数(基于 MVCC

面试#lock7 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。