在并发环境中,我们为了保证共享可变数据的线程安全性,需要使用加锁机制,如果锁使用不当可能会引起死锁,线程饥饿等问题。
在JAVA应用程序中如果发生死锁,程序是无法自动恢复的,严重会造成程序崩溃,所以开发中在设计阶段就要规避死锁发生的情况。
死锁:每个线程拥有其他线程需要的资源,同时又等待其他线程拥有的资源,并且每个线程在获得所需要的资源前都不会放弃已经拥有的资源。
程序死锁发生的场景:
1)交叉锁导致死锁
在线程A持有锁L并想获取锁R的同时,线程B持有锁R并尝试获得锁L,那么这两个线程将永远的阻塞下去。交叉锁的发生一般是因为线程以不同的顺序获取锁。
2)资源死锁
内存不足或者我们在程序中使用了线程池和信号量对资源进行限制时,两个线程互相等待彼此释放资源而进入永久阻塞。
3)死循环死锁
程序由于代码缺陷或者重试机制而使代码陷入死循环,造成了内存和cpu的大量消耗而使线程进入阻塞。
当Java程序发生死锁时,阻塞的线程将永远不能使用了,而且可能造成程序停止或者使CPU飙高使程序性能很差。恢复程序的唯一方式就是重启应用。
死锁的发生大多数是偶然情况,并不代表一个类发生死锁,它就一直死锁,这也是死锁难以排查的原因。
通过死锁发生的场景我们可以总结出死锁发生的条件:
如果一个程序一次最多获得一个锁,那么就不会发生死锁问题,但是开发中经常出现程序需要获取多个锁的场景,那么这个时候就必须考虑锁的顺序问题。
如果所有的线程以固定的顺序获取锁也是不会出现死锁问题的,当线程试图以不同的顺序来获取锁时,死锁将会发生。
下面的示例将会发生死锁:
public class DeadlockTest {
//创建两个锁对象
private final Object leftMonitor = new Object();
private final Object rightMonitor = new Object();
/**
* 持有L锁想要获取R锁
*/
@SneakyThrows
public void leftForRight() {
synchronized (leftMonitor){
//休眠一下,给R加锁的机会
TimeUnit.SECONDS.sleep(1);
synchronized (rightMonitor){
System.out.println("leftForRight获取到锁");
}
}
}
/**
* 持有R锁获取L锁
*/
public void rightForLeft() {
synchronized (rightMonitor){
synchronized (leftMonitor){
System.out.println("rightForLeft获取到锁");
}
}
}
public static void main(String[] args) {
DeadlockTest deadlockTest = new DeadlockTest();
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(()->{
deadlockTest.leftForRight();
});
executor.execute(()->{
deadlockTest.rightForLeft();
});
executor.shutdown();
}
}
我们可以通过JDK提供的jstack或者jconsole工具查看死锁信息。
jstack -l pid查看堆栈信息:
或者jconsole连接到进程上:
通过堆栈信息能够很直接看到死锁信息。
linux环境下dump出堆栈信息的方法我们后续再聊。
我们可以通过打破死锁发生的条件来避免死锁。
程序中的业务要求我们必须使用独占锁而不能使用共享锁,那我们就不能打破锁的互斥性。
破坏占有且等待:一次性申请所有资源;
破坏不可抢占:使用显示锁Lock中的tryLock功能来代替内置锁synchronized,可以检测死锁和从死锁中恢复过来。使用内置锁的线程获取不到锁会被阻塞,而显式锁可以指定一个超时时限(Timeout),在等待设置的时间后tryLock就会返回一个失败信息,也会释放其拥有的资源。
破坏循环等待:使线程按照固定的顺序获取锁,在设计中我们应尽量减少锁的交互数量,提前设计好锁的顺序并严格遵守。
并发编程系列基础知识的学习到此结束了,后续如果遇到相关的知识再补充。
下一个系列《Java基础》扬帆启航,类加载、数据结构(包括线程安全的数据结构)、泛型等知识将与你相遇。
祝大家圣诞节快乐!!