【并发编程】(六)线程活跃性问题(死锁、饥饿、活锁)

1.活跃性问题

除了线程安全性以外,还需要注意线程的活跃性问题。
线程的安全性描述的是程序正确的运行,而活跃性描述的是应该运行的程序一定会运行,如果某些代码造成了后面应该运行的代码一直运行不到,那就是活跃性问题。
比如代码中有无限循环,后面的代码就一直执行不到,就造成了活跃性问题。

线程的活跃性问题主要有三种:死锁、饥饿、活锁。下面会依次提到。

1.1.什么是死锁

死锁属于活跃性问题的一种,线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁。

2.产生死锁的原因

想要触发死锁,需要满足4个条件:

  • 互斥:同一时间只能有一个线程获取资源。
  • 占有且等待:线程等待过程中不会释放已占有的资源。
  • 不可抢占:一个线程已经占有的资源,在释放之前不会被其它线程抢占。
  • 循环等待:多个线程互相等待对方释放资源。

在使用synchronized加锁的时候,天然满足了前面三个条件,而在synchronized使用不当的时候就可能产生顺序死锁。

2.1.顺序死锁

对多个synchronized嵌套使用的情况下,如果两个方法中使用同样的两个对象加锁,但加锁的顺序不一致,就可能导致死锁。

2.1.1.显式加锁顺序导致的死锁

看一下显示加锁造成死锁的demo:

public class ExplicitLock {

    private final ExplicitLock lock1 = new ExplicitLock();
    private final ExplicitLock lock2 = new ExplicitLock();

    public static void main(String[] args) {
        ExplicitLock explicitLock = new ExplicitLock();

        Thread t1 = new Thread(explicitLock::test1);
        Thread t2 = new Thread(explicitLock::test2);

        t1.start();
        t2.start();
    }

    private void test1() {
        try {
            synchronized (lock1) {
                Thread.sleep(1000L);
                System.out.println("test1成功获取锁lock1");
                synchronized (lock2) {
                    System.out.println("test1成功获取锁lock2");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void test2() {
        try {
            synchronized (lock2) {
                Thread.sleep(1000L);
                System.out.println("test2成功获取锁lock2");
                synchronized (lock1) {
                    System.out.println("test2成功获取锁lock1");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

运行这段代码,打印出:

test2成功获取锁lock2
test1成功获取锁lock1

然后程序阻塞,发生死锁,test1无法获取lock2,test2也无法获取lock1。如果显式加锁的顺序不一致,一定会发生死锁,开发中应该避免这种写法,使用同样的访问顺序进行加锁。


动态锁顺序死锁问题
除了在代码中写死的调用顺序之外,synchronized的锁对象还有可能是通过方法参数传入的,如果不同线程调用同一个方法传入的锁顺序不正确,也会出现死锁问题。

这种死锁的解决也很简单,如果是业务中定义的对象作为锁对象,则保证每个对象有个唯一标识,在加锁之前比较一下两个对象中唯一标识的大小,按从小到大的顺序进行加锁。

2.2.2.对象互相调用导致的死锁

两个协作对象,都有使用synchronized修饰的方法,各自的方法中又调用了对方的同步方法,这种方式也属于是synchronized的嵌套使用,虽然没有显示的指定对象调用方法的顺序,但实际调用顺序是未知的,当加锁顺序颠倒时,也有可能会导致死锁。

用一个Demo来模拟排号和调用的过程(模拟死锁的Demo,实际并不是这么写的):
先创建两个类排号机和调度器:

/**
 * @author 挥之以墨
 * <p>
 * 排号机
 */
public class LineUpMachine {

    private DispatcherMachine dispatcherMachine;

    public LineUpMachine(DispatcherMachine dispatcherMachine) {
        this.dispatcherMachine = dispatcherMachine;
    }

    /**
     * 每次调用排号方法拿的号
     */
    private volatile int number = 0;

    /**
     * 排号方法
     */
    public synchronized void lineUp() {
        System.out.println("排号机开始排号!");
        boolean success = dispatcherMachine.lineUp(this, ++number);
        if (success) {
            System.out.println("排号成功!当前排号为" + number);
        }
    }

    /**
     * 叫号
     */
    public synchronized void calling(Integer number) {
        System.out.println("叫号成功!请" + number + "号顾客到xx桌用餐");
    }

}

/**
 * @author 挥之以墨
 * 调度器
 */
public class DispatcherMachine {

    /**
     * 排号队列
     */
    private volatile Queue<Integer> numberQueue = new LinkedBlockingQueue<>();
    private volatile Set<LineUpMachine> lineUpMachineSet = new HashSet<>();

    /**
     * 在队列中放入数字,并保存排号机对象
     */
    public synchronized boolean lineUp(LineUpMachine lineUpMachine, Integer number) {
        lineUpMachineSet.add(lineUpMachine);
        return numberQueue.add(number);
    }

    /**
     * 叫号
     */
    public synchronized void calling() {
        System.out.println("调度器开始叫号调度");
        Integer number = numberQueue.poll();
        // 所有排号机一起叫号
        for (LineUpMachine lineUpMachine : lineUpMachineSet) {
            lineUpMachine.calling(number);
        }
    }

}

然后做一个测试:

/**
 * @author 挥之以墨
 * 协作对象测试
 */
public class CollaboratingTest {

    public static void main(String[] args) {
        DispatcherMachine dispatcherMachine = new DispatcherMachine();
        LineUpMachine lineUpMachine = new LineUpMachine(dispatcherMachine);

        for (int i = 0; i < 5; i++) {
            Thread t1 = new Thread(lineUpMachine::lineUp);
            Thread t2 = new Thread(dispatcherMachine::calling);
            t1.start();
            try {
                // 等待排号先完成
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            t2.start();
        }
    }
}

在这里插入图片描述

在第一次排号成功后,开始叫号,同时开始第二次排号,此时程序被阻塞无法结束,发生了死锁。


在这段代码中,调用其他对象的方法是不需要加锁的,排号机和调度器对象各自保证自身成员变量的线程安全性就可以了。所以在这里可以将同步方法修改为同步代码块,减少加锁的粒度。

修改两处关键位置:

// LineUpMachine中的lineUp()方法
public void lineUp() {
	synchronized (this) {
	    System.out.println("排号机开始排号!");
	    ++number;
	}
	boolean success = dispatcherMachine.lineUp(this, number);
	if (success) {
	    System.out.println("排号成功!当前排号为" + number);
	}
}

// DispatcherMachine中的calling()方法
public void calling() {
	Integer number;
	synchronized (this) {
	    System.out.println("调度器开始叫号调度");
	    number = numberQueue.poll();
	}
	// 所有排号机一起叫号
	for (LineUpMachine lineUpMachine : lineUpMachineSet) {
	    lineUpMachine.calling(number);
	}
}

在这里插入图片描述
此时成功安排了5位顾客,并退出了程序。
所以我们在开发的过程中,应该避免在同步方法中调用其他的同步方法。

2.2.资源死锁

上面说到了顺序死锁是因为同步块(或同步方法)的嵌套,两个线程竞争锁时的顺序不一致导致了循环等待。其本质上就是因为两个线程互相持有了对方需要的资源导致的。

资源死锁描述的就是,即使没有加锁的情况下,由于资源限制导致的死锁,这里的资源限制指的就是线程池\连接池(信号量实现的)。
举个极端的例子:有两个不同数据库的连接池DB1和DB2,各自只有一个连接,线程A需要先操作DB1再操作DB2,线程B需要先操作DB2,再操作DB1。线程A操作完DB1后,要获取DB2的连接,此时发现DB2的连接已经被线程2持有,而线程2又在等待DB1的连接释放。互相占用资源并循环等待,造成了死锁。

资源死锁并不是一个常见的问题,资源池越大发生的概率就越小,因为只要稍微有那么一个多余的空闲连接,就可以解决这个死锁的问题,所以我们平时开发中可能并不会注意这个问题。

3.JStack诊断死锁

使用2.1.1的例子,在Windows中打开cmd,使用jps查看Java进程号:
在这里插入图片描述
找到启动的Java程序,这里是ExplicitLock,得知进程号为66540,jstack 66540:
在线程栈信息中可以找到死锁的位置:
在这里插入图片描述
可以看到的是,已经打印出了是哪一个锁对象发生了死锁,并打印出了方法名和代码行数,可以根据这些信息进行排查。

4.如何避免死锁

上面也提到一些解决死锁的办法,这里总结一下,破坏死锁只需要破坏四个死锁条件的其中一个就可以了。

  • 互斥:锁的特性,这个破坏不了。
  • 占有且等待:一次性获取需要执行代码中所有需要获取的资源,而不是使用到的时候才获取。
  • 不可剥夺:如果获取不到资源,就先主动释放自己持有的资源。
  • 循环等待:按照顺序进行加锁和解锁。

5.其它活跃性问题

4.1.饥饿

饥饿是指的线程无法获取到它执行所需要的资源,可以分为两种情况:

  • 一种是其他的线程在临界区做了无限循环或无限制等待资源的操作,让其他的线程一直不能拿到锁进入临界区,对其他线程来说,就进入了饥饿状态。
  • 另一种是因为线程优先级不合理的分配,导致部分线程始终无法获取到CPU资源而一直无法执行。

要解决饥饿的问题,要避免在Java中修改线程的优先级,并且在存在线程竞争的那部分代码,要完善释放资源的条件,不能让一个线程一直占有资源。

4.2.活锁

活锁与死锁不同之处在于,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且一直失败重试。

我们开发中使用的异步消息队列就有可能造成活锁的问题,在消息队列的消费端如果没有正确的ack消息,并且执行过程中报错了,就会再次放回消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的ack之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。

其它活锁情况也类似,总之就是加入可以破坏这种无限循环失败的补偿机制,就可以破坏活锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

挥之以墨

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

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

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

打赏作者

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

抵扣说明:

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

余额充值