【大白话说Java面试题 第107题】【并发篇】第7题:说说 Lock 锁?

📌 微服务架构基于Spring Cloud Alibaba的分布式事务处理:Seata AT模式与Sentinel协同实现高并发下数据最终一致性

第7题:说说 Lock 锁?

📚 回答:

  • 核心考点
    Lock 是 JUC 包的核心接口,大厂面试不会只问"有哪些方法",而是深入考察 AQS(AbstractQueuedSynchronizer)框架原理ReentrantLock 的公平/非公平实现差异Condition 条件队列与 Object wait/notify 的本质区别读写锁的锁降级与锁升级策略 ,以及 StampedLock 的乐观读模式。面试官真正想判断的是:你是否理解 Lock 体系从"API 层"到"AQS 框架层"再到"CAS + 队列层"的完整架构,以及能否在生产环境中正确选型。

1. Lock 接口体系与核心方法

java.util.concurrent.locks.Lock 是 Java 显式锁的根接口,定义了与 synchronized 不同的锁范式 citation:1

方法 功能 与 synchronized 对比
lock() 阻塞获取锁,不可中断 synchronized 等价
lockInterruptibly() 阻塞获取锁,可响应中断 synchronized 不支持
tryLock() 非阻塞尝试获取,立即返回 boolean synchronized 不支持
tryLock(time, unit) 限时阻塞获取,超时返回 false synchronized 不支持
unlock() 释放锁(必须在 finally 中调用) synchronized 自动释放
newCondition() 创建条件队列,支持多条件等待 synchronized 只有一个 wait/notify

关键差异synchronized 是"隐式锁"(JVM 自动管理),Lock 是"显式锁"(开发者手动控制),提供了更精细的并发控制能力 citation:1


2. ReentrantLock 的底层实现------AQS 框架
  • 2.1 AQS 核心架构

    ReentrantLock 的底层依赖 AbstractQueuedSynchronizer(AQS),这是 JUC 包中几乎所有同步组件的基石(ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 等)citation:2

    AQS 核心结构 citation:2citation:3

    复制代码
    ┌─────────────────────────────────────────┐
    │  AbstractQueuedSynchronizer             │
    ├─────────────────────────────────────────┤
    │  volatile int state          // 同步状态 │
    │  volatile Node head          // 队列头节点 │
    │  volatile Node tail          // 队列尾节点 │
    │  Thread exclusiveOwnerThread // 独占锁持有者 │
    └─────────────────────────────────────────┘

    state 字段的语义

    • ReentrantLock:0 表示无锁,>0 表示重入次数;
    • CountDownLatch:初始值为计数,减到 0 时唤醒等待线程;
    • Semaphore:剩余可用许可数。

    Node 节点结构

    复制代码
    ┌─────────────────────────────────────────┐
    │  Node (双向链表)                         │
    ├─────────────────────────────────────────┤
    │  volatile int waitStatus    // 节点状态  │
    │  volatile Node prev           // 前驱节点 │
    │  volatile Node next           // 后继节点 │
    │  volatile Thread thread       // 绑定的线程 │
    │  Node nextWaiter            // 条件队列链接 │
    └─────────────────────────────────────────┘
    waitStatus 含义
    CANCELLED 1 节点已取消(超时或中断)
    SIGNAL -1 后继节点需要被唤醒
    CONDITION -2 节点在条件队列中
    PROPAGATE -3 共享模式下向后传播唤醒
  • 2.2 非公平锁的获取流程

    java 复制代码
    // ReentrantLock.NonfairSync.lock()
    final void lock() {
        if (compareAndSetState(0, 1))  // ① 直接 CAS 尝试获取
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);                   // ② CAS 失败,进入 AQS 队列
    }

    acquire(1) 的核心逻辑 citation:2

    1. tryAcquire(1):再次尝试获取(检查 state 是否为 0,或是否重入);
    2. addWaiter(Node.EXCLUSIVE):创建 Node 节点,CAS 加入队列尾部;
    3. acquireQueued():自旋/CAS 检查前驱是否为头节点,是则尝试获取锁;
    4. park() :自旋失败则调用 LockSupport.park() 挂起线程。

    非公平性体现 :新线程到达时先 CAS 尝试(不排队),失败后才进入队列。这允许"插队",吞吐量更高但可能饥饿 citation:3

  • 2.3 公平锁的获取流程

    java 复制代码
    // ReentrantLock.FairSync.lock()
    final void lock() {
        acquire(1);  // 直接走 acquire,没有前置 CAS
    }
    
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&  // ← 关键:检查是否有前驱节点
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        // ... 重入逻辑
    }

    公平性体现hasQueuedPredecessors() 检查队列中是否有等待线程,有则不允许插队,必须排队 citation:3

  • 2.4 锁释放流程

    java 复制代码
    // ReentrantLock.unlock() → AQS.release(1)
    public final boolean release(int arg) {
        if (tryRelease(arg)) {  // state 减 1,检查是否为 0
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);  // 唤醒头节点的后继
            return true;
        }
        return false;
    }

    关键设计 :释放锁时只唤醒一个 后继线程(unparkSuccessor),被唤醒的线程继续自旋竞争锁。这是"独占模式"的设计 citation:2


3. 公平锁 vs 非公平锁深度对比
对比维度 非公平锁(默认) 公平锁
获取策略 新线程先 CAS 尝试,失败再排队 必须检查队列,有等待者则排队
吞吐量 高(减少线程切换) 低(严格排队,切换频繁)
饥饿风险 存在(新线程可能一直插队) 无(严格 FIFO)
适用场景 高并发、短临界区 长临界区、需要避免饥饿
性能差距 比公平锁高 10%~20% 相对低

压测数据:在 100 线程竞争下,非公平锁的吞吐量约为公平锁的 1.2~1.5 倍 citation:3


4. Condition 条件队列------多条件等待/唤醒
  • 4.1 与 Object wait/notify 的本质区别

    synchronized 只有一个隐式条件队列(waitSet),而 ReentrantLock 可以创建多个 Condition,实现更精细的线程协作 citation:1

    特性 Object wait/notify Condition await/signal
    条件队列数量 1 个(每个对象一个) 多个(每个 Lock 可创建多个)
    唤醒精度 notify() 随机唤醒一个,notifyAll() 全部唤醒 signal() 唤醒一个,signalAll() 全部唤醒,可精确控制
    使用前提 必须持有对象锁 必须持有 Lock 锁
    中断响应 不区分中断原因 awaitUninterruptibly() 可选
  • 4.2 Condition 的底层实现

    每个 Condition 维护一个独立的条件队列(单向链表),与 AQS 的主队列(同步队列)分离 citation:2

    复制代码
    Lock (AQS)
      ├── 同步队列(Sync Queue):双向链表,等待锁的线程
      │     head → Node(T1) ↔ Node(T2) ↔ Node(T3) → tail
      │
      ├── Condition1 条件队列(Wait Queue):单向链表
      │     firstWaiter → Node(T4) → Node(T5) → null
      │
      └── Condition2 条件队列(Wait Queue):单向链表
            firstWaiter → Node(T6) → null

    await() 流程 citation:2

    1. 释放 Lock(fullyRelease),保存重入次数;
    2. 创建 Node 加入 Condition 的条件队列;
    3. park() 挂起线程;
    4. signal() 唤醒后,从条件队列转移到同步队列,重新竞争 Lock。

    signal() 流程 citation:2

    1. 检查是否持有 Lock;
    2. 从条件队列头部取出一个 Node;
    3. 将其转移到同步队列尾部;
    4. 设置前驱节点状态为 SIGNAL,唤醒该线程。
  • 4.3 经典示例------生产者消费者(双条件)

    java 复制代码
    public class BoundedBuffer<T> {
        private final Lock lock = new ReentrantLock();
        private final Condition notFull = lock.newCondition();   // 队列不满条件
        private final Condition notEmpty = lock.newCondition();  // 队列不空条件
        private final Queue<T> queue = new LinkedList<>();
        private final int capacity;
    
        public void put(T item) throws InterruptedException {
            lock.lock();
            try {
                while (queue.size() == capacity) {
                    notFull.await();  // 队列满,等待"不满"信号
                }
                queue.add(item);
                notEmpty.signal();      // 通知消费者"不空"了
            } finally {
                lock.unlock();
            }
        }
    
        public T take() throws InterruptedException {
            lock.lock();
            try {
                while (queue.isEmpty()) {
                    notEmpty.await();   // 队列空,等待"不空"信号
                }
                T item = queue.poll();
                notFull.signal();       // 通知生产者"不满"了
                return item;
            } finally {
                lock.unlock();
            }
        }
    }

    优势notFullnotEmpty 是两个独立的条件队列,生产者只唤醒消费者,消费者只唤醒生产者,避免了 notifyAll() 的"惊群效应" citation:1


5. ReentrantReadWriteLock------读写分离
  • 5.1 设计动机

    读操作不修改数据,多个读线程可以并行;写操作需要独占。ReentrantReadWriteLock 将锁拆分为读锁 (共享)和写锁(独占),提升读多写少场景的并发度 citation:4

    锁类型 获取条件 并发性
    读锁readLock() 无写锁或写锁由当前线程持有 多个读线程可同时持有
    写锁writeLock() 无读锁且无其他写锁 独占
  • 5.2 AQS 中的实现

    AQS 的 state 被拆分为高 16 位(读锁计数)和低 16 位(写锁重入次数):

    复制代码
    state (32 bit)
    ├─ 高 16 bit:读锁持有数(包括重入)
    └─ 低 16 bit:写锁重入次数(0 表示无写锁)

    读锁获取 :检查低 16 位是否为 0(无写锁),是则高 16 位 +1;

    写锁获取:检查高 16 位是否为 0(无读锁)且低 16 位为 0 或当前线程持有,是则低 16 位 +1 citation:4

  • 5.3 锁降级(Lock Downgrading)

    java 复制代码
    ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    ReentrantReadWriteLock.ReadLock rl = rwl.readLock();
    ReentrantReadWriteLock.WriteLock wl = rwl.writeLock();
    
    wl.lock();
    try {
        // 修改数据
        data = newValue;
        // 锁降级:在释放写锁前获取读锁
        rl.lock();  // ← 允许!当前线程持有写锁时可直接获取读锁
    } finally {
        wl.unlock(); // 释放写锁,此时持有读锁
    }
    
    try {
        // 使用数据(保证看到最新值,同时允许其他读线程并行)
        use(data);
    } finally {
        rl.unlock();
    }

    作用:保证数据修改后的可见性,同时降低锁粒度,允许其他读线程并行 citation:4

    ⚠️ 注意 :不支持锁升级(持有读锁时获取写锁会导致死锁)。


6. StampedLock------乐观读与性能优化

JDK 8 引入的 StampedLock,在读多写少场景下性能优于 ReentrantReadWriteLock citation:5

模式 方法 特点
写锁 writeLock() 独占,与读写锁类似
悲观读锁 readLock() 共享,与读写锁类似
乐观读 tryOptimisticRead() 无锁读取,返回 stamp,验证失败再转悲观读

乐观读示例 citation:5

java 复制代码
public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    // 乐观读
    public double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();  // ① 获取乐观读 stamp(无锁)
        double currentX = x, currentY = y;      // ② 拷贝变量
        if (!sl.validate(stamp)) {             // ③ 验证 stamp 是否被写操作修改
            stamp = sl.readLock();              // ④ 失败,转为悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

原理:乐观读不真正获取锁,只是记录一个版本戳(stamp)。读取完成后验证 stamp,如果期间没有写操作,直接返回结果;如果有写操作,升级为悲观读锁重试 citation:5

性能对比:在纯读场景下,StampedLock 的乐观读性能接近无锁,远超 ReentrantReadWriteLock。


7. Lock 与 synchronized 的选型决策
场景 推荐方案 理由
简单同步代码块 synchronized 语法简洁,JVM 自动优化(偏向锁、轻量级锁)
需要超时获取 ReentrantLock.tryLock(time, unit) 避免无限等待
需要响应中断 ReentrantLock.lockInterruptibly() 可中断阻塞
需要公平锁 ReentrantLock(true) 避免饥饿
需要多条件等待 ReentrantLock + Condition 精确唤醒,避免惊群
读多写少 ReentrantReadWriteLock 读锁共享,提升并发
读极多写极少 StampedLock(乐观读) 无锁读,性能最高
高并发计数 LongAdder 分段累加,无锁

8. 生产环境避坑指南
  • 8.1 必须在 finally 中 unlock

    java 复制代码
    // ❌ 错误:异常时锁不释放
    lock.lock();
    doSomething();  // 如果抛异常,锁永远不释放
    lock.unlock();
    
    // ✅ 正确
    lock.lock();
    try {
        doSomething();
    } finally {
        lock.unlock();  // 确保释放
    }
  • 8.2 避免锁顺序导致的死锁

    java 复制代码
    // ❌ 错误:不同顺序获取锁
    // 线程 A:lock1.lock(); lock2.lock();
    // 线程 B:lock2.lock(); lock1.lock();
    
    // ✅ 正确:全局统一顺序
    // 所有线程都按 lock1 → lock2 的顺序获取
  • 8.3 注意 tryLock 的返回值

    java 复制代码
    // ❌ 错误:忽略返回值
    lock.tryLock();
    doSomething();  // 如果没获取到锁,也在执行!
    
    // ✅ 正确
    if (lock.tryLock()) {
        try {
            doSomething();
        } finally {
            lock.unlock();
        }
    }
  • 8.4 StampedLock 不支持重入

    StampedLock 不是可重入锁,同一线程重复获取会导致死锁 citation:5

  • 8.5 读写锁的写锁饥饿

    ReentrantReadWriteLock 默认非公平模式下,读线程可能持续涌入,导致写线程长期等待(写饥饿)。可通过 ReentrantReadWriteLock(true) 使用公平模式,或限制读锁持有时间 citation:4


9. 面试官追问与高分回答模板
  • 追问 1:"说说 Lock 锁?"

    低分回答:"Lock 是显式锁,需要手动 lock/unlock,支持公平锁、可中断、超时。"(太浅)

    高分回答

    "Lock 是 JUC 包的显式锁接口,与 synchronized 相比提供了更精细的并发控制能力:

    1. 可中断lockInterruptibly() 允许在等待锁时响应中断;
    2. 超时获取tryLock(time, unit) 避免无限等待;
    3. 公平性:可选公平/非公平模式;
    4. 多条件队列newCondition() 创建多个条件队列,精确唤醒。
      底层实现上,ReentrantLock 依赖 AQS 框架,通过 CAS + 自旋 + 队列实现锁的获取与释放。AQS 的 state 字段表示同步状态,Node 双向链表维护等待队列。
      选型上,简单场景用 synchronized,需要超时/中断/多条件时用 ReentrantLock,读多写少用 ReentrantReadWriteLock,读极多用 StampedLock。" citation:1citation:2
  • 追问 2:"ReentrantLock 的公平锁和非公平锁底层怎么实现的?"

    高分回答

    "两者都基于 AQS 框架,差异在 tryAcquire 方法:

    • 非公平锁(默认) :新线程到达时先 CAS 尝试获取锁compareAndSetState(0, 1)),成功则直接获取,失败才进入 AQS 队列。这允许'插队',吞吐量高但可能饥饿。
    • 公平锁tryAcquire 中先调用 hasQueuedPredecessors() 检查队列中是否有等待线程。如果有,即使 state 为 0 也不允许获取 ,必须排队。
      非公平锁性能更高(减少线程切换),但公平锁避免饥饿。压测显示非公平锁吞吐量比公平锁高 10%~20%。" citation:2citation:3
  • 追问 3:"Condition 和 Object 的 wait/notify 有什么区别?"

    高分回答

    "核心区别有三点:

    1. 队列数量synchronized 每个对象只有一个隐式 waitSet;ReentrantLock 可以创建多个 Condition,每个有独立的条件队列。
    2. 唤醒精度notify() 随机唤醒一个,notifyAll() 全部唤醒;signal() 唤醒一个,signalAll() 全部唤醒,且可以精确控制唤醒哪个条件的线程。
    3. 底层实现wait() 将线程加入对象头的 Monitor 的 _WaitSetawait() 将线程加入 Condition条件队列 (与 AQS 主队列分离),被 signal() 后转移到 AQS 主队列重新竞争锁。
      经典应用是生产者消费者模型:用 notFullnotEmpty 两个 Condition,生产者只唤醒消费者,消费者只唤醒生产者,避免 notifyAll() 的惊群效应。" citation:1citation:2
  • 追问 4:"AQS 是什么?它的核心设计是什么?"

    高分回答

    "AQS(AbstractQueuedSynchronizer)是 JUC 包的并发框架基石,ReentrantLockCountDownLatchSemaphoreReentrantReadWriteLock 都基于它实现。

    核心设计是 '状态 + 队列' 模式:

    1. state 字段volatile int 表示同步状态,具体语义由子类定义(ReentrantLock 的重入次数、CountDownLatch 的剩余计数等);
    2. FIFO 队列Node 双向链表维护等待线程,头节点是持有锁的线程(或虚拟节点),后续节点自旋/CAS 检查前驱;
    3. 模板方法模式 :AQS 定义了 acquire/release 框架,子类只需实现 tryAcquire/tryRelease 等钩子方法。
      获取锁时:CAS state → 失败则加入队列尾部 → 自旋检查前驱 → 失败则 LockSupport.park() 挂起。释放锁时:修改 state → 唤醒后继节点。" citation:2citation:3
  • 追问 5:"StampedLock 的乐观读是什么?有什么使用限制?"

    高分回答

    "StampedLock 的乐观读是一种无锁读取机制:

    1. 调用 tryOptimisticRead() 获取一个版本戳(stamp),不真正加锁;
    2. 拷贝需要读取的变量;
    3. 调用 validate(stamp) 检查 stamp 是否被写操作修改;
    4. 如果未修改,直接返回结果;如果修改了,升级为悲观读锁(readLock())重试。
      优势 :纯读场景下性能接近无锁,远超 ReentrantReadWriteLock
      限制
    5. 不可重入:同一线程重复获取会导致死锁;
    6. 不支持条件队列 :没有 newCondition() 方法;
    7. stamp 必须验证 :忘记 validate() 会导致读到脏数据;
    8. 写锁饥饿:乐观读不阻塞写线程,但大量乐观读可能导致写线程长期等待。" citation:5
  • 追问 6:"ReentrantReadWriteLock 的锁降级是什么?为什么需要?"

    高分回答

    "锁降级是指持有写锁的线程,在释放写锁前获取读锁,然后释放写锁,继续持有读锁。

    java 复制代码
    wl.lock();
    try {
        data = newValue;      // 修改数据
        rl.lock();            // 锁降级:写锁内获取读锁
    } finally {
        wl.unlock();          // 释放写锁,此时仍持有读锁
    }
    try {
        use(data);            // 使用数据,允许其他读线程并行
    } finally {
        rl.unlock();
    }

    为什么需要?

    1. 保证可见性:写锁释放后立即可见最新数据,但如果不持有读锁,其他写线程可能立即获取写锁修改数据;
    2. 降低锁粒度:持有读锁时允许其他读线程并行,提升并发度;
    3. 替代 volatile :某些场景下锁降级比 volatile + 写锁更安全。
      注意ReentrantReadWriteLock 不支持锁升级(读锁内获取写锁会导致死锁)。" citation:4

10. 方案选型速查表
业务场景 推荐方案 核心理由
简单同步 synchronized 语法简洁,JVM 自动优化
需要超时/中断 ReentrantLock tryLock/lockInterruptibly
需要公平锁 ReentrantLock(true) 避免饥饿
生产者消费者(多条件) ReentrantLock + Condition 精确唤醒,避免惊群
读多写少 ReentrantReadWriteLock 读锁共享
读极多写极少 StampedLock(乐观读) 无锁读,性能最高
缓存(读多写少) ReentrantReadWriteLock 锁降级保证可见性
高并发计数 LongAdder 分段累加,无锁

💡 面试官想要的满分总结

Lock 体系是 Java 并发编程从"语法糖"走向"精细化控制"的标志。理解 Lock 必须抓住三条主线:

第一条:AQS 框架ReentrantLockCountDownLatchSemaphore 等几乎所有 JUC 同步组件都基于 AQS 实现。核心设计是 state(同步状态)+ Node 队列(FIFO 等待队列)+ CAS + 自旋 + park/unpark。掌握 AQS 就掌握了 JUC 的半壁江山。

第二条:公平与非公平的权衡。非公平锁允许"插队"(先 CAS 尝试),吞吐量高但可能饥饿;公平锁严格 FIFO,避免饥饿但切换开销大。默认非公平是工程实践的最优解。

第三条:Condition 的多条件队列 。与 synchronized 的单 waitSet 相比,Condition 允许创建多个独立条件队列,实现精确唤醒(生产者只唤醒消费者),避免 notifyAll() 的惊群效应。

选型上,简单场景用 synchronized,需要高级功能用 ReentrantLock,读多写少用 ReentrantReadWriteLock,读极多用 StampedLock。永远记住:先保证正确性(finally 中 unlock),再追求性能(乐观读、锁降级)


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯

相关推荐
星栈独行1 小时前
用 Rust + Makepad 做一个 JSON 查看器:从零到能用的全过程
开发语言·程序人生·ui·rust·json
咸鱼翻身小阿橙1 小时前
VS2008 C# WinForm 简易计算器
开发语言·c#
189228048611 小时前
NV091固态MT29F16T08EWLCHD8-QJES:C
c语言·开发语言
杨了个杨89821 小时前
Dockerfile介绍及镜像制作
java·开发语言
c++之路1 小时前
CMake 系列教程(三):变量、条件与控制流
java·windows·spring
AI科技星1 小时前
《数术工坊:无穷套娃录》 一部用数学套娃写成的“天书小说”
c语言·开发语言·网络·量子计算·agi
阿正的梦工坊1 小时前
【Rust】01-认识 Rust:语言定位、工具链与第一个程序
开发语言·后端·rust
焦虑的说说2 小时前
mysql为什么回表会慢
mysql·面试
一条泥憨鱼2 小时前
苍穹外卖【day5|Redis与店铺营业状态设置】
java·后端·mybatis·苍穹外卖