赞
踩
除了线程安全性以外,还需要注意线程的活跃性问题。
线程的安全性描述的是程序正确的运行,而活跃性描述的是应该运行的程序一定会运行,如果某些代码造成了后面应该运行的代码一直运行不到,那就是活跃性问题。
比如代码中有无限循环,后面的代码就一直执行不到,就造成了活跃性问题。
线程的活跃性问题主要有三种:死锁、饥饿、活锁。下面会依次提到。
死锁属于活跃性问题的一种,线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁。
想要触发死锁,需要满足4个条件:
在使用synchronized加锁的时候,天然满足了前面三个条件,而在synchronized使用不当的时候就可能产生顺序死锁。
对多个synchronized嵌套使用的情况下,如果两个方法中使用同样的两个对象加锁,但加锁的顺序不一致,就可能导致死锁。
看一下显示加锁造成死锁的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的锁对象还有可能是通过方法参数传入的,如果不同线程调用同一个方法传入的锁顺序不正确,也会出现死锁问题。
这种死锁的解决也很简单,如果是业务中定义的对象作为锁对象,则保证每个对象有个唯一标识,在加锁之前比较一下两个对象中唯一标识的大小,按从小到大的顺序进行加锁。
两个协作对象,都有使用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位顾客,并退出了程序。
所以我们在开发的过程中,应该避免在同步方法中调用其他的同步方法。
上面说到了顺序死锁是因为同步块(或同步方法)的嵌套,两个线程竞争锁时的顺序不一致导致了循环等待。其本质上就是因为两个线程互相持有了对方需要的资源导致的。
资源死锁描述的就是,即使没有加锁的情况下,由于资源限制导致的死锁,这里的资源限制指的就是线程池\连接池(信号量实现的)。
举个极端的例子:有两个不同数据库的连接池DB1和DB2,各自只有一个连接,线程A需要先操作DB1再操作DB2,线程B需要先操作DB2,再操作DB1。线程A操作完DB1后,要获取DB2的连接,此时发现DB2的连接已经被线程2持有,而线程2又在等待DB1的连接释放。互相占用资源并循环等待,造成了死锁。
资源死锁并不是一个常见的问题,资源池越大发生的概率就越小,因为只要稍微有那么一个多余的空闲连接,就可以解决这个死锁的问题,所以我们平时开发中可能并不会注意这个问题。
使用2.1.1的例子,在Windows中打开cmd,使用jps查看Java进程号:
找到启动的Java程序,这里是ExplicitLock,得知进程号为66540,jstack 66540:
在线程栈信息中可以找到死锁的位置:
可以看到的是,已经打印出了是哪一个锁对象发生了死锁,并打印出了方法名和代码行数,可以根据这些信息进行排查。
上面也提到一些解决死锁的办法,这里总结一下,破坏死锁只需要破坏四个死锁条件的其中一个就可以了。
饥饿是指的线程无法获取到它执行所需要的资源,可以分为两种情况:
要解决饥饿的问题,要避免在Java中修改线程的优先级,并且在存在线程竞争的那部分代码,要完善释放资源的条件,不能让一个线程一直占有资源。
活锁与死锁不同之处在于,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且一直失败重试。
我们开发中使用的异步消息队列就有可能造成活锁的问题,在消息队列的消费端如果没有正确的ack消息,并且执行过程中报错了,就会再次放回消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的ack之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。
其它活锁情况也类似,总之就是加入可以破坏这种无限循环失败的补偿机制,就可以破坏活锁。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。