一、什么是死锁?它就像一场尴尬的交通堵塞

想象一下,你开车到一个狭窄的单行道上,对面也来了一辆车。你们俩都需要通过这条路,但谁都不愿意先倒车。于是,你们就僵持在那里,谁也动不了。在Java多线程的世界里,这种情况就叫“死锁”。

具体来说,死锁通常发生在两个或多个线程互相等待对方释放锁的时候。每个线程都握着自己需要的“钥匙”(锁),同时又在等待对方手里的“钥匙”,结果就是所有线程都卡住,程序“假死”在那里。这听起来很糟糕,但好消息是,我们可以通过一些聪明的规则和设计来避免它。

二、制造一场“死锁”:先看看问题是怎么来的

要解决问题,我们得先知道问题是怎么发生的。下面我们来写一个经典的死锁场景。这个例子非常简单,但非常典型。

技术栈:Java

public class DeadlockDemo {
    // 两把不同的“钥匙”(锁对象)
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        // 线程1:先拿lockA,再想拿lockB
        Thread thread1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("线程1:成功拿到了锁A");
                try {
                    // 稍微睡一会儿,让线程2有机会拿到锁B
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程1:等待获取锁B...");
                synchronized (lockB) {
                    System.out.println("线程1:成功拿到了锁A和锁B");
                }
            }
        });

        // 线程2:先拿lockB,再想拿lockA
        Thread thread2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("线程2:成功拿到了锁B");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程2:等待获取锁A...");
                synchronized (lockA) {
                    System.out.println("线程2:成功拿到了锁B和锁A");
                }
            }
        });

        // 启动两个线程
        thread1.start();
        thread2.start();
    }
}

运行这段代码,你很可能会看到程序打印出“线程1:等待获取锁B...”和“线程2:等待获取锁A...”后就停在那里,再也不动了。这就是死锁。两个线程互相等,天荒地老。

三、破解死锁的四大实战心法

知道了死锁的成因,我们就可以对症下药了。下面介绍几个最常用、最有效的解决方案。

心法一:固定顺序拿锁

这是最简单粗暴也最有效的方法之一。回想一下我们的死锁例子,问题出在两个线程拿锁的顺序是反的。如果我们定下一个规矩:不管谁,想用这两把锁,都必须先拿A,再拿B。这样,线程1先拿到A,线程2想拿A就得等,等线程1用完A和B释放后,线程2才能按顺序拿到A和B。交通规则立好了,堵塞自然就解开了。

技术栈:Java

public class FixedOrderLock {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            // 严格遵守顺序:先A后B
            synchronized (lockA) {
                System.out.println("线程1:拿到锁A");
                synchronized (lockB) {
                    System.out.println("线程1:拿到锁A和锁B,开始工作");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            // 同样严格遵守顺序:先A后B
            synchronized (lockA) {
                System.out.println("线程2:拿到锁A");
                synchronized (lockB) {
                    System.out.println("线程2:拿到锁A和锁B,开始工作");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

这个方法的好处是简单明了,但缺点是需要你在设计初期就规划好所有锁的全局顺序,在复杂系统中维护这个顺序可能会比较麻烦。

心法二:尝试拿锁,拿不到就放弃

有时候,我们没必要“在一棵树上吊死”。Java的并发包java.util.concurrent.locks提供了一个强大的工具:ReentrantLock。它有一个tryLock()方法,可以尝试去获取锁,如果获取不到,它不会傻等,而是立刻返回false。这样,线程就有机会释放自己已经持有的锁,退一步海阔天空,过一会儿再重试。

技术栈:Java

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDemo {
    private static final Lock lockA = new ReentrantLock();
    private static final Lock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            boolean gotLockA = false;
            boolean gotLockB = false;
            try {
                // 尝试获取锁A
                gotLockA = lockA.tryLock();
                if (gotLockA) {
                    System.out.println("线程1:拿到锁A");
                    // 拿到A后,尝试获取锁B
                    gotLockB = lockB.tryLock();
                    if (gotLockB) {
                        System.out.println("线程1:成功拿到锁A和锁B,开始工作");
                    } else {
                        System.out.println("线程1:没拿到锁B,准备释放锁A重试");
                    }
                }
            } finally {
                // 最终无论如何,都要确保释放已获得的锁
                if (gotLockB) lockB.unlock();
                if (gotLockA) lockA.unlock();
                // 如果没拿到全部锁,可以在这里加一个短暂的休眠,然后重试整个逻辑
                if (!(gotLockA && gotLockB)) {
                    try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
                    // 这里可以递归或循环调用自身来重试,示例中省略
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            // 线程2的逻辑与线程1类似,尝试获取锁A和锁B
            boolean gotLockA = false;
            boolean gotLockB = false;
            try {
                gotLockA = lockA.tryLock();
                if (gotLockA) {
                    System.out.println("线程2:拿到锁A");
                    gotLockB = lockB.tryLock();
                    if (gotLockB) {
                        System.out.println("线程2:成功拿到锁A和锁B,开始工作");
                    } else {
                        System.out.println("线程2:没拿到锁B,准备释放锁A重试");
                    }
                }
            } finally {
                if (gotLockB) lockB.unlock();
                if (gotLockA) lockA.unlock();
                if (!(gotLockA && gotLockB)) {
                    try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

这种方法给了我们更多的灵活性和控制力,但代码会变得复杂,需要小心处理锁的释放和重试逻辑,否则容易出错。

心法三:给等待加个“闹钟”

synchronized关键字最大的问题之一就是,它会让线程无限期地等待锁。而ReentrantLock的另一个强大功能是tryLock(long time, TimeUnit unit),它可以指定一个超时时间。比如,我尝试拿锁,等5秒还拿不到,我就放弃,去干点别的(比如释放自己的锁、记录日志、抛异常等),绝不无休止地等下去。

技术栈:Java

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TimeoutLockDemo {
    private static final Lock lockA = new ReentrantLock();
    private static final Lock lockB = new ReentrantLock();

    public static void work(String threadName, Lock firstLock, Lock secondLock) {
        boolean gotFirst = false;
        boolean gotSecond = false;
        try {
            // 尝试获取第一把锁,最多等100毫秒
            gotFirst = firstLock.tryLock(100, TimeUnit.MILLISECONDS);
            if (gotFirst) {
                System.out.println(threadName + ":拿到第一把锁");
                // 尝试获取第二把锁,最多等100毫秒
                gotSecond = secondLock.tryLock(100, TimeUnit.MILLISECONDS);
                if (gotSecond) {
                    System.out.println(threadName + ":成功拿到两把锁,开始工作");
                    // 模拟工作
                    Thread.sleep(200);
                } else {
                    System.out.println(threadName + ":获取第二把锁超时,放弃");
                }
            } else {
                System.out.println(threadName + ":获取第一把锁超时,放弃");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 一定要在finally块中释放锁
            if (gotSecond) secondLock.unlock();
            if (gotFirst) firstLock.unlock();
            System.out.println(threadName + ":释放了所有持有的锁");
        }
    }

    public static void main(String[] args) {
        // 线程1按A->B顺序尝试
        Thread t1 = new Thread(() -> work("线程1", lockA, lockB));
        // 线程2按B->A顺序尝试(故意制造可能死锁的条件)
        Thread t2 = new Thread(() -> work("线程2", lockB, lockA));

        t1.start();
        t2.start();
    }
}

使用超时机制,即使发生了锁竞争,最坏的情况也就是线程等一会儿然后失败,而不会导致整个系统僵死。这是构建健壮高并发系统的重要技巧。

心法四:把多把锁“打包”成一把

如果多个资源总是需要被同时访问,那么一个更根本的解决办法是:不要用多把锁,而是用一把更大的锁来保护所有这些资源。虽然这可能会降低一点并发度(因为锁的粒度变粗了),但它彻底消除了因获取多锁顺序不当而导致死锁的可能。在简单场景下,这常常是最省心的方法。

技术栈:Java

public class CoarseGrainedLock {
    // 共享资源
    private static int sharedResourceX = 0;
    private static int sharedResourceY = 0;

    // 一把大锁,保护所有相关资源
    private static final Object globalLock = new Object();

    public static void updateBothResources() {
        synchronized (globalLock) {
            // 安全地访问和修改resourceX和resourceY
            sharedResourceX++;
            sharedResourceY--;
            System.out.println("更新资源: X=" + sharedResourceX + ", Y=" + sharedResourceY);
        }
    }

    public static void main(String[] args) {
        // 多个线程同时调用,但由同一把锁保护
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                for (int j = 0; j < 3; j++) {
                    updateBothResources();
                }
            }).start();
        }
    }
}

这种方法简单安全,但需要权衡。如果resourceXresourceY实际上关联不大,频繁被独立访问,那么用一把大锁就会导致不必要的线程等待,影响性能。

四、如何选择与实战要点

应用场景:

  • 固定顺序锁:适用于锁的数量不多、顺序容易定义清晰的场景,比如转账业务(总是先锁账户ID小的,再锁账户ID大的)。
  • 尝试锁/超时锁:适用于对响应时间有要求、或者需要构建高可用服务的场景,比如电商秒杀系统,不能因为某个锁争用导致整个服务线程池耗尽。
  • 粗粒度锁:适用于一组资源总是需要被原子性操作的场景,或者是在快速原型开发阶段,优先保证正确性。

技术优缺点:

  • synchronized:优点是用起来简单,JVM自动管理锁的获取和释放,不容易出现忘记解锁的问题。缺点就是功能单一,无法实现尝试锁、超时、公平锁等高级功能。
  • ReentrantLock:优点是功能强大,提供了尝试锁、超时锁、可中断锁、公平锁等多种选择,控制灵活。缺点是需要手动lock()unlock(),必须写在try-finally块中确保释放,否则会导致严重问题,对开发者要求更高。

注意事项:

  1. 锁一定要释放:使用ReentrantLock时,unlock()调用必须放在finally代码块中,确保无论业务逻辑是否抛出异常,锁都能被释放。
  2. 避免嵌套过深:尽量减少需要同时持有的锁的数量,锁的嵌套层次越深,发生死锁的复杂度就越高。
  3. 使用工具检测:在测试阶段,可以借助jstack命令或者JConsoleVisualVM等可视化工具来检测程序中是否存在死锁线程。
  4. 设计优于补救:在系统设计之初,就规划好资源的并发访问策略,比后期修补死锁问题要有效得多。

文章总结: 死锁是多线程编程中的一个经典难题,但并非无解。其核心在于多个线程对锁资源的循环等待。我们破解它的思路也就围绕于此:要么破坏等待条件(通过固定顺序),要么破坏不可抢占条件(通过尝试锁或超时锁),要么从根本上减少锁的竞争点(使用粗粒度锁)。在实际开发中,synchronized适合大多数简单的同步场景,而ReentrantLock则为我们提供了应对复杂并发问题的工具箱。理解这些原理,并在代码中谨慎地管理锁的获取顺序和时机,是编写出稳定、高效并发程序的关键。记住,多线程编程的艺术,往往在于如何在“尽可能并发”和“保证绝对安全”之间找到那个最佳的平衡点。