前言
死锁、活锁、饥饿锁是关于多线程是否活跃出现的运行阻塞障碍问题,如果线程出现了这三种情况,即线程不再活跃,不能再正常地执行下去了。
尽管死锁是最常见的活跃性危险,但在并发程序中,活锁、饥饿锁、丢失信号也是存在的。
死锁
线程死锁
死锁是多线程中最差的一种情况,多个线程相互占用对方的资源的锁,而又相互等对方释放锁,此时若无外力干预,这些线程则一直处理阻塞的假死状态,形成死锁。
举个例子,A 同学抢了 B 同学的钢笔,B 同学抢了 A 同学的书,两个人都相互占用对方的东西,都在让对方先还给自己自己再还,这样一直争执下去等待对方还而又得不到解决。
老师知道此事后就让他们相互还给对方,这样在外力的干预下他们才解决,当然这只是个例子没有老师他们也能很好解决,计算机不像人如果发现这种情况没有外力干预还是会一直阻塞下去的。
资源死锁
正如同,当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
假设有两个线程池,例如两个不同数据库的连接池。资源池通常采用信号量来实现当资源池为空时的阻塞的行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程 A 可能持有与数据库 D1 的连接,并等待与数据库 D2 的连接,而线程 B 则持有与 D2 的连接并等待与 D1 的连接。(资源池越大,出现这种情况的可能性就越小。如果每个资源池都由 N 个连接,那么在发生死锁时不仅需要 N 个循环等待的线程,而且还需要大量不恰当的执行时序)。
另一种基于资源的死锁形式就是线程饥饿死锁。在单线程的 Executor 中,如果一个任务将另一个任务提交到同一个 Executor,并且等待这个被提交任务的结果,那么通常会发生死锁。第二个任务停留在工作队列中,并等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。
public class DeadLock {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(MyTask::new);
executor.submit(MyTask::new);
}
public static class MyTask implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("complete");
return "complete";
}
}
}
避免死锁
如果一个程序一次只获取一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实,但如果能避免这种情况,那么就省去很多工作。
还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用 Lock 类中的定时 tryLock 功能。显式锁可以指定超时时限,在等待超时该时间后 tryLock 会返回一个失败信息。
通过线程转储信息来分析死锁
虽然防止死锁的主要责任在于你自己,但 JVM 仍然通过线程转储(Thread Dump)来帮助识别死锁的发生。
线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。
线程转储还包含了加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取到哪一个锁。在生成线程转储之前,JVM 会在等待关系图中通过所搜循环来找出死锁。
活锁
活锁这个概念大家应该很少有人听说或理解它的概念,而在多线程中这确实存在。活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又不能执行成功,这就是活锁。
该问题不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。
活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。
由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。
这种活锁通常是过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。
举个例子,在使用 RabbitMQ 监听消息,处理成功给 Broker 发送一个确认消息,随后删除了这条消息,处理失败给 Broker 发送一个消费失败的消息,重入队列。但如果我们处理这条消息时,就是因为我们的代码出现的问题导致的,那么重入队列再进行消费也没有多大的意义,而且还会影响到后面的消息消费。如果发生多次错误,就应该给 Broker 发送一个消费失败的消息,重入队列,但是状态是不可消费,等待程序关闭后改变这个状态。
饥饿锁
我们知道多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿。
通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿问题的风险。
你经常能发现某个程序里会在一些奇怪的地方调用 Thread.sleep 或 Thread.yield,这是因为该程序试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更过的时间。
当然还有一种饥饿的情况,即饥饿死锁。
无锁
无锁,即没有对资源进行锁定,即所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁典型的特点就是一个修改操作在一个循环内进行,线程会不断的尝试修改共享资源,如果没有冲突就修改成功并退出否则就会继续下一次循环尝试。所以,如果有多个线程修改同一个值必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
CAS 就是无锁的实现,在并发工具类和并发容器里,它的出镜率很高。
可以看出,无锁是一种非常良好的设计,它不会出现线程出现的跳跃性问题,锁使用不当肯定会出现系统性能问题,虽然无锁无法全面代替有锁,但无锁在某些场合下是非常高效的。