一般来说,每一种使用线程的语言中都存在线程死锁问题,Java开发中遇到线程死锁问题也是非常普遍。笔者在程序开发中就常常碰到死锁的问题,并经常束手无策。本文分享笔者在JAVA开发中对线程死锁的一些看法。

一. 什么是线程
在谈到线程死锁的时候,我们首先必须了解什么是Java线程。一个程序的进程会包含多个线程,一个线程就是运行在一个进程中的一个逻辑流。多线程允许在程序中并发执行多个指令流,每个指令流都称为一个线程,彼此间互相独立。

线程又称为轻量级进程,它和进程一样拥有独立的执行控制,由操作系统负责调度,区别在于线程没有独立的存储空间,而是和所属进程中的其它线程共享一个存储空间,这使得线程间的通信较进程简单。笔者的经验是编写多线程序,必须注意每个线程是否干扰了其他线程的工作。每个进程开始生命周期时都是单一线程,称为“主线程”,在某一时刻主线程会创建一个对等线程。如果主线程停滞则系统就会切换到其对等线程。和一个进程相关的线程此时会组成一个对等线程池,一个线程可以杀死其任意对等线程。

因为每个线程都能读写相同的共享数据。这样就带来了新的麻烦:由于数据共享会带来同步问题,进而会导致死锁的产生。

二. 死锁的机制
由多线程带来的性能改善是以可靠性为代价的,主要是因为有可能产生线程死锁。死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不能正常运行。简单的说就是:线程死锁时,第一个线程等待第二个线程释放资源,而同时第二个线程又在等待第一个线程释放资源。这里举一个通俗的例子:如在人行道上两个人迎面相遇,为了给对方让道,两人同时向一侧迈出一步,双方无法通过,又同时向另一侧迈出一步,这样还是无法通过。假设这种情况一直持续下去,这样就会发生死锁现象。

导致死锁的根源在于不适当地运用“synchronized”关键词来管理线程对特定对象的访问。“synchronized”关键词的作用是,确保在某个时刻只有一个线程被允许执行特定的代码块,因此,被允许执行的线程首先必须拥有对变量或对象的排他性访问权。当线程访问对象时,线程会给对象加锁,而这个锁导致其它也想访问同一对象的线程被阻塞,直至第一个线程释放它加在对象上的锁。

Java中每个对象都有一把锁与之对应。但Java不提供单独的lock和unlock操作。下面笔者分析死锁的两个过程“上锁”和“锁死” 。

(1) 上锁
许多线程在执行中必须考虑与其他线程之间共享数据或协调执行状态,就需要同步机制。因此大多数应用程序要求线程互相通信来同步它们的动作,在 Java 程序中最简单实现同步的方法就是上锁。在 Java 编程中,所有的对象都有锁。线程可以使用 synchronized 关键字来获得锁。在任一时刻对于给定的类的实例,方法或同步的代码块只能被一个线程执行。这是因为代码在执行之前要求获得对象的锁。

为了防止同时访问共享资源,线程在使用资源的前后可以给该资源上锁和开锁。给共享变量上锁就使得 Java 线程能够快速方便地通信和同步。某个线程若给一个对象上了锁,就可以知道没有其他线程能够访问该对象。即使在抢占式模型中,其他线程也不能够访问此对象,直到上锁的线程被唤醒、完成工作并开锁。那些试图访问一个上锁对象的线程通常会进入睡眠状态,直到上锁的线程开锁。一旦锁被打开,这些睡眠进程就会被唤醒并移到准备就绪队列中。

(2)锁死
如果程序中有几个竞争资源的并发线程,那么保证均衡是很重要的。系统均衡是指每个线程在执行过程中都能充分访问有限的资源,系统中没有饿死和死锁的线程。当多个并发的线程分别试图同时占有两个锁时,会出现加锁冲突的情形。如果一个线程占有了另一个线程必需的锁,互相等待时被阻塞就有可能出现死锁。

在编写多线程代码时,笔者认为死锁是最难处理的问题之一。因为死锁可能在最意想不到的地方发生,所以查找和修正它既费时又费力。例如,常见的例子如下面这段程序。

public int sumArrays(int[] a1, int[] a2) {
    int value = 0;
    int size = a1.length;
    if (size == a2.length) {
        synchronized(a1) { //1
            synchronized(a2)       { //2
                for (int i = 0; i < size; i++)
                    value += a1[i] + a2[i];
            }
        }
    }
    return value;
}

这段代码在求和操作中访问两个数组对象之前锁定了这两个数组对象。它形式简短,编写也适合所要执行的任务;但不幸的是,它有一个潜在的问题。这个问题就是它埋下了死锁的种子。

三. 如何检测死锁的根源
Java并不提供对死锁的检测机制。笔者认为常用分析Java代码问题的最有效的工具仍然是java thread dump。当死锁发生时,JVM通常处于挂起状态,thread dump可以给出静态稳定的信息,查找死锁只需要查找有问题的线程。Java虚拟机死锁发生时,从操作系统上观察,虚拟机的CPU占用率为零,很快会从top或prstat的输出中消失。这时可以收集thread dump,查找"waiting for monitor entry"的thread,如果大量thread都在等待给同一个地址上锁(因为对于Java,一个对象只有一把锁),这说明很可能死锁发生了。

为了确定问题,笔者建议在隔几分钟后再次收集一次thread dump,如果得到的输出相同,仍然是大量thread都在等待给同一个地址上锁,那么肯定是死锁了。如何找到当前持有锁的线程是解决问题的关键。一般方法是搜索thread dump,查找"locked,找到持有锁的线程。如果持有锁的线程还在等待给另一个对象上锁,那么还是按上面的办法顺藤摸瓜,直到找到死锁的根源为止。

另外,在thread dump里还会经常看到这样的线程,它们是等待一个条件而主动放弃锁的线程。有时也需要分析这类线程,尤其是线程等待的条件。

四. 几种常见死锁及对策
解决死锁没有简单的方法,这是因为线程产生死锁都各有各的原因,而且往往具有很高的负载。大多数软件测试产生不了足够多的负载,所以不可能暴露所有的线程错误。在这里中,笔者将讨论开发过程常见的4类典型的死锁和解决对策。

(1)数据库死锁
在数据库中,如果一个连接占用了另一个连接所需的数据库锁,则它可以阻塞另一个连接。如果两个或两个以上的连接相互阻塞,则它们都不能继续执行,这种情况称为数据库死锁。

数据库死锁问题不易处理,通常数据行进行更新时,需要锁定该数据行,执行更新,然后在提交或回滚封闭事务时释放锁。由于数据库平台、配置的隔离级以及查询提示的不同,获取的锁可能是细粒度或粗粒度的,它会阻塞(或不阻塞)其他对同一数据行、表或数据库的查询。基于数据库模式,读写操作会要求遍历或更新多个索引、验证约束、执行触发器等。每个要求都会引入更多锁。此外,其他应用程序还可能正在访问同一数据库模式中的某些对象,并获取不同应用程序所具有的锁。

所有这些因素综合在一起,数据库死锁几乎不可能被消除了。值得庆幸的是,数据库死锁通常是可恢复的:当数据库发现死锁时,它会强制销毁一个连接(通常是使用最少的连接),并回滚其事务。这将释放所有与已经结束的事务相关联的锁,至少允许其他连接中有一个可以获取它们正在被阻塞的锁。

由于数据库具有这种典型的死锁处理行为,所以当出现数据库死锁问题时,数据库常常只能重试整个事务。当数据库连接被销毁时,会抛出可被应用程序捕获的异常,并标识为数据库死锁。如果允许死锁异常传播到初始化该事务的代码层之外,则该代码层可以启动一个新事务并重做先前所有工作。

当出现问题就重试,由于数据库可以自由地获取锁,所以几乎不可能保证两个或两个以上的线程不发生数据库死锁。此方法至少能保证在出现某些数据库死锁情况时,应用程序能正常运行。
(2)资源池耗尽死锁

客户端的增加导致资源池耗尽死锁是由于负载而造成的,即资源池太小,而每个线程需要的资源超过了池中的可用资源。假设连接池最多有10个连接,同时有10个对外部并发调用。这些线程中每一个都需要一个数据库连接用来清空池。现在,每个线程都执行嵌套的调用。则所有线程都不能继续,但又都不放弃自己的第一个数据库连接。这样,10个线程都将被死锁。

研究此类死锁,会发现线程存储中有大量等待获取资源的线程,以及同等数量的空闲且未阻塞的活动数据库连接。当应用程序死锁时,如果可以在运行时检测连接池,就能确认连接池实际上已空。

修复此类死锁的方法包括:增加连接池的大小或者重构代码,以便单个线程不需要同时使用很多数据库连接。或者可以设置内部调用使用不同的连接池,即使外部调用的连接池为空,内部调用也能使用自己的连接池继续。

(3)单线程、多冲突数据库连接死锁

对同一线程执行嵌套的调用有时出现死锁,此情形即使在非高负载系统中通常也会发生。当第一个(外部)连接已获取第二个(内部)连接所需要的数据库锁,则第二个连接将永久阻塞第一个连接,并等待第一个连接被提交或回滚,这就出现了死锁情形。因为数据库没有注意到两个连接之间的关系,所以数据库不会将此情形检测为死锁。这样即使不存在并发,此代码也将导致死锁。此情形有多种具体的变种,可以涉及多个线程和两个以上的数据库连接。

(4)Java虚拟机锁与数据库锁冲突

这种情形发生在数据库锁与Java虚拟机锁并存的时候。在这种情况下,一个线程占有一个数据库锁并尝试获取Java虚拟机锁。同时,另一个线程占有Java虚拟机锁并尝试获取数据库锁。此时,数据库发现一个连接阻塞了另一个连接,但由于无法阻止连接继续,所以不会检测到死锁。Java虚拟机发现同步的锁中有一个线程,并有另一个尝试进入的线程,所以即使Java虚拟机能检测到死锁并对它们进行处理,它还是不会检测到这种情况。

总而言之,JAVA应用程序中的死锁是一个大问题——它能导致整个应用程序慢慢终止,还很难被分离和修复,尤其是当开发人员不熟悉如何分析死锁环境的时候。

五. 死锁的经验法则
笔者在开发中总结以下死锁问题的经验。
(1) 对大多数的Java程序员来说最简单的防止死锁的方法是对竞争的资源引入序号,如果一个线程需要几个资源,那么它必须先得到小序号的资源,再申请大序号的资源。可以在Java代码中增加同步关键字的使用,这样可以减少死锁,但这样做也会影响性能。如果负载过重,数据库内部也有可能发生死锁。

(2)了解数据库锁的发生行为。假定任何数据库访问都有可能陷入数据库死锁状况,但是都能正确进行重试。例如了解如何从应用服务器获取完整的线程转储以及从数据库获取数据库连接列表(包括互相阻塞的连接),知道每个数据库连接与哪个Java线程相关联。了解Java线程和数据库连接之间映射的最简单方法是向连接池访问模式添加日志记录功能。

(3)当进行嵌套的调用时,了解哪些调用使用了与其它调用同样的数据库连接。即使嵌套调用运行在同一个全局事务中,它仍将使用不同的数据库连接,而不会导致嵌套死锁。

(4)确保在峰值并发时有足够大的资源池。

(5)避免执行数据库调用或在占有Java虚拟机锁时,执行其他与Java虚拟机无关的操作。

最重要的是,多线程设计虽然是困难的,但在开始编程之前详细设计系统能够帮助你避免难以发现死锁的问题。死锁在语言层面上不能解决,就需要一个良好设计来避免死锁。