并发编程-锁的那些事儿【五:死锁,活锁,饥饿锁,锁优化】

前言

经过前面几篇锁特性原理了解后,在面向实际使用场景会产生的问题,进行更深一步学习和解刨; 在前面的例子,都是用在synchronize关键字进行讲述,那么大家思考一个问题,在 中提到 “受保护资源和锁之间的关联关系是 N:1 的关系”,也就意味着一把锁可以同时锁住很多个资源; 那么对应到平常项目中,岂不是高度的串行化执行。 这样的性能肯定是不可以接受的。 所以这时有引出来一个新的名词“细粒度锁”;

细粒度锁

细粒度锁的概念很简单:原来你用一个锁去保护的一个堆资源池的关系,现在可以根据资源池不同的功能块,根据不同维度的功能,在对其加锁使用。那么在使用这个资源池时,就可以做不同事的人,可以并行处理。

看一个好理解的例子:

背景 : 古代,钱庄里,账房先生,手上俩个账本,a账本管存钱的记录,b账本管取钱的记录
c账本记录账户信息有张三和李四的信息

场景:张三需要取100两银子,存放在李四账户下

串行化记账场景:
一个账房先生就是一把锁,a和b、c三个账簿就是受保护的资源。 张三来取钱,账房先生把3个账本都取出来,先从c账本中找到张三信息,账户的余额还够数,那就从余额里面扣减100两,在到b账簿上记录一笔张三取走一笔100两的记录。 那么在拿出a账本,记录上一条存入李四的账户100两银子,最后在c账户上找到李四在余额上加上100两。

细粒度分化场景:
现在有三个账房先生,他们各管一个账簿。 同样还是张三来取钱,那么管理c账本的先生,可以给他查找张三信息,那么在找管理b账薄的账房先生来记录一笔存钱明细,在找来管理a账簿先生记录李四存100两的明细,于此同时如果关系c账户先生没有事情,就可以同时找到李四的信息,等a账簿先生记录好后,就可以直接李四余额上加上余额了。

那么这里面就会有3中情况了:
1、a和b先生都在,一起找来;
2、a和b先生有一个在,那么就得等另一个来。
3、a和b先生都不在,那么就等吧。

那么通过上述俩个例子来看,肯定是细粒度的方式会更加高效些,那么这也是优化性能的重要方式,但与此同时也会引入不小的问题,那就是死锁了,我们用以第2种情况为例。

##死锁造成:

a和b先生有一个在,得等另一个来。张三给李四存钱,同时李四也给张三存钱

那么张三还是按正常流程再走,c账户查信息—》发现b先生在,a账簿先生不在,那就先取钱。 此时,李四也来了,他在c账户查信息—》发现只有a先生在,先找来,然后等着b先生存钱。 这时你就会发现,张三占用这管理取钱b先生,等着a先生, 李四占用这管理存钱的a先生,等着b先生。 这可不就完了,他俩是怎么都不可能等到。 so那这就死等了吧, 那么在程序中我们就成为死锁;

一个比较专业的说法:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象,称为死锁 用大白话来讲,就是我占用了你要等待的资源,而你占用了我需要用的资源。 就展开了拉山头。

###死锁的解决和预防:

如果产生了死锁,那么对运行的应用,影响是巨大的,轻则导致资源不释放,频繁JavaGC,重则直接导致服务器假死,乃至宕机。
那么出现这类问题后,怎么来排查?其实也就是根据线程dump信息来发现,这段放置后续篇幅讲解。 那么对应的解决办法,也就只能重启,CR代码,优化导致死锁的代码。 但这个方式,是事后的,而且不友好。 那么有啥办法可以提前预防死锁么? 答案是有滴:

死锁产生会满足一下4种情况。 那么就是说,破坏其中之一,就可以防止死锁

  1. 互斥,受保护的资源,每次只能被1个线程使用
    这个条件的破坏,就是使用加锁实现。 那么可以使用synchronize关键字 或者 使用Lock相关的锁技术;

  2. 占有且等待,线程占用受保护资源产生阻塞时,不释放
    在上诉场景中,细粒度正好对应的这个方式。 那么最简单的当时就是可以一次性把需要的所有资源获取。 对应到场景运用中那么就是需要来多出一个账簿总管出来,由他来分配a、b、c先生的使用情况。

  3. 不可抢占,其他线程不能强行抢占正在运行线程中占有的资源
    这个方式需要看使用加锁的方式,如synchronize与Lock虽然要实现的目的是一致的,但是实现过程还是有所区别,并且附带的能力也是不同的。 synchronize方式,一旦申请锁失败,就有用户太转为内核态,那么资源也无法释放,所以次关键字慎用。 那么相反lock中能力是比较强大的,lock申请到不,也可以循环申请,而且也附带线程中断的等方法,后续细讲。

  4. 循环等待,俩个线程同时阻塞,等待对方释放资源
    能够产生循环处理,那么也就意味着使用这个资源是有一定顺序的,那么就可以对于这个资源打上编号,按编号去申请。 那么就可以提高系统的利用率和吞吐量,但是对于硬件上是肯定需要付出一定的消耗;

死锁分析总结

整体来讲,死锁是并发编程中,至关重要的一环。 那么在使用锁时,尤其是需要锁定多个资源时,就要尤其注意资源的竞争。

活锁

进过上述的死锁理解后,那么相反就会有相反意义存在,那就是活锁;

造成活锁的原理: 死锁是因为N个线程互相一致等待,形成“永久阻塞”。 那么还有一种情况就是“在没有阻塞的情况下,代码也会执行不下去”; 看个例子:
一个门,a人在门里面,从左手边出门,b人在门外面,从右手边进门。 这时a人就想我让路,从右手边出。 b人也想让路,从左手边进。实则没啥区别。 这样一来,一直让来让去,还是进不来,出不去。都在那让路;

那么解决方式其实也很简单:给对方都几个随机时间嘛,进出门的时间错开即可;

饥饿锁

指的是线程因无法访问所需资源而无法执行下去的情况。 线程也是有优先级的,如果略低的线程,在繁忙的CPU下,一致无法分配到资源,长时间无法响应,就会产生饥饿锁;
解决方式:1、增加硬件,多一些资源
2、公平分配资源。 lock只支持公平锁的能力,synchronize就不行老;
3、给加锁的线程带个超时时间。 不然老是让你占用资源。 与第二条一样,synchronize不支持。

锁优化

这里说的锁优化,实际上是指在JDK1.6版本后,对synchronize加锁方式的性能的提升,所以也称重量级锁,但是在lock的设计中也都能看到相关的概念;

  • 自旋锁
    当需要去申请某一个资源的时候,如果当前未能申请到资源,那么不讲线程状态做改变。 而是进入循环体,不断尝试去申请。 与重量级锁最大的区别就是: 当申请不到资源时,进入阻塞状态,放弃了CPU时间,线程状态由用户态转换内核态,这个转换过程是极其损耗性能。 所以当线程数不大时,自旋锁是可以很大提供应用性能。

  • 锁消除
    官方解释:JIT编译器对内部锁的具体实现所做的一种优化。在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
    说白了,就会编译器有种方式会检测到,你这段代码块,加不加锁度不会造成线程安全问题。 但你有给加锁了,那么在编译时,就会帮你去掉这段加锁逻辑。

  • 锁粗化
    这点其实跟最开说的细粒度锁有着莫大关系; 一般情况下,是 提倡细粒度锁,例如并发容器,ConcurrentHashMap就是使用细粒度锁概念。 但是当加锁太细的时候,则就过度了。 例如在一个for循环体内, for循环内的操作时线程安全的,不安全的是体外,锁又加在for体内。
    那么当JIT发现加锁操作出现在循环体中的时候,或者一系列连续的操作都对同一个对象反复加锁和解锁,就会将加锁同步的范围扩散(粗化)到整个操作序列的外部。

你可能感兴趣的:(并发编程)