Java可重入读写锁 - 如何在读锁定时安全地获取写锁定?

75

目前我的代码中正在使用重入读写锁(ReentrantReadWriteLock)来同步访问类似树形结构的数据。这个结构很大,被许多线程同时读取,并且偶尔对其进行小部分修改 - 因此似乎很适合使用读写模式。我知道使用这个特定的类,无法将读锁升级为写锁,因此根据Javadocs,在获取写锁之前必须释放读锁。我以前在非可重入环境中成功地使用过这种模式。

然而我发现,我无法可靠地获取写锁而不会一直阻塞。由于读锁是可重入的,我实际上正在使用它作为这样的锁,简单的代码

lock.getReadLock().unlock();
lock.getWriteLock().lock()

如果我已经重入地获取了读锁,那么可能会出现阻塞。每次调用 unlock 只会减少 hold count,只有当 hold count 减为零时,锁才会被实际释放。

编辑 为了澄清这一点,因为我认为我最初没有解释得太好 - 我知道在这个类中没有内置的锁升级,我必须简单地释放读锁并获取写锁。我的问题是/曾经是,无论其他线程正在做什么,调用 getReadLock().unlock() 可能不会实际释放该线程对锁的持有,如果它以可重入方式获取了锁,则调用 getWriteLock().lock() 将永远阻塞,因为该线程仍然持有读锁并且因此阻塞自己。

例如,即使在单线程下运行且没有其他线程访问锁,此代码片段也永远不会达到 println 语句:

final ReadWriteLock lock = new ReentrantReadWriteLock();
lock.getReadLock().lock();

// In real code we would go call other methods that end up calling back and
// thus locking again
lock.getReadLock().lock();

// Now we do some stuff and realise we need to write so try to escalate the
// lock as per the Javadocs and the above description
lock.getReadLock().unlock(); // Does not actually release the lock
lock.getWriteLock().lock();  // Blocks as some thread (this one!) holds read lock

System.out.println("Will never get here");
所以我想问,有没有一个好的习惯用语来处理这种情况?具体地说,当持有读锁的线程(可能是可重入的)发现需要进行一些写操作时,它希望“挂起”自己的读锁以获取写锁(必要时阻止其他线程持有读锁),然后以相同状态“恢复”对读锁的持有? 由于此ReadWriteLock实现专门设计为可重入,因此肯定存在一些合理的方法可以将读锁提升为写锁,即当可以重新获取锁时。这是关键部分,这意味着天真的方法行不通。

你的问题是否来自于在同一线程上尝试进行无法预知数量的读锁定,而同时又进行写入操作?难道你不能在另一个线程上进行写入吗? - justinhj
12个回答

33

这是一个古老的问题,但这里有解决问题的方案和一些背景信息。

正如其他人指出的那样,传统的读写锁(例如 JDK 的ReentrantReadWriteLock)本质上不支持将读锁升级为写锁,因为这样做容易死锁。

如果你需要安全地获取写锁而无需先释放读锁,则有一个更好的选择:看看读-写-更新锁

我编写了一个 ReentrantReadWrite_Update_Lock,并在 Apache 2.0 许可下作为开源项目发布。我还在 JSR166 论坛发表了该方法的详细信息,并且这种方法经过论坛成员的反复审查。

这种方法非常简单,正如我在 concurrency-interest 上提到的那样,这个想法并不是完全新颖的,至少在 2000 年时就在 Linux 内核的邮件列表上讨论过。此外,.Net 平台的ReaderWriterLockSlim也支持锁升级。因此,在 Java 上这个概念直到现在都没有被实现。

这个想法是除了读锁和写锁之外提供一种更新锁。更新锁是介于读锁和写锁之间的一种类型。像写锁一样,只有一个线程可以同时获取更新锁。但是像读锁一样,它允许持有它的线程读取访问,并且允许持有常规读锁的其他线程并发地读取访问。关键特点是更新锁可以从只读状态升级为写锁,而且这不容易产生死锁,因为只有一个线程可以持有更新锁并处于升级位置。
这支持锁升级,并且在具有"先读后写"访问模式的应用程序中比传统的读写锁更有效,因为它会短时间阻塞读取线程。
示例使用说明在网站上提供。该库测试覆盖率为100%,并在Maven中心。

这个还在维护吗?我看到自述文件已经更新了,但代码已经两年没有变化了。 - Navin
4
我是作者,我对指责这个仍然可以使用的项目需要不断关注的说法感到不满 :) 该项目仍在维护中,但除非有人记录故障或功能请求,否则就没有更多可做的了。该库已具备完整的测试覆盖率,并且开发已经完成。 - npgall
啊,好的,很少见到一个“完整”的开源项目。这个项目有没有可能成为Java n+1标准库的一部分呢?看起来很多人都在重新发明这种东西。 - Navin
是的,我同意。我在并发兴趣列表上询问了它是否最终可以包含在JDK中。但讨论集中在其他方面,没有人对此发表意见。我个人也希望看到它被包含进来。 - npgall
这太棒了。这个答案应该得到比12更多的投票。好吧,现在是13票。 - Mike Nakis

29

我在这方面取得了一点进展。通过将锁变量明确声明为ReentrantReadWriteLock而不是简单地ReadWriteLock(虽然不太理想,但在这种情况下可能是必要的),我可以调用getReadHoldCount()方法。这让我可以获取当前线程持有读锁的次数,从而可以释放相同次数的读锁(并在之后重新获取相同次数的读锁)。所以这个方法是有效的,这在一个快速而脏的测试中已被证明:

final int holdCount = lock.getReadHoldCount();
for (int i = 0; i < holdCount; i++) {
   lock.readLock().unlock();
}
lock.writeLock().lock();
try {
   // Perform modifications
} finally {
   // Downgrade by reacquiring read lock before releasing write lock
   for (int i = 0; i < holdCount; i++) {
      lock.readLock().lock();
   }
   lock.writeLock().unlock();
}

不过,这难道就是我能做到的最好吗?这种方法并不太优雅,我仍然希望有一种更少“手动”处理的方式来处理它。


1
此外,我认为getReadHoldCount返回所有线程的计数(尽管文档没有明确说明)。虽然RRWL允许释放其他线程的读锁,但您的第二个for循环正在重新获取当前线程的所有读锁,这超出了您的要求。 - Olivier
16
我认为你应该看的是getReadLOCKCount*(),而不是getReadHOLDCount*()。后者在Javadocs中没有这样的警告,并且确实返回当前线程的持有数(例如,在线程A中两次锁定,然后在线程B中三次锁定,它将在线程A中返回2而不是5)。 - Andrzej Doyle
1
没错!这基本上使我的两个评论无效了 :-) - Olivier
1
很棒的解决方案。我会把它放进我的技巧包里,以备将来之需。 - Mike Clark
3
你可以失去树的一致性。读锁是为了确保在操作树时不会发生变化。但同时你又试图用自己的方式更改这个结构。因此,当你继续遍历树时,它可能完全不同,你将不得不返回到迭代的开头。 - porfirion

25
你想要做的事情应该是可以实现的。问题在于Java没有提供一个能够将读锁升级到写锁的实现。具体地,javadoc ReentrantReadWriteLock表示不允许从读锁升级到写锁。
无论如何,Jakob Jenkov描述了如何实现它。请参见http://tutorials.jenkov.com/java-concurrency/read-write-locks.html#upgrade了解详细信息。
为什么需要将读锁升级为写锁
从读锁升级到写锁是有效的(尽管其他答案中有相反的声明)。死锁可能会发生,因此实现的一部分是识别死锁并通过在线程中抛出异常来打破死锁。这意味着作为事务的一部分,您必须处理DeadlockException,例如重新执行任务。一个典型的模式是:
boolean repeat;
do {
  repeat = false;
  try {
   readSomeStuff();
   writeSomeStuff();
   maybeReadSomeMoreStuff();
  } catch (DeadlockException) {
   repeat = true;
  }
} while (repeat);

如果没有这种能力,实现一个可序列化事务以一致地读取一堆数据,然后基于所读取的内容写入数据的唯一方法是在开始之前预期需要写入,因此在写入所需写入的数据之前获取所有已读取数据的写入锁。 这是 Oracle 使用的 KLUDGE(SELECT FOR UPDATE ...)。 此外,它实际上会减少并发性,因为在事务运行时其他人无法读取或写入任何数据!

特别地,在获取写入锁之前释放读取锁将产生不一致的结果。考虑:

int x = someMethod();
y.writeLock().lock();
y.setValue(x);
y.writeLock().unlock();

你需要知道someMethod(),或者它调用的任何方法是否在y上创建了可重入读锁!假设您知道它确实这样做。那么如果您首先释放读锁:

int x = someMethod();
y.readLock().unlock();
// problem here!
y.writeLock().lock();
y.setValue(x);
y.writeLock().unlock();

在你释放读锁之后,在你获得写锁之前,另一个线程可能会更改y的值。因此,y的值将不等于x。

测试代码:将读锁升级为写锁将会阻塞:

import java.util.*;
import java.util.concurrent.locks.*;

public class UpgradeTest {

    public static void main(String[] args) 
    {   
        System.out.println("read to write test");
        ReadWriteLock lock = new ReentrantReadWriteLock();

        lock.readLock().lock(); // get our own read lock
        lock.writeLock().lock(); // upgrade to write lock
        System.out.println("passed");
    }

}

使用Java 1.6进行输出:

read to write test
<blocks indefinitely>

12

你试图以这种方式做的事情根本不可能。

你无法拥有一个读/写锁,可以在不出现问题的情况下从读取锁升级到写入锁。例如:

void test() {
    lock.readLock().lock();
    ...
    if ( ... ) {
        lock.writeLock.lock();
        ...
        lock.writeLock.unlock();
    }
    lock.readLock().unlock();
}

假设现在有两个线程进入这个函数。(你假定是并发的,对吧?否则你一开始就不会关心锁了...)

假设这两个线程同时开始并且运行速度相同。那么,它们都将获取一个读锁,这是完全合法的。然而,接下来它们都将尝试获取写锁,但它们中没有一个人会获得这个锁:另一个线程持有一个读锁!

允许将读锁升级为写锁的锁本质上容易产生死锁。很抱歉,但你需要修改你的方法。


6

4
您需要的是升级锁,使用标准的java.concurrent ReentrantReadWriteLock不能做到这一点(至少不是原子性的)。您最好的方法是解锁/锁定,然后检查没有人在其中进行了修改。
强制所有读锁让路并不是一个很好的主意。读锁的存在是有原因的,那就是您不应该写入。 :)
编辑: 正如Ran Biron指出的那样,如果您的问题是饥饿(读锁一直设置和释放,从未降为零),则可以尝试使用公平排队。但是您的问题似乎不是这个?
第二次编辑: 我现在看到您的问题了,您实际上在堆栈上获取了多个读锁,而且您想将它们转换为写锁(升级)。实际上,使用JDK实现是不可能的,因为它不跟踪读锁的所有者。可能有其他人持有您看不到的读锁,并且它不知道多少个读锁属于您的线程,更不用说您当前的调用堆栈(即您的循环正在杀死所有读锁,而不仅仅是您自己,因此您的写锁不会等待任何并发读者完成,您将手忙脚乱)。
实际上,我曾经遇到过类似的问题,并最终编写了自己的锁,跟踪谁获得了哪些读锁并将其升级为写锁。虽然这也是一种写入/写出类型的读/写锁(允许一个写入者沿着读者),但仍然有所不同。

没错,这就是问题所在。我不想强制解除所有读锁,只是当前线程保持的那些。我希望写锁获取等待其他线程释放其读锁。 - Andrzej Doyle
实际上,JDK的实现在某种程度上确实会跟踪锁定读锁的线程;getReadHoldCount()仅返回当前线程的计数(通过ThreadLocal),因此在这种情况下可以准确地调用正确数量的unlocks()。我在自己的答案中放入的代码实际上是有效的。 - Andrzej Doyle
有趣的是,这在jdk1.6中已经改变了,jdk1.5根本不使用线程本地变量。 - falstro
1
没有办法将读锁升级为写锁,并保证一定成功。对我来说,“解锁/加锁,然后检查是否有人在期间进行了修改”的变体方法行之有效。 - Seun Osewa

2

给OP的建议:

只需解锁与您输入的锁相同的次数,就像这样简单:

boolean needWrite = false;
readLock.lock()
try{
  needWrite = checkState();
}finally{
  readLock().unlock()
}

//the state is free to change right here, but not likely
//see who has handled it under the write lock, if need be
if (needWrite){
  writeLock().lock();
  try{
    if (checkState()){//check again under the exclusive write lock
   //modify state
    }
  }finally{
    writeLock.unlock()
  }
}

在写锁中,任何自重的并发程序都需要检查所需状态。

HoldCount不应该超出调试/监视器/快速失败检测之外的使用。


2
这个“something like this”是什么意思呢?
class CachedData
{
    Object data;
    volatile boolean cacheValid;

    private class MyRWLock
    {
        private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        public synchronized void getReadLock()         { rwl.readLock().lock(); }
        public synchronized void upgradeToWriteLock()  { rwl.readLock().unlock();  rwl.writeLock().lock(); }
        public synchronized void downgradeToReadLock() { rwl.writeLock().unlock(); rwl.readLock().lock();  }
        public synchronized void dropReadLock()        { rwl.readLock().unlock(); }
    }
    private MyRWLock myRWLock = new MyRWLock();

    void processCachedData()
    {
        myRWLock.getReadLock();
        try
        {
            if (!cacheValid)
            {
                myRWLock.upgradeToWriteLock();
                try
                {
                    // Recheck state because another thread might have acquired write lock and changed state before we did.
                    if (!cacheValid)
                    {
                        data = ...
                        cacheValid = true;
                    }
                }
                finally
                {
                    myRWLock.downgradeToReadLock();
                }
            }
            use(data);
        }
        finally
        {
            myRWLock.dropReadLock();
        }
    }
}

这段代码容易出现死锁。如果一个线程正在执行upgradeToWriteLock()方法,而另一个线程已经持有了读锁,那么第一个线程会在等待写锁时被阻塞,但是由于两个方法都是同步的,另一个线程将无法调用dropReadLock()方法。 - TrogDor

1

我认为ReentrantLock是由树的递归遍历所激发的:

public void doSomething(Node node) {
  // Acquire reentrant lock
  ... // Do something, possibly acquire write lock
  for (Node child : node.childs) {
    doSomething(child);
  }
  // Release reentrant lock
}

你不能重构你的代码,将锁处理移出递归吗?

public void doSomething(Node node) {
  // Acquire NON-reentrant read lock
  recurseDoSomething(node);
  // Release NON-reentrant read lock
}

private void recurseDoSomething(Node node) {
  ... // Do something, possibly acquire write lock
  for (Node child : node.childs) {
    recurseDoSomething(child);
  }
}

不容易。这里有一个访问者模式的元素,意味着我们为了读取节点而锁定它,然后调用其他类,这些类的逻辑可能会或可能不会回调指向相同的节点。此外,在更抽象的意义上,如果不能安全地使用重入锁,那么为什么要使锁可重入呢? :-) - Andrzej Doyle
这个想法是在客户端调用级别获取读锁。你的其他类可以指向“内部”方法(可能会变成受保护的),知道它们正在读锁的上下文中操作。 - Olivier
是的,我可以这样做,但是这些方法并非天然线程安全。尽可能地,我不想依赖客户端需要获取锁定并具有微妙错误行为(如果他们没有)的情况。 - Andrzej Doyle

1

所以,我们期望Java仅在此线程尚未贡献readHoldCount时增加读信号量计数吗?这意味着不像只维护int类型的ThreadLocal readholdCount,它应该维护类型为Integer的ThreadLocal Set(维护当前线程的hasCode)。如果这样可以,我建议(至少目前)不要在同一类中调用多个读取调用,而是使用一个标志检查当前对象是否已经获得了读锁。

private volatile boolean alreadyLockedForReading = false;

public void lockForReading(Lock readLock){
   if(!alreadyLockedForReading){
      lock.getReadLock().lock();
   }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接