Java死锁 如何定位?如何避免Java死锁?(图解+秒懂+史上最全)

简介: Java死锁 如何定位?如何避免Java死锁?(图解+秒懂+史上最全)

本文 的 原文 地址

原始的内容,请参考 本文 的 原文 地址

本文 的 原文 地址

什么是死锁

死锁是指两个或多个任务(比如线程、进程)彼此等待对方释放资源,结果谁也不愿意先让步,导致程序卡住不动。

如下图场景汇总:

  • 任务A 拿着资源1,还要资源2才能继续。
  • 任务B 拿着资源2,还要资源1才能继续。
  • 谁都不肯放手自己手里的资源,于是都卡在那等着。
  • 这种互相等待的情况就叫 死锁

image-20250703145149498

死锁通常有两种情况:

  • 一种是使用 synchronized 内置锁 造成的,

  • 另一种是使用 Lock 显式锁引起的。

下面我们分别来看这两种情况。

内置锁 死锁 synchronized 版

这是一个演示死锁的小程序。我们用两个线程和两把锁来展示什么是死锁。

基本流程:

1、 创建两个锁对象:lockAlockB
2、 启动两个线程:

  • 线程 1 先拿 lockA,然后尝试拿 lockB

  • 线程 2 先拿 lockB,然后尝试拿 lockA

3、 每个线程拿到第一个锁之后都会停顿 1 秒钟,再去拿第二个锁。
4、 最终两个线程都在等对方释放锁,导致死锁

Mermaid

源码如下:


public class DeadLockExample {
    public static void main(String[] args) {
        Object lockA = new Object(); // 创建锁 A
        Object lockB = new Object(); // 创建锁 B

        // 创建线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 先获取锁 A
                synchronized (lockA) {
                    System.out.println("线程 1:获取到锁 A!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 尝试获取锁 B
                    System.out.println("线程 1:等待获取 B...");
                    synchronized (lockB) {
                        System.out.println("线程 1:获取到锁 B!");
                    }
                }
            }
        });
        t1.start(); // 运行线程

        // 创建线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 先获取锁 B
                synchronized (lockB) {
                    System.out.println("线程 2:获取到锁 B!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 尝试获取锁 A
                    System.out.println("线程 2:等待获取 A...");
                    synchronized (lockA) {
                        System.out.println("线程 2:获取到锁 A!");
                    }
                }
            }
        });
        t2.start(); // 运行线程
    }
}

以上程序的执行结果如下:

image-20250703142425355

从输出可以看到:

  • 线程 1 拿到了 A,等待 B;
  • 线程 2 拿到了 B,等待 A;

双方都无法继续执行,程序卡住。 这就是典型的死锁场景

显式锁 死锁 Lock 版

这个案例演示演示使用Lock场景下的死锁

1、 创建两个锁对象:lockAlockB

2、 创建两个线程 t1 和 t2

3、 t1 先获取 lockA,然后尝试获取 lockB

4、 t2 先获取 lockB,然后尝试获取 lockA

Mermaid

源码如下:


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

public class DeadLockByReentrantLockExample {
    public static void main(String[] args) {
        Lock lockA = new ReentrantLock(); // 创建锁 A
        Lock lockB = new ReentrantLock(); // 创建锁 B

        // 创建线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockA.lock(); // 加锁
                System.out.println("线程 1:获取到锁 A!");
                try {
                    Thread.sleep(1000);
                    System.out.println("线程 1:等待获取 B...");
                    lockB.lock(); // 加锁
                    try {
                        System.out.println("线程 1:获取到锁 B!");
                    } finally {
                        lockA.unlock(); // 释放锁
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lockA.unlock(); // 释放锁
                }
            }
        });
        t1.start(); // 运行线程

        // 创建线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockB.lock(); // 加锁
                System.out.println("线程 2:获取到锁 B!");
                try {
                    Thread.sleep(1000);
                    System.out.println("线程 2:等待获取 A...");
                    lockA.lock(); // 加锁
                    try {
                        System.out.println("线程 2:获取到锁 A!");
                    } finally {
                        lockA.unlock(); // 释放锁
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lockB.unlock(); // 释放锁
                }
            }
        });
        t2.start(); // 运行线程
    }
}

以上程序的执行结果如下:

image-20250703142425355

死锁产生的条件

死锁的产生必须满足以下四个条件。

当这四个条件同时满足时,就可能发生死锁。

死锁发生的四个必要条件

当下面这四个条件同时满足时,系统就有可能发生死锁(即多个进程或线程互相等待对方释放资源,导致谁都无法继续执行)。

1、 互斥:资源不能共享,一次只能被一个进程使用。 比如打印机、某个文件的写权限等。

2、 持有并等待:一个进程在等待其他资源时,并不释放自己已经占有的资源。 就像你一只手拿着笔,另一只手等着拿书,谁也不放手。

3、 不可抢占:资源只能由持有它的进程主动释放,不能被强制抢走。 有点像借了别人的东西,必须他自己愿意才能还。

4、 循环等待:存在一个进程链,每个进程都在等待下一个进程所持有的资源。 A 等 B,B 等 C,C 又在等 A,形成一个“死循环”。

Mermaid

这个流程帮助我们判断系统中是否可能出现死锁。只要打破其中一个条件,就可以防止死锁的发生。

必要条件1:互斥

一个资源不能同时被多个线程使用。

如果一个线程已经用了这个资源,其他线程就得等它用完才能继续用。

这就是常说的“互斥锁”。

image-20250703145204991

比如上图中,线程T1已经拿到了资源,那T2就不能拿。T2只能等着,直到T1用完了释放资源。

必要条件2:占有并且等待

当一个线程已经拿到了某个资源,还想再拿另一个被别的线程占用的资源时,它就得等着。

在等的过程中,它不会把自己已经拿到的资源释放掉

image-20250703145236441

比如上图中:

  • 线程 T1 拿到了资源1,又去申请资源2;
  • 但资源2已经被线程 T3 占用;所以 T1 就卡住等待;
  • 但 T1 在等待的时候,还是抓着资源1不放

必要条件3:不可抢占

一个资源一旦被某个线程占用了,只有这个线程用完后才能释放。

其他线程不能抢这个资源。

比如下图中

  • 线程 T1 已经拿到了资源,在它没用完之前,T2 线程是拿不到这个资源的。

  • T2 只能等 T1 用完了、释放了,才能去使用。

image-20250703145322874

必要条件4:循环等待

当发生死锁时,一定会出现一个“线程和资源”的 等待 环。

在这个等待 环 里,每个线程都在等下一个线程占用的资源,结果谁也继续不下去。

比如下图中,线程 T1 在等 T2 占着的资源,而 T2 又在等 T1 占着的资源。

两者互相等待,就卡住了,这就是典型的死锁情况。

image-20250703145338314

死锁排查工具

工具1: arthas

Arthas 是一个用于线上 Java 应用监控和问题排查的工具。

它能让你实时看到应用的负载、内存、垃圾回收、线程等运行状态,还能在不修改代码的前提下,查看方法调用的参数、返回值、执行时间等问题信息,帮助你快速定位线上问题。

当你使用 watch 命令监控某个方法时,Arthas 的工作流程如下:

1、 Arthas找到这个方法在内存中的位置

2、 在这个方法的开头和结尾偷偷加上记录参数和返回值的代码

3、 方法被调用时,这些额外代码就会把信息传回给你看

4、 等你不需要监控了,Arthas就把这些额外代码去掉

arthas排查死锁

(1) 启动Arthas

首先,你需要启动Arthas并附加到目标Java进程:


java -jar arthas-boot.jar

然后选择你要诊断的Java进程。

image-20250701205550804

(2)使用thread命令查看线程状态


thread #查看所有线程状态
thread --state BLOCKED #查看特定状态的线程

image-20250701205702348

(3) 检测死锁

Arthas提供了专门的命令来检测死锁:


thread -b

或者


thread --block

这个命令会直接显示当前JVM中存在的死锁线程信息。

image-20250701205810888

(4) 分析死锁线程堆栈

如果发现死锁,可以使用以下命令查看线程的详细堆栈:


thread <thread-id>

例如:


thread 12

image-20250701205931156

工具2: jstack

在使用 jstack 分析问题前,我们先要用 jps 找到正在运行的 Java 程序对应的进程编号(PID)。

操作方法如下:

image-20250701112908854

  • 命令:jps -l
  • 功能:列出本机所有 Java 程序的 PID 和启动类名。

jps 是 Java 自带的一个小工具,可以快速查看当前有哪些 Java 程序在运行。

拿到 PID 后,就可以用 jstack -l PID 来分析线程状态,比如查找有没有线程死锁。

效果如下图:

image-20250701113234024

  • jstack 的作用:抓取当前 Java 程序中所有线程的状态快照。
  • -l 参数:显示更详细的锁信息,有助于排查死锁。

小提示:输入 jstack -help 可以查看更多参数说明。

工具3:jconsole

1、 打开 JDK 安装目录下的 bin 文件夹,找到 jconsole.exe,双击运行。

image-20250701113428404

2、 在弹出的界面中,选择你要监控的 Java 程序,点击“连接”。

3、 进入主界面后,切换到“线程”标签页,然后点击“检测死锁”按钮。

image-20250703150320386

4、 稍等几秒,工具会自动找出死锁的线程,并显示相关信息。

image-20250701113737422

工具4:jvisualvm

jvisualvm 是 JDK 自带的一个性能分析工具,放在 JDK 的 bin 目录下,直接双击就能打开:

image-20250701113847811

打开后等几秒钟,本地运行的所有 Java 程序就会显示出来。双击你要查看的程序,然后切换到“线程”标签页,如下图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

进入线程页面后,如果程序中有死锁,会直接提示出来。点击“线程 Dump”,可以查看死锁的详细信息,如下图所示:

image-20250701114056520

工具5:jmc

JMC 是 Java Mission Control 的缩写,是 JDK 自带的一个工具,用来监控和分析 Java 程序的运行情况。

它放在 JDK 安装目录下的 bin 文件夹里,直接双击就能启动。

启动界面如下图:

image-20250701114324443

打开后会看到 JMC 的主页界面:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

然后在列表中找到你要排查的 Java 程序,右键选择“启动 JMX 控制台”,就可以查看这个程序的详细运行状态了,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

接着点击顶部的“线程”标签,在页面中勾选“死锁检测”,系统就会自动检查是否有线程死锁,并显示死锁的具体信息:

image-20250701114821539

如何避免死锁 ?

死锁的发生必须同时满足四个条件:互斥、占有并等待、不可剥夺、循环等待。

  • 只要打破其中一个,就能防止死锁。

  • 只要打破其中一个,就能防止死锁。

  • 只要打破其中一个,就能防止死锁。

  • 只要打破其中一个,就能防止死锁。

重要的,说4遍

我们可以从这四个方面入手,系统性地避免死锁问题。

条件 破坏手段 关键技术 典型场景
互斥 无锁编程 AtomicXXXThreadLocal 计数器、状态标志
占有并等待 原子分配 全局资源管理器、两阶段锁 银行转账
不可剥夺 超时/中断 tryLock()、事务回滚 实时交易
循环等待 锁排序 资源ID排序、分层锁 多资源批量操作

破坏必要条件1:打破“互斥”这个条件

避免死锁,核心是打破“互斥”这个条件,常见的做法有以下几种:

(1)用无锁结构代替锁

比如用 ConcurrentHashMap 替代加了锁的 Map,这样多个线程可以更高效地操作数据,不需要排队等锁。

(2)使用原子类

AtomicInteger 这样的类,可以在不加锁的情况下保证操作的原子性,适合简单的并发场景。

(3)线程私有变量

ThreadLocal 给每个线程分配自己的变量,彼此之间不共享,自然就不会抢资源。

(4)读写分离

通过读写锁(如 ReentrantReadWriteLock),允许多个读操作同时进行,但写操作独占。这样在读多写少的场景下效率更高。

示例代码如下:


ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();  // 多个读线程可以同时进来
rwLock.writeLock().lock(); // 写线程必须单独占用

破坏必要条件2:打破“破坏占有并等待”这个条件

破坏占有并等待条件,避免死锁,可以有两种方案:

方案1:一次性申请所有资源

设计思路:

  • 一次把需要的所有资源都申请到,否则一个也不拿。
  • 这样就不会出现“拿到了A还在等B”的情况,自然也就不会死锁。

实现方式:

  • 用一个叫 AtomicResourceAllocator 的工具类来统一管理资源申请和释放。

代码如下:


import java.util.*;

class AtomicResourceAllocator {
    // 使用ConcurrentHashMap保证线程安全
    private final Map<Object, Boolean> resourceStatus = new ConcurrentHashMap<>();

    /**
     * 原子性地申请多个资源
     * @param resources 需要申请的资源数组
     * @return 全部申请成功返回true,否则false
     */
    public boolean acquireAll(Object... resources) {
        // 先检查所有资源是否可用
        for (Object res : resources) {
            if (resourceStatus.putIfAbsent(res, true) != null) {
                // 有资源已被占用,释放已暂存的资源
                for (Object acquiredRes : resources) {
                    if (acquiredRes == res) break;
                    resourceStatus.remove(acquiredRes);
                }
                return false;
            }
        }
        return true;
    }

    /**
     * 释放多个资源
     */
    public void releaseAll(Object... resources) {
        for (Object res : resources) {
            resourceStatus.remove(res);
        }
    }
}

转账服务:使用原子分配,转账前先一次性申请两个账户的使用权,成功后再操作余额。


class TransferService {
    private final AtomicResourceAllocator allocator = new AtomicResourceAllocator();

    public boolean transfer(BankAccount from, BankAccount to, int amount) {
        // 1. 原子性申请两个账户资源
        if (!allocator.acquireAll(from, to)) {
            System.out.println(Thread.currentThread().getName() + 
                ": 资源申请失败,有其他转账正在进行");
            return false;
        }

        try {
            // 2. 检查余额是否充足
            if (from.getBalance() < amount) {
                System.out.println("余额不足");
                return false;
            }

            // 3、 执行转账
            from.debit(amount);
            to.credit(amount);

            System.out.println(Thread.currentThread().getName() + 
                ": 成功转账 " + amount + " 从 " + 
                from.getAccountId() + " 到 " + to.getAccountId());
            return true;
        } finally {
            // 4. 释放资源
            allocator.releaseAll(from, to);
        }
    }
}

方案2:两阶段加锁协议(2PL)

两阶段锁协议(Two-Phase Locking, 2PL)是数据库系统和并发控制中广泛使用的技术,通过将锁的获取和释放分为两个明确的阶段,彻底破坏"占有并等待"条件,从而避免死锁。

其核心规则是:

阶段 操作规则 目的
扩张阶段 线程只能获取锁,不能释放任何已持有的锁 确保资源集中申请,避免零散占用
收缩阶段 线程只能释放锁,不能获取任何新锁 确保资源有序释放,避免循环等待

工作原理(以银行转账为例)


// 账户资源
Object lockA = new Object();
Object lockB = new Object();

void transfer(Account from, Account to, int amount) {
    // ===== 扩张阶段:只加锁 =====
    synchronized (lockA) {  // 获取第一个锁
        synchronized (lockB) {  // 获取第二个锁
            // ===== 收缩阶段:只释放 =====
            if (from.balance >= amount) {
                from.balance -= amount;
                to.balance += amount;
            }
        } // 自动释放lockB(收缩阶段)
    } // 自动释放lockA(收缩阶段)
}

关键点

  • 一旦开始释放锁(进入收缩阶段),就不能再申请新锁,从而切断"持有A等B"的死锁链条。
  • 锁的释放顺序通常与获取顺序相反(后进先出)。

破坏必要条件3:打破“打破不可抢占条件”这个条件

方案1:超时机制

有时候我们不想一直等下去,而是希望只尝试一段时间去获取锁。这时候可以用 tryLock 方法。示例代码如下:


Lock lock = new ReentrantLock();
if(lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // 临界区
    } finally {
        lock.unlock();
    }
} else {
    // 超时后的处理
}

也就是:我最多等1秒钟,能拿到锁就干,拿不到就算了。

方案2:可中断锁

还有一种情况,如果一个线程正在等待锁,但你中途不想让它等了,想“打断”它,就可以使用 lockInterruptibly() 方法。


lock.lockInterruptibly();  // 可响应中断的加锁

意思是: 这个线程在等锁的时候,如果你通知它“别等了”,它就会停下来,而不是傻傻地一直等。

破坏必要条件2:打破“ 循环等待”这个条件

方案1:统一加锁顺序

在转账时,为了避免两个账户互相等待对方释放锁导致“卡住”,我们约定一个固定的加锁顺序:谁的账户ID小,就先锁谁

这样不管谁转给谁,都按这个规则来,就不会出现死锁问题。


public void transfer(Account from, Account to, int amount) {
    // 确定锁顺序:按账户ID排序
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;

    synchronized(first) {  // 先锁ID小的账户
        synchronized(second) {  // 再锁ID大的账户
            if (from.getBalance() >= amount) {
                from.debit(amount);
                to.credit(amount);
            }
        }
    }
}

方案2:分层加锁

有些系统有多个层级(比如数据库、表、行),为了防止混乱,我们要按照从上到下的顺序加锁:

  • 先锁数据库
  • 再锁表
  • 最后锁具体某一行

不能跳级也不能倒着来,否则会报错。

定义锁层级


public enum LockLevel {
    DATABASE(1), 
    TABLE(2), 
    ROW(3);

    private final int level;

    public static void checkOrder(LockLevel current, LockLevel next) {
        if (current.level >= next.level) {
            throw new IllegalStateException("违反锁层级顺序");
        }
    }
}

使用


public void updateRecord(Database db, Table table, Row row) {
    synchronized(db) {
        LockLevel.DATABASE.checkOrder(LockLevel.TABLE);
        synchronized(table) {
            LockLevel.TABLE.checkOrder(LockLevel.ROW);
            synchronized(row) {
                // 执行操作
            }
        }
    }
}

方案3:资源编号策略

当需要同时锁多个资源时,我们可以根据它们的编号(比如内存地址)进行排序,然后按顺序一个个加锁。

这样也能避免死锁。

示例代码:


public class CompositeResource {
    private final Object[] resources;

    public CompositeResource(Object... resources) {
        this.resources = Arrays.stream(resources)
            .sorted(Comparator.comparingInt(System::identityHashCode))
            .toArray();
    }

    public void lockAll() {
        for (Object res : resources) {
            synchronized(res) {}
        }
    }

    public void unlockAll() {
        for (int i = resources.length - 1; i >= 0; i--) {
            synchronized(resources[i]) {}
        }
    }
}

使用


CompositeResource cr = new CompositeResource(accountA, accountB);
try {
    cr.lockAll();
    // 执行转账操作
} finally {
    cr.unlockAll();
}

死锁的定时检测和恢复

篇幅太长,请参考原文

本文 的 原文 地址

原始的内容,请参考 本文 的 原文 地址

[本文 的 原文 地址](https://mp.weixin.qq.com/s/HV9NaPUO1hJe9vsSFCd9Q

相关文章
|
5月前
|
监控 Java Unix
6个Java 工具,轻松分析定位 JVM 问题 !
本文介绍了如何使用 JDK 自带工具查看和分析 JVM 的运行情况。通过编写一段测试代码(启动 10 个死循环线程,分配大量内存),结合常用工具如 `jps`、`jinfo`、`jstat`、`jstack`、`jvisualvm` 和 `jcmd` 等,详细展示了 JVM 参数配置、内存使用、线程状态及 GC 情况的监控方法。同时指出了一些常见问题,例如参数设置错误导致的内存异常,并通过实例说明了如何排查和解决。最后附上了官方文档链接,方便进一步学习。
581 4
|
4月前
|
搜索推荐 Java 定位技术
Java实现利用GeoLite2-City.mmdb根据IP定位城市的方法
在城市,国家,地区等地理位置数据获取之后,你可以依指定的业务需求,来进行进一步的数据处理。例如,你可以设计一个应用,根据用户的 IP 地址来个性化地展示内容,或者用于分析网络请求的来源等。
775 20
|
XML 数据采集 存储
使用Java和XPath在XML文档中精准定位数据
在数据驱动的时代,从复杂结构中精确提取信息至关重要。XML被广泛用于数据存储与传输,而XPath则能高效地在这些文档中导航和提取数据。本文深入探讨如何使用Java和XPath精准定位XML文档中的数据,并通过小红书的实际案例进行分析。首先介绍了XML及其挑战,接着阐述了XPath的优势。然后,提出从大型XML文档中自动提取特定产品信息的需求,并通过代理IP技术、设置Cookie和User-Agent以及多线程技术来解决实际网络环境下的数据抓取问题。最后,提供了一个Java示例代码,演示如何集成这些技术以高效地从XML源中抓取数据。
366 7
使用Java和XPath在XML文档中精准定位数据
|
7月前
|
安全 Java 开发者
Java并发迷宫:同步的魔法与死锁的诅咒
在Java并发编程中,合理使用同步机制可以确保线程安全,避免数据不一致的问题。然而,必须警惕死锁的出现,采取适当的预防措施。通过理解同步的原理和死锁的成因,并应用有效的设计和编码实践,可以构建出高效、健壮的多线程应用程序。
122 21
|
Java Maven 容器
java依赖冲突解决问题之ClassNotFoundException定位确认异常如何解决
java依赖冲突解决问题之ClassNotFoundException定位确认异常如何解决
|
10月前
|
监控 算法 Java
jvm-48-java 变更导致压测应用性能下降,如何分析定位原因?
【11月更文挑战第17天】当JVM相关变更导致压测应用性能下降时,可通过检查变更内容(如JVM参数、Java版本、代码变更)、收集性能监控数据(使用JVM监控工具、应用性能监控工具、系统资源监控)、分析垃圾回收情况(GC日志分析、内存泄漏检查)、分析线程和锁(线程状态分析、锁竞争分析)及分析代码执行路径(使用代码性能分析工具、代码审查)等步骤来定位和解决问题。
201 6
|
11月前
|
存储 Java
深入理解java对象的访问定位
这篇文章深入探讨了Java对象的访问定位机制,比较了使用句柄和直接指针两种主流的对象访问方式,并指出了它们各自的优势,例如句柄访问在对象移动时的稳定性和直接指针访问的速度优势。
102 1
深入理解java对象的访问定位
|
11月前
|
Java
Java面试题之cpu占用率100%,进行定位和解决
这篇文章介绍了如何定位和解决Java服务中CPU占用率过高的问题,包括使用top命令找到高CPU占用的进程和线程,以及使用jstack工具获取堆栈信息来确定问题代码位置的步骤。
736 0
Java面试题之cpu占用率100%,进行定位和解决
|
小程序 JavaScript Java
【Java】服务CPU占用率100%,教你用jstack排查定位
本文详细讲解如何使用jstack排查定位CPU高占用问题。首先介绍jstack的基本概念:它是诊断Java应用程序线程问题的工具,能生成线程堆栈快照,帮助找出程序中的瓶颈。接着,文章通过具体步骤演示如何使用`top`命令找到高CPU占用的Java进程及线程,再结合`jstack`命令获取堆栈信息并进行分析,最终定位问题代码。
1273 2
【Java】服务CPU占用率100%,教你用jstack排查定位
|
监控 安全 Java
Java多线程调试技巧:如何定位和解决线程安全问题
Java多线程调试技巧:如何定位和解决线程安全问题
267 2