死锁活锁和饥饿
在使用锁的时候,可能会因为使用不当产生死锁、活锁和饥饿的现象。
在单体应用中,加锁是否能解决所有的线程安全问题?
不能,因为加锁使用不当会有死锁、活锁和饥饿等问题。
死锁
什么是死锁?
死锁指的是两个或多个线程之间,互相占用着对方请求的资源,而且不会释放已持有的资源,造成了多线程之间无限等待的现象,就叫做死锁。
死锁发生后,会浪费大量的系统资源,并且在高并发下存在严重的安全隐患,甚至导致整个系统崩溃。
死锁产生的条件
互斥
某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
请求和保持
一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不可抢占
进程已获得的资源,在末使用完之前,其它进程不能强行剥夺;
循环等待
若干进程之间形成一种头尾相接的循环等待资源关系。
当以上四个条件均满足,必然会造成死锁,发生死锁的进程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。所以死锁情况是会浪费系统资源和影响计算机的使用性能的。那么,解决死锁问题就是相当有必要的了。
如何避免死锁
避免死锁就要破坏死锁产生的条件,破坏一个或多个就可以避免死锁。
互斥 - 不可破坏
由于互斥条件是要访问的共享资源决定的,是保证多线程安全的前提,所以不能破坏。
破坏请求和保持条件
- 一次性分配进程需要的所有资源。(破坏请求条件)
- 只要进程需要的资源有一个得不到分配,即使进程需要的其它资源有空闲,也不分配给该进程。(破坏保持条件)
破坏不可抢占
当某进程获取部分资源后,得不到其它资源时,可以立即或者有限等待释放已占有的资源。
破坏循环等待
将资源按序编号,进程申请资源时必须统一按照顺序申请。
手写实现死锁代码
@Slf4j
public class DeadLock implements Runnable {
private ReentrantLock oneLock;
private ReentrantLock twoLock;
public DeadLock(ReentrantLock oneLock, ReentrantLock twoLock) {
this.oneLock = oneLock;
this.twoLock = twoLock;
}
@Override
public void run() {
//第一把锁
oneLock.lock();
try {
log.info(Thread.currentThread().getName() + "======>第一把锁加锁");
Thread.sleep(100);
log.info(Thread.currentThread().getName() + "======>开始加第二把锁");
//请求第二把锁
twoLock.lock();
try {
log.info(Thread.currentThread().getName() + "======>第二把锁加锁");
Thread.sleep(100);
} finally {
twoLock.unlock();
log.info(Thread.currentThread().getName() + "======>第二把锁解锁完成!!!");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
oneLock.unlock();
log.info(Thread.currentThread().getName() + "======>第一把锁解锁完成!!!");
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantLock aLock=new ReentrantLock();
ReentrantLock bLock=new ReentrantLock();
//开启两个线程
DeadLock threadA = new DeadLock(aLock,bLock);
DeadLock threadB = new DeadLock(bLock,aLock);
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.submit(threadA);
executorService.submit(threadB);
Thread.sleep(20000);
}
}
//output
pool-1-thread-2======>第一把锁加锁
pool-1-thread-1======>第一把锁加锁
pool-1-thread-1======>开始加第二把锁
pool-1-thread-2======>开始加第二把锁
使用 synchronized 测试死锁
public class DeadLockTest {
private static ReentrantLock lockA = new ReentrantLock(false);
private static ReentrantLock lockB = new ReentrantLock(false);
public static void main(String[] args) {
new Thread(() -> {
System.out.println("第1个线程");
update();
}).start();
new Thread(() -> {
System.out.println("第2个线程");
add();
}).start();
}
@SneakyThrows
public static void add() {
synchronized (lockA) {
Thread.sleep(500);
System.out.println("add");
update();
System.out.println(Thread.currentThread());
}
}
@SneakyThrows
public static void update() {
synchronized (lockB) {
Thread.sleep(500);
System.out.println("update");
System.out.println(Thread.currentThread());
add();
}
}
}
- 保证获取锁顺序一致
- 使用带过期时间的锁
如何排查死锁
使用 jps 命令查看运行中的 java 进程 Id。
使用 jstack 分析线程状态。
jstack 进程Id
线程状态
通过分析进程可以得到,
DeadLockTest
进程的两个线程分别为pool-1-thread-2
(简称2)和pool-1-thread-1
(简称1)。通过打印的线程信息可以发现,线程 2 和 1 的线程状态都是 WAITING,其中线程 2 在等待锁
<0x000000076b4b2640>
, 线程 1 在等待锁<0x000000076b4b2670>
。而线程 1 和线程 2 本质上等待的都是对方已经持有的锁,进而引发了死锁问题。死锁现象
同时 jstack 命令分析出了进程中存在的死锁问题,并分析出了死锁的原因。
活锁
活锁产生的条件
出现活锁的前提是采用加锁阻塞便释放已占有资源的方式,这也是避免死锁的一种方式。
在破坏死锁产生的条件不可抢占时。当某进程获取部分资源后,得不到其它资源时,可以立即或者有限等待释放已占有的资源。
当两个线程互相持有了对方需要的资源,请求对方资源阻塞后,会释放已占有的资源。然后重新申请资源,再次请求对方的资源,继续阻塞释放已占有的资源。可能会发生多次循环,进而导致资源的浪费,这就是活锁的现象。
比如有两个线程 1 和 2,分别需要锁 A 和 锁 B。
- 线程 1 的加锁顺序为:A -》B;
- 线程 2 的加锁顺序为:B -》A;
线程 1 和 2 按照加锁顺序进行,很可能发生下列情况:
线程 1
加锁A -> 加锁B -> 阻塞(线程2占有B锁)-> 释放锁A ->(循环) 加锁A -> 加锁B -> 阻塞(线程2占有B锁)-> 释放锁A
线程 2
加锁B -> 加锁A -> 阻塞(线程1占有A锁)-> 释放锁B ->(循环) 加锁B -> 加锁A -> 阻塞(线程1占有A锁)-> 释放锁B
根据例子可以发现活锁的现象就像两个人从独木桥两端过桥一样,当人到大桥中间时发现对向来人,主动谦让退回桥头,如此往复,谁也过不了桥。
如何避免活锁
在因为获取资源阻塞后,释放已占有资源时采用随机时间释放,增加谦让已占有资源的随机性。
手写实现活锁代码
@Slf4j
public class LivelockTest implements Runnable {
private ReentrantLock oneLock;
private ReentrantLock twoLock;
public LivelockTest(ReentrantLock oneLock, ReentrantLock twoLock) {
this.oneLock = oneLock;
this.twoLock = twoLock;
}
@SneakyThrows
@Override
public void run() {
while (true) {
oneLock.lock();
try {
log.info(Thread.currentThread().getName() + "======> lock -----"+oneLock.hashCode());
Thread.sleep(100);
//尝试加第二把锁(加不上直接释放第一把锁)
if (twoLock.tryLock()) {
try {
log.info(Thread.currentThread().getName() + "======> lock -----"+twoLock.hashCode());
} finally {
twoLock.unlock();
log.info(Thread.currentThread().getName() + "======> unlock!!!-----"+twoLock.hashCode());
}
return;
}
} finally {
oneLock.unlock();
log.info(Thread.currentThread().getName() + "======> unlock!!!-----"+oneLock.hashCode());
}
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantLock aLock = new ReentrantLock();
ReentrantLock bLock = new ReentrantLock();
LivelockTest threadA = new LivelockTest(aLock, bLock);
LivelockTest threadB = new LivelockTest(bLock, aLock);
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.submit(threadA);
executorService.submit(threadB);
Thread.sleep(20000);
}
}
pool-1-thread-1======> lock -----374251926
pool-1-thread-2======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----374251926
pool-1-thread-1======> lock -----374251926
pool-1-thread-2======> unlock!!!-----1532567941
pool-1-thread-2======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----374251926
pool-1-thread-2======> lock -----374251926
pool-1-thread-2======> unlock!!!-----374251926
pool-1-thread-2======> unlock!!!-----1532567941
pool-1-thread-1======> lock -----374251926
pool-1-thread-1======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----1532567941
pool-1-thread-1======> unlock!!!-----374251926
饥饿
饥饿现象发生在多个线程竞争共享资源的情况下,若某个线程很久得不到共享资源无法执行下去,这时就是发生了饥饿。
如何避免饥饿
公平分配资源
线程有优先级的属性,优先级越高,越容易获取资源。而锁有非公平锁和公平锁之分,在使用非公平锁时,不是按照申请锁的顺序来占有锁,就容易造成一些优先级低的线程发生饥饿现象。而公平锁是按照申请锁的顺序来占有锁,能够保证线程按照申请顺序来占有锁,进而避免了饥饿现象的发生。
减少持有锁的时间
保证资源充足
生产者消费者-饥饿问题
在生产者消费者模式的读者写者问题中,若读者优先,即读进程读取临界区资源时,写进程不允许操作临界区。若读进程过多,就会造成写进程一直等待,进而发生饥饿问题。