并发编程学习笔记之死锁(八)
死锁
每个人手里都有其他人需要的资源,自己又不会放下手上的资源,这么一直等待下去,就会发生死锁.
当一个线程永远占有一个锁,而其他线程尝试去获得这个锁,那么它们将永远被阻塞.
当线程A占有锁L时,想要获得锁M,同时线程B持有M,并尝试得到L,两个线程将永远等待下去,这种情况是死锁最简单的形式(或称致命的拥抱,deadly embrace)
数据库不会发生死锁的情况,它会选择一个牺牲者,强行释放锁,让程序可以继续执行下去.
JVM不行,只能重启程序.
死锁并不会每次都出现
死锁很少能立即发现.一个类如果有发生死锁的潜在可能并不意味着每次都将发生,它只发生在该发生的时候.
当死锁出现的时候,往往是遇到了最不幸的时候--- 在高负载下.
锁顺序死锁
public class LeftRightDeadLock {
private Object leftLock = new Object();
private Object rightLock = new Object();
public void getLeftLock(){
synchronized (this.rightLock){
synchronized (this.leftLock){
//do something
}
}
}
public void getRightLock(){
synchronized (this.leftLock){
synchronized (this.rightLock){
//do something.
}
}
}
}
两个线程分别进入getRightLock和getLeftLock方法,同时获得第一个锁,在等待下一个锁的时候,就会发生锁顺序死锁.
发生死锁的原因: 两个线程试图通过不同的顺序获得多个相同的锁.
如果请求的顺序相同就不会出现循环的锁依赖现象,就不会产生死锁了.
如果所有线程以通用的固定秩序获得锁,程序就不会出现锁顺序死锁问题了.
动态的锁顺序死锁
public class DynamicDeadLock {
public void transferMoney(Account fromAcount,Account toAccount){
synchronized (fromAcount){
synchronized (toAccount){
//转账操作
}
}
}
}
当两个线程同时调用transferMoney,一个从X向Y转账,另一个从Y向X转账,那就会发生死锁.
transferMoney(myAccount,yourAccount)
transferMoney(yourAccount,myAccount)
之前说了,造成死锁的原因就是以不同的顺序获得相同的锁.
那么要解决这个问题,我们就必须制定锁的顺序.
System.indentityHashCode(传入对象)方法可以得到对象的哈希码.我们通过哈希码来决定锁的顺序.
public class DynamicDeadLock {
private Object obj = new Object();
public void transferMoney(Account fromAcount,Account toAccount){
//这个内部类秒啊,可以减少重复代码
class Helper {
public void transferMoney(){
//真正的转账操作..
//假装使用 外部的两个参数 fromAcount和toAccount做一下操作..
}
}
//制定锁的顺序
int fromHash = System.identityHashCode(fromAcount);
int toHash = System.identityHashCode(toAccount);
if(fromHash<toHash){
synchronized (fromAcount){
synchronized (toAccount){
new Helper().transferMoney();
}
}
}else if(fromHash>toHash){
synchronized (toAccount){
synchronized (fromAcount){
new Helper().transferMoney();
}
}
}else{
//使用成员变量的锁
synchronized (obj){
synchronized (fromAcount){
synchronized (toAccount){
new Helper().transferMoney();
}
}
}
}
}
}
虽然有点麻烦,但是减少了发生死锁的可能性.
注意上面代码的最后一种else的情况,使用了一个额外的obj的锁,这是因为极少数的情况下会出现hashcode相同的情况,当hashCode相同的时候,使用之前的两种顺序锁,两个线程同时调用两个方法,参数换位,颠倒顺序计算哈希值,就又有了出现死锁的可能,所以引入第三种锁来保证锁的顺序,从而减少死锁发生的可能性.
如果经常出现hash值冲突,那么并发性会降低(因为多加了一个锁),但是因为
System.identityHashCode的哈希冲突出现频率很低,所以这个技术以最小的代价,换来了最大的安全性.
如果Account具有一个唯一的,不可变的,并且具有可比性的key,比如账号,那么就可以通过账号来排定对象顺序,这样就能省去obj的锁了.
协作对象间的死锁
public class A {
private final B b ;
public A(B b) {
this.b = b;
}
public synchronized void methodA(){
//do something.
//调用B的同步的方法
b.methodB();
}
}
public class B {
private final A a;
public B(A a) {
this.a = a;
}
public synchronized void methodB(){
//do something
//调用A的同步的方法
a.methodA();
}
}
在持有锁的时候调用外部方法是在挑战活跃度问题,外部方法可能会获得其他锁(产生死锁的风险),或者遭遇严重超时的阻塞,当你持有锁的时候会延迟其他试图获得该锁的线程
开放调用
在持有锁的时候调用一个外部方法很难进行分析,因此是危险的.
当调用的方法不需要持有锁时,这被称为开放调用(open call). 依赖于开放调用的类更容易与其他的类合作.
使用开放调用来避免死锁类似于使用封装来提供线程安全:对一个有效封装的类进行线程安全分析,要比分析没有封装的类容易得多.
类似地,分析一个完全依赖于开放调用的程序的程序活跃度,比分析哪些非开放调用的程序更简单.
尽量让你自己使用开放调用,这比获得多重锁后识别代码路径更简单,因为可以确保使用一致的顺序获得锁.
不使用synchronized修饰方法,减少synchronized包住的代码块,来避免协作对象间的死锁.
public class A {
private final B b;
public A(B b) {
this.b = b;
}
public void methodA() {
//关键在这
synchronized (this) {
//do something.
}
//调用B的同步的方法
b.methodB();
}
}
除了能避免死锁以外,因为同步的代码块变小,所以使得响应速度得到提高.
在程序中尽量使用开放调用.依赖于开放调用的程序,相比于那些在持有锁的时候还调用外部方法的程序,更容易进行死锁自由度(deadlock-freedom)的分析.
在同步方法之间互相调用的时候,尽量使用开放调用来避免死锁.
避免和诊断死锁
使用定时的锁
使用显示的Lock类中定时tryLock方法来替代synchronized,可以设置超时时间,超时会失败,这样避免了死锁.
其他的活跃度失败.
除了死锁,还有一些其他的活跃度危险:
- 饥饿
- 丢失信号
- 活锁
饥饿
当线程访问它所需要的资源时却被永久拒绝,以至于不能再继续进行,这样就发生了饥饿(starvation).
引发饥饿的情况:
- 使用线程的优先级不当
- 在锁中执行无终止的构建(无限循环,或者无尽等待资源).
归根结底是因为线程不能再执行.
线程优先级并不是方便的工具,它改变线程优先级的效果往往不明显;提高一个线程的优先级往往什么都不能改变,或者总是会引起一个线程的调度优先高于其他线程,从而导致饥饿.
抵制使用线程优先级的诱惑,因为这会增加平台依赖性,并且可能引起活跃度问题.大多数并发应用程序可以对所有线程使用相同的优先级.
弱响应性
当计算密集型后台计算任务影响到响应性时,这种情况下可以使用线程优先级.降低执行后台任务的线程的优先级,从而提高程序的响应性.
活锁
活锁(livelock)是线程活跃度失败的另一种形式,尽管没有被阻塞,线程缺仍然不能继续,因为他不断重试相同的操作,却总是失败.
例如程序处理一段代码出错了,业务逻辑使它回退重复执行,然后又错了,再回退重新执行,如此反复.这就是活锁.
这种形式的活跃通常来源于过渡的错误恢复代码,误将不可修复的错误当做是可修复的错误.
还有另一个例子: 多个相互协作的线程间,他们为了彼此响应而修改了状态,使得没有一个线程能够继续前进,那么就发生了活锁.
就好比两个有礼貌的人在路上相遇,他们给对方让路,于是在另一条路又遇上了,如此反复...
在并发程序中,通过随机等待和撤回来进行重试能够相当有效地避免活锁的发生.
总结:
活跃度失败是非常严重的问题,因为除了中止应用程序,没有任何机制可以恢复这种失败.
最常见的活跃度失败是死锁.应该在设计时就避免锁顺序死锁:确保多个线程在获得多个锁时,使用一致的顺序.
最好的解决方法是在程序中使用开放调用,这会大大减少一个线程一次请求多个锁的情况.
下篇会更新提高响应速度的方式.
喜欢我的博客就请点赞+【关注】一波