死锁:线程卡死不是偶然,而是设计问题


网罗开发 (小红书、快手、视频号同名)

  大家好,我是 展菲,目前在上市企业从事人工智能项目研发管理工作,平时热衷于分享各种编程领域的软硬技能知识以及前沿技术,包括iOS、前端、Harmony OS、Java、Python等方向。在移动端开发、鸿蒙开发、物联网、嵌入式、云原生、开源等领域有深厚造诣。

图书作者:《ESP32-C3 物联网工程开发实战》
图书作者:《SwiftUI 入门,进阶与实战》
超级个体:COC上海社区主理人
特约讲师:大学讲师,谷歌亚马逊分享嘉宾
科技博主:华为HDE/HDG

我的博客内容涵盖广泛,主要分享技术教程、Bug解决方案、开发工具使用、前沿科技资讯、产品评测与使用体验 。我特别关注云服务产品评测、AI 产品对比、开发板性能测试以及技术报告,同时也会提供产品优缺点分析、横向对比,并分享技术沙龙与行业大会的参会体验。我的目标是为读者提供有深度、有实用价值的技术洞察与分析。

展菲:您的前沿技术领航员

👋 大家好,我是展菲!

📱 全网搜索"展菲",即可纵览我在各大平台的知识足迹。

📣 公众号"Swift社区",每周定时推送干货满满的技术长文,从新兴框架的剖析到运维实战的复盘,助您技术进阶之路畅通无阻。

💬 微信端添加好友"fzhanfei",与我直接交流,不管是项目瓶颈的求助,还是行业趋势的探讨,随时畅所欲言。

📅 最新动态:2025 年 3 月 17 日

快来加入技术社区,一起挖掘技术的无限潜能,携手迈向数字化新征程!

文章目录

    • 前言
    • 什么是死锁?用最直观的方式理解
      • [1. 一个最经典的死锁 Demo(可直接运行)](#1. 一个最经典的死锁 Demo(可直接运行))
      • [2. 这段代码到底发生了什么?](#2. 这段代码到底发生了什么?)
    • 真实业务中,死锁通常长什么样?
      • [常见场景 1:订单 + 库存](#常见场景 1:订单 + 库存)
      • [常见场景 2:缓存 + 数据库](#常见场景 2:缓存 + 数据库)
    • 解决方案一:按固定顺序加锁(最重要)
      • [1. 原则很简单](#1. 原则很简单)
      • [2. 改造上面的死锁代码](#2. 改造上面的死锁代码)
      • [3. 为什么这种方式最靠谱?](#3. 为什么这种方式最靠谱?)
    • [解决方案二:使用 tryLock,避免无限等待](#解决方案二:使用 tryLock,避免无限等待)
      • [1. 核心思想](#1. 核心思想)
      • [2. 示例代码(可运行)](#2. 示例代码(可运行))
      • [3. tryLock 的真实价值](#3. tryLock 的真实价值)
    • 解决方案三:使用并发包,减少"自己加锁"
      • [1. 示例:用 ConcurrentHashMap 替代 synchronized Map](#1. 示例:用 ConcurrentHashMap 替代 synchronized Map)
      • [2. 为什么并发包更安全?](#2. 为什么并发包更安全?)
    • 一些非常实用的经验总结
    • 总结

前言

只要你写过稍微复杂一点的并发代码,大概率都遇到过这种情况:

  • 程序不报错
  • CPU 占用不高
  • 日志也不动
  • 但是服务就是「卡住了」

十有八九,你遇到的是 死锁。

死锁本身并不神秘,本质只有一句话:

多个线程互相等待对方持有的锁资源,谁也不肯先放手。

但真正的问题在于------
死锁往往不是一行代码的问题,而是整体设计的问题。

下面我们一步一步来拆。

什么是死锁?用最直观的方式理解

先别急着上概念,我们直接看代码。

1. 一个最经典的死锁 Demo(可直接运行)

下面这段代码,几乎是所有死锁案例的「祖师爷」。

java 复制代码
public class DeadLockDemo {

    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1 拿到了 lockA");
                sleep(100);
                synchronized (lockB) {
                    System.out.println("Thread-1 拿到了 lockB");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2 拿到了 lockB");
                sleep(100);
                synchronized (lockA) {
                    System.out.println("Thread-2 拿到了 lockA");
                }
            }
        });

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

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ignored) {}
    }
}

2. 这段代码到底发生了什么?

执行流程非常容易脑补:

  • Thread-1

    • 先拿 lockA
    • 再等 lockB
  • Thread-2

    • 先拿 lockB
    • 再等 lockA

结果就是:

  • Thread-1 等 Thread-2 放 lockB
  • Thread-2 等 Thread-1 放 lockA
  • 双方无限期等待

程序不会崩,也不会报错,但它已经「死」了。

真实业务中,死锁通常长什么样?

死锁在真实项目中,很少像上面这么"工整",但模式几乎一模一样。

常见场景 1:订单 + 库存

text 复制代码
线程 A:锁订单 → 锁库存
线程 B:锁库存 → 锁订单

只要顺序一反,风险立刻出现。

常见场景 2:缓存 + 数据库

  • 一个线程:先查缓存,没命中再锁 DB
  • 另一个线程:先更新 DB,再回写缓存

在高并发下,非常容易卡死。

重点不是你用了几把锁,而是:锁的顺序有没有被统一设计过。

解决方案一:按固定顺序加锁(最重要)

这是成本最低、收益最高的一种方式。

1. 原则很简单

只要多个线程获取多个锁,顺序必须完全一致。

2. 改造上面的死锁代码

java 复制代码
public class OrderedLockDemo {

    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1 拿到了 lockA");
                sleep(100);
                synchronized (lockB) {
                    System.out.println("Thread-1 拿到了 lockB");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lockA) {   // 注意:顺序统一
                System.out.println("Thread-2 拿到了 lockA");
                sleep(100);
                synchronized (lockB) {
                    System.out.println("Thread-2 拿到了 lockB");
                }
            }
        });

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

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ignored) {}
    }
}

3. 为什么这种方式最靠谱?

  • 不依赖 JVM 特性
  • 不依赖并发工具类
  • 不增加复杂度
  • 靠 设计约束 消灭死锁

在实际项目中,很多团队会直接把锁顺序写进编码规范。

解决方案二:使用 tryLock,避免无限等待

如果你用的是 ReentrantLock,那就有了更灵活的手段。

1. 核心思想

拿不到锁,就不要一直等。

2. 示例代码(可运行)

java 复制代码
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDemo {

    private static final ReentrantLock lockA = new ReentrantLock();
    private static final ReentrantLock lockB = new ReentrantLock();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            try {
                if (lockA.tryLock(1, TimeUnit.SECONDS)) {
                    System.out.println("Thread-1 拿到 lockA");
                    sleep(100);
                    if (lockB.tryLock(1, TimeUnit.SECONDS)) {
                        try {
                            System.out.println("Thread-1 拿到 lockB");
                        } finally {
                            lockB.unlock();
                        }
                    }
                }
            } catch (InterruptedException ignored) {
            } finally {
                if (lockA.isHeldByCurrentThread()) {
                    lockA.unlock();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                if (lockB.tryLock(1, TimeUnit.SECONDS)) {
                    System.out.println("Thread-2 拿到 lockB");
                    sleep(100);
                    if (lockA.tryLock(1, TimeUnit.SECONDS)) {
                        try {
                            System.out.println("Thread-2 拿到 lockA");
                        } finally {
                            lockA.unlock();
                        }
                    }
                }
            } catch (InterruptedException ignored) {
            } finally {
                if (lockB.isHeldByCurrentThread()) {
                    lockB.unlock();
                }
            }
        });

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

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ignored) {}
    }
}

3. tryLock 的真实价值

  • 不会「卡死」
  • 失败可重试
  • 可上报监控
  • 适合 非核心路径

但要注意:

  • 代码复杂度会上升
  • 释放锁必须非常小心
  • 更适合中高级并发场景

解决方案三:使用并发包,减少"自己加锁"

很多死锁,本质原因只有一个:

你加了太多不必要的锁。

1. 示例:用 ConcurrentHashMap 替代 synchronized Map

java 复制代码
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentDemo {

    private static final ConcurrentHashMap<String, Integer> map =
            new ConcurrentHashMap<>();

    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            map.put("A", 1);
            map.computeIfAbsent("B", k -> 2);
        });

        Thread t2 = new Thread(() -> {
            map.put("B", 3);
            map.computeIfAbsent("A", k -> 4);
        });

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

2. 为什么并发包更安全?

  • 内部锁粒度更细
  • 避免全局大锁
  • 经过大量生产验证

在业务代码中:

  • 能用并发容器,就别手写锁
  • 能用原子类,就别 synchronized

一些非常实用的经验总结

结合实际项目,关于死锁我一般只记这几条:

  1. 死锁不是"偶发 bug",而是设计缺陷
  2. 多锁场景,第一反应永远是「顺序是否一致」
  3. tryLock 是兜底方案,不是万能解
  4. 并发包不是为了性能,而是为了安全
  5. 看到 synchronized 嵌套,警惕性要拉满

总结

死锁最可怕的地方不在于它难,而在于:

  • 它不会立刻暴露
  • 它只在高并发下出现
  • 它往往发生在你最不想出问题的时候
相关推荐
一 乐3 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长4 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn4 小时前
Spring 注解使用详解
java·spring
一条小小yu4 小时前
Spring IoC的理解
java·后端·spring
落魄实习生4 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈4 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白6 小时前
核心功能(Service接口)
java·mybatis
乌暮7 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习
大猫和小黄7 小时前
深入理解 Java Optional:从设计哲学到工程实战
java