Java死锁案例分享

wen java案例 2

Java死锁实战案例深度剖析:从现象到原理,教你彻底规避多线程“卡死”陷阱


📚 目录导读

  1. 什么是死锁?—— 四个必要条件通俗解读
  2. 经典案例复现:一个简单的转账系统为何“卡死”?
  3. 死锁的“元凶”:代码级拆解与JVM堆栈分析
  4. 线上故障排查实战:如何用jstack快速定位死锁
  5. 六大避坑策略与最佳实践(含代码改造)
  6. 高频面试问答:关于死锁你不得不知的5个问题
  7. 从“遇险”到“排雷”的思维跃迁

什么是死锁?—— 四个必要条件通俗解读

在多线程编程中,死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法推进。

Java死锁案例分享

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

  • 互斥条件:资源在同一时刻只能被一个线程占用。
  • 持有并等待:一个线程已持有了至少一个资源,同时又去请求新的资源,而该新资源已被其他线程占用。
  • 不可剥夺:线程已获得的资源,在未使用完之前不能被其他线程强行抢走。
  • 循环等待:若干线程之间形成一种头尾相接的循环等待资源关系。

❓ 问:死锁和活锁有什么区别? :死锁是“大家都动不了”,活锁是“大家都能动,但一直在重复无意义的动作”(比如两个人都给对方让路,结果左让右让还是堵在一起),活锁线程状态是RUNNABLE,而死锁线程是BLOCKED。


经典案例复现:一个简单的转账系统为何“卡死”?

我们模拟一个银行转账场景:账户A和账户B,线程1执行“A转给B”,线程2执行“B转给A”,代码如下(简化版):

class Account {
    int balance;
    // 转账方法
    void transfer(Account target, int amount) {
        synchronized (this) {      // 先锁住自己
            synchronized (target) { // 再锁住对方
                if (this.balance >= amount) {
                    this.balance -= amount;
                    target.balance += amount;
                }
            }
        }
    }
}

执行场景

  • 线程1:锁住A,请求锁B
  • 线程2:锁住B,请求锁A
  • 结果:线程1等B,线程2等A,永久等待

这个案例在真实的微服务分布式系统中也极为常见(比如同时扣减两个库存字段时加锁顺序不一致)。

❓ 问:为什么要加两把锁? :为了保证转账操作的原子性——必须同时更新两个账户的余额,防止并发下出现余额不一致或“超扣”问题,初衷是正确的,但锁顺序没统一,就成了典型死锁。


死锁的“元凶”:代码级拆解与JVM堆栈分析

当死锁发生时,最直观的判定方式是通过 jstack 打印线程堆栈,下面是上述案例的堆栈片段:

"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at com.demo.Account.transfer(Account.java:12)
    - waiting to lock <0x000000076b3c6d68> (a com.demo.Account)  // 等待锁B
    - locked <0x000000076b3c6d60> (a com.demo.Account)          // 已持有锁A
"Thread-2" - Thread t@13
   java.lang.Thread.State: BLOCKED
    at com.demo.Account.transfer(Account.java:12)
    - waiting to lock <0x000000076b3c6d60> (a com.demo.Account)  // 等待锁A
    - locked <0x000000076b3c6d68> (a com.demo.Account)          // 已持有锁B

JVM 甚至会在堆栈末尾打印 Found one Java-level deadlock: 字样,直接告诉我们该死锁线程的ID。

❓ 问:死锁一定会在生产环境立刻暴露吗? :不一定,死锁触发依赖于线程调度时序,有时需要高并发或特定请求顺序才会触发,这也是为什么它很难在测试环境被发现,一旦上线遇到高峰才“露头”。


线上故障排查实战:如何用jstack快速定位死锁

假设你收到了线上告警(接口长时间无响应),CPU占用率却不高,此时大概率是死锁或锁竞争,排查步骤如下:

  1. 找到Java进程PIDjps -lps -ef | grep java
  2. 输出线程快照jstack PID > threaddump.txt
  3. 搜索关键标识:执行 grep -A 20 "Found one Java-level deadlock" threaddump.txt,即可看到死锁线程及持有/等待的锁地址。
  4. 定位代码行:根据堆栈中的类名和行号(Account.java:12),直接修复。

❓ 问:除了jstack,还有别的工具吗? :还可以用 jconsole(图形化监控)、VisualVM(自动检测死锁),以及阿里开源的 Arthasthread -b 一键查找阻塞线程),但 jstack 是最轻量、无侵入的命令行方案。


六大避坑策略与最佳实践(含代码改造)

  • 加锁顺序一致化(最常用),比如所有转账都先锁ID较小的账户,再锁ID较大的账户,改造代码:
    int fromHash = System.identityHashCode(this);
    int toHash = System.identityHashCode(target);
    if (fromHash > toHash) { // 交换目标,保证固定顺序
        // 先锁 target,再锁 this
    } else {
        // 先锁 this,再锁 target
    }
  • 使用 tryLock 带超时,用 ReentrantLocktryLock(3, TimeUnit.SECONDS),拿不到锁就回滚并重试,而不是死等。
  • 使用并发工具类java.util.concurrent.ConcurrentHashMap 的原子方法,或 StampedLock,减少多把锁嵌套。
  • 缩小同步块范围,锁内只做必要操作,不要做IO或耗时计算。
  • 使用无锁编程,例如采用CAS(比较并交换)实现账户余额更新,不行就重试。
  • 死锁检测机制,启动一个后台线程定期扫描 ThreadMXBeanfindDeadlockedThreads(),发现后干预(比如重启线程或记录日志报警)。

❓ 问:策略一(顺序锁)有没有副作用? :有,如果两个账户ID相差很大,可能造成“锁饥饿”——ID小的账户总被先锁,但整体公平性可接受,更公平的做法是使用 ReentrantLock(true) 公平锁,但性能略降。


高频面试问答:关于死锁你不得不知的5个问题

  • Q1:检测死锁的底层原理是什么?
    JVM 通过跟踪每个线程的 monitor 持有状态和等待关系,构建一张“资源-线程”有向图,如果图中存在环,则判定为死锁。

  • Q2:数据库死锁和Java死锁有区别吗?
    数据库死锁是数据库引擎检测到事务间互相锁行,会自动选一个事务回滚(牺牲品);Java级别没有自动回滚机制,只能靠开发者手段避免或手动干预。

  • Q3:volatile 能解决死锁吗?
    不能,volatile 只保证可见性和有序性,不解决原子性,也无法避免锁的竞争和循环等待。

  • Q4:死锁时线程状态是 BLOCKED 还是 WAITING?
    synchronized 造成的死锁是 BLOCKED;LockSupport.park()Object.wait() 造成的死锁是 WAITING,堆栈中都能看出来。

  • Q5:如何设计一个不可死锁的架构?
    核心原则:要么避免嵌套锁,要么用超时机制兜底,要么将资源抽象成单一竞争入口(比如用消息队列串行化操作)。


从“遇险”到“排雷”的思维跃迁

死锁是并发编程中最隐蔽的“杀手”之一,通过上面的案例我们可以看到,死锁的根源往往不在于锁的数量,而在于锁的推进顺序和对资源的无限等待,解决死锁,最高效的手段不是频繁排查,而是从一开始就建立“锁顺序规范”和“超时兜底”的防御性编程习惯。

希望这篇案例剖析,能帮助你在实际项目中少踩一个坑,如果你曾遇到更诡异的死锁场景,欢迎在评论区留言交流——毕竟,每一个死锁案例背后,都是多线程世界里一次值得记录的“交通事故”。

抱歉,评论功能暂时关闭!