线程同步机制:从锁到死锁,再到活锁与饥饿

说到线程同步,我脑子里第一个蹦出来的就是「锁」。说实话,刚入行那会儿,我觉得锁就是个简单的开关——锁上就安全,打开就危险。后来被线上问题教育了几次,才明白这里面的门道深着呢。

今天咱们就把 Lock、RLock、死锁、活锁、饥饿这些概念,一个一个掰开揉碎了讲。我会结合自己踩过的坑,帮你把这些知识点真正吃透。

Lock 锁:最基础的同步工具

Lock 是 Java 并发包里最基础的锁。它的用法很简单:lock() 获取锁,unlock() 释放锁。但简单不等于容易用好。

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock();  // 一定要在 finally 里释放
}

这个 finally 块,我强调过无数次了。为什么?因为一旦临界区抛出异常,锁没释放,其他线程就永远拿不到锁了。我在项目中遇到过这样的案例:一个同事忘了写 finally,结果某个异常路径下锁没释放,整个服务卡死了半小时。

⚠️ 重要提醒:Lock 的 unlock() 必须放在 finally 块中。这不是建议,是铁律。

RLock 可重入锁:同一个线程可以多次获取

RLock,全称 ReentrantLock,意思是「可重入」。什么叫可重入?说白了,就是同一个线程可以多次获取同一把锁,而不会把自己锁死。

你想想看,如果没有可重入机制,下面这段代码就会出问题:

public void methodA() {
    lock.lock();
    try {
        methodB();  // 同一个线程再次获取锁
    } finally {
        lock.unlock();
    }
}

public void methodB() {
    lock.lock();
    try {
        // 做一些事情
    } finally {
        lock.unlock();
    }
}

如果 Lock 不支持重入,线程在 methodA 里获取锁后,调用 methodB 时再次尝试获取同一把锁——自己等自己释放锁,这不就死锁了吗?RLock 解决了这个问题:它会记录当前持有锁的线程和获取次数。每次 lock 加 1,每次 unlock 减 1,减到 0 才真正释放。

💡 我个人习惯:只要涉及递归调用或方法嵌套,优先用 ReentrantLock 而不是 synchronized。虽然 synchronized 也是可重入的,但 Lock 提供了更灵活的 tryLock、超时等能力。

死锁的产生与预防

死锁,是并发编程里最让人头疼的问题之一。我刚开始写多线程代码时,就栽过跟头。

死锁的四个必要条件,教科书上写得很清楚:

条件 说明
互斥 资源一次只能被一个线程占用
持有并等待 线程持有资源的同时,等待其他资源
不可剥夺 资源不能被强制拿走,只能主动释放
循环等待 多个线程形成资源等待的环路

这四个条件同时满足,死锁就发生了。怎么预防?破坏任意一个条件就行。

我常用的方法是「固定锁顺序」。比如两个线程都需要锁 A 和锁 B,那就规定:必须先拿 A,再拿 B。这样就不会出现 A 等 B、B 等 A 的循环了。

// 错误示例:可能死锁
public void transfer(Account from, Account to, int amount) {
    synchronized (from) {
        synchronized (to) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

// 正确示例:按账户 ID 排序,固定锁顺序
public void transfer(Account from, Account to, int amount) {
    Account first = from.id < to.id ? from : to;
    Account second = from.id < to.id ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

我曾经在一个支付系统中遇到过死锁问题。两个账户互相转账,恰好同时发起请求,结果两个线程各持一把锁,互相等待对方释放。那次事故导致一批交易卡住,排查了整整一个下午。从那以后,所有涉及多锁的代码我都强制要求固定锁顺序。

活锁与饥饿现象

死锁大家都熟悉,但活锁和饥饿,很多人容易忽略。

活锁:线程虽然没有阻塞,但一直在做无用功,无法真正推进。就像两个人面对面走路,你往左让,他也往左让;你往右让,他也往右让——结果谁也过不去。

活锁的典型场景是:两个线程检测到冲突后,各自释放锁并重试,但重试的时机总是撞在一起。我见过一个案例:两个任务互相谦让资源,结果在循环里转了几万次才成功,CPU 飙升到 100%。

解决活锁的办法很简单:引入随机延迟。让每个线程重试前等待一个随机时间,错开彼此的节奏。

// 活锁示例:两个线程互相谦让
while (true) {
    if (tryLock(lockA)) {
        if (tryLock(lockB)) {
            // 成功
            break;
        } else {
            unlock(lockA);  // 释放并重试
        }
    }
    // 没有随机延迟,可能活锁
}

// 解决方案:加入随机延迟
while (true) {
    if (tryLock(lockA)) {
        if (tryLock(lockB)) {
            break;
        } else {
            unlock(lockA);
        }
    }
    Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50)); // 随机延迟
}

饥饿:一个线程长时间无法获得所需的资源。原因可能是优先级太低,或者某些线程「霸占」了资源不放。

饥饿的典型场景是:高优先级线程不断抢占 CPU,低优先级线程永远得不到执行机会。或者读写锁中,读线程太多,写线程一直等不到机会。

预防饥饿的方法:

  • 使用公平锁(Fair Lock),让线程按请求顺序获取锁
  • 避免线程优先级差异过大
  • 读写锁中,限制读线程数量或给写线程更高优先级
💡 我个人建议:除非你非常清楚自己在做什么,否则尽量用公平锁。虽然性能略低,但能避免很多诡异的饥饿问题。我在一个日志系统中就吃过亏:写线程被大量读线程饿死,日志积压了十几分钟才刷出去。

知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个思维导图来理解:

线程同步机制 Lock 锁 lock() / unlock() finally 释放 RLock 可重入锁 同一线程可多次获取 计数机制 死锁 四个必要条件 固定锁顺序预防 活锁与饥饿 活锁:随机延迟解决 饥饿:公平锁预防 核心原则:锁要成对出现,顺序要固定,重试要随机 避免死锁、活锁、饥饿,保证线程安全

嗯,以上就是线程同步机制的核心内容。Lock 和 RLock 是工具,死锁、活锁、饥饿是陷阱。工具要会用,陷阱要会躲。我在实际项目中,每次写锁相关的代码都会多问自己一句:这个锁会不会死锁?会不会活锁?会不会饿死别人?

养成这个习惯,你也能写出健壮的并发代码。

最后送你一句话:锁不是越多越好,也不是越少越好,而是刚刚好。该锁的地方锁住,不该锁的地方放开。这个「度」,需要你在实践中慢慢体会。