开发者

Java死锁原因及预防方法超详细讲解

目录
  • 前言
  • 一、 Java 死锁是如何产生的?
    • 经典死锁场景示例(哲学家就餐问题简化版)
    • 分析死锁条件满足情况
  • 二、 如何防止 Java 死锁?
    • 1. 破坏"循环等待"条件 - 锁顺序化 (Lock Ordering)
    • 2. 破坏"持有并等待"条件 - 一次性申请所有锁 (Atomically Acquire All Locks)
    • 3. 避免不必要的锁 / 缩小锁的范围
    • 4. 使用锁超时 (Lock Timeout) - 破坏"不可剥夺"的间接效果
    • 5. 死锁检测与恢复
  • 总结与建议

    前言

    Java 死锁是多线程编程中一种经典且棘手的问题,它会导致多个线程相互等待对方持有的资源而永久阻塞。理解其产生原因和预防措施至关重要。

    一、 Java 死锁是如何产生的?

    死锁的发生需要同时满足以下四个必要条件(缺一不可):

    1. 互斥使用 (Mutual Exclusion):

      • 资源(如对象锁、数据库连接、文件句柄等)一次只能被一个线程独占使用。
      • synchronized 关键字或 Lock 对象实现的锁机制本质上就提供了这种互斥性。
    2. 持有并等待 (Hold and Wait / Partial Allocation):

      • 一个线程在持有至少一个资源(锁)的同时,又去申请获取另一个线程当前正持有的资源(锁)。
    3. 不可剥夺 (No Preemption):

      • 一个线程已经获得的资源(锁)在它主动释放之前,不能被其他线程强行剥夺。
      • 在 Java 中,synchronized 锁不能被强制中断释放;Lock.lock() 获取的锁也不能被其他线程强制解锁(除非使用 Lock.lockInterruptibly() 并中断线程,但这通常也不是“强行剥夺”的含义)。
    4. 循环等待 (Circular Wait):

      • 存在一组等待的线程 {T1, T2, ..., Tn},其中:
        • T1 等待 T2 持有的资源,
        • T2 等待 T3 持有的资源,
        • …,
        • Tn 等待 T1 持有的资源。
      • 所有线程形成一个等待资源的环。

    经典死锁场景示例(哲学家就餐问题简化版)

    public class DeadlockExample {
    
        static final Object lockA = new Object();
        static final Object lockB = new Object();
    
        public static void main(String[] arjavascriptgs) {
            Thread thread1 = new Thread(() -> {
                synchronized (lockA) { // 线程1获取lockA
                    System.out.println("Thread1 acquired lockA");
                    try {
                        Thread.sleep(100); // 模拟操作,增加死锁发生概率
                    } catch (InterruptedException e) {}
                    synchronized (lockB) { // 线程1尝试获取lockB(此时可能被线程2持有)
                        System.out.println("Thread1 acquired lockB");
                    }
                }
            });
    
            Thread thread2 = new Thread(() -> {
                synchronized (lockB) { // 线程2获取lockB
                    System.out.println("Thread2 acquired lockB");
                    try {
                 编程客栈       Thread.sleep(100); // 模拟操作,增加死锁发生概率
                    } catch (InterruptedException e) {}
                    synchronized (lockA) { // 线程2尝试获取lockA(此时被线程1持有)
                        System.out.println("Thread2 acquired lockA");
                    }
                }
            });
    
            thread1.start();
            thread2.start();
        }
    }
    

    分析死锁条件满足情况

    1. 互斥: lockAlockB 都是 synchronized 使用的对象,具有互斥性。
    2. 持有并等待:
      • 线程1 持有 lockA,同时等待获取 lockB
      • 线程2 持有 lockB,同时等待获取 lockA
    3. 不可剥夺: Java synchronized 锁不能被其他线程强行剥夺。
    4. 循环等待:
      • 线程1 在等待线程2 释放的 lockB
      • 线程2 在等待线程1 释放的 lockA
      • 形成了一个闭环:线程1 -> 等待lockB(被线程2持有) -> 线程2 -> 等待lockA(被线程1持有) -> 线程1。

    二、 如何防止 Java 死锁?

    防止死锁的核心策略就是破坏上述四个必要条件中的至少一个。以下是常用的方法:

    1. 破坏"循环等待"条件 - 锁顺序化 (Lock Ordering)

    • 原理: 强制所有线程以全局一致的固定顺序获取锁。
    • 实现:
      • 为所有需要获取的锁定义一个全局的获取顺序(例如,按对象的 hashCode、按一个预定义的唯一ID、按名称排序等)。
      • 在任何需要获取多个锁的地方,都严格按照这个全局顺序去申请锁。
    • 效果: 从根本上消除了循环等待的可能性。如果一个线程需要锁 L1 和 L2,并且顺序规定必须先 L1 后 L2,那么所有线程都会按这个顺序申请。这样就不会出现线程1 持 L1 等 L2,而线程2 持 L2 等 L1 的循环情况。
    • 示例修改: 修改上面的例子,强制两个线程都先获取 lockA,再获取 lockB
    Thread thread1 = new Thread(() -> {
        synchronized (lockA) {
            System.out.println("Thread1 acquired lockA");
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            synchronized (lockB) { // 总是先A后B
                System.out.println("Thread1 acquired lockB");
            }
        }
    });
    
    Thread thread2 = new Thread(() -> {
        synchronized (lockA) { // 线程2也先尝试获取lockA
            System.out.println("Thread2 acquired lockA");
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            synchronized (lockB) { // 再获取lockB
                System.out.println("Thread2 acquired lockB");
            }
        }
    });
    
    • 注意: 严格遵守顺序是关键。有时确定一个一致的全局顺序可能比较复杂(尤其是锁是动态创建或数量不确定时),但这是最推荐、最有效的预防策略之一。可以使用 System.identityHashCode(Object) 作为最后手段来排序,但要注意哈希冲突。

    2. 破坏"持有并等待"条件 - 一次性申请所有锁 (Atomically Acquire All Locks)

    • 原理: 一个线程在开始执行任务前,一次性申请它所需的所有锁。如果无法一次性获取全部锁,它就不持有任何已获得的锁(全部释放),等待一段时间再重试或采用其他策略。
    • 实现:
      • 设计一个获取多个锁的机制(例如,一个包含所有需要锁的集合)。
      • 尝试一次性获取集合中所有的锁(通常使用 tryLock)。
      • 如果成功获取所有锁,执行任务。
      • 如果获取任何一个锁失败(超时或立即失败),则释放它已经成功获取的所有锁,然后进行回退(等待、重试、放弃javascript任务等)。
    • 效果: 线程要么同时持有所有需要的锁(不等待),要么不持有任何锁(不保持部分锁去等待其他锁),破坏了“持有并等待”。
    • 工具: Java 的 Lock 接口(特别是 ReentrantLock)提供了 tryLock() 方法(可带超时)来实现这种细粒度控制,这比 synchronized 更灵活。
    • 示例修改 (使用 ReentrantLock 和 tryLock):
    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;
    
    public class DeadlockPrevention {
    
        static Lock lockA = new ReentrantLock();
        static Lock lockB = new ReentrantLock();
    
        public static void main(String[] args) {
            Thread thread1 = new Thread(() -> acquireLocksAndwork(lockA, lockB, "Thread1"));
            Thread thread2 = new Thread(() -> acquihttp://www.devze.comreLocksAndWork(lockB, lockA, "Thread2")); // 注意顺序不同,但方法内部处理
            thread1.start();
            thread2.start();
        }
    
        public static void acquireLocksAndWork(Lock firstLock, Lock secondLock, String threadName) {
            while (true) {
                boolean gotFirst = false;
                boolean gotSecond = false;
                try {
                    // 尝试获取第一个锁(带超时避免无限等待)
                    gotFirst = firstLock.tryLock(100, TimeUnit.MILLISECONDS);
                    if (gotFirst) {
                        System.out.println(threadName + " acquired first lock");
                        // 尝试获取第二个锁(带超时)
                        gotSecond = secondLock.tryLock(100, TimeUnit.MILLISECONDS);
                        if (gotSecond) {
                            System.out.println(threadName + " acquired second lock");
                            // 成功获取两个锁,执行工作
                            System.out.println(threadName + " doing work...");
                            Thread.sleep(500); // 模拟工作
                            break; // 工作完成,跳出循环
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 无论如何,在退出前确保释放已获得的锁
                    if (gotSecond) secondLock.unlock();
                    if (gotFirst) firstLock.unlock();
                }
                // 如果没能一次性获得两个锁,等待随机时间后重试,避免活锁
                try {
                    Thread.sleep((long) (Math.random() * 100));
                } catch (InterruptedException e) {}
            }
        }
    }
    

    3. 避免不必要的锁 / 缩小锁的范围

    • 原理: 减少锁的持有时间和锁的数量,从而降低线程在持有锁期间去请求另一个锁的机会(破坏持有并等待的机会),也减少了形成循环等待的可能性。
    • 实现:
      • 只锁必要的代码块: 尽可能缩小 synchronized 块的范围,只保护真正需要互斥访问的共享数据操作。不要在锁内执行耗时操作(如IO)。
      • 使用线程安全类: 优先使用 ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger 等并发容器和原子类,它们内部实现了高效的并发控制,减少了你显式加锁的需要。
      • 不可变对象: 使用不可变对象(final 字段,构造后状态不变)。访问不可变对象不需要同步。
      • 线程本地存储: 使用 ThreadLocal 为每个线程创建变量的副本,避免共享。
    • 效果: 虽然不是直接破坏必要条件,但这是良好的并发编程实践,能显著降低死锁发生的概率和影响范围。

    4. 使用锁超时 (Lock Timeout) - 破坏"不可剥夺"的间接效果

    • 原理: 在尝试获取锁时,不无限期等待,而是设置一个超时时间。如果超时还没获取到,则放弃当前持有的所有锁(如果需要),释放资源,进行回退(重试、记录日志、失败等)。
    • 实现: 主要依赖 Lock 接口的 tryLock(long time, TimeUnit unit) 方法。synchronized 无法直接实现超时。
    • 效果: 它本身并不直接强行剥夺一个线程已持有的锁(不破坏“不可剥夺”的本意),但它允许一个线程主动放弃等待(等待超时),从而打破了死锁环中等待的僵局。它破坏了死锁发生的“永久阻塞”特性,给了系统恢复的机会。结合第2点(释放已持有锁),效果更好。
    • 示例: 见上面第2点(一次性申请所有锁)的代码示例,其中就使用了 tryLock 带超时。

    5. 死锁检测与恢复

    • 原理: 不主动预防死锁,而是允许死锁发生,但系统定期检测死锁的存在(如通过构建资源分配图并检测环),一旦检测到,采取强制措施打破死锁(例如:终止一个或多个死锁线程、剥夺其资源(在Java中很难安全实现))。
    • Java 实现:
      • 检测: Java 没有内置的通用死锁检测API。但可以通过 ThreadMXBeanfindDeadlockedThreads()findMonitorDeadlockedThreads() 方法来检测由 synchronizedownable synchronizers (如 ReentrantLock) 引起的死锁。JMX 工具(如 JConsole, VisualVM)通常集成了这个功能。
      • 恢复: Java 本身没有提供安全的、标准的线程终止或资源剥夺机制来恢复死锁。通常检测到死锁后,只能记录日志、告警,然后人工介入重启应用或相关服务。强行终止线程 (Thread.stop()) 是极其危险已被废弃的方法,会导致数据不一致等严重问题,绝对不要使用
    • 应用场景: 更适合框架、应用服务器、数据库等底层系统或需要高可靠性的复杂系统,它们有更完善的资源管理和恢复机制。普通应用开发更应注重预防。

    总结与建议

    1. 首选锁顺序化: 在设计多锁交互时,强制全局一致的锁获取顺序是最有效且推荐的预防策略。
    2. 善用 Lock 和 tryLock: 当锁顺序难以严格保证或需要更灵活控制时,使用 ReentrantLock 及其 tryLock(带超时)方法,实现一次性申请所有锁或锁超时机制。务必在 finally 块中释放锁
    3. 良好的并发习惯:
      • 最小化锁范围(缩小 synchronized 块)。
      • 优先使用并发集合 (java.util.concurrent.*) 和原子变量。
      • 考虑不可变对象和线程本地存储 (ThreadLocal)。
    4. 避免嵌套锁: 尽量避免在一个锁保护的代码块内再去获取另一个锁。如果必须,严格应用锁顺序化。
    5. 超时机制: 在可能长时间等待的地方(包括锁获取、条件等待 Condition.await、线程 joinFuture.get 等)使用超时参数,防止永久阻塞,给系统提供回退的机会。
    6. 工具检测: 利用 JConsole、VisualVM、jstack 命令行工具等定期检查或在线诊断潜在的死锁。jstack -l <pid> 输出的线程转储会明确标识出找到的死锁和涉及的线程/锁。

    记住: 预防死锁的关键在于设计和编码阶段就意识到风险并应用上述策略。事后检测和恢复往往android是代价高昂的最后手段。

    到此这篇关于Java死锁原因及预防方法的文章就介绍到这了,更多相关Java死锁原因及预防内容请搜索编程客栈(www.devze.com)以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程客栈(www.devze.com)!

    0

    上一篇:

    下一篇:

    精彩评论

    暂无评论...
    验证码 换一张
    取 消

    最新开发

    开发排行榜