ReentrantLock 最佳实践

0 阅读5分钟

一、锁的获取与释放(强制规范)

✅ 规则 1:必须使用 try-finally 释放锁

错误示范

lock.lock();
// 如果这里抛出异常,unlock() 永远不会执行
doSomething();
lock.unlock();

正确示范

lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();  // 无论是否异常,都会释放
}

原因ReentrantLock显式锁,不会自动释放。一旦业务代码抛出异常导致 unlock() 被跳过,锁将永久持有,其他线程永远阻塞(死锁)。


✅ 规则 2:lock() 必须写在 try 块之前

错误示范

try {
    lock.lock();  // 如果 lock() 抛出异常(极少见),finally 中的 unlock() 会出问题
    doSomething();
} finally {
    lock.unlock();
}

正确示范

lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();
}

原因:虽然 lock() 几乎不抛异常,但万一发生 Error(如内存溢出),unlock() 可能操作一个未成功持有的锁状态,引发 IllegalMonitorStateException。将 lock() 放在 try 外更安全。


✅ 规则 3:超时获取必须检查返回值
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try {
        doSomething();
    } finally {
        lock.unlock();
    }
} else {
    // 必须处理获取失败的情况:降级、重试或抛出异常
    handleTimeout();
}

原因tryLock 失败时没有持有锁,若仍然调用 unlock() 会抛出 IllegalMonitorStateException


二、公平性选择原则

场景类型推荐策略理由
高并发、追求吞吐量非公平锁(默认)减少上下文切换,吞吐量高 5~10 倍
任务必须按提交顺序执行公平锁防止线程饥饿,保证 FIFO
混合型(偶尔需要顺序)非公平锁 + 额外队列用业务层队列保证顺序,锁仅做互斥

示例:保证顺序但不使用公平锁

// 使用 ConcurrentLinkedQueue 维护提交顺序,锁只保护临界区
private final Queue<Runnable> taskQueue = new ConcurrentLinkedQueue<>();
private final ReentrantLock lock = new ReentrantLock();

public void submit(Runnable task) {
    taskQueue.offer(task);
    lock.lock();
    try {
        Runnable next = taskQueue.poll();
        if (next != null) next.run();
    } finally {
        lock.unlock();
    }
}

三、临界区设计原则

✅ 规则 1:临界区越小越好
// 不好:锁住了耗时 I/O
lock.lock();
try {
    String data = readFromFile();   // I/O 操作
    processData(data);
} finally {
    lock.unlock();
}

// 更好:只锁必要的数据结构操作
String data = readFromFile();        // 无锁读取
lock.lock();
try {
    sharedCache.put(key, data);      // 仅锁共享数据修改
} finally {
    lock.unlock();
}
✅ 规则 2:禁止在临界区内调用未知的外部代码
lock.lock();
try {
    // 危险:回调函数可能再次获取同一把锁,导致死锁
    listener.onDataChanged(data);    
} finally {
    lock.unlock();
}

解决:使用锁降级异步通知,将回调移到锁外执行。

✅ 规则 3:避免嵌套锁,保持锁顺序一致
// 危险:锁顺序不一致可能导致死锁
// 线程1:lockA -> lockB
// 线程2:lockB -> lockA

// 解决:统一锁获取顺序
lockA.lock();
try {
    lockB.lock();
    try {
        // ...
    } finally { lockB.unlock(); }
} finally { lockA.unlock(); }

四、Condition 使用规范

✅ 规则 1:必须在 lock 保护区域内使用
lock.lock();
try {
    while (conditionNotMet) {
        condition.await();   // 正确
    }
} finally {
    lock.unlock();
}
✅ 规则 2:使用 while 而非 if 检查条件
// 错误:if 无法处理虚假唤醒
if (queue.isEmpty()) {
    notEmpty.await();
}

// 正确:while 循环保证条件真正满足
while (queue.isEmpty()) {
    notEmpty.await();
}

原因:线程可能被虚假唤醒(spurious wakeup),或被其他线程意外唤醒,必须重新检查条件。

✅ 规则 3:优先使用 signal() 而非 signalAll()
  • 只有一个线程在等待某个条件时,用 signal() 精准唤醒,减少惊群效应。
  • 多个线程可能等待同一条件且无法区分时,用 signalAll() 确保不丢失唤醒。
✅ 规则 4:await() 后重新获取锁,需注意状态变更
lock.lock();
try {
    while (condition) {
        condition.await();  // 释放锁并等待
    }
    // 被唤醒后,锁已重新获取,但共享状态可能已被其他线程修改
    // 必须重新评估或读取最新数据
} finally {
    lock.unlock();
}

五、避免死锁与饥饿

问题解决方案
死锁(互相等待)1. 固定锁获取顺序
2. 使用 tryLock(timeout) 超时放弃
3. 使用 lockInterruptibly() 允许外部中断
饥饿(某些线程永远得不到锁)1. 使用公平锁
2. 避免临界区过长导致持锁时间过久
活锁(线程不断重试却无法进展)引入随机退避(如指数退避重试)

超时避免死锁示例

while (true) {
    if (lock1.tryLock(50, TimeUnit.MILLISECONDS)) {
        try {
            if (lock2.tryLock(50, TimeUnit.MILLISECONDS)) {
                try {
                    // 执行业务
                    return;
                } finally { lock2.unlock(); }
            }
        } finally { lock1.unlock(); }
    }
    // 随机休眠后重试
    Thread.sleep(ThreadLocalRandom.current().nextInt(10, 100));
}

六、性能监控与调优

监控方法列表
方法用途
getQueueLength()查看等待线程数,评估锁竞争激烈程度
hasQueuedThreads()判断是否有线程在排队
getHoldCount()调试时检查当前线程重入次数
isLocked()判断锁是否被任意线程持有

示例:记录锁竞争情况

if (lock.getQueueLength() > 10) {
    logger.warn("锁竞争激烈,等待线程数: {}", lock.getQueueLength());
}
调优建议
  • getQueueLength() 持续较高,考虑缩小临界区分段锁(如 ConcurrentHashMap 思想)。
  • 若公平锁吞吐量过低,评估是否真的需要严格顺序,改为非公平锁。

七、与 synchronized 的选择指南

需要以下任一特性时,使用 ReentrantLock:
├── 需要超时获取(tryLock timeout)
├── 需要可中断的锁获取(lockInterruptibly)
├── 需要多个 Condition 精准唤醒
├── 需要公平锁保证顺序
└── 需要监控锁状态(getQueueLength 等)

否则,优先使用 synchronized:
├── 代码简洁,自动释放,不易出错
├── JVM 持续优化(偏向锁、轻量级锁),性能足够
└── 大多数普通同步场景已满足需求

八、常见陷阱速查

陷阱后果规避方法
忘记 unlock()死锁try-finally 强制释放
Condition 外调用 await/signalIllegalMonitorStateException确保在 lock/unlock 之间调用
使用 if 而非 while 判断条件虚假唤醒导致逻辑错误永远用 while 循环
公平锁 + 长临界区吞吐量急剧下降缩小临界区或改用非公平锁
临界区内调用 Thread.sleep()持锁休眠,其他线程全部阻塞sleep 移到锁外,或用 Condition.awaitNanos
重入次数溢出Error 抛出,程序崩溃避免无限递归加锁

总结

ReentrantLock 是强大的同步工具,但“权力越大,责任越大”。遵循上述最佳实践,可以在享受其灵活性的同时,避免死锁、饥饿和性能陷阱。核心原则可概括为:

  1. 显式锁,显式放 —— try-finally 是铁律。
  2. 临界区,短平快 —— 只锁必须锁的代码。
  3. 选公平,需谨慎 —— 吞吐与顺序不可兼得。
  4. 用条件,要规范 —— while 循环,lock 之内。

当不确定是否需要 ReentrantLock 的高级特性时,synchronized 往往是更安全、更简洁的选择。