安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另外一个目标,即“某件正确的事情最终会发生”。 当某个操作无法继续执行下去时,就会发生活跃性问题。
在串行程序中,活跃性问题的形式之一便是无意中造成的无限循环。从而使循环之后的代码无法被执行。而线程将会带来其他的一些活跃性问题,例如我们前面所讲的死锁,以及我们下面将要介绍的饥饿和活锁。
饥饿(Starvation)指的是线程无法访问到所需要的资源而无法执行下去的情况。
引发饥饿最常见的资源便是CPU时钟周期。如果Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环或者无限制地等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程无法得到它。
通常,我们尽量不要改变线程的优先级,在大部分并发应用程序中,可以使用默认的线程优先级。只要改变了线程的优先级,程序的行为就将与平台相关,并且可能导致发生饥饿问题的风险(例如优先级高的线程会一直获取资源,而低优先级的线程则将一直无法获取到资源)。
当某个程序会在一些奇怪的地方调用Thread.sleep或Thread.yield,那是这个程序在试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。
饥饿问题的实质可以用孔子老人家说过的一句话来总结:不患寡而患不均。
解决饥饿问题,有以下三种方案:
保证资源充足。
公平地分配资源。
避免持有锁的线程长时间执行。
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。所以,方案二的适用场景会多一点。在并发编程里,我们可以使用公平锁来公平的分配资源。所谓公平锁,是一种FIFO方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
活锁活锁(Livelock)是另一种形式的活跃性问题,它和死锁很相似,但是它却不会阻塞线程。活锁尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。
活锁通常发生在处理事务消息的应用程序中:如何不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放置到队列的开头。如果消息处理器在处理某种特定的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这个消息又被放到队列开头,因此处理器将被反复调用,并返回相同的处理结果。(有时候也被称为毒药消息,Poison Message。)虽然处理消息的线程没有被阻塞,但也无法执行下去。这种形式的活锁,通常由过度的错误恢复代码造成,因为它错误地将不可修复的错误作为可修复的错误。
当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。 这就好比两个过于礼貌的人在半路上相遇,为了不相撞,他们彼此都给对方让路,结果导致他们又相撞。他们如此反复下一,便造成了活锁问题。
解决这种活锁问题,我们在重试机制中引入随机性。即,让他们在谦让时尝试等待一个随机的时间。如此,他们便不会相撞而顺序通行。我们在以太网协议的二进制指数退避算法中,也可以看到引入随机性降低冲突和反复失败的好处。在并发应用程序中,通过等待随机长度的时间和回退可以有效避免活锁的发生。
性能问题与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不够好,因为我们通常希望正确事情尽快发生。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐量过低,资源消耗过高,或者可伸缩性降低等。与活跃性和安全性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于实现线程而引入的其他性能问题。