1. 死锁
死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
说白了就是:两个线程互相持有对方所需的资源,互不释放且互相等待
- t1 线程获得A对象锁,接下来想获取B对象的锁
- t2 线程获得B对象锁,接下来想获取A对象的锁
我们举个栗子:
public class DeadLock {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
new Thread(()->{
synchronized (A){
log.debug("t1 lock A");
sleep(0.5);
synchronized (B){
log.debug("t1 lock B");
log.debug("操作...");
}
}
},"t1").start();
new Thread(()->{
synchronized (B){
log.debug("t2 lock B");
sleep(0.5);
synchronized (A){
log.debug("t2 lock A");
log.debug("操作...");
}
}
},"t2").start();
}
static void sleep(double time){
try {
Thread.sleep((int)(1000 * time));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
可以看到t1和t2各持有一把锁,当尝试获取对方线程锁的时候自己却不释放锁,这就导致死锁发生
2. 定位死锁
如果发生了死锁,那我们如何定位死锁呢?我们需要借助一些工具:
2.1 jstack工具使用
我们首先在项目路径下使用JPS 命令查看正在运行的Java线程
接着我们可以使用jstack +可能发生死锁的线程ID打印线程情况:
然后我们就可以看到一些线程的信息
当然我们也能在最后面看到发生死锁的信息
2.2 jconsole工具使用:
Jconsole (Java Monitoring and Management Console),是一种基于JMX的可视化监视、管理工具、
Jconsole 基本使用:
- 点击JDK/bin 目录下面的
jconsole.exe 即可启动 - 然后会自动自动搜索本机运行的所有虚拟机进程。
- 选择其中一个进程可开始进行监视
可以看到本机所有的虚拟机进程
检测死锁
可以查看到线程发生死锁的信息和发生死锁的行数
注意:
- 避免死锁要注意加锁顺序
- 另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 排查
3. 解决死锁
3.1 哲学家就餐问题
在解决死锁之前,我们先来通过一个经典的问题来分析一下死锁
有五位哲学家,围坐在圆桌旁。
- 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。
- 吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子。
- 如果筷子被身边的人拿着,自己就得等待
五位哲学家相当于五个线程,无根筷子相当于五份互斥的资源,当每个哲学家即线程持有一根筷子时,他们都在等待另一个线程释放锁,因此造成了死锁(典型的持有一个资源,等待对方释放资源的场景)
这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有活锁和饥饿者两种情况
下面通过具体的代码演示下这个死锁的问题:
筷子类:
public class Chopstick {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}
哲学家类:
public class Philosopher extends Thread {
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
private void eat() {
log.debug("eating...");
Sleeper.sleep(1);
}
@Override
public void run() {
while (true) {
synchronized (left) {
synchronized (right) {
eat();
}
}
}
}
}
测试类:
public class TestPhilosopher {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底",c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
我们的程序运行一会后发生了死锁:
使用Jconsole 可以看到:
-------------------------------------------------------------------------
名称: 阿基米德
状态: com.fx.Synchronized.philosopher@1540e19d (筷子1) 上的BLOCKED, 拥有者: 苏格拉底
总阻止数: 2, 总等待数: 1
堆栈跟踪:
com.fx.Synchronized.philosopher.run(TestDinner.java:48)
- 已锁定 com.fx.Synchronized.philosopher@6d6f6e28 (筷子5)
-------------------------------------------------------------------------
名称: 苏格拉底
状态: com.fx.Synchronized.philosopher@677327b6 (筷子2) 上的BLOCKED, 拥有者: 柏拉图
总阻止数: 2, 总等待数: 1
堆栈跟踪:
com.fx.Synchronized.philosopher.run(TestDinner.java:48)
- 已锁定 com.fx.Synchronized.philosopher@1540e19d (筷子1)
-------------------------------------------------------------------------
名称: 柏拉图
状态: com.fx.Synchronized.philosopher@14ae5a5 (筷子3) 上的BLOCKED, 拥有者: 亚里士多德
总阻止数: 2, 总等待数: 0
堆栈跟踪:
com.fx.Synchronized.philosopher.run(TestDinner.java:48)
- 已锁定 com.fx.Synchronized.philosopher@677327b6 (筷子2)
-------------------------------------------------------------------------
名称: 亚里士多德
状态: com.fx.Synchronized.philosopher@7f31245a (筷子4) 上的BLOCKED, 拥有者: 赫拉克利特
总阻止数: 1, 总等待数: 1
堆栈跟踪:
com.fx.Synchronized.philosopher.run(TestDinner.java:48)
- 已锁定 com.fx.Synchronized.philosopher@14ae5a5 (筷子3)
-------------------------------------------------------------------------
名称: 赫拉克利特
状态: com.fx.Synchronized.philosopher@6d6f6e28 (筷子5) 上的BLOCKED, 拥有者: 阿基米德
总阻止数: 2, 总等待数: 0
堆栈跟踪:
com.fx.Synchronized.philosopher.run(TestDinner.java:48)
- 已锁定 com.fx.Synchronized.philosopher@7f31245a (筷子4)
这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有 活锁 和 饥饿者 两种情况
解决方案在第六节
4. 活锁
4.1 活锁原因
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束
举个栗子:
public class LiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
while (count > 0) {
Sleeper.sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
while (count < 20) {
Sleeper.sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}
观察程序运行结果,我们会发现这两个线程在一直运行,且不会停止,因为自己线程的停止条件被其他线程修改了,导致线程停止不了
活锁和死锁不同,死锁是线程发生堵塞,而活锁是线程不发生堵塞但是却不会停止
4.2 活锁解决
- 让互相影响的线程执行的顺序交错开
- 睡眠的时间差异化
- 在开发中可以通过增加随机睡眠时间来避免活锁
5. 饥饿
饥饿:线程因无法访问所需资源而无法执行下去的情况,说白了就是:假设有1万个线程,还没等前面的线程执行完,后面的线程就饿死了
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束
先来看看使用顺序加锁的方式解决之前的死锁问题,就是两个线程对两个不同的对象加锁的时候都使用相同的顺序进行加锁
我们看下面的图,当线程1和线程2都分别持有一把锁(获取锁的顺序不一样),又想要获取对方锁的时候就会发生死锁情况
我们可以通过顺序加锁的方式来避免这种死锁,即线程1和线程2都按A、B的顺序去获取锁
显然这样就可以解决死锁的情况发生,但是这样又可能会导致饥饿 的现象发生
这里以上面哲学家就餐的例子演示一下:
我们看之前的代码,都是按顺序获取锁
我们现在将其改成不按顺序争抢锁
我们观察运行结果就会发现一个神奇的现象
- 首先并没有发生死锁
- 赫拉克利特线程获取锁的频率最高,等运行几次后阿基米德线程就再也没有获取到锁了
这样就是一种线程饥饿的现象发生了,即一些线程一直等不到cpu调度
6. 使用ReentrantLock解决死锁、活锁、饥饿
ReentrantLock相对于 synchronized 它具备如下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量,即对与不满足条件的线程可以放到不同的集合中等待
ReentrantLock基本使用思维导图
6.1 解决哲学家就餐死锁问题
我们知道导致死锁必须的四个条件:
- 互斥条件:共享资源被一个线程占用
- 请求与保持条件(占有且等待):一个进程因请求资源而被阻塞时,对已经获得资源保持不释放
- 不可剥夺条件(不可抢占):进程已获得资源,在未使用完之前,不能进行剥夺
- 循环等待条件:多个线程 循环等待资源,而且是循环的互相等待
对应着如果想要避免死锁,我们只需要让上面的四个条件其中一个不成立即可
- 请求与保持条件:放大锁范围,去除对资源的抢占
- 不剥夺:换成可重入锁ReentrantLock
- 循环等待:改成顺序加锁,避免循环等待
- 互斥是多线程的特性,所以这个条件无法避免
我们可以看到发生死锁的代码主要是下面的代码
当前线程获得左手筷子线程时,由于右手筷子拿不到就会一直等待,导致死锁(锁不可剥夺)
synchronized (left) {
synchronized (right) {
eat();
}
}
现在我们可以通过ReentrantLock的tryLock 对上面的代码进行改进
首先让筷子类继承自ReentrantLock,让其拥有锁的性质
public class ReentrantLockChopstick extends ReentrantLock {...}
接着我们改写上面的代码
while (true) {
if(left.tryLock()){
try {
if(right.tryLock()){
try {
eat();
} finally {
right.unlock();
}
}
} finally {
left.unlock();
}
}
}
接着测试一下,我们会发现死锁问题解决
总之,死锁的问题就是要解决资源不可剥夺、循环等待、请求与保持条件等问题,而在Java并发包内提供了很多工具类和方法来为我们保证多线程下程序的安全性
|