从0学习java并发编程实战-读书笔记-活跃性问题

在安全性与活跃性之间通常存在着某些制衡。

锁顺序死锁(Lock-Ordering Deadlock)
资源死锁(Resource Deadlock)

死锁

在数据库系统的设计中考虑了检测死锁以及从死锁中恢复。当数据库系统检测到一组事务发生了死锁(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者,释放其全部资源,放弃这个事务。

JVM解决死锁的问题远没有数据库服务那么强大,当一组java线程发生死锁时,这些线程将永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特地子系统停止,或是性能降低。恢复应用程序的唯一方式就是中止并重启它。

与其他的并发危险一样,一个类有可能发生死锁,并不是每次都会发生死锁。在死锁发生的时候,往往是最糟糕的时候--高负载情况下。

锁顺序死锁

这个是最常见的死锁发生方式:线程1和线程2,线程1调用A(),获得了锁A,想要继续调用B();线程2调用了B(),获得了锁B,想要继续调用A()。两个线程都在等待对方释放资源。

这里死锁的原因,是因为两个线程试图以不同的顺序获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性。也就不会发生死锁了。

如果所有线程以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁的问题。

动态的锁顺序死锁

有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。

动态的锁顺序死锁(不要这么做)

/**
 * 容易发生死锁 
 */
public void transferMoney(Account fromAccount, Account toAccount, DollarAmount amount) throws InsufficientFundsException {
    synchronized(fromAccount){
        synchronized(toAccount){
            if(fromAccount.getBalance.compareTo(account) < 0){
                throw new InsufficientFundsException();
            } else {
                fromAccount.debit(amount);
                toAccount.credit(amount);
            }
        }
    }
}

所有的线程看起来都是按照相同的顺序来获取锁,事实上锁的顺序取决于传递给transferMoney的顺序。如果一个线程从X向Y转账,另一个线程从Y向X转账,那么就有可能发送锁顺序死锁。

通过锁顺序来避免死锁

private static final Object tieLock = new Object();

public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) 
                    throws InsufficientFundsException {
    class Helper{
        public void transfer() throws throws InsufficientFundsException {
            if(fromAccount.getBalance.compareTo(account) < 0){
                    throw new InsufficientFundsException();
            } else {
                fromAccount.debit(amount);
                toAccount.credit(amount);
            }
        }
    }

    int fromHash = System.identityHashCode(fromAcct);
    int toHash = System.identityHashCode(toAcct);

    if(fromHash < toHash){
        synchronized(fromAccount){
            synchronized(toAccount){
                new Helper().transfer();
            }
        }
    } else if(fromHash > toHash){
         synchronized(toAccount){
            synchronized(fromAcct){
                new Helper().transfer();
            }
        }
    }else{
        synchronized(tieLock){
            synchronized(toAccount){
            synchronized(fromAcct){
                    new Helper().transfer();
                }
            }
        }
    }
}

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任务的方法来决定锁的顺序,否则可能又会陷入死锁。为了避免这种情况,引入了“加时赛(Tie-Breaking)”锁。在获取两个锁之前,首先获得这个“加时赛”锁,从而保证只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性。有不少朋友问,如何深入学习Java后端技术栈,今天分享一个,互联网牛人整理出来的Java深入学习路线图,以及开发工具包,【戳我进入】学习裙。

如果Account中包含一个唯一的,不可变的且具备可比性的键值,比如id,账号,那么只需要根据键值对对象进行排序,不需要使用“加时赛”锁。

在协作对象之间发生死锁

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在外部方法中可能获得其他锁(这可能会产生死锁),或阻塞时间过长,导致其他线程无法即使获得当前被持有的锁。

开放调用

方法调用相当于一种抽象屏障,因为你无需了解被调用方法中所执行的操作。但也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法难以进行分析,从而可能出现死锁。

如果在调用某个方法时不需要持有锁,那么这种调用被称为 开放调用(Open Call) 。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更容易编写。通过尽可能的使用开放调用,将更容易找出那些需要获取多个锁的代码路径,因此也更容易确保采用一致的顺序来获取锁。

在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更容易对依赖于开放调用的程序进行死锁分析。

资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵守相同的顺序,那么也可能出现死锁(资源池越大,死锁概率越小)。

另一种基于资源的死锁形式就是 线程饥饿死锁 :一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。这种情况下,第一个任务将永远的等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源。 有界线程池/资源池与相互依赖的任务不能一起使用。

死锁的避免和诊断

如果一个程序每次最多只能获取一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实。如果必须获得很多锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入文档并始终遵守。

在使用细粒度锁的程序中,可以通过使用一种 两阶段策略(Two-Part Strategy) 来检查代码中的死锁:

  1. 首先,找出在什么地方将获得多个锁(使这个集合尽量小)
  2. 对所有这些实例进行全局分析,从而确保它们在整个程序中获得锁的顺序都是一致的。
  3. 尽可能使用开放性调用,这样可以极大地简化分析过程。

支持定时的锁

有一项技术可以检测死锁和从死锁中恢复过来,即显式使用 Lock类中的定时tryLock功能 来代替内置锁机制。当使用内置锁的时,只要没获得到锁,就会永远的等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后,tryLock会返回一个失败信息。如果超时时限比获取锁的时间长很多,那么可以在发生某个意外情况后重新获得控制权。

当定时锁失败时,你并不需要知道失败的原因,或许是因为发生了死锁,或许是某个线程在持有锁时错误进入了无限循环,还有可能是某个操作的执行时间远远超过了你的预期。然而至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式重新启动计算,而不是关闭整个进程。

即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效的应对死锁问题。如果在获取锁时超时,那么就释放这个锁,然后后退一段时间后再次尝试,从而消除了发生死锁的条件,使程序恢复过来。(只有在同时获取两个锁时才有效,如果如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它)。

其他活跃性危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些活跃性危险: 饥饿 、 丢失信号活锁 等。

饥饿

当线程由于无法访问它所需的资源而不能执行时,就发生了 饥饿(Starvation) 。引发饥饿的最常见资源就是CPU时钟周期,如果在java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环、无限等待资源等),那么也有可能导致饥饿,因为其他需要这个锁的线程将无法得到它。

操作系统的线程调度器会尽力提供公平的,活跃性良好的调度,甚至远超出java语言规范的需求范围。在大多数java应用程序中,所有线程都具有相同的优先级Thread.NORMAL_PRIORITY。线程优先级并不是直观的机制,提高了优先级以后可能起不到任何作用,也可能使得某个线程的调度优先于其他线程,从而导致饥饿。

我们要尽量避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在绝大多数并发应用中,使用默认的线程优先级即可。

糟糕的响应

不良的锁管理可能导致糟糕的响应,例如某个线程长时间占有一个锁(或许正在对一个超大容器进行迭代,并对每个元素进行计算密集的处理),从而使其他想要访问这个容器的线程就必须等待很长时间。

活锁

活锁(Livelock) 是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但是也不能继续执行,因为线程将不断重复执行相同的操作,而且总是会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放入这个队列的开头。再次执行到,又都会发出错误并回滚,因此处理器将被反复调用,虽然线程并没有阻塞,但是也无法执行下去。这种活锁通常是由过度的错误恢复代码造成的: 错误的将不可修复的错误作为可修复的错误。

要解决活锁问题,需要在重试机制中引入随机性。例如通过等待随机长度和回退可以有效地避免活锁发生。

小结

活跃性问题是一个非常严重的问题,因为当出现活跃性问题时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障中恢复。最常见的活跃性问题就是锁顺序死锁。在设计的时候应该要避免尝试锁顺序死锁,确保线程在获取多个锁的时候保持一致的顺序。如果情况允许,尽可能多的使用开放调用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值