【大白话说Java面试题 第127题】【并发篇】第27题:实现一把锁的思路

第27题:实现一把锁的思路

📚 回答:

  • 核心考点: 实现一把锁是 Java 并发编程的终极试金石,大厂面试不会只问"用 CAS 设置状态位",而是深入考察 锁的完整语义(互斥、可重入、公平性、可中断)、AQS 的 CLH 变体队列设计自旋与阻塞的权衡内存屏障的插入位置,以及 从简单自旋锁到工业级 AQS 锁的演进路径。面试官真正想判断的是:你是否能从"能跑"进化到"跑得好",理解锁设计的每一个工程决策背后的原因。

1. 锁的核心语义与设计要素

实现一把锁需要满足以下核心语义:

语义说明实现复杂度
互斥性同一时刻只有一个线程持有锁
可重入性同一线程可多次获取锁⭐⭐
公平性按请求顺序获取锁(FIFO)⭐⭐⭐
可中断性等待锁时可被中断⭐⭐⭐
超时获取等待指定时间后放弃⭐⭐⭐
条件变量支持 await/signal 协作⭐⭐⭐⭐

2. 方案一:简单自旋锁(Spin Lock)
  • 2.1 原理

使用 AtomicBoolean + CAS 实现最基础的互斥锁。线程获取锁失败时自旋等待(忙等)。

public class SpinLock {
    private final AtomicBoolean locked = new AtomicBoolean(false);

    public void lock() {
        // ★ CAS 自旋:预期 false,更新为 true
        while (!locked.compareAndSet(false, true)) {
            // 自旋等待,CPU 空转
        }
    }

    public void unlock() {
        locked.set(false); // 释放锁
    }
}
  • 2.2 优缺点分析
特性说明
优点实现简单,无线程切换开销,低竞争下性能极高
缺点高竞争下 CPU 空转严重;无公平性保证;不可重入
适用场景临界区极短(如计数器自增)、低竞争环境
  • 2.3 优化:Ticket Lock(公平自旋锁)
public class TicketLock {
    private final AtomicInteger ticket = new AtomicInteger(0);  // 发号器
    private final AtomicInteger owner = new AtomicInteger(0);   // 当前服务号

    public void lock() {
        int myTicket = ticket.getAndIncrement(); // 取号
        while (myTicket != owner.get()) {
            // 自旋等待叫号
        }
    }

    public void unlock() {
        owner.incrementAndGet(); // 叫下一个号
    }
}

优势:保证了 FIFO 公平性,避免了"后来者先服务"的饥饿问题。[citation:0]


3. 方案二:CLH 锁(队列自旋锁)
  • 3.1 原理

CLH 锁(Craig, Landin, Hagersten)是一种 基于隐式链表 的公平自旋锁。每个线程有一个自己的节点,通过前驱节点的状态来判断是否获取锁。

public class CLHLock {
    private final ThreadLocal<Node> myNode = ThreadLocal.withInitial(Node::new);
    private final AtomicReference<Node> tail = new AtomicReference<>(new Node());

    private static class Node {
        volatile boolean locked = false; // true: 需要等待;false: 锁已释放
    }

    public void lock() {
        Node node = myNode.get();
        node.locked = true; // 标记自己需要等待

        Node pred = tail.getAndSet(node); // 加入队列尾部,获取前驱
        while (pred.locked) {
            // ★ 自旋等待前驱节点释放锁(监听前驱状态,非全局状态)
        }
    }

    public void unlock() {
        Node node = myNode.get();
        node.locked = false; // 释放锁
        myNode.set(new Node()); // 为下次加锁准备新节点
    }
}
  • 3.2 CLH 锁的核心优势
优势说明
缓存友好每个线程只监听自己的前驱节点状态,不访问全局变量,减少缓存一致性流量
公平性隐式队列保证 FIFO,无饥饿
可扩展性适用于高并发多核系统,不会因为锁竞争而出现严重抖动
  • 3.3 CLH vs MCS 锁
特性CLH 锁MCS 锁
自旋对象前驱节点的状态自己的节点状态
释放操作修改自己的节点(前驱已离开)修改后继节点的状态
内存开销较低(ThreadLocal 复用)较高(每个节点独立)
NUMA 友好一般更好(自旋本地内存)

[citation:1]


4. 方案三:AQS 框架——工业级锁的实现(核心)

AQS(AbstractQueuedSynchronizer)是 Java 并发包的基石,ReentrantLockSemaphoreCountDownLatch 全部基于它实现。理解 AQS 就等于掌握了 Java 并发框架的心脏。

  • 4.1 AQS 核心设计
┌─────────────────────────────────────────┐
│  AQS 核心组件                            │
├─────────────────────────────────────────┤
│  state (volatile int)                   │
│  ├── 独占模式:0=未锁定,1=锁定,>1=重入   │
│  └── 共享模式:剩余许可数                  │
├─────────────────────────────────────────┤
│  CLH 变体队列(双向链表)                 │
│  ├── head:虚拟头节点(不存储线程)        │
│  ├── tail:尾节点                        │
│  └── Node:{waitStatus, prev, next, thread}│
├─────────────────────────────────────────┤
│  模板方法(子类实现)                      │
│  ├── tryAcquire / tryRelease            │
│  └── tryAcquireShared / tryReleaseShared  │
└─────────────────────────────────────────┘
  • 4.2 Node 节点的 waitStatus 状态
状态值名称含义
0初始状态节点刚创建时的状态
-1SIGNAL后继节点需要被唤醒
-2CONDITION节点在条件队列中等待
-3PROPAGATE共享模式下无条件传播
1CANCELLED节点被取消(超时或中断)
  • 4.3 独占锁获取流程(acquire)
// AQS.acquire() 核心逻辑
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&                          // 1. 尝试获取锁(子类实现)
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 2. 加入队列 + 自旋/阻塞
        selfInterrupt();                              // 3. 恢复中断标志
}

详细流程

线程调用 acquire()
    │
    ▼
tryAcquire(arg) ──成功?──→ 获取锁,返回
    │ 失败
    ▼
addWaiter(Node.EXCLUSIVE) ──→ 创建节点,CAS 加入队列尾部
    │
    ▼
acquireQueued(node, arg)
    │
    ├── 前驱是 head?──→ tryAcquire() 再试一次
    │       │ 成功 → 设置自己为 head,返回
    │       │ 失败 → 继续
    │
    ├── shouldParkAfterFailedAcquire() ──→ 检查前驱状态
    │       │ 前驱 SIGNAL → 可以 park
    │       │ 前驱 CANCELLED → 跳过,找有效前驱
    │       │ 其他 → CAS 设置前驱为 SIGNAL
    │
    └── parkAndCheckInterrupt() ──→ LockSupport.park() 阻塞
            │ 被 unpark/中断唤醒
            └── 循环回到顶部,再次尝试获取锁

[citation:2]

  • 4.4 独占锁释放流程(release)
// AQS.release() 核心逻辑
public final boolean release(int arg) {
    if (tryRelease(arg)) {          // 1. 尝试释放锁(子类实现)
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);     // 2. 唤醒后继节点
        return true;
    }
    return false;
}

unparkSuccessor 逻辑

  1. 将 head 的 waitStatus 从 SIGNAL 改为 0。
  2. 从 tail 向前遍历,找到离 head 最近的有效后继节点。
  3. 调用 LockSupport.unpark() 唤醒该节点。
  • 4.5 自定义独占锁(基于 AQS)
public class CustomLock implements Lock {
    private final Sync sync = new Sync();

    // AQS 子类实现
    private static class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int acquires) {
            // 非公平锁:直接 CAS 抢锁
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (current == getExclusiveOwnerThread()) {
                // ★ 可重入:state + 1
                int nextc = c + acquires;
                if (nextc < 0) throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) { // ★ 重入次数归零才真正释放
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState() != 0;
        }

        Condition newCondition() {
            return new ConditionObject();
        }
    }

    @Override public void lock() { sync.acquire(1); }
    @Override public void unlock() { sync.release(1); }
    @Override public Condition newCondition() { return sync.newCondition(); }
    @Override public boolean tryLock() { return sync.tryAcquire(1); }
    @Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }
    @Override public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }
}

[citation:3]


5. 方案四:ReentrantLock 的公平与非公平实现
  • 5.1 非公平锁(NonfairSync)
// ReentrantLock.NonfairSync.tryAcquire()
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // ★ 非公平:直接 CAS 抢锁,不管队列中是否有等待线程
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // ... 重入逻辑
    return false;
}

特点:新来的线程可以和队列中的线程竞争锁,吞吐量更高,但可能导致饥饿。

  • 5.2 公平锁(FairSync)
// ReentrantLock.FairSync.tryAcquire()
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;
        }
    }
    // ... 重入逻辑
    return false;
}

特点:按 FIFO 顺序获取锁,无饥饿,但吞吐量略低。

  • 5.3 性能对比
模式吞吐量公平性适用场景
非公平锁高(约 2~5 倍)不保证通用场景,追求性能
公平锁较低严格 FIFO避免饥饿,如定时任务调度

[citation:4]


6. 锁的内存语义与 happens-before
  • 6.1 锁获取的内存屏障
// ReentrantLock.lock() 的内存语义等价于:
// 1. 插入 LoadLoad + LoadStore 屏障(确保后续读写不会重排序到锁获取之前)
// 2. 从主内存加载共享变量的最新值
  • 6.2 锁释放的内存屏障
// ReentrantLock.unlock() 的内存语义等价于:
// 1. 插入 StoreStore + StoreLoad 屏障(确保锁释放前的写操作对其他线程可见)
// 2. 将修改刷新到主内存

happens-before 规则:解锁操作 happens-before 后面对同一个锁的加锁操作。这意味着线程 A 解锁前对共享变量的修改,对线程 B 加锁后可见。


7. 生产环境避坑指南
  • 7.1 不要自己实现锁,优先使用 JUC 工具
// ❌ 错误:自己实现锁,容易出 Bug
public class MyLock { ... }

// ✅ 正确:使用 JUC 提供的成熟工具
ReentrantLock lock = new ReentrantLock();
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
StampedLock stampedLock = new StampedLock();
  • 7.2 锁的粒度要细
// ❌ 错误:大锁,串行化严重
public synchronized void process() {
    // 读配置(只读,不需要锁)
    Config config = loadConfig();
    // 写数据库(需要锁)
    writeDB(config);
    // 发送通知(不需要锁)
    sendNotify();
}

// ✅ 正确:细粒度锁
public void process() {
    Config config = loadConfig(); // 无锁
    synchronized (dbLock) {
        writeDB(config);
    }
    sendNotify(); // 无锁
}
  • 7.3 避免锁嵌套导致死锁
// ❌ 错误:嵌套锁,顺序不一致导致死锁
void methodA() {
    lockA.lock();
    lockB.lock(); // 可能死锁!
    // ...
}

void methodB() {
    lockB.lock();
    lockA.lock(); // 可能死锁!
    // ...
}

// ✅ 正确:全局统一的加锁顺序
void methodA() {
    Lock first = lockA.hashCode() < lockB.hashCode() ? lockA : lockB;
    Lock second = lockA.hashCode() < lockB.hashCode() ? lockB : lockA;
    first.lock();
    second.lock();
    // ...
}
  • 7.4 使用 try-finally 确保释放
// ❌ 错误:异常时锁不释放
lock.lock();
doWork(); // 抛异常 → 锁永远不释放!
lock.unlock();

// ✅ 正确:try-finally
lock.lock();
try {
    doWork();
} finally {
    lock.unlock(); // 确保释放
}
  • 7.5 优先使用读写锁
// 读多写少场景
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

// 读操作(并发)
rwLock.readLock().lock();
try {
    return cache.get(key);
} finally {
    rwLock.readLock().unlock();
}

// 写操作(互斥)
rwLock.writeLock().lock();
try {
    cache.put(key, value);
} finally {
    rwLock.writeLock().unlock();
}

8. 面试官追问与高分回答模板
  • 追问 1:“实现一把锁的思路是什么?”

低分回答:“用 CAS 设置一个状态位,成功就获取锁,失败就自旋等待。”(太浅,没有触及队列管理和阻塞)

高分回答

"实现一把锁需要分层次考虑:

第一层:互斥性
使用 CAS 操作竞争一个共享状态(如 AtomicBooleanvolatile int)。成功则获取锁,失败则进入等待逻辑。

第二层:等待管理
竞争失败的线程不能一直自旋(CPU 空转),需要进入等待队列。有两种选择:

  • 自旋锁:适用于临界区极短、低竞争场景,如 CLH 锁通过监听前驱节点状态减少缓存一致性流量。
  • 阻塞锁:使用 LockSupport.park() 挂起线程,由 unpark() 唤醒。这是 AQS 的选择。

第三层:队列管理
使用 CLH 变体队列(双向链表)管理等待线程。每个线程封装为 Node 节点,维护 waitStatus(SIGNAL/CANCELLED 等)、前驱/后继指针。AQS 通过 shouldParkAfterFailedAcquire() 清理 CANCELLED 节点,确保队列健康。

第四层:高级语义

  • 可重入性:通过 state 计数 + exclusiveOwnerThread 记录持有线程。
  • 公平性:公平锁在 tryAcquire() 中先检查 hasQueuedPredecessors()
  • 可中断/超时acquireInterruptibly()tryAcquireNanos() 支持响应中断和超时。
  • 条件变量ConditionObject 维护独立的条件队列,实现 await/signal

工业级实现直接继承 AQS,重写 tryAcquire/tryRelease 即可,如 ReentrantLock。"

  • 追问 2:“AQS 的 CLH 队列和原始 CLH 锁有什么区别?”

高分回答

"AQS 的队列是 CLH 的变体,有三点核心差异:

  1. 双向链表 vs 单向链表:原始 CLH 是隐式单向链表(通过 ThreadLocal 的前驱引用),AQS 是显式双向链表(prev + next),支持从尾部向前遍历清理 CANCELLED 节点。
  2. 阻塞 vs 自旋:原始 CLH 是自旋锁(监听前驱状态),AQS 是自旋 + 阻塞混合(先自旋几次,失败则 park())。
  3. 虚拟头节点:AQS 使用虚拟头节点(不存储线程),原始 CLH 没有。虚拟头节点简化了边界条件处理。

AQS 的设计权衡:自旋几次再 park,既避免了短临界区的线程切换开销,又避免了长临界区的 CPU 空转。"

  • 追问 3:“为什么 AQS 用 LockSupport.park() 而不是 Object.wait()?”

高分回答

"AQS 选择 LockSupport.park() 而非 Object.wait() 有四个核心原因:

  1. 无需持有锁wait() 必须在同步块内调用,AQS 的队列管理不需要与某个对象的 Monitor 绑定。
  2. 精确唤醒unpark(thread) 可以唤醒指定线程;notify() 随机唤醒,notifyAll() 产生惊群效应。AQS 需要精确唤醒队列中的后继节点。
  3. permit 防信号丢失unpark() 可以先于 park() 调用(permit 缓存),而 notify() 发送时无人等待则信号丢失。
  4. 功能更丰富:支持超时 parkNanos()、不响应中断等,满足复杂锁的需求。

底层实现上,park() 调用 Unsafe.park(),在 Linux 下是 pthread_cond_wait,Windows 下是 WaitForSingleObject,直接操作线程调度器。"

  • 追问 4:“ReentrantLock 的公平锁和非公平锁有什么区别?性能差距有多大?”

高分回答

"核心区别在于 tryAcquire() 的实现:

  • 非公平锁:线程直接 CAS 抢锁,不管队列中是否有等待线程。新来的线程可以和队列中的线程竞争,吞吐量更高(约 2~5 倍),但可能导致队列中的线程长期饥饿。
  • 公平锁:线程在 tryAcquire() 前先调用 hasQueuedPredecessors() 检查队列中是否有前驱节点。如果有,即使锁空闲也不抢,按 FIFO 顺序获取。无饥饿,但吞吐量较低。

性能差距:在 JDK 的基准测试中,非公平锁的吞吐量通常是公平锁的 2~5 倍。原因是公平锁的线程切换更频繁(每个线程获取锁后很快释放,下一个线程立即被唤醒),而非公平锁允许线程"连续获取",减少了切换。

适用场景

  • 非公平锁:通用场景,追求吞吐量(默认)。
  • 公平锁:避免饥饿的场景,如定时任务调度、资源分配器。"
  • 追问 5:“如何实现锁的可重入性?”

高分回答

"可重入性通过两个字段实现:

  1. state(int):记录锁的重入次数。state = 0 表示未锁定,state > 0 表示被锁定且值为重入次数。
  2. exclusiveOwnerThread(Thread):记录当前持有锁的线程。

获取逻辑

if (state == 0) {
    // 尝试 CAS 获取锁
} else if (current == exclusiveOwnerThread) {
    state++; // 同一线程重入
    return true;
}

释放逻辑

if (current != exclusiveOwnerThread) throw new IllegalMonitorStateException();
int c = state - 1;
if (c == 0) {
    exclusiveOwnerThread = null;
    state = 0; // 真正释放
    return true; // 需要唤醒后继
}
state = c; // 只是减少重入次数
return false; // 不需要唤醒

关键:state 归零时才真正释放锁,唤醒后继节点。"

  • 追问 6:“如果让你设计一个支持 1000 并发的高性能锁,你会怎么设计?”

高分回答

"1000 并发的高性能锁设计需要分层优化:

1. 锁类型选择

  • 读多写少 → ReentrantReadWriteLockStampedLock(乐观读)。
  • 写多读少 → ReentrantLock(非公平)。
  • 纯计数 → LongAdder(分段 CAS,无锁)。

2. 锁粒度优化

  • 全局锁 → 分段锁(如 ConcurrentHashMap 的 16 段)。
  • 按哈希值分锁:locks[hash & (N-1)]

3. 自旋优化

  • 短临界区:先自旋几次(如 AQS 的 spinForTimeoutThreshold),再 park。
  • 自适应自旋:根据历史成功率动态调整自旋次数。

4. 队列优化

  • 使用 CLH 变体队列,减少缓存一致性流量。
  • 批量唤醒:共享模式下传播唤醒信号,减少 unpark() 调用。

5. 无锁化

  • 如果业务允许,使用 CAS 无锁算法(如 AtomicReference + 版本号)替代锁。
  • 使用 VarHandle(JDK 9+)实现更底层的原子操作。

6. 监控与调优

  • 暴露锁竞争指标(等待时间、队列长度)。
  • 动态调整公平性(根据负载切换公平/非公平模式)。"

9. 方案选型速查表
场景推荐方案核心理由
学习/面试手写 CAS 自旋锁 → CLH 锁 → AQS 锁理解锁的演进路径
生产环境通用ReentrantLock(非公平)成熟、高性能、功能丰富
避免饥饿ReentrantLock(公平)严格 FIFO
读多写少ReentrantReadWriteLock读锁共享,写锁互斥
读极多写极少StampedLock(乐观读)乐观读无锁,性能更高
纯计数/累加LongAdder分段 CAS,无锁
高并发缓存分段锁(如 CHM)16 段分散竞争
自定义同步器继承 AQS只需实现 tryAcquire/tryRelease

💡 面试官想要的满分总结

实现一把锁不是"用 CAS 设置一个标志位"这么简单,而是需要 状态管理 + 队列管理 + 阻塞唤醒 + 高级语义 的完整系统。

演进路径:简单自旋锁(CAS)→ Ticket Lock(公平自旋)→ CLH 锁(队列自旋,缓存友好)→ AQS(自旋+阻塞混合,工业级)。

AQS 的核心设计

  1. state:同步状态,独占模式表示锁的重入次数,共享模式表示剩余资源。
  2. CLH 变体队列:双向链表管理等待线程,waitStatus 控制唤醒/取消逻辑。
  3. 模板方法:子类只需实现 tryAcquire/tryRelease,AQS 处理队列、阻塞、中断、超时。
  4. LockSupport.park/unpark:精确唤醒指定线程,无需锁,permit 防丢失。

工程实践

  • 不要自己实现锁,优先使用 JUC 工具。
  • 锁粒度要细,避免大锁。
  • try-finally 确保释放。
  • 读多写少用读写锁或 StampedLock

面试中能讲清楚 CLH 队列的缓存优化AQS 的 acquire/release 完整流程公平/非公平的性能差异,就已经超越了 95% 的候选人。


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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AI人工智能+电脑小能手

若对您有所帮助,请点点关注哟~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值