ReadWriteLock读写锁升级的踩坑:Kotlin作弊,最好使用StampedLock - javaspecialists


在Java 5中,我们获得了ReadWriteLock接口,并带有ReentrantReadWriteLock实现。它具有明智的限制,我们可以将写锁降级为读锁,但不能将读锁升级为写锁。当我们尝试时,我们将立即陷入死锁。出现此限制的原因是,如果两个线程都具有读锁,那么如果两个线程都尝试同时升级怎么办?为了安全起见,它会始终使尝试升级的所有线程陷入死锁。、
降级ReentrantReadWriteLock可以正常工作,在这种情况下,我们可以同时持有读取和写入锁定。降级意味着在持有写锁的同时,我们也锁定了读锁,然后释放了写锁。这意味着我们不允许任何其他线程写入,但它们可以读取。

import java.util.concurrent.locks.*;
// This runs through fine
public class DowngradeDemo {
  public static void main(String... args) {
    var rwlock = new ReentrantReadWriteLock();
    System.out.println(rwlock);
// w=0, r=0
    rwlock.writeLock().lock();
    System.out.println(rwlock);
// w=1, r=0
    rwlock.readLock().lock();
    System.out.println(rwlock);
// w=1, r=1
    rwlock.writeLock().unlock();
   
// at this point other threads can also acquire read locks
    System.out.println(rwlock);
// w=0, r=1
    rwlock.readLock().unlock();
    System.out.println(rwlock);
// w=0, r=0
  }
}

尝试将ReentrantReadWriteLock从读取升级为写入会导致死锁:

// This deadlocks
public class UpgradeDemo {
  public static void main(String... args) {
    var rwlock = new ReentrantReadWriteLock();
    System.out.println(rwlock);
// w=0, r=0
    rwlock.readLock().lock();
    System.out.println(rwlock);
// w=0, r=1
    rwlock.writeLock().lock();
// deadlock
    System.out.println(rwlock); 
    rwlock.readLock().unlock();
    System.out.println(rwlock);
    rwlock.writeLock().unlock();
    System.out.println(rwlock);
  }
}

Kotlin中的ReadWriteLock
让我们看一下Kotlin如何管理ReadWriteLock。
下面是降级代码:

// DowngradeDemoKotlin.kt
import java.util.concurrent.locks.*
import kotlin.concurrent.*

fun main() {
  val rwlock = ReentrantReadWriteLock()
  println(rwlock)
// w=0, r=0
  rwlock.write {
    println(rwlock)
// w=1, r=0
    rwlock.read {
      println(rwlock)
// w=1, r=1
    }
    println(rwlock)
// w=1, r=0
  }
  println(rwlock)
// w=0, r=0
}

下面是升级:

// UpgradeDemoKotlin.kt
fun main() {
  val rwlock = ReentrantReadWriteLock()
  println(rwlock)
// w=0, r=0
  rwlock.read {
    println(rwlock)
// w=0, r=1
    rwlock.write {
      println(rwlock)
// w=1, r=0
    }
    println(rwlock)
// w=0, r=1
  }
  println(rwlock)
// w=0, r=0
}

竟然没有发生死锁。
如果我们窥视Kotlin扩展功能的实现,ReentrantReadWriteLock.write()将会看到以下内容:

Kotlin的扩展功能ReentrantReadWriteLock.write()通过在升级之前放开读锁来作弊,从而为竞赛条件打开了大门。

/ ** 
 *在此锁的写锁下执行给定的[action]。
 * 
 *如果需要,该功能会从读取锁定升级为写入锁定,
 *但是此升级不是原子升级
 因为[ReentrantReadWriteLock] 不支持此类升级。
 *为了进行这种升级,此功能首先释放
 该线程持有的所有*读锁,然后获取写锁,并且
 *释放后再重新获取读锁。
 * 
 *因此,如果已
 通过检查某些条件启动了* 写锁
 内部的[action] ,则必须在[action]内部重新检查条件*以避免可能的争用。
 * 
 * @return操作的返回值。
 * /

@kotlin.internal.InlineOnly
public inline
fun <T> ReentrantReadWriteLock.write(action: () -> T): T {
  val rl = readLock()

  val readCount = if (writeHoldCount == 0) readHoldCount else 0
  repeat(readCount) { rl.unlock() }

  val wl = writeLock()
  wl.lock()
  try {
    return action()
  } finally {
    repeat(readCount) { rl.lock() }
    wl.unlock()
  }
}

原来,Kotlin的扩展功能ReentrantReadWriteLock.write()通过在升级之前放开读锁来作弊,从而为竞争打开了漏洞大门。

使用StampedLock升级
Java 8 StampedLock使我们可以更好地控制应该如何处理失败的升级。StampedLock 不是可重入的,这意味着我们不能同时持有读取和写入锁。戳记未绑定到特定线程,因此我们也不能同时从一个线程持有两个写锁。我们可以同时持有许多读锁,每个读锁都有不同的标记。但是我们只能得到一个写锁。这是一个演示:

public class StampedLockDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    var stamps = new ArrayList<Long>();
    System.out.println(sl); // Unlocked
    for (int i = 0; i < 42; i++) {
      stamps.add(sl.readLock());
    }
    System.out.println(sl);
// Read-Locks:42
    stamps.forEach(sl::unlockRead);
    System.out.println(sl);
// Unlocked

    var stamp1 = sl.writeLock();
    System.out.println(sl);
// Write-Locked
    var stamp2 = sl.writeLock();
// deadlocked
    System.out.println(sl);
// Not seen...
  }
}

由于StampedLock不知道哪个线程拥有锁,因此DowngradeDemo会死锁:

public class StampedLockDowngradeFailureDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long wstamp = sl.writeLock();
    System.out.println(sl);
// Write-Locked
    long rstamp = sl.readLock();
// deadlocked
    System.out.println(sl);
// Not seen...
  }
}

但是,StampedLock确实允许我们尝试升级或降级我们的锁。这还将把戳记转换为新类型。例如,这是我们如何正确进行降级。请注意,我们不需要解锁写锁,因为戳记是从写转换为读的。

public class StampedLockDowngradeDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long wstamp = sl.writeLock();
    System.out.println(sl);
// Write-locked
    long rstamp = sl.tryConvertToReadLock(wstamp);
    if (rstamp != 0) {
      System.out.println(
"Converted write to read");
      System.out.println(sl);
// Read-locks:1
      sl.unlockRead(rstamp);
      System.out.println(sl);
// Unlocked
    } else {
// this cannot happen (famous last words)
      sl.unlockWrite(wstamp);
      throw new AssertionError(
"Failed to downgrade lock");
    }
  }
}

从读锁升级到写锁的代码:

public class StampedLockUpgradeDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long rstamp = sl.readLock();
    System.out.println(sl);
// Read-locks:1
    long wstamp = sl.tryConvertToWriteLock(rstamp);
    if (wstamp != 0) {
     
// works if no one else has a read-lock
      System.out.println(
"Converted read to write");
      System.out.println(sl);
// Write-locked
      sl.unlockWrite(wstamp);
    } else {
     
// we do not have an exclusive hold on read-lock
      System.out.println(
"Could not convert read to write");
      sl.unlockRead(rstamp);
    }
    System.out.println(sl);
// Unlocked
  }
}

与Kotlin ReentrantReadWriteLock.write()扩展功能不同,这将自动进行转换。但是,它仍然可能失败,例如,如果另一个线程当前也持有读取锁。在这种情况下,一种合理的方法是跳出并重试,或者以写锁定开始。