目录
一个资源应该单独使用一把锁。
比如,一个对象中有多个共享资源,但有多个线程需要使用其中的不同资源
此时如果把对象整体作为一把锁,那并发就很低。
可以考虑,把每个共享资源都单独拆出来,分别上锁,这样每个线程都能各取所需,提高了并发度。
坏处是,可能导致线程死锁。
线程的代码是有限的,但由于某种原因,线程一直执行不完,称为线程的活跃性。
活跃性有三种原因:死锁、活锁、饥饿。
1、死锁
1、死锁的定义
死锁:一组互相竞争资源的线程因互相等待获取对方的资源,导致线程一直阻塞的情况。
2、为什么会产生死锁
一个线程需要同时获取多把锁时,就容易引发死锁。
简单的例子:
- t1 线程 获得 A对象 锁,接下来想获取 B对象 的锁
- t2 线程 获得 B对象 锁,接下来想获取 A对象 的锁
它俩又不会释放自己已经持有的锁,一直耗着,产生了死锁。
总结
如果锁应用不当,造成“两个线程都在等待对方释放锁
”,那么它们会一直等待,无法运行,这就发生了死锁。
简单说,死锁问题是由两个或以上的线程并行执行时,因为争抢资源而造成互相等待造成的
。
3、死锁的四要素
只要同时满足这四个条件,就肯定会死锁:
- 互斥条件
- 持有并等待条件
- 不可剥夺条件
- 环路等待条件
4、避免死锁的三种思路
避免死锁,只需要破坏掉四大条件的其中一个即可。
互斥条件没办法破坏,本来锁的目的就是互斥
所以只需要破坏以下三项中的其中一项即可:
- 破坏持有并等待条件:一个线程必须
一次性申请所有的锁
,不能单独持有某一个锁 - 破坏不可剥夺条件:一个线程
获取不到锁时,就先主动释放持有的所有锁
- 破坏环路等待条件:规定各个线程
获取锁的顺序
注意:三种思路,但是在具体的场景中,每种做法的开销都是不同的,需要找到开销最低的方式。
一般来说:
- 破坏持有并等待需要死循环检查条件,而且锁的粒度也很大,一般不去使用。
- 破坏环路等待只需要规定加锁顺序,效率较高
5、避免死锁的三种实现方式
1、破坏持有并等待条件
一次性申请所有锁这个动作属于临界区,应该抽取出一个类来管理,让它作为锁,向外提供两个同步方法:同时获取所有锁、同时释放所有锁。
由于它要作为锁,所以这个对象必须是单例的。
在尝试申请所有资源时,使用while()死循环,注意要加上超时判断。
class Allocator { // 维护一个资源列表 private List<Object> als = new ArrayList<>(); // 一次性申请所有资源 synchronized boolean apply(Object from, Object to){ if(als.contains(from) || als.contains(to)){ return false; } else { als.add(from); als.add(to); } return true; } // 归还资源 synchronized void free(Object from, Object to){ als.remove(from); als.remove(to); } } class Account { // actr应该为单例 private Allocator actr; private int balance; // 转账 void transfer(Account target, int amt){ // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, target)) try{ // 锁定转出账户 synchronized(this){ // 锁定转入账户 synchronized(target){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } finally { actr.free(this, target) } } }
此处while()与synchronized锁粗粒度的区别
在申请“锁”时,两种方式都是串行的。
但是,while()在通过单例对象获取到全部资源后,只需要申请所需的资源,不会影响其他无关的同类操作,可以并行执行。
而synchronized锁粗粒度,在执行时也只能串行执行。
2、破坏不可抢占条件
线程在获取不到锁时,释放手头持有的锁。这一点Java在语言层面是做不到的,不过Java在SDK层面实现了。
因为 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了。线程进入阻塞状态后啥都干不了,也释放不了线程已经占有的资源。
java.util.concurrent 包下的 Lock 是可以轻松解决这个问题的。
它提供了一个方法:tryLock(long, TimeUnit),在一段时间内尝试获取锁,如果最终没有获取到,就执行释放锁的逻辑。
3、破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源,这样就能避免多个线程交叉加锁的情况。
比如转账,就在代码中写死,按照账户id从小到大依次加锁,就不会有问题。
2、活锁
1、什么是活锁
活锁是指,线程没有发生阻塞,但依然执行不下去的情况。
2、活锁的例子
如果两个线程互相改变对方的结束条件,就可能导致双方谁也无法结束。
比如这个程序:
public class TestLiveLock { static volatile int count = 10; static final Object lock = new Object(); public static void main(String[] args) { new Thread(() -> { // 期望减到 0 退出循环 while (count > 0) { sleep(0.2); count--; log.debug("count: {}", count); } }, "t1").start(); new Thread(() -> { // 期望超过 20 退出循环 while (count < 20) { sleep(0.2); count++; log.debug("count: {}", count); } }, "t2").start(); } }
相当于一个抽水一个注水,水池永远不会空或者满
3、死锁与活锁的区别
- 死锁:两个线程在执行过程中阻塞住了
- 活锁:两个线程一直没有阻塞,但都无法停止运行
4、活锁的解决方案
增加随机的睡眠时间,将这样的两个线程错开执行,只要一方先运行完了,那么另一方也就能运行完
3、饥饿
1、什么是线程饥饿
饥饿指的是线程因无法访问所需资源而无法执行下去的情况:
- 在CPU繁忙时,如果一个线程优先级太低,就有可能遇到一直得不到执行
- 持有锁的线程,如果执行的时间过长,会导致其他阻塞的线程一直获取不到锁
2、线程饥饿的解决方案
有三种方案:
- 保证资源充足
- 公平地分配资源,如果有需求可以使用公平锁,不过效率较低,很少使用。
- 避免持有锁的线程长时间执行