深入剖析Java并发编程中的死锁问题

引言

java并发编程中的死锁在单体java进程中也算是比较棘手的问题,所以本文将针对死锁问题的本质和一些规避手段进行详细的介绍,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

死锁问题的本质

关于死锁,哲学家进餐就是一个非常典型的案例,假设有5个哲学家和5根筷子,每个哲学家必须拿到一双筷子后才能进餐,完成进餐后放下筷子进入思考。我们试想这样一种极端情况,每个哲学家左手都拿一根筷子,都在等待其他人放下一根筷子进餐,由此各自都在等待且不放下彼此手里筷子的过程,就造成了死锁:

这很好的解释了死锁的概念,本质来说造成死锁的四大原因有:

  1. 互斥:一个资源只能被一个线程获取
  2. 请求与保持:线程拿到资源后,在没有获取到想要的资源前,不会释放已有资源
  3. 不可剥夺:资源被其他线程持有时,其他线程无法剥夺资源使用权
  4. 循环等待条件:若干线程获取资源时,双方按照相反的方向获取,头尾相接构成环路

而这个条件同理换到哲学家进餐问题上就是:

  1. 每根筷子只能被一个哲学家获取
  2. 哲学家拿到筷子之后,除非拿到一双筷子完成进餐,否则不会放下筷子
  3. 哲学家持有筷子期间,其他人不可剥夺
  4. 5个哲学家左手都拿着筷子,彼此都在等待其他哲学家手里筷子,造成阻塞环路

JVM面对死锁问题没有像数据库那样强大(默认超时释放资源),一旦线程陷入死锁,就可能不可再用了,进而造成:

  1. 线程僵死导致整个java进程业务流程阻塞
  2. 死锁线程不可处理新的任务,造成服务吞吐量下降,进而权限瘫痪

除非显示的将系统完全中止重启,并希望不再发生类似的事情。

死锁的危害

饥饿问题

死锁问题会导致大量线程僵持活跃在cpu中长时间执行,使得CPU时钟周期被长期霸占,例如:

  1. Java中线程优先级使用不当且因为各种原因进入死锁,导致其他低优先级的线程长时间得不到时间执行时间片而执行超时。
  2. 持有锁的线程迟迟未能结束(因为活跃性问题等原因进入无限循环或者本身就是一个大循环),导致其他线程长时间等待。

中java线程的函数中虽然定义了setPriorit用于设置线程的优先级,但这只是作为操作系统调度的参考,手动设置java线程优先级的作用是微乎其微的,对于问题1出现的概率也不高,同时笔者也建议非必要的情况下不要去调整线程的优先级。

糟糕的响应

对于计算密集型的后台任务,利用使用并发容器在后台频繁写入一些热点数据,这就可能导致并发读操作因为这些写操作而阻塞,导致等待时间变长。为了保证直观的GUI应用的响应,我们建议可以适当调低后台任务的线程优先级,异或者采用分段锁等方式分散并发压力。

活锁问题

活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但可能也会出现线程不能继续执行后续工作,例如当前线程将处理失败的任务每次失败都提交到队列首部,不断重试执行这个失败任务,造成后面的任务无法执行,这就是典型的线程饥饿。

解决办法即随机分配,例如redis raft选举主观下线后各个节点随机一段时间发起拉票,从而降低平票的概率,保证尽可能早的选举出leader,同样的我们的随机策略也可以将失败的任务随机采用随机性重试机制,在指定时间后将任务存入队列中重试。

scss 复制代码
void sentinelTimer(void) {
    // 前置检查事件定期任务是否因为系统负载过大或者各种原因导致时钟回拨,或者处理过长,进入tilt模式,该模式哨兵只会定期发送和接收命令
    sentinelCheckTiltCondition();
    //监听的master节点作为参数传入,进行逐个通信处理
    sentinelHandleDictOfRedisInstances(sentinel.masters);
    //......
    //随机调整执行频率避免同时执行,确保提高选举一次性成功的概率
    server.hz = REDIS_DEFAULT_HZ + rand() % REDIS_DEFAULT_HZ;
}

详解不同的死锁案例与解决方案

锁顺序造成的死锁

基于上述的基本概念,我们引出第一个死锁的案例,造成该死锁的原因是两个线程获取锁的方向是相反的,各自拿到第一把锁之后,都在等待对方的第二把锁,出现了加锁依赖性问题,出现阻塞死锁。

假设线程1执行leftRight对应先拿leftLock再获取rightLock,线程2反之,对应的出现这样一个造成死锁的流程:

  1. 线程0上leftLock
  2. 与此同时,线程1上rightLock
  3. 线程0尝试获取rightLock,发现被线程1持有,陷入阻塞等待
  4. 线程1尝试获取leftLock,发现被线程0持有,陷入阻塞等待
  5. 彼此僵持构成死锁

对应上述事例,我们给出下面这段代码:

arduino 复制代码
private static final Object leftLock = new Object();
    private static final Object rightLock = new Object();


    /**
     * 线程0先上左锁 再上右锁
     */
    public static void leftRight() {
        synchronized (leftLock) {
            synchronized (rightLock) {
                Console.log("线程0上锁成功");
            }
        }
    }

    /**
     * 线程1先上右锁再上左锁
     */
    public static void rightLeft() {
        synchronized (rightLock) {
            synchronized (leftLock) {
                Console.log("线程1上锁成功");
            }
        }
    }

对应的笔者给出下面这段测试用例:

scss 复制代码
 Thread t1 = new Thread(() -> leftRight());
        Thread t2 = new Thread(() -> rightLeft());
        
        t1.start();
        t2.start();

启动后发现两个线程僵持着,笔者基于jstack -b pid定位到了这两个线程的死锁代码段,也就是我们上文的两个函数对应的第二次上锁的位置:

因为造成该问题的原因上因为两者顺序相反造成死锁环路,所以解决的方式也很简单,让线程0和线程1保持一样的上锁顺序,即让二者从相同的方向竞争获取两把锁:

隐蔽的动态函数死锁

我们再来看看这个案例,该函数的逻辑比较简单,即直接将from账户的钱扣减,并加到to账户身上,从而完成一次转账操作,为了保证并发安全,该函数中执行转账操作时会以转账方和收款方实例作为锁的对象:

csharp 复制代码
public static void transfer(Account from, Account to, int amount) {
        synchronized (from) {
            synchronized (to) {
                //转账用户扣除转账额度
                from.setMoney(from.getMoney() - amount);
                //接收方增加额度
                to.setMoney(to.getMoney() + amount);
            }
        }
    }

咋一看这段代码没有问题,但我们还是试想一下这样一个场景:

  1. 小明打算给小王转账100,触发transfer调用
  2. 与此同时,小王也打算给小明转账200,触发transfer调用
  3. 小明的转账函数先对自己上锁,然后尝试锁住小王实例
  4. 小王的转账函数先对自己的实例上锁,然后尝试锁住小明的实例

是不是很熟悉?两个函数调用在小明和小王之间僵持阻塞,再一次构成环路死锁:

对应的代码如下代码所示,两个不同的账户按照相反的方向给对方转账,由此出现因为时序问题,造成动态调用死锁:

scss 复制代码
     Account from = new Account();
        Account to = new Account();
        //两个线程按照相反的方向给对方转账
        new Thread(()->transfer(from, to, 100)).start();
        new Thread(()->transfer(to, from, 200)).start();

对于这种问题的解决思路,永远是要保证让并发线程竞争锁的顺序,因为锁是由外部传参进来的,随机性比较大,所以正确的排序锁的方式笔者这样一个思路:

  1. 比对转账双方实例的hashCode,那个小先尝试上那把锁
  2. 因为hashcode存在冲突碰撞的情况,所以在hashCode一样的情况下,则采用加时赛机制,我们建议线程同一去争抢加时锁(tile breaking lock)然后再尝试获取from和to两把锁,由此避免环路死锁:

对应代码示例如下,读者可结合注释理解:

scss 复制代码
//加时锁
private static final Object tieLock = new Object();

public static void transfer(Account from, Account to, int amount) {

        if (from.hashCode() > to.hashCode()) {//如果from大先从小的to开始上锁
            synchronized (to) {
                synchronized (from) {
                    doTransfer(from, to, amount);
                }
            }
        } else if (from.hashCode() < to.hashCode()) {//from小于to按照正常顺序执行
            synchronized (from) {
                synchronized (to) {
                    doTransfer(from, to, amount);
                }
            }

        } else { //hash值一样则上加时锁
            synchronized (tieLock) {
                synchronized (from) {
                    synchronized (to) {
                        doTransfer(from, to, amount);
                    }
                }
            }

        }
    }

嵌套包含死锁和开放调用

协作对象死锁问题相较于上述问题来说更加隐蔽,这两个锁并不一定是在一个方法上获取的,同样是持有当前方法锁的情况下,尝试获取外部入参的协作对象的实例锁,即多线程并发争抢当前方法的实例锁,再到函数内部调用入参对象的方法实例锁。

举个例子,服务A和服务B彼此会互相调用,服务A希望调用完服务B之后完成一次调用次数累计,服务B同理,因为要保证计数统计操作的原子性,彼此的函数都在方法上加了synchronized关键字如下代码所示:

arduino 复制代码
private static class AService {
        private int count;

        public synchronized void aFunc(BService bService) {//先上自己的实例锁
            bService.func(this);//再调用外部对象的方法,尝试上入参实例锁
            count++;//完成方法计数统计
        }

        public synchronized void func(BService bService) {

        }
    }

    private static class BService {
        private int count;

        public synchronized void bFunc(AService aService) {
            aService.func(this);
            count++;
        }

        public synchronized void func(AService aService) {

        }
    }

我们事项这样一个情况:

  1. 线程0调用aService的aFunc,先获取到aService的实例锁
  2. 线程1调用bService的bFunc,先获取到bService的实例锁
  3. 线程0尝试获取入参bService的实例锁,阻塞等待线程1释放
  4. 线程1同理

还是熟悉的环路,只不过这个涉及多个对象之间的函数调用更加的隐蔽:

其实仔细审查上述代码之间的调用链,从逻辑分析的角度来看,它不仅仅是一个环路,更像是两把实例锁之间对于彼此使用权的争抢,从调用链路来看,无论是a服务还是b服务,从调用的那一刻起就已经决定整个函数的调用必须是持有一把锁,尝试把另一把锁包在当前实例锁的维度中,也就是这种带有包含关系的锁竞争,最终将平行为度的锁资源竞争变成了各自持有一把锁情况下争抢包含锁的死锁问题:

所以解决这种嵌套包含锁的问题,就必须打破锁之间的嵌套包哈关系,以本文为例,我们只需将方法锁的关键字移动到仅仅需要保证互斥关系的count变量上,将包含关系变为并行关系:

java 复制代码
private static class AService {
        private int count;

        public void aFunc(BService bService) {//先上自己的实例锁
            bService.func(this);//再调用外部对象的方法,尝试上入参实例锁

            synchronized (this) {
                count++;//完成方法计数统计
            }
        }

        public synchronized void func(BService bService) {

        }
    }

    private static class BService {
        private int count;

        public void bFunc(AService aService) {
            aService.func(this);

            synchronized (this) {
                count++;//完成方法计数统计
            }
        }

        public synchronized void func(AService aService) {

        }
    }

代码改造完成后,从整个服务调用链路来看:

  1. 线程0 执行aFunc尝试调用func
  2. 线程1执行bFunc尝试调用func
  3. 线程0调用b服务的func成功,拿到b服务的锁
  4. 线程1尝试调用a服务的func成功,完成后准备上自己的b服务实例锁,发现被线程0持有,阻塞等待
  5. 线程0完成b服务调用返回,上自己的实例锁完成服务调用,释放所有锁
  6. 线程1完成所有调用

需要补充一点,开放调用虽然解决了协作对象间请求与保持的死锁条件,但这会使得原本原子操作变成非原子操作,所以使用时还是需要结合业务场景综合考量一下。

死锁的诊断

死锁故障定位技巧和工具推荐

以笔者个人经验,对于死锁的排查方式大体遵循如下步骤:

  1. 通过监控工具定位阻塞的代码段,例如笔者上文所使用的jstack指令
  2. 基于代码段定位阻塞的锁以及所有涉及该锁调用的函数,梳理出调用链
  3. 基于该调用链,推理出发生死锁的情况并复现问题
  4. 基于上述几种锁的情况和解决步骤进行修复

本文是基于JVM的jstack工具查看最下方的线程堆栈信息,实际上类似于arthas等这种监控工具会更方便,感兴趣的读者可以参考笔者这篇文章:

简明的Arthas使用教程:mp.weixin.qq.com/s/_K_OwlYbD...

小结

本文介绍的死锁的基本概念和发生的原因,同时也说明死锁的危害并复现了几种经典的死锁案例与解决对策,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

参考

《Java并发编程实战》

本文使用 markdown.com.cn 排版

相关推荐
嘉伟桑14 小时前
Python调用电价API返回JSON示例:分时电价、现货电价和字段解析
后端
武子康15 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅15 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries15 小时前
文件上传系统
后端
止语Lab16 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活16 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困16 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn16 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户2986985301416 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户772831049084016 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端