拯救Java线程死锁问题!最强解决方案看这里!

@T拯救Java线程死锁问题!最强解决方案看这里!OC

众所周知,在Java 语言中每个Object都有一个隐含的锁,通过该锁,我们可以使用synchronized 关键字来保证代码块的原子性。synchronized 能够使线程在执行到该代码块时,自动获取此内部锁,而一旦离开该代码块,无论是完成或者中断都会自动释放锁。显然这是一个独占锁,每个锁请求之间是互斥的。相对于众多高级锁 (Lock/ReadWriteLock 等),synchronized 的代价都比后者要高,但是 synchronzied 的语法比较简单,而且也比较容易使用和理解。Lock 一旦调用了 lock() 方法获取到锁而未正确释放的话,便很有可能造成死锁,因此,我们总是在finally代码块中调用unlock(),用以保证锁一定会被释放,而这在代码结构上也是一次调整和冗余。

Lock 的实现已经将硬件资源用到了极致,所以未来可优化的空间不大,除非硬件有了更高的性能,但是synchronized不同,它只是一种规范的实现方式,在不同的平台以及不同的硬件,都还有很高的提升空间,也是未来java锁优化的主要方向。既然 synchronzied 都不可能避免死锁产生,那么死锁情况便会是经常出现的错误,本期,ISEC实验室的老师为大家具体描述死锁发生的原因及解决方法。

一、死锁描述

死锁是操作系统层面的一个错误,是进程死锁的简称,最早在 1965 年由 Dijkstra 在研究银行家算法时提出的,它是计算机操作系统乃至整个并发程序设计领域最难处理的问题之一。

事实上,计算机世界有很多事情需要用多线程方式解决,因为这样才能最大程度上利用资源,体现出计算的高效。但是,实际上来说,计算机系统中有很多一次只能由一个进程使用资源的情况,例如打印机,同时只能有一个进程控制它。在多通道程序设计环境中,若干进程往往要共享这类资源,而且一个进程所需要的资源很可能不止一个。因此就会出现若干进程竞争有限资源,又推进顺序不当,从而构成无限期循环等待的局面,我们称这种状态为死锁。

简单一点描述,死锁是指多个进程循环等待他方占有的资源而无限期地僵持下去的局面。很显然,如果没有外力的作用,那么死锁涉及到的各个进程都将永远处于封锁状态。

系统发生死锁现象不仅浪费大量的系统资源,还会导致整个系统崩溃,带来灾难性后果。所以,对于死锁问题在理论上和技术上都必须予以高度重视。

银行家算法

一个银行家如何将一定数目的资金安全地借给若干个客户,既能使客户借到钱完成要干的事,同时又能使自己收回全部资金而不至于破产呢?银行家就像一个操作系统,客户就像运行的进程,银行家的资金就是系统的资源。

银行家算法需要确保以下四点:

1.当一个顾客对资金的最大需求量不超过银行家现有的资金时就可接纳该顾客;

2.顾客可以分期贷款, 但贷款的总数不能超过最大需求量;

3.当银行家现有的资金不能满足顾客尚需的贷款数额时,对顾客的贷款可推迟支付,但总能使顾客在有限的时间里得到贷款;

4.当顾客得到所需的全部资金后,一定能在有限的时间里归还所有的资金。

清单 1. 银行家算法实现

死锁示例

死锁问题是多线程特有的问题,它可以被认为是线程间切换消耗系统性能的一种极端情况。在死锁时,线程间相互等待资源,而又不释放自身的资源,导致无穷无尽的等待,其结果是系统任务永远无法执行完成。死锁问题是在多线程开发中应该坚决避免和杜绝的问题。

一般来说,出现死锁问题需要满足以下条件:

  1. 互斥条件:一个资源每次只能被一个线程使用。

  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不可剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。

  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

只要破坏死锁 4 个必要条件之一中的任何一个,死锁问题就能被解决。我们先来看一个示例,前面说过,死锁是两个甚至多个线程被永久阻塞时的一种运行局面,造成这种局面,至少需要两个线程以及两个或者多个共享资源。

如以下清单 2 所示的代码示例,我们编写了一个简单的程序,它将会引起死锁发生,这样我们就会明白如何分析它。

清单 2. 死锁示例

在上面的程序中同步线程实现了 Runnable 接口,它工作的是两个对象,这两个对象向对方寻求死锁而且都在使用同步阻塞。在主函数中,我使用了三个为同步线程运行的线程,而且在其中每个线程中都有一个可共享的资源。这些线程以向第一个对象获取封锁这种方式运行,但是当它试着向第二个对象获取封锁时,便会进入等待状态,因为它已经被另一个线程封锁住了,这样,在线程引起死锁的过程中,就形成了一个依赖于资源的循环。当我执行上面的程序时,就产生了输出,但是程序却因为死锁无法停止,输出如以下清单 3 所示。

清单 3. 清单 2 运行输出

在此我们可以清楚地在输出结果中辨认出死锁局面,但是在实际所用的应用中,发现死锁并将它排除是非常难的。

二、死锁情况诊断

JVM 提供了一些工具可以来帮助诊断死锁的发生,如下面程序清单 4 所示,我们实现了一个死锁,以linux为例,然后尝试通过 jstack 命令追踪、分析死锁发生。

清单 4. 死锁示例代码

执行代码后,在shell的命令窗口找到当前发生死锁的进程号,如下清单5:

根据上面的进程号,通过jstack命令查找对应的堆栈信息,如下清单6:

stack 可用于导出 Java 应用程序的线程堆栈,-l 选项用于打印锁的附加信息。我们运行 jstack 命令,输出如上清单6。 从打印出的堆栈信息(清单6)中,可直观的确认出现死锁的位置。

三、死锁解决方案

死锁是由四个必要条件导致的,所以一般来说,只要破坏这四个必要条件中的一个条件,死锁情况就应该不会发生。

1.如果想要打破互斥条件,我们需要允许进程同时访问某些资源,这种方法受制于实际场景,不太容易实现条件。

2.打破不可抢占条件,这样需要允许进程强行从占有者那里夺取某些资源,或者简单一点理解,占有资源的进程不能再申请占有其他资源,必须释放手上的资源之后才能发起申请,这个其实也很难找到适用场景。

3.进程在运行前申请得到所有的资源,否则该进程不能进入准备执行状态。这个方法看似有点用处,但是它的缺点是可能导致资源利用率和进程并发性降低。

4.避免出现资源申请环路,即对资源事先分类编号,按号分配。这种方式可以有效提高资源的利用率和系统吞吐量,但是增加了系统开销,增大了进程对资源的占用时间。

如果我们在死锁检查时发现了死锁情况,那么就要努力消除死锁,使系统从死锁状态中恢复过来。以下为消除死锁的几种方式:

1.最简单、最常用的方法就是进行系统的重新启动,不过这种方法代价很大,它意味着在这之前所有的进程已经完成的计算工作都将付之东流,包括参与死锁的那些进程,以及未参与死锁的进程。

2.撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁,这时又分两种情况:一次性撤消参与死锁的全部进程,剥夺全部资源;或者逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。一般来说,选择逐步撤消的进程时要按照一定的原则进行,目的是撤消那些代价最小的进程,比如按进程的优先级确定进程的代价;考虑进程运行时的代价和与此进程相关的外部作业的代价等因素。

3.进程回退策略,即让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行,以求再次执行时不再发生死锁。虽然这是个较理想的办法,但是操作起来系统开销极大,要有堆栈这样的机构来记录进程的每一步变化,以便今后的回退,有时这是无法做到的。

其实,即便是商业产品,依然会有很多死锁情况发生,例如 MySQL 数据库,它也经常容易出现死锁案例。

MySQL死锁情况解决方法

假设我们用 Show innodb status 检查引擎状态时发现了死锁情况,如以下清单 7 所示。

清单 7. MySQL 死锁

我们假设涉事的数据表上面有一个索引,这次的死锁就是由于两条记录同时访问到了相同的索引造成的。

我们首先来看看 InnoDB 类型的数据表,只要能够解决索引问题,就可以解决死锁问题。MySQL 的 InnoDB 引擎是行级锁,需要注意的是,这不是对记录进行锁定,而是对索引进行锁定。在 UPDATE、DELETE 操作时,MySQL 不仅锁定 WHERE 条件扫描过的所有索引记录,而且会锁定相邻的键值,即所谓的 next-key locking;

如语句 UPDATE TSK_TASK SET UPDATE_TIME = NOW() WHERE ID > 10000 会锁定所有主键大于等于 1000 的所有记录,在该语句完成之前,你就不能对主键等于 10000 的记录进行操作;当非簇索引 (non-cluster index) 记录被锁定时,相关的簇索引 (cluster index) 记录也需要被锁定才能完成相应的操作。

再分析一下发生问题的两条 SQL 语句:

执行时,MySQL 会使用 KEY_TSKTASK_MONTIME2 索引,因此首先锁定相关的索引记录,因为 KEY_TSKTASK_MONTIME2 是非簇索引,为执行该语句,MySQL 还会锁定簇索引(主键索引)。

假设“update TSK_TASK set STATUS_ID=1067,UPDATE_TIME=now () where ID in (9921180)”几乎同时执行时,本语句首先锁定簇索引 (主键),由于需要更新 STATUS_ID 的值,所以还需要锁定 KEY_TSKTASK_MONTIME2 的某些索引记录。

这样第一条语句锁定了 KEY_TSKTASK_MONTIME2 的记录,等待主键索引,而第二条语句则锁定了主键索引记录,而等待 KEY_TSKTASK_MONTIME2 的记录,这样死锁就产生了。

我们通过拆分第一条语句解决了死锁问题:即先查出符合条件的 ID:select ID from TSK_TASK where STATUS_ID=1061 and MON_TIME < date_sub(now(), INTERVAL 30 minute);然后再更新状态:update TSK_TASK set STATUS_ID=1064 where ID in (….)。

四、结束语

我们发现,死锁虽然是较早就被发现的问题,但是很多情况下我们设计的程序里还是经常发生死锁情况。我们不能只是分析如何解决死锁这类问题,还需要具体找出预防死锁的方法,这样才能从根本上解决问题。总的来说,还是需要系统架构师、程序员不断积累经验,从业务逻辑设计层面彻底消除死锁发生的可能性。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kerreys

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值