线程的死锁、活锁和饥饿现象

目录

1、死锁

2、活锁

3、饥饿


一个资源应该单独使用一把锁。

比如,一个对象中有多个共享资源,但有多个线程需要使用其中的不同资源

此时如果把对象整体作为一把锁,那并发就很低。

可以考虑,把每个共享资源都单独拆出来,分别上锁,这样每个线程都能各取所需,提高了并发度。

坏处是,可能导致线程死锁。

线程的代码是有限的,但由于某种原因,线程一直执行不完,称为线程的活跃性。

活跃性有三种原因:死锁、活锁、饥饿。

1、死锁

1、死锁的定义

死锁:一组互相竞争资源的线程因互相等待获取对方的资源,导致线程一直阻塞的情况。

2、为什么会产生死锁

一个线程需要同时获取多把锁时,就容易引发死锁。

简单的例子:

  • t1 线程 获得 A对象 锁,接下来想获取 B对象 的锁
  • t2 线程 获得 B对象 锁,接下来想获取 A对象 的锁

它俩又不会释放自己已经持有的锁,一直耗着,产生了死锁。

总结

如果锁应用不当,造成“两个线程都在等待对方释放锁”,那么它们会一直等待,无法运行,这就发生了死锁。

简单说,死锁问题是由两个或以上的线程并行执行时,因为争抢资源而造成互相等待造成的

3、死锁的四要素

只要同时满足这四个条件,就肯定会死锁:

  • 互斥条件
  • 持有并等待条件
  • 不可剥夺条件
  • 环路等待条件

4、避免死锁的三种思路

避免死锁,只需要破坏掉四大条件的其中一个即可。

互斥条件没办法破坏,本来锁的目的就是互斥

所以只需要破坏以下三项中的其中一项即可:

  • 破坏持有并等待条件:一个线程必须一次性申请所有的锁,不能单独持有某一个锁
  • 破坏不可剥夺条件:一个线程获取不到锁时,就先主动释放持有的所有锁
  • 破坏环路等待条件:规定各个线程获取锁的顺序

注意:三种思路,但是在具体的场景中,每种做法的开销都是不同的,需要找到开销最低的方式。

一般来说:

  • 破坏持有并等待需要死循环检查条件,而且锁的粒度也很大,一般不去使用。
  • 破坏环路等待只需要规定加锁顺序,效率较高

5、避免死锁的三种实现方式

1、破坏持有并等待条件

一次性申请所有锁这个动作属于临界区,应该抽取出一个类来管理,让它作为锁,向外提供两个同步方法:同时获取所有锁、同时释放所有锁。

由于它要作为锁,所以这个对象必须是单例的。

在尝试申请所有资源时,使用while()死循环,注意要加上超时判断。

class Allocator {
    // 维护一个资源列表
    private List 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、线程饥饿的解决方案

有三种方案:

  • 保证资源充足
  • 公平地分配资源,如果有需求可以使用公平锁,不过效率较低,很少使用。
  • 避免持有锁的线程长时间执行

你可能感兴趣的:(java,jvm,开发语言)