平时写代码的时候,碰到多线程问题,你是不是第一反应就是加个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("玩家死亡,触发复活逻辑");
        // 复杂的死亡处理...
    }
}

这种可重入的特性让我们的代码写起来自然很多,不用时刻担心锁的嵌套问题。

几个使用中的小贴士

  1. 一定要在finally里解锁

    这个看似简单,但很容易忘。我曾经就遇到过因为异常导致锁没释放,整个系统卡死的惨痛经历。

  2. tryLock比lock更友好

    特别是用在可能会长时间等待的场景中,tryLock可以设置超时时间,避免线程无限期等待。

  3. 公平锁不是默认选择

    除非真的有强制的顺序要求,否则先用非公平锁。在大多数情况下,非公平锁的性能要好很多。

  4. 条件变量要用while循环检查

    记住这个模式:while (!condition) { condition.await(); }

最后说两句

说实话,在平时的开发中,synchronized确实能满足80%的需求。但当你需要更细粒度的控制,比如可中断的锁获取、超时机制、多个条件变量时,ReentrantLock的优势就体现出来了。

不过也要注意,能力越大责任越大。ReentrantLock用起来比synchronized复杂,需要手动加锁解锁,要是忘了解锁,那问题就大了。

所以我的建议是:先用synchronized,当它真的不够用时,再请出ReentrantLock这把利器。毕竟,合适的工具用在合适的场景,才是最好的编程实践。

你在项目中用过ReentrantLock吗?有没有什么有趣的经历或坑想要分享?欢迎在评论区聊聊你的想法!

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]