关于J2EE中死锁问题的研究

关于J2EE中死锁问题的研究
2010年09月16日
  大多数重要的应用程式都涉及高度并发性和多个抽象层。并发性和资源争用有关,并且是导致死锁问题增多的因素之一。多个抽象层使隔离并修复死锁环境的工作变得更加困难。
    通常,当同时执行两个或两个以上的线程时,如果每个线程都占有一个资源并请求另一个资源,这时就会出现死锁情况。因为如果一个线程不能获取资源,则所有线程都不能继续执行,我们称那个特定的线程被阻塞;如果每个线程都由于同组中另一个线程所占有的资源而被阻塞,我们就称这个线程组被死锁。
    在本文中,我们将讨论发生在典型的重要J2EE应用程式中的两大类死锁情况:“简单”数据库死锁和跨资源死锁。虽然我们的讨论基于J2EE平台,但也适用于其他技术平台。
    [b]数据库死锁[/b]
    在数据库中,如果一个连接占用了另一个连接所需的数据库锁,则他能阻塞另一个连接。如果两个或两个以上的连接相互阻塞,则他们都不能继续执行,这种情况称为死锁。
    数据库死锁问题不易处理,这是因为涉及到的锁定通常不是显式的。通常,对数据行进行隐式更新时,需要锁定该数据行,执行更新,然后在提交或回滚封闭事务时释放锁。由于数据库平台、设置的隔离级及查询提示的不同,获取的锁可能是细粒度或粗粒度的,他会阻塞(或不阻塞)其他对同一数据行、表或数据库的查询。
    获取的锁依赖于内部生成的查询计划。当数据大小和分步随时间发生变化时,该计划也可能改动。这样在一个环境中获取一组锁的查询能尝试在另一个环境中获取一组完全不同的锁。必要时,数据库能随意地增加他的锁。例如,数据库可能会选择锁定整页,而不是锁定同一数据页中的10个数据行,这会阻塞对无需锁定的数据行的读写权限。
    基于数据库模式,读写操作会需求遍历或更新多个索引、验证约束、执行触发器等。每个需求都会引入更多锁。此外,其他应用程式还可能正在访问同一数据库模式中的某些对象,并获取不同于你的应用程式所具有的锁。
    所有这些因素综合在一起,数据库死锁几乎不可能被消除了。值得庆幸的是,数据库死锁通常是可恢复的:当数据库发现死锁时,他会强制销毁一个连接(通常是使用最少的连接),并回滚其事务。这将释放所有和已结束的事务相关联的锁,至少允许其他连接中有一个能获取他们正在被阻塞的锁。
    由于数据库具有这种典型的死锁处理行为,所以当出现数据库死锁问题时,数据库常常只能重试整个事务。当数据库连接被销毁时,会抛出可被应用程式捕捉的异常,并标识为数据库死锁情况。如果允许死锁异常传播到初始化该事务的代码层之外,则该代码层能只启动一个新事务并重做先前所有工作。要正确使用此策略,则在事务成功提交之前,他的代码不能有其他操作。注意:要限制重试次数,否则易导致死锁的代码块会永久循环下去。
    如果出现问题就重试,这种方法有点笨。不过,由于数据库能自由地获取锁,所以几乎不可能确保两个或两个以上的线程不发生数据库死锁。此方法至少能确保在出现某些罕见的数据库死锁情况时,应用程式能正常运行。这比需求用户去重试操作要好得多。
    在J2EE应用程式中,研发人员能设置一个EJB调用以使用Bean托管事务(BMT)――研发人员启动、提交或回滚特定的事务或容器托管事务(CMT)――调用方法前启动事务,并在方法完成后提交或回滚事务。如果EJB供给商提供retry-on-deadlock参数,从而能通过容器托管事务自动完成此操作,那当然再好不过了。如果没有这种自动功能,研发人员最终将仅为了对死锁进行重试而强制EJB调用使用Bean托管事务。
    遇见死锁问题和锁定其他线程的锁的具体频率在非常大程度上取决于数据库平台、硬件、数据库模式和查询。在使用基于锁的并发控制的数据库(如MSSQL)中,未提交的写操作会阻止读操作,而未提交的读操作会阻止写操作,使数据库更易出现死锁问题。在多版本并发控制(MVCC)数据库(如Oracle)中,未提交的写操作不阻止读操作――读操作仅查看旧版本数据行。这虽然会引入其他问题,但不会造成同样多的死锁机会。我们要让自己熟悉这些数据库锁定模式,并注意自己正在使用的类型。
    在查找、修复及避免数据库死锁方面,有一些非常好的参考方法,但他们都不能完全消除死锁的可能性。
    [b]跨资源死锁 [/b]
    当死锁情况不完全局限于数据库时,将更难找到他。数据库对占有和请求的锁有识别能力,所以能检测整个数据库中的死锁;此外,数据库事务在确定哪些东西是原子、哪些不是方面提供了一个良好的界线,所以能轻松地回滚事务,使其从死锁中恢复。其他环境(如Java虚拟机)中的死锁或可跨环境的死锁更加危险,因为环境不能(或没有)检测到这些死锁并尝试恢复。更糟糕的是,这些死锁会产生综合效果――如果两个线程占有某些资源集时出现死锁,则其他所有尝试访问其中一个资源的线程也将被阻塞,该线程已获取的所有资源也被阻塞。这些死锁常常不易发现,但对常见模式有一定的了解将有助于识别和修复死锁问题。
    当环境中出现可疑的死锁情况时,你就需要考虑一些问题了。这些问题的答案将说明你正在处理的情形是下列情形中的哪一种(如果有的话),并提供了修复以下问题的周详信息。要考虑的一些重要事项包括:
  涉及什么线程,他们的调用堆栈是什么?这需要进行一些周详的分析,将实际的死锁线程从那些只是被死锁的线程阻塞了的线程中分离出来。
  这种死锁情况总是在特定的代码路径中出现(每次执行这些特定的操作时),还是依赖于两个或两个以上同时执行的代码路径呢?
  涉及的数据库连接是什么?每个连接占有的数据库锁是什么?每个连接尝试获取的数据库锁是什么?每个数据库连接响应的Java虚拟机线程是什么?  下一小节介绍了三种常见的发生跨资源死锁的情形。
   [b]跨资源死锁情形之1:客户端的增加导致资源池耗尽 [/b]
    我们要介绍的第一种死锁情形是单纯由于负载而造成的,即资源池太小,而每个线程需要的资源超过了池中的可用资源。例如,考虑一个使用数据库连接的EJB调用,执行一个嵌套的EJB调用(使用同一连接池中不同的数据库连接)。例如,如果该嵌套的EJB调用声明为RequiresNew,就会出现死锁情形。
    在正常负载或有足够大小的连接池的情况下,EJB调用将从池中获取一个数据库连接,然后调用嵌套的EJB。嵌套的EJB调用将从池中获取另一个数据库连接,提交内部事务,然后向池返回连接。外部EJB调用将提交自己的事务,并向池返回其连接。
    不过,假设连接池最多有10个连接,同时有10个对外部EJB的并发调用。这些线程中每一个都需要一个数据库连接用来清空池。目前,每个线程都执行嵌套的EJB调用(需要获取第二个数据库连接)。则所有线程都不能继续,但又都不放弃自己的第一个数据库连接。这样,10个线程都将被死锁。
    如果研究此类死锁情形,会发现线程转储中有大量等待获取资源的线程,及同等数量的空闲且未阻塞的活动数据库连接。当应用程式死锁时,如果能在运行时检测连接池,应该能确认连接池实际上已空。
    修复此类死锁的方法包括:增加连接池的大小或重构代码,以便单个线程不必同时使用非常多数据库连接。如果单线程需要的最大数据库连接数为M,且可能的最大并发调用数为N,则要避免此问题,在池中所需的最小连接数为(N*(M01))+1。或能设置内部EJB调用以使用不同的连接池,即使外部调用的连接池为空,内部调用也能使用自己的连接池继续。
    [b]跨资源死锁情形之2:单线程、多冲突数据库连接 [/b]
    对同一线程执行嵌套的EJB调用时还会出现第二种跨资源死锁情形,此情形即使在非高负载系统中通常也会发生。同上面的示例相同,两个EJB调用使用不同的连接来连接到同一个数据库。因为只有嵌套调用完成后调用方才能继续,所以调用方的数据库连接实际上被嵌套调用的数据库连接阻塞了,虽然数据库没有注意到这种关系。如果第一个(外部)连接已获取第二个(内部)连接所需要的数据库锁,则第二个连接将永久阻塞第一个连接,并等待第一个连接被提交或回滚,这就出现了死锁情形。因为数据库没有注意到两个连接之间的关系,所以数据库不会将此情形检测为死锁。
    作为一个具体的示例,考虑一个数据加载EJB调用。此EJB调用获取一个大型对象,并在不同阶段中将其保存在数据库中。当他执行数据加载时,他会更新一个独立的表,以记录挂起数据加载操作的状态。我们希望状态更新即时可见,但不希望在未完成的状态下看到加载的数据,所以要通过调用“RequiresNew” EJB来完成。总的来说,这种不完善的数据加载方法如清单1中的代码所示。
    清单1public void bulkLoadData(DataBatch batch) {int batchId = batch.getId();// Since this executeUpdate call doesn? happen in a separate// transaction, it wouldn’t be visible anyway, but the effect is// far worse: a cross-resource deadlock.executeUpdate("update batch_status set status=’Started’ " +"where batch_id=" + batchId);validateData(batch);updateBatchStatus(batchId, "Validated"); // RequiresNew EJB callloadDataStage1(batch);updateBatchStatus(batchId, "Stage 1 complete"); // RequiresNew EJB callloadDataStage2(batch);updateBatchStatus(batchId, "Stage 2 complete"); // RequiresNew EJB callfinalizeDataLoad(batch);updateBatchStatus(batchId, "Complete"); // RequiresNew EJB call}
    在上面的示例中,使用updateBatchStatus方法执行“RequiresNew” EJB调用实际上能更新batch_status数据库表,即使没有看到当前事务的效果,也能即时看到状态的改动。对executeUpdate的调用不是EJB调用,所以他和bulkLoadData的其他部分在同一个事务中执行。
    如上所述,即使不存在并发,此代码也将导致死锁。当bulkLoadData调用executeUpdate方法时,他更新现有的数据库行,这涉及为该行获取写锁。对updateBatchStatus的嵌套EJB调用将在独立的数据库连接上执行,并尝试执行一个非常相似的查询,但他将阻塞,因为不能获取必需的写锁。从数据库的角度来说,只要提交或回滚第一个连接的事务,第二个连接就能继续。不过,Java虚拟机不允许在完成所有对updateBatchStatus的调用前完成bulkLoadD调用,这样就出现了死锁情形。
    该示例表明,一个更新会阻塞另一个更新,所以他会在所有数据库中导致死锁。如果初始更新查询是个简单的选择查询,那么该示例仅在使用基于锁的并发控制的数据库上导致死锁,在这种数据库中,一个连接的读锁能阻止另一个连接获取写锁。不管在哪种情况下,此类死锁即不依赖于同步,也不依赖于负载,而且线程转储将显示一个等待数据库响应的Java线程,但该线程和两个有效的数据库连接相关联。在这些数据库连接中,有一个将处于空闲状态,但会阻塞其他连接。
    此情形有多种具体的变种,能涉及多个线程和两个以上的数据库连接。例如,外部EJB调用的数据库连接可能已获取了数据库锁,该锁阻塞了另一个无关数据库连接的继续,但这个无关数据库连接已获取了阻塞嵌套EJB调用的数据库操作的锁。这个特例是依赖于同步的,并将显示多个等待数据库响应的Java线程。其中至少有一个Java线程将和两个活动数据库连接相关联。
    [b]跨资源死锁情形之3:Java虚拟机锁和数据库锁相冲突 [/b]
    第三种死锁情形发生在数据库锁和Java虚拟机锁并存的时候。在这种情况下,一个线程占有一个数据库锁并尝试获取Java虚拟机锁(尝试进入同步的锁)。同时,另一个线程占有Java虚拟机锁并尝试获取数据库锁。再次地,数据库发现一个连接阻塞了另一个连接,但由于无法阻止连接继续,所以不会检测到死锁。Java虚拟机发现同步的锁中有一个线程,并有另一个尝试进入的线程,所以即使Java虚拟机能检测到死锁并对他们进行处理,他还是不会检测到这种情况。
    为了说明此种死锁情形,我们以一个简单的(不完善的)read-through cache为例。该cache是数据库表中备份的HashMap。如果出现缓存命中,他就从HashMap返回一个值。但在缓存缺失的情况下,他将从数据库读取值,将其添加到HashMap,然后返回该值,如清单2所示。
    清单 2public class SimpleCache {private Map cache = new HashMap();public synchronized Object get(String key) {if (cache.containsKey(key)) {return cache.get(key);} else {Object value = queryForValue(key);cache.put(key, value);return value;}}private Object queryForValue(String key) {return executeQuery("select value from cache_table " +"where key=’" + key + "’");}public synchronized void clearCache() {cache.clear();}// other methods omitted for brevity}
    这是个简单的遍历cache。注意:get()方法是同步的,这是因为我们访问了非线程安全容器,并需求containsKey/put组合在缓存缺失时是原子性的。
    该cache相当简单易懂:他约定,如果更改支持缓存的表中的数据,则应调用clearCache(),这样缓存就能避免处理陈旧的数据。产生的缓存缺失将相应地重新进入缓存。
    我们目前来考虑能更改此数据并清除缓存的代码:
  public void updateData(String key, String value) {executeUpdate("update cache_table set value=’" + value +"’ where key=’" + key + "’");SimpleCache.getInstance().clearCache();}
    上面的代码在简单的例子中能正常运行。不过,在使用基于锁的并发控制的数据库中,updateData中的查询将阻止queryForValue中的选择查询的执行,因为update语句将获取一个写锁,从而阻止选择查询获取同一数据行上的读锁。如果同步没有问题,一个线程能尝试读取缓存中的给定值,并在另一个线程在数据库中更新该值时得到缓存缺失。如果数据库先执行update语句,他将阻塞select语句继续执行。不过,执行select语句的线程来自同步的get方法,所以他获取了SimpleCache上的锁。要返回updateData中的线程,他必须调用clearCache(),但不能获取锁(clearCache()是同步的)。
    当处理此情形的实例时,将有一个等待数据库响应的Java线程和一个等待获取Java虚拟机锁的线程。每个线程将和一个数据库连接相关联,其中一个连接阻塞另一个连接。修复方法是占有Java虚拟机锁时避免执行数据库操作,能重写leCache的get()方法,如下所示:
    
  public Object get(String key) {synchronized(this) {if (cache.containsKey(key)) {return cache.get(key);}}Object value = queryForValue(key);synchronized(this) {cache.put(key, value);}return value;}
    既然目前我们知道了会发生此死锁情况,就能使用Thread.holdsLock()向queryForValue方法添加检查以尝试避免死锁情况:
  private Object queryForValue(String key) {assert(!Thread.holdsLock(this));return executeQuery(...);}
    上例中的Thread.holdsLock()非常有用,不过只有在我们知道需要留心哪个锁时他才会发挥作用。如果有一个类似的方法能确定当前线程占有哪个Java虚拟机锁,那么会非常有用。所有执行所有种类的RPC调用、数据库访问等的代码片段都能抛出异常或记录警告,指示在占有Java虚拟机锁时执行这些操作会有危险。
    注意:虽然我们修复了上例中的死锁问题,但他仍有缺陷,因为在提交updateData的事务之前清空了缓存。如果在调用clearCache后、提交updateData事务前出现缓存缺失,则该缓存将加载旧数据,因为新数据尚未可见。这里的修复方法是仅在提交更改后清空缓存。注意,这只在MVCC数据库中发生。在基于锁的数据库中,挂起的update将阻塞缓存的读操作,所以在提交update的事务后缓存才能读取正确值。
   [b]经验法则 [/b]
    下面的这些指导能帮你避免死锁问题,或至少在出现死锁时能诊断并修复他们。
  保持事务简短。
  了解数据库锁行为(及事务分离层)。
  假定所有数据库访问都有可能陷入数据库死锁状况,不过能正确重试。
  事务完成前不要更新所有非事务状态(内存状态、缓存等)。
  确保在峰值并发时有足够大的资源池。
  尝试不在同一时刻获取多个资源。如果必需,则按相同的顺序每次获取一个资源。
  了解怎么从应用服务器获取完整的线程转储及从数据库获取数据库连接列表(包括互相阻塞的连接),知道每个数据库连接和哪个Java线程相关联。了解Java线程和数据库连接之间映射的最简单方法是向连接池访问模式添加日志记录功能。
  当进行嵌套的EJB调用时,了解哪些调用使用和调用方同样的数据库连接。即使嵌套的调用运行在同一个全局事务中,他仍将使用不同的数据库连接,而这会导致跨资源死锁。
  避免执行数据库调用和EJB调用,或在占有Java虚拟机锁时,执行其他和Java虚拟机无关的操作。如果有需要留心的特定Java虚拟机锁,就使用assert(!Thread.holdsLock(...)),从而避免以后的代码更改不会在无意间违背此规则。
    [b]结束语[/b]
    J2EE应用程式中的跨资源死锁是个大问题――他能导致整个应用程式慢慢终止,还非常难被分离和修复,尤其是当研发人员不熟悉怎么分析死锁环境的时候。我们讨论的情形将有助于你理解一些常见的死锁情形,并为你提供查找死锁的思路。更重要的是,我们概括的经验法则提供了一些要在代码中遵守的惯例,从而避免所有类似的死锁问题。
  [b]
[/b]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值