什么是死锁,产生死锁的原因

什么是死锁,产生死锁的原因,第1张

所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 因此我们举个例子来描述,如果此时有一个线程A,按照先锁a再获得锁b的的顺序获得锁,而在此同时又有另外一个线程B,按照先锁b再锁a的顺序获得锁。如下图所示:

可归结为如下两点:

a 竞争资源

b 进程间推进顺序非法

产生死锁的必要条件:

1、以确定的顺序获得锁

如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:

如果此时把获得锁的时序改成:

那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生,该算法在这里就不再赘述了,有兴趣的可以自行了解一下。

2、超时放弃

当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

三把锁:synchronized 、ReentrantLock、ReadWriteLock 概述

synchronized

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁

静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁

同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized是java内置的关键字,它提供了一种独占的加锁方式。synchronized的获取和释放锁有JVM实现,用户不需要显式的释放锁,非常方便,然而synchronized也有一定的局限性

例如:

1、当线程尝试获取锁的时候,如果获取不到锁就会一直阻塞。

2、如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁会一直等待。

JDK15之后发布的concurrent包,提供了Lock接口,用来提供更多扩展的加锁功能。Lock弥补了synchronized的局限性,提供了更加细粒度的加锁功能。

ReentrantLock

JDK15之后发布的concurrent包,提供了Lock接口,用来提供更多扩展的加锁功能。Lock弥补了synchronized的局限性,提供了更加细粒度的加锁功能。

ReentrantLock是Lock的默认实现之一。

1、可重入锁:可重入锁是指一个线程可以多次获取同一把锁。ReentrantLock和Synchronized都是可重入锁。

2、可中断锁:可中断锁是指线程尝试获取锁的过程是否可以响应终端。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。

3、公平锁与非公平锁:公平所指多个线程同时尝试获取同一把锁时,获取锁的顺序按照线程达到的顺序,而非公平锁则允许线程“插队”。synchronized是非公平锁,而ReentrantLock的默认实现是非公平锁,但是也可以设置为公平锁。

ReadWriteLock

ReadWriteLock,读写锁。

ReentrantReadWriteLock 是 ReadWriteLock 的一种实现。

特点:

包含一个 ReadLock 和 一个 WriteLock 对象

读锁与读锁不互斥;读锁与写锁,写锁与写锁互斥

适合对共享资源有读和写 *** 作,写 *** 作很少,读 *** 作频繁的场景

可以从写锁降级到读锁。获取写锁->获取读锁->释放写锁

无法从读锁升级到写锁

读写锁支持中断

写锁支持Condition;读锁不支持Condition

分布式锁的实现方式如下:

1、基于数据库实现分布式锁:主要是利用数据库的唯一索引来实现,唯一索引天然具有排他性,这刚好符合我们对锁的要求:同一时刻只能允许一个竞争者获取锁。

2、基于缓存实现分布式锁:理论上来说使用缓存来实现分布式锁的效率最高,加锁速度最快。一般使用Redis来实现分布式锁都是利用Redis的SETNXkeyvalue这个命令。

3、基于Zookeeper:Zookeeper一般用作配置中心,其实现分布式锁的原理和Redis类似,我们在Zookeeper中创建瞬时节点,利用节点不能重复创建的特性来保证排他性。

分布式锁应该具备的条件

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行。

2、高可用的获取锁与释放锁。

3、高性能的获取锁与释放锁。

4、具备可重入特性。

5、具备锁失效机制,防止死锁。

6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

Java中的锁主要包括synchronized锁和JUC包中的锁,这些锁都是针对单个JVM实例上的锁,对于分布式环境如果我们需要加锁就显得无能为力。在单个JVM实例上,锁的竞争者通常是一些不同的线程,而在分布式环境中,锁的竞争者通常是一些不同的线程或者进程。如何实现在分布式环境中对一个对象进行加锁呢?答案就是分布式锁。

目前分布式锁的实现方案主要包括三种:

基于数据库实现分布式锁主要是利用数据库的唯一索引来实现,唯一索引天然具有排他性,这刚好符合我们对锁的要求:同一时刻只能允许一个竞争者获取锁。加锁时我们在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,第二个竞争者再来加锁就会抛出唯一索引冲突,如果抛出这个异常,我们就判定当前竞争者加锁失败。防重业务id需要我们自己来定义,例如我们的锁对象是一个方法,则我们的业务防重id就是这个方法的名字,如果锁定的对象是一个类,则业务防重id就是这个类名。

基于缓存实现分布式锁:理论上来说使用缓存来实现分布式锁的效率最高,加锁速度最快,因为Redis几乎都是纯内存 *** 作,而基于数据库的方案和基于Zookeeper的方案都会涉及到磁盘文件IO,效率相对低下。一般使用Redis来实现分布式锁都是利用Redis的 SETNX key value 这个命令,只有当key不存在时才会执行成功,如果key已经存在则命令执行失败。

基于Zookeeper:Zookeeper一般用作配置中心,其实现分布式锁的原理和Redis类似,我们在Zookeeper中创建瞬时节点,利用节点不能重复创建的特性来保证排他性。

在实现分布式锁的时候我们需要考虑一些问题,例如:分布式锁是否可重入,分布式锁的释放时机,分布式锁服务端是否有单点问题等。

上面已经分析了基于数据库实现分布式锁的基本原理:通过唯一索引保持排他性,加锁时插入一条记录,解锁是删除这条记录。下面我们就简要实现一下基于数据库的分布式锁。

id字段是数据库的自增id,unique_mutex字段就是我们的防重id,也就是加锁的对象,此对象唯一。在这张表上我们加了一个唯一索引,保证unique_mutex唯一性。holder_id代表竞争到锁的持有者id。

如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。

解锁很简单,直接删除此条记录即可。

是否可重入 :就以上的方案来说,我们实现的分布式锁是不可重入的,即是是同一个竞争者,在获取锁后未释放锁之前再来加锁,一样会加锁失败,因此是不可重入的。解决不可重入问题也很简单:加锁时判断记录中是否存在unique_mutex的记录,如果存在且holder_id和当前竞争者id相同,则加锁成功。这样就可以解决不可重入问题。

锁释放时机 :设想如果一个竞争者获取锁时候,进程挂了,此时distributed_lock表中的这条记录就会一直存在,其他竞争者无法加锁。为了解决这个问题,每次加锁之前我们先判断已经存在的记录的创建时间和当前系统时间之间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其他竞争者无法解锁。这点可以通过holder_id字段来判定。

数据库单点问题 :单个数据库容易产生单点问题:如果数据库挂了,我们的锁服务就挂了。对于这个问题,可以考虑实现数据库的高可用方案,例如MySQL的MHA高可用解决方案。

使用Jedis来和Redis通信。

可以看到,我们加锁就一行代码:

jedisset(String key, String value, String nxxx, String expx, int time);

这个set()方法一共五个形参:

第一个为key,我们使用key来当锁,因为key是唯一的。

第二个为value,这里写的是锁竞争者的id,在解锁时,我们需要判断当前解锁的竞争者id是否为锁持有者。

第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set *** 作;若key已经存在,则不做任何 *** 作。

第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期时间的设置,具体时间由第五个参数决定;

第五个参数为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1当前没有锁(key不存在),那么久进行加锁 *** 作,并对锁设置一个有效期,同时value表示加锁的客户端。2已经有锁存在,不做任何 *** 作。

上述解锁请求中, SET_IF_NOT_EXIST (不存在则执行)保证了加锁请求的排他性,缓存超时机制保证了即使一个竞争者加锁之后挂了,也不会产生死锁问题:超时之后其他竞争者依然可以获取锁。通过设置value为竞争者的id,保证了只有锁的持有者才能来解锁,否则任何竞争者都能解锁,那岂不是乱套了。

解锁的步骤:

注意到这里解锁其实是分为2个步骤,涉及到解锁 *** 作的一个原子性 *** 作问题。这也是为什么我们解锁的时候用Lua脚本来实现,因为Lua脚本可以保证 *** 作的原子性。那么这里为什么需要保证这两个步骤的 *** 作是原子 *** 作呢?

设想:假设当前锁的持有者是竞争者1,竞争者1来解锁,成功执行第1步,判断自己就是锁持有者,这是还未执行第2步。这是锁过期了,然后竞争者2对这个key进行了加锁。加锁完成后,竞争者1又来执行第2步,此时错误产生了:竞争者1解锁了不属于自己持有的锁。可能会有人问为什么竞争者1执行完第1步之后突然停止了呢?这个问题其实很好回答,例如竞争者1所在的JVM发生了GC停顿,导致竞争者1的线程停顿。这样的情况发生的概率很低,但是请记住即使只有万分之一的概率,在线上环境中完全可能发生。因此必须保证这两个步骤的 *** 作是原子 *** 作。

是否可重入 :以上实现的锁是不可重入的,如果需要实现可重入,在 SET_IF_NOT_EXIST 之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。

锁释放时机 :加锁时我们设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,我们的锁服务最多也就在超时时间的这段时间之内不可用。

Redis单点问题 :如果需要保证锁服务的高可用,可以对Redis做高可用方案:Redis集群+主从切换。目前都有比较成熟的解决方案。

利用Zookeeper创建临时有序节点来实现分布式锁:

其基本思想类似于AQS中的等待队列,将请求排队处理。其流程图如下:

解决不可重入 :客户端加锁时将主机和线程信息写入锁中,下一次再来加锁时直接和序列最小的节点对比,如果相同,则加锁成功,锁重入。

锁释放时机 :由于我们创建的节点是顺序临时节点,当客户端获取锁成功之后突然session会话断开,ZK会自动删除这个临时节点。

单点问题 :ZK是集群部署的,主要一半以上的机器存活,就可以保证服务可用性。

Zookeeper第三方客户端curator中已经实现了基于Zookeeper的分布式锁。利用curator加锁和解锁的代码如下:

为了方便叙述,这里把 AQS 中的同步队列描述为主同步队列,以区别于 AQS 中的条件等待队列。把主同步队列中排队等待获取写锁的线程称为写线程,把主同步队列中排队等待获取读锁的线程称为读线程。

可重入读写锁获取和释放锁的模型使用的仍然是 AQS 的主同步队列,没有使用额外的队列。主同步队列是一个双向队列,而且这个双向队列中会存储排队等候的写线程或读线程对应的节点,也就是说主同步队列中排队等候的线程既有可能是写线程,也有可能是读线程,还有可能是两种线程的混合。

在可重入读写锁中, AQS 的 state 值被用来同时存储当前持有读锁计数和写锁计数,注意这个计数是所有线程持有的总的读锁和写锁计数。其中 state 的低16位被用来存储写锁持有计数,高16位被用来存储读锁计数。因此读锁和写锁的最大持有计数为 2^16-1 。

由于可重入读写锁的写锁是互斥的,所以读写锁的写锁获取和 ReentrantLock 锁获取类似。

读锁是共享锁,多个读线程可以同时获取读锁,读锁活跃期间,写锁无法获取

8如果读锁获取失败,那么和写锁获取失败一样,会创建一个对应线程的共享模式的节点进入主同步队列,重试2次,如果还是没有获取到读锁,那么阻塞等待唤醒。

写锁释放的条件是当前线程持有写锁,否则抛出 IllegalMonitorStateException

读锁释放的条件也是当前线程必须持有写锁,否则抛出 IllegalMonitorStateException

2获取 state 值,减少一个读锁持有计数单位,并更新 state 。由于读锁是共享的,可能有多个线程释放或获取读锁,更新 state 值需要使用 CAS ,并且通过循环保证 CAS 一定成功。

3最后判断如果 state 的值是否是0,由于读锁和写锁互斥,释放读锁时, state 值中的写锁计数一定是0,而如果 state 是0,那么说明读锁计数也是0,说明没有线程持有读锁了。如果 state 不是0,说明读锁持有计数不是0,那么释放读锁只需要将当前线程的读锁持有计数和 state 中总的读锁持有计数-1即可。如果 state==0 ,那么没有线程持有读锁了,这时释放读锁会唤醒主同步队列中第一个需要唤醒的线程节点(节点的 waitStatus==NodeSIGNAL ),并且这个线程只能是写线程。因为当前线程持有读锁期间,如果有读线程获取读锁,根据读锁的获取方式推断,读锁一定是能够获取成功的,所以不会在主同步队列中堆积。因此在主同步队列中堆积的第一个线程一定是写线程。所以在释放读锁时,如果需要唤醒主同步队列中排队等待唤醒的线程也一定是写线程。

>

以上就是关于什么是死锁,产生死锁的原因全部的内容,包括:什么是死锁,产生死锁的原因、三把锁小程序叫什么、分布式锁的实现方式等相关内容解答,如果想了解更多相关内容,可以关注我们,你们的支持是我们更新的动力!

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/web/9391103.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-04-27
下一篇 2023-04-27

发表评论

登录后才能评论

评论列表(0条)

保存