有没有比读写锁更快的锁?

在读多写少的场景中,还有没有更快的技术方案呢?还真有,Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好。
我们就来介绍一下 StampedLock 的使用方法、内部工作原理以及在使用过程中需要注意的事项。

StampedLock 支持的三种锁模式

我们先来看看在使用上 StampedLock 和 ReadWriteLock 有哪些区别。
ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而 StampedLock 支持三种模式,分别是:写锁悲观读锁乐观读。其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。

  1. final StampedLock sl =
  2. new StampedLock();
  3. // 获取 / 释放悲观读锁示意代码
  4. long stamp = sl.readLock();
  5. try {
  6. // 省略业务相关代码
  7. } finally {
  8. sl.unlockRead(stamp);
  9. }
  10. // 获取 / 释放写锁示意代码
  11. long stamp = sl.writeLock();
  12. try {
  13. // 省略业务相关代码
  14. } finally {
  15. sl.unlockWrite(stamp);
  16. }
  1. StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。<br />注意这里,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,**乐观读这个操作是无锁的**,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。<br />下面这段代码是出自 Java SDK 官方示例,并略做了修改。在 distanceFromOrigin() 这个方法中,首先通过调用 tryOptimisticRead() 获取了一个 stamp,这里的 tryOptimisticRead() 就是我们前面提到的乐观读。之后将共享变量 x y 读入方法的局部变量中,不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 x y 读入方法局部变量时,x y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的。
  1. class Point {
  2. private int x, y;
  3. final StampedLock sl =
  4. new StampedLock();
  5. // 计算到原点的距离
  6. int distanceFromOrigin() {
  7. // 乐观读
  8. long stamp =
  9. sl.tryOptimisticRead();
  10. // 读入局部变量,
  11. // 读的过程数据可能被修改
  12. int curX = x, curY = y;
  13. // 判断执行读操作期间,
  14. // 是否存在写操作,如果存在,
  15. // 则 sl.validate 返回 false
  16. if (!sl.validate(stamp)){
  17. // 升级为悲观读锁
  18. stamp = sl.readLock();
  19. try {
  20. curX = x;
  21. curY = y;
  22. } finally {
  23. // 释放悲观读锁
  24. sl.unlockRead(stamp);
  25. }
  26. }
  27. return Math.sqrt(
  28. curX * curX + curY * curY);
  29. }
  30. }
  1. 在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x y 的正确性和一致性),而循环读会浪费大量的 CPU

进一步理解乐观读

如果用过数据库的乐观锁,可能会发现 StampedLock 的乐观读和数据库的乐观锁有异曲同工之妙。所以这里有必要再介绍一下数据库里的乐观锁。
在 ERP 的生产模块里,会有多个人通过 ERP 系统提供的 UI 同时修改同一条生产订单,那如何保证生产订单数据是并发安全的呢?我采用的方案就是乐观锁。
乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version,每次更新 product_doc 这个表的时候,都将 version 字段加 1。生产订单的 UI 在展示的时候,需要查询数据库,此时将这个 version 字段和其他业务字段一起返回给生产订单 UI。

  1. select id,... version
  2. from product_doc
  3. where id=777
  1. 用户在生产订单 UI 执行保存操作的时候,后台利用下面的 SQL 语句更新生产订单,此处我们假设该条生产订单的 version=9
  1. update product_doc
  2. set version=version+1,...
  3. where id=777 and version=9
  1. 如果这条 SQL 语句执行成功并且返回的条数等于 1,那么说明从生产订单 UI 执行查询操作到执行保存操作期间,没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据,那么版本号字段一定会大于 9。<br />你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp

StampedLock 使用注意事项

对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
StampedLock 在命名上并没有增加 Reentrant,想必你已经猜测到 StampedLock 应该是不可重入的。事实上,的确是这样的,StampedLock 不支持重入。这个是在使用中必须要特别注意的。
另外,StampedLock 的悲观读锁、写锁都不支持条件变量,这个也需要注意。
还有一点需要特别注意,那就是:如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。例如下面的代码中,线程 T1 获取写锁之后将自己阻塞,线程 T2 尝试获取悲观读锁,也会阻塞;如果此时调用线程 T2 的 interrupt() 方法来中断线程 T2 的话,你会发现线程 T2 所在 CPU 会飙升到 100%。

  1. final StampedLock lock
  2. = new StampedLock();
  3. Thread T1 = new Thread(()->{
  4. // 获取写锁
  5. lock.writeLock();
  6. // 永远阻塞在此处,不释放写锁
  7. LockSupport.park();
  8. });
  9. T1.start();
  10. // 保证 T1 获取写锁
  11. Thread.sleep(100);
  12. Thread T2 = new Thread(()->
  13. // 阻塞在悲观读锁
  14. lock.readLock()
  15. );
  16. T2.start();
  17. // 保证 T2 阻塞在读锁
  18. Thread.sleep(100);
  19. // 中断线程 T2
  20. // 会导致线程 T2 所在 CPU 飙升
  21. T2.interrupt();
  22. T2.join();
  1. 所以,**使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()**。这个规则一定要记清楚。