捣蛋汪星人免安装绿色版
350M · 2025-11-01
平时写代码的时候,碰到多线程问题,你是不是第一反应就是加个synchronized了事?说实话,这招在大多数情况下确实够用。但当你真正遇到复杂的并发场景时,就会发现synchronized有点力不从心了。
今天咱们就来深入聊聊Java并发包里的那把更强大的锁——ReentrantLock。
简单来说,ReentrantLock是Java5之后提供的一种可重入的互斥锁。什么叫可重入?就是说同一个线程可以多次获取同一把锁,不会自己把自己给锁死了。
想象一下这个场景:你在一个加锁的方法里调用了另一个也需要同样锁的方法。要是不可重入,那就完蛋了,线程直接卡死在自己手里。但ReentrantLock很聪明,它记得这个锁是谁拿的,如果是自己人,就放心让你进去。
说到原理,就不得不提AQS(AbstractQueuedSynchronizer),这可以说是Java并发包的灵魂所在。ReentrantLock的所有能力,都建立在AQS的基础之上。
AQS内部维护了一个状态变量state和一个等待队列。state为0表示锁没人占用,大于0表示被占用,而且数值正好记录着重入的次数。等待队列则是用来管理那些没抢到锁的线程,让它们乖乖排队。
// 简化的加锁流程
public void lock() {
if (CAS操作成功) {
// 拿到锁了,美滋滋
setExclusiveOwnerThread(当前线程);
} else {
// 没拿到,进队列等着吧
acquire(1);
}
}
这里用到了CAS(Compare And Swap),这是个原子操作,保证同时只有一个线程能修改成功。没抢到锁的线程不会傻傻地空转,而是会被挂起,等锁释放时再被唤醒。
ReentrantLock有个很有意思的特性:它支持公平和非公平两种模式。
非公平锁就像挤公交车,谁力气大谁先上,不管你先来后到。线程来了直接尝试抢锁,抢不到再排队。这种方式吞吐量高,但可能造成后来者先得,也就是线程饥饿。
公平锁就像排队买票,讲究先来后到。新来的线程会先看看队列里有没有人在等,有的话就乖乖去队尾排队。
// 创建锁时的选择
ReentrantLock unfairLock = new ReentrantLock(); // 默认非公平
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
那么在实际开发中该怎么选呢?如果你的场景中线程持有锁的时间很短,或者对吞吐量要求很高,用非公平锁通常效果更好。但如果担心某些线程长期抢不到锁,那就得用公平锁来保证公平性了。
做金融系统最怕的就是死锁。想象两个账户互相转账,如果处理不好,线程A锁了账户1等账户2,线程B锁了账户2等账户1,直接就死锁了。
用ReentrantLock可以这样解决:
public boolean transfer(Account from, Account to, BigDecimal amount) {
// 按账户ID排序,总是先锁ID小的那个
Account firstLock = from.id < to.id ? from : to;
Account secondLock = from.id < to.id ? to : from;
if (!firstLock.lock.tryLock()) {
return false;
}
try {
if (!secondLock.lock.tryLock(1, TimeUnit.SECONDS)) {
return false;
}
try {
// 执行转账逻辑
return doTransfer(from, to, amount);
} finally {
secondLock.lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
firstLock.lock.unlock();
}
}
这里用了tryLock而不是直接lock,还设置了超时时间,这样即使真的发生死锁,也不会无休止地等下去。
做缓存的时候,我们经常需要控制缓存的大小。当缓存满了,写入线程需要等待;当缓存空了,读取线程需要等待。这种场景用synchronized配合wait/notify也能做,但代码写起来很别扭。
用ReentrantLock的条件变量就优雅多了:
public class BoundedCache<K, V> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(K key, V value) throws InterruptedException {
lock.lock();
try {
while (cache.size() >= capacity) {
// 缓存满了,等着
notFull.await();
}
cache.put(key, value);
// 通知等待的读取线程:现在有数据了
notEmpty.signal();
} finally {
lock.unlock();
}
}
public V get(K key) throws InterruptedException {
lock.lock();
try {
while (cache.isEmpty()) {
// 缓存空了,等着
notEmpty.await();
}
V value = cache.get(key);
notFull.signal(); // 通知写入线程:有空间了
return value;
} finally {
lock.unlock();
}
}
}
注意这里用的是while而不是if来检查条件,这是因为线程被唤醒后条件可能又发生了变化,需要重新检查。
在游戏服务器里,经常需要对玩家对象进行各种操作。比如玩家受到伤害,血量减少,如果血量降到0,触发死亡逻辑。
public class Player {
private final ReentrantLock lock = new ReentrantLock();
private int health = 100;
public void takeDamage(int damage) {
lock.lock();
try {
this.health -= damage;
if (this.health <= 0) {
// 注意:这里直接调用onDeath,不需要再次加锁
onDeath();
}
} finally {
lock.unlock();
}
}
private void onDeath() {
// 因为已经是锁的持有者,所以可以直接执行
// 如果是不可重入锁,这里就会死锁
System.out.println("玩家死亡,触发复活逻辑");
// 复杂的死亡处理...
}
}
这种可重入的特性让我们的代码写起来自然很多,不用时刻担心锁的嵌套问题。
一定要在finally里解锁
这个看似简单,但很容易忘。我曾经就遇到过因为异常导致锁没释放,整个系统卡死的惨痛经历。
tryLock比lock更友好
特别是用在可能会长时间等待的场景中,tryLock可以设置超时时间,避免线程无限期等待。
公平锁不是默认选择
除非真的有强制的顺序要求,否则先用非公平锁。在大多数情况下,非公平锁的性能要好很多。
条件变量要用while循环检查
记住这个模式:while (!condition) { condition.await(); }
说实话,在平时的开发中,synchronized确实能满足80%的需求。但当你需要更细粒度的控制,比如可中断的锁获取、超时机制、多个条件变量时,ReentrantLock的优势就体现出来了。
不过也要注意,能力越大责任越大。ReentrantLock用起来比synchronized复杂,需要手动加锁解锁,要是忘了解锁,那问题就大了。
所以我的建议是:先用synchronized,当它真的不够用时,再请出ReentrantLock这把利器。毕竟,合适的工具用在合适的场景,才是最好的编程实践。
你在项目中用过ReentrantLock吗?有没有什么有趣的经历或坑想要分享?欢迎在评论区聊聊你的想法!