文章目录
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之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。
其它活锁情况也类似,总之就是加入可以破坏这种无限循环失败的补偿机制,就可以破坏活锁。