首页 » 编写高质量代码:改善Java程序的151个建议 » 编写高质量代码:改善Java程序的151个建议全文在线阅读

《编写高质量代码:改善Java程序的151个建议》建议128:预防线程死锁

关灯直达底部

线程死锁(DeadLock)是多线程编码中最头疼的问题,也是最难重现的问题,因为Java是单进程多线程语言,一旦线程死锁,则很难通过外科手术式的方法使其起死回生,很多时候只有借助外部进程重启应用才能解决问题。我们看看下面的多线程代码是否会产生死锁:


class Foo implements Runnable{

public void run(){

//执行递归函数

fun(10);

}

//递归函数

public synchronized void fun(int i){

if(--i>0){

for(int j=0;j<i;j++){

System.out.print(/"*/");

}

System.out.println(i);

fun(i);

}

}

}


注意fun方法是一个递归函数,而且还加上了synchronized关键字,它保证同时只有一个线程能够执行,想想synchronized关键字的作用:当一个带有synchronized关键字的方法在执行时,其他synchronized方法会被阻塞,因为线程持有该对象的锁。比如有这样的代码:


static class Foo{

public synchronized void m1(){

try{

Thread.sleep(1000);

}catch(InterruptedException e){

//异常处理

}

System.out.println(/"m1执行完毕/");

}

public synchronized void m2(){

System.out.println(/"m2执行完毕/");

}}

public static void main(Stringargs)throws Exception{

final Foo foo=new Foo();

//定义一个线程

Thread t=new Thread(new Runnable(){

public void run(){

foo.m1();

}

});

t.start();

//等待t1线程启动完毕

Thread.sleep(10);

//m2方法需要等待m1执行完毕

foo.m2();

}


相信读者明白会先输出“m1执行完毕”,然后再输出“m2执行完毕”,因为m1方法在执行时,线程t持有foo对象的锁,要想主线程获得m2方法的执行权限就必须等待m1方法执行完毕,也就是释放当前锁。明白了这个问题,我们思考一下上例中带有synchronized的递归函数是否能执行?会不会产生死锁?运行结果如下:


*********9

********8

*******7

******6

*****5

****4

***3

**2

*1


一个倒三角形,没有产生死锁,正常执行,这是为何呢?很奇怪,是吗?那是因为在运行时当前线程(Thread-0)获得了foo对象的锁(synchronized虽然是标注在方法上的,但实际作用的是整个对象),也就是该线程持有了foo对象的锁,所以它可以多次重入fun方法,也就是递归了。可以这样来思考该问题,一个宝箱有N把钥匙,分别由N个海盗持有(也就是我们Java中的线程了),但是同一时间只能由一把钥匙打开宝箱,获取宝物,只有在上一个海盗关闭了宝箱(释放锁)后,其他海盗才能继续打开锁获取宝物,这里还有一个规则:一旦一个海盗打开了宝箱,则该宝箱内的所有宝物对他来说都是开放的,即使是“宝箱中的宝箱”(即内箱)对他也是开放的。可以用以下代码来表述。


class Foo implements Runnable{

public void run(){

method1();

}

public synchronized void method1(){

method2();

}

public synchronized void method2(){

//Do Something

}

}


方法method1是synchronized修饰的,方法method2也是synchronized修饰的,method1调用method2是没有任何问题的,因为是同一个线程持有对象锁,在一个线程内多个synchronized方法重入完全是可行的,此种情况下不会产生死锁。

那什么情况下会产生死锁呢?看如下代码:


//资源A

static class A{

public synchronized void a1(B b){

String name=Thread.currentThread().getName();

System.out.println(name+/"进入A.a1()/");

try{

//休眠1秒,仍然持有锁

Thread.sleep(1000);

}catch(Exception e){

//异常处理

}

System.out.println(name+/"试图访问B.b2()/");

b.b2();

}

public synchronized void a2(){

System.out.println(/"进入a.a2()/");

}

}

//资源B

static class B{

public synchronized void b1(A a){

String name=Thread.currentThread().getName();

System.out.println(name+/"进入B.b1()/");

try{

//休眠1秒,仍然持有锁

Thread.sleep(1000);

}catch(Exception e){

//异常处理

}

System.out.println(name+/"试图访问A.a2()/");

a.a2();

}

public synchronized void b2(){

System.out.println(/"进入B.b2()/");

}

}

public static void main(String args){

final A a=new A();

final B b=new B();

//线程A

new Thread(new Runnable(){

public void run(){

a.a1(b);

}

},/"线程A/").start();

//线程B

new Thread(new Runnable(){

public void run(){

b.b1(a);

}

},/"线程B/").start();

}


此段程序定义了两个资源A和B,然后在两个线程A、B中使用了该资源,由于两个资源之间有交互操作,并且都是同步方法,因此在线程A休眠1秒钟后,它会试图访问资源B的b2方法,但是线程B持有该类的锁,并同时在等待A线程释放其锁资源,所以此时就出现了两个线程在互相等待释放资源的情况,也就是死锁了,运行结果如下:


线程A进入A.a1()

线程B进入B.b1()

线程B试图访问A.a2()

线程A试图访问B.b2()


此种情况下,线程A和线程B会一直互等下去,直到有外界干扰为止,比如终止一个线程,或者某一线程自行放弃资源的争抢,否则这两个线程就始终处于死锁状态了。我们知道要达到线程死锁需要四个条件:

互斥条件:一个资源每次只能被一个线程使用。

资源独占条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。

不剥夺条件:线程已获得的资源在未使用完之前,不能强行剥夺。

循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

只有满足了这些条件才可能产生线程死锁,这也同时告诫我们如果要解决线程死锁问题,就必须从这四个条件入手,一般情况下可以按照以下两种方式来解决:

(1)避免或减少资源共享

一个资源被多个线程共享,若采用了同步机制,则产生的死锁可能性很大,特别是在项目比较庞大的情况下,很难杜绝死锁,对此最好的解决办法就是减少资源共享。

例如一个B/S结构的办公系统可以完全忽略资源共享,这是因为此类系统有三个特征:一是并发访问不会太高,二是读操作多于写操作,三是数据质量要求比较低,因此即使出现数据资源不同步的情况也不可能产生太大的影响,完全可以不使用同步技术。但是如果是一个支付清算系统就必须慎重考虑资源同步问题了,因为此类系统一是数据质量要求非常高(如果产生数据不同步的情况那可是重大生产事故),二是并发量大,不设置数据同步则会产生非常多的运算逻辑失效的情况,这会导致交易失败,产生大量的“脏”数据,系统可靠性将大大降低。

(2)使用自旋锁

回到前面的例子,线程A在等待线程B释放资源,而线程B又在等待线程A释放资源,僵持不下,那如果线程B设置了超时时间是不是就可以解决该死锁问题了呢?比如线程B在等待2秒后还是无法获得资源,则自行终结该任务,代码如下:


public void b2(){

try{

//立刻获得锁,或者2秒等待锁资源

if(lock.tryLock(2,TimeUnit.SECONDS)){

System.out.println(/"进入B.b2()/");

}

}catch(InterruptedException e){

//异常处理

}finally{

//释放锁

lock.unlock();

}

}


上面代码中使用tryLock实现了自旋锁(Spin Lock),它跟互斥锁一样,如果一个执行单元要想访问被自旋锁保护的共享资源,则必须先得到锁,在访问完共享资源后,也必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将“自旋”在那里,直到该自旋锁的保持者释放了锁为止。在我们的例子中就是线程A等待线程B释放锁,在2秒内不断尝试是否能够获得锁,达到2秒后还未获得锁资源,线程A则结束运行,线程B将获得资源继续执行,死锁解除。

对于死锁的描述最经典的案例是哲学家进餐(五位哲学家围坐在一张圆形餐桌旁,人手一根筷子,做以下两件事情:吃饭和思考。要求吃东西的时候停止思考,思考的时候停止吃东西,而且必须使用两根筷子才能吃东西),解决此问题的方法很多,比如引入服务生(资源调度)、资源分级等方法都可以很好地解决此类死锁问题。在我们Java多线程并发编程中,死锁很难避免,也不容易预防,对付它的最好办法是测试:提高测试覆盖率,建立有效的边界测试,加强资源监控,这些方法能使死锁无处遁形,即使发生了死锁现象也能迅速查找到原因,提高系统的可靠性。