并发编程原则
(五)
避免并发编程活跃度危险
前
言:
在这个系列文章中的前几篇文章中,我们的讨论主要围绕着在并发环境下如何确保对共享对象的访问安全。但是很多情况下安全性与活跃度是一对矛盾。通常我们使用锁来保证线程安全,但是如果锁被滥用可能引起系统的“活跃度”的下降,使系统的响应减缓以及吞吐量的降低。更严重的情况是可能会引起各种活跃度危险,比如:
CPU
饥饿,弱响应,活锁甚至死锁。
(
1
)、死锁:
在所有的并发编程活跃度问题中死锁无疑是最为严重的,在
Java
编写的系统中更为严重,因为
Java
应用程序不能从死锁中恢复,唯一的办法就是重新启动服务器,然后默默向上苍祷告不要再出现这样的噩梦。
我想凡是正规计算机专业毕业的朋友都会学习操作系统课程,在操作系统中一定会接触过经典的“哲学家进餐”问题,这个问题很好的揭示了死锁。当一个线程永远占有一个锁,而其他线程尝试去获得这个锁,那么他们将会被永远的阻塞。这种情况便形成了一种死锁情况,这种死锁是一种简单形式的死锁,它由环路锁依赖引起的。死锁是很难被发现的,它不会随时爆发,而只会在某些情况下突然爆发,而且很不幸这些情况通常都是在高负载情况下出现。
1、
锁顺序死锁:
请看下面的
1.1.1
代码所示:
public class DeadLock{
private final Object left=new Object();
private final Object right=new Object();
public void leftlock(){
synchronized(left){
synchronized(right){
doSomthig();
}
}
}
public void rightlock(){
synchronized(right){
synchronized(left){
doSomthing();
}
}
}
}
当我们在多线程环境下运行上述代码后,马上就会出现死锁。造成这种死锁的原因就是:两个线程试图以不同的顺序获得多个相同的锁。也就是说上面的代码会造成获取
left
和
right
锁的线程互相等待,而又互不相让的情况,造成程序停滞不前无法运行。因此如果所有线程以通用的固定顺序获取锁,程序将不会出现锁顺序死锁。
2、
动态的锁顺序死锁:
有时候你并不能一目了然能看出哪里存在死锁隐患,这就是所谓的动态的顺序死锁。如下面
1.1.2
代码所示:
public void transfermoney(Account fromaccount,
Account toaccount,
BigDecimal amount) throws Exception{
synchronized(fromaccount){
synchronized(toaccount){
if(fromaccount.getBalance().compareTo(amount)<0){
throw new Exception(“Balance insufficiency!”);
}
else{
fromaccount.debit(amount);
toaccount.credit(amount);
}
}
}
}
上面的代码看起来所有的线程都是通过相同的顺序来获取锁,但是事实上锁的顺序取决于传递给
transfermoney
的参数顺序,如果有两个线程同时调用
transfermoney
并且以下面的顺序调用:
A:transfermoney(myaccount,youraccount,new BigDecimal(10));
B:transfermoney(youraccount,myaccount,new BigDecimal(20));
在偶发时序中,第一个线程获得
myaccount
的锁,并等待
youraccount
的锁,然而此时第二个线程已经持有
youraccount
的锁,并且正在等待
myaccount
的锁。这样就动态的形成了锁顺序死锁。我们可以采用下面
1.1.3
代码来改进上面的程序以减少死锁的危险:
private static final Object tielock=new Object();
public void transfermoney(final Account fromaccount,
final Account toaccount,
final BigDecimal amount) throws Exception{
class Helper{
public void transefer() throws Exception{
if(fromaccount.getBalance().compareTo(amount)<0){
throw new Exception(“Balance insufficiency!”);
}
else{
fromaccount.debit(amount);
toaccount.credit(amount);
}
}
}
int fromhash=System.identityHashCode(fromaccount);
int tohash=System.identityHashCode(toaccount);
if(fromhash<tohash){
synchronized(fromaccount){
synchronized(toaccount){
new Helper().transfer();
}
}
}
else if(fromhash>tohash){
synchronized(toaccount){
synchronized(fromaccount){
new Helper().transfer();
}
}
}
else{
synchronized(tielock){
synchronized(fromaccount){
synchronized(toaccount){
new Helper().transfer();
}
}
}
}
}
上面代码中使用了
System.identityHashCode
方法来生成对象的
hashcode
,并且按照生成
hashcode
的大小决定加锁顺序,虽然加入了新的代码,但是这却能够减少死锁发生的可能性。通常情况下通过
System.identityHashCode
返回的
hashcode
不会出现相同的值,但是为了防止那极少数的情况,我们还加入了一个额外的测试锁
tielock
,如果两个对象返回的
hashcode
出现了相等的情况,这时通过强制要求在获取两个
Account
锁之前,先获取
tielock
锁,这样就能保证一次只有一个线程执行这个有风险的操作,从而减少死锁发生的可能性。
3、
协作对象间死锁:
如果一个对象的方法正在调用一个外部方法,而这个外部方法是一个需要持有锁的方法,这种情况可能就是死锁发生的一种警示。因为外部方法可能会获得其它锁(产生死锁风险),或者遭遇严重的超时阻塞。当持有锁时会延迟其它试图获得该锁的线程。如下面代码
1.1.3
所示:
class Taxi{
private final Dispather dispather;
private final Point location,destination;
public Taxi(Dispather dispather){
this.dispather=dispatcher;
}
public synchronized point getLocation(){
return location;
}
public synchronized void setLocation(Point location){
this.location=location;
if(location.equals(destination)){
dispatcher.notifyAvailable(this);
}
}
}
class Dispather{
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispather(){
taxis=new HashSet<Taxi>();
availableTaxis=new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi){
availableTaxis.add(taxi);
}
public synchronized Image getImage(){
Image image=new Image();
for(Taxi t:taxis){
image.draw(t.getLocation());
}
return image;
}
}
在上面的代码中在某种时序下会产生协作死锁。因为在调用
setLocation
方法时线程获取
Taxi
的锁,然后再获取
Dispather
的锁。类似的当调用
getImage
方法时线程先获取
Dispather
的锁,然后再获取
Taxi
的锁。这样就形成了锁顺序死锁。
以上我们讨论了三种死锁情形,这三种情形可以说都是锁顺序死锁,其中后两种是第一种情况的在复杂情况下的变体,因此也更加隐蔽更加难以发现。其中最复杂的是对象协作间死锁,为了有效的避免它,我们应该尽量采用“开放调用”。当调用的方法不需要持有锁时,这被称为开放调用。依赖开放调用的类会具有更好的行为,并且比那些需要获取锁才能调用的方法具有更容易与其它类合作的特点。这个原则可以进一步的发展,因为有时我们无法回避锁的使用,但是我们可以尽量减少锁的使用范围,是一个方法中的大部分代码成为开放的代码。本着这个原则我们通过以下
1.1.4
代码来优化上面
1.1.3
代码:
class Taxi{
private final Dispather dispather;
private final Point location,destination;
public Taxi(Dispather dispather){
this.dispather=dispatcher;
}
public synchronized point getLocation(){
return location;
}
public synchronized void setLocation(Point location){
boolean reachedDestination;
synchronized(this){
this.location=location;
reachedDestination =location.equals(destination)
}
if(reachedDestination){
dispatcher.notifyAvailable(this);
}
}
}
class Dispather{
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispather(){
taxis=new HashSet<Taxi>();
availableTaxis=new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi){
availableTaxis.add(taxi);
}
public Image getImage(){
Set<Taxi> copy;
synchronized(this){
copy=new HashSet<Taxi>(taxis)
}
Image image=new Image();
for(Taxi t:taxis){
image.draw(t.getLocation());
}
return image;
}
}
上面代码我们通过减少
synchronized
块的范围,使大部分代码成为开放代码,只让锁来保护那些共享变量。这样更加细化的加锁规范了加锁顺序,减小了死锁产生的可能性。
4、
资源死锁:
还有一种死锁不同于上面的死锁情况,这种死锁的产生源于对共享资源的竞争,所以称为资源死锁。这种死锁的常见场所就是数据库连接池的访问。对资源池的实现通常采用信号量,当资源池为空时资源池将被阻塞。假如有两个数据库连接池
D1
和
D2
,每个池中各有一个连接,有两个线程
A/B
,当线程
A
获取
D1
的连接并等待
D2
的连接,而线程
B
获取
D2
连接并等待
D1
的连接,这样就产生了资源死锁。资源池越大,这种情况发生的机率越小。另外应该记住有界池不应该同相互依赖的任务放在一起使用。
(
2
)、其它活跃度危险:
尽管死锁使我们遇到的最主要的活跃度危险,并发编程中仍然存在其他一些并发活跃度危险,下面我将介绍几种:
1、
CPU
饥饿:
当线程访问它需要的资源时却被永远的拒绝,以至于不能再继续进行,这就发生了饥饿。最常见的饥饿是
CPU
饥饿。在
Java
应用程序中,使用线程的优先级不当可能引起饥饿。在锁中无休止的构建也可能引起饥饿(无限循环,或者无休止的等待资源),因为其他需要这个所的线程将永远不会得到它。另外需要等待其他任务结果的任务是生成线程饥饿的重要来源。
2
、活锁:
活锁是线程活跃度失败的另一种形式,尽管没有被阻塞,线程却仍然无法继续,因为它不断重试相同的操作,却总是失败。活锁通常来源于过渡的错误恢复代码,误将不可恢复的错误当作可修复的错误。因此我们在开发时要识别错误类别,当不可恢复的错误发生时就要“彻底的失败”,而不要试图去恢复。解决活锁的一种方案就是在重试中加入随机机制。比如我们熟悉的以太网的
CSMA/CD
机制,当两台机站采用相同的载波发送数据包,这时会发生冲突,机站发现冲突后会延迟一段时间后重发,如果它们都很默契的延迟一秒钟后重发,那么还会引发冲突,并且不断冲突下去。如果我们使各个机站采用一个随机组件来生成延迟时间,那么将会减少再次冲突的可能。在并发程序中,通过随机等待和撤销重试能够相当有效的避免活锁发生。
除此之外还有很多活跃度危险,比如由于不良的锁管理策略引发的弱响应性,或者由于信号丢失引起的挂起等等。