并发编程学习笔记之死锁(八)

死锁

每个人手里都有其他人需要的资源,自己又不会放下手上的资源,这么一直等待下去,就会发生死锁.

当一个线程永远占有一个锁,而其他线程尝试去获得这个锁,那么它们将永远被阻塞.

当线程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)是线程活跃度失败的另一种形式,尽管没有被阻塞,线程缺仍然不能继续,因为他不断重试相同的操作,却总是失败.

例如程序处理一段代码出错了,业务逻辑使它回退重复执行,然后又错了,再回退重新执行,如此反复.这就是活锁.

这种形式的活跃通常来源于过渡的错误恢复代码,误将不可修复的错误当做是可修复的错误.

还有另一个例子: 多个相互协作的线程间,他们为了彼此响应而修改了状态,使得没有一个线程能够继续前进,那么就发生了活锁.

就好比两个有礼貌的人在路上相遇,他们给对方让路,于是在另一条路又遇上了,如此反复...

在并发程序中,通过随机等待和撤回来进行重试能够相当有效地避免活锁的发生.

总结:

活跃度失败是非常严重的问题,因为除了中止应用程序,没有任何机制可以恢复这种失败.

最常见的活跃度失败是死锁.应该在设计时就避免锁顺序死锁:确保多个线程在获得多个锁时,使用一致的顺序.

最好的解决方法是在程序中使用开放调用,这会大大减少一个线程一次请求多个锁的情况.

下篇会更新提高响应速度的方式.

posted @ 2018-10-24 14:41  lbr617  阅读(636)  评论(0编辑  收藏  举报