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


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

大家好,我是 展菲,目前在上市企业从事人工智能项目研发管理工作,平时热衷于分享各种编程领域的软硬技能知识以及前沿技术,包括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 嵌套,警惕性要拉满

总结

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

  • 它不会立刻暴露
  • 它只在高并发下出现
  • 它往往发生在你最不想出问题的时候
相关推荐
uup2 小时前
防止短信验证码接口被盗刷问题
java
xxxmine2 小时前
ConcurrentHashMap 和 Hashtable 的区别详解
java·开发语言
凛_Lin~~2 小时前
安卓 面试八股文整理(原理与性能篇)
android·java·面试·安卓
weixin_436525072 小时前
NestJS-TypeORM QueryBuilder 常用 SQL 写法
java·数据库·sql
oioihoii2 小时前
C++虚函数表与多重继承内存布局深度剖析
java·jvm·c++
wangchen_02 小时前
深入理解 C/C++ 强制类型转换:从“暴力”到“优雅”
java·开发语言·jvm
Wang15302 小时前
Java三大核心热点专题笔记
java
潲爺3 小时前
《Java 8-21 高频特性实战(上):5 个场景解决 50% 开发问题(附可运行代码)》
java·开发语言·笔记·学习
资生算法程序员_畅想家_剑魔3 小时前
算法-回溯-14
java·开发语言·算法