极客时间 ——《Java并发编程实战》 18 | StampedLock:比读写锁更快的锁

2020-10-28   10 次阅读


上篇文章中介绍了读写锁:

  • {% post_link 读书笔记/极客时间/Java并发编程实战/第一部分—基础/17|ReadWriteLock 17 | ReadWriteLock:如何快速实现一个完备的缓存 %}

学完之后知道了读写锁比互斥锁的快的原因在于:读写锁允许多个线程同时读共享变量,适用于读多写少的场景。在读多写少的场景中还有一个比读写锁更快的方案,JDK8 新增加了一个叫 StampedLock 的锁,性能比读写锁还要好。

这篇文章介绍 StampedLock使用方法内部工作原理使用时的注意事项

StampedLock 支持的三种锁模式

锁类型支持的模式详情
StampedLock写锁悲观读锁乐观读
相同点:
写锁:语义类似 ReadWriteLock 写锁
悲观读锁:语义类似 ReadWriteLock 读锁
允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁。
不同:
StampedLock 里的 写锁悲观读锁加锁成功后会返回一个 stamp,解锁的时候需要传入这个对象。
ReadWriteLock读锁写锁

StampedLock 相关示例代码:

public class StampedDemo {
    public static void main(String[] args) {
        final StampedLock sl = new StampedLock();
        
        // 获取/释放 悲观读锁
        long readStamp = sl.readLock();
        try {
            //相关业务代码在这里写
        }finally {
            sl.unlockRead(readStamp);
        }
        
        // 获取/释放 悲观写锁
        long writeStamp = sl.writeLock();
        try {
            //相关业务代码在这里写
        }finally {
            sl.unlockWrite(writeStamp);
        }
    }
}

StampedLock 性能之所比 ReadWriteLock要好,关键就是 StampedLock 支持的**乐观读方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读时,写操作会被阻塞。而 StampedLock 提供的乐观读允许在读的同时一个线程获取写锁,也就是说不是所有写操作都被阻塞**。

这里作者强调的是 「乐观读」而不是乐观读锁,说明这个操作是无锁操作,所以相比 ReadWriteLock 的读锁,乐观读的性能更好一些

下面这段代码来自 JDK 官方示例,并略做修改。

官方注释中的例子:

image-20200809023029078

作者修改后的例子:(例子中的方法返回值有问题,作者写的是 int 实际上应该是 double)

public class Point {
    private int x, y;
    final StampedLock sl = new StampedLock();

    //计算到原点的距离
    double distanceFromOrigin() {
        // 乐观读
        long stamp = sl.tryOptimisticRead();
        // 读入局部变量,读的过程数据可能被修改(将类字段赋值给方法中的局部变量)
        int curX = x, curY = y;
        // 判断执行读操期间类变量是否被修改过,如果修改过则 validate 方法返回 false
        if (!sl.validate(stamp)) {
            // 如果被修改过,将乐观读升级为悲观读锁
            stamp = sl.readLock();
            try {
                // 重新赋值
                curX = x;
                curY = y;
            } finally {
                // 释放悲观读锁
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(curX * curX + curY * curY);
    }
}

distanceFromOrigin() 这个方法中,首先通过调用 tryOptimisticRead() 获取了一个 stamp,这里的 tryOptimisticRead() 就是我们之前提到的乐观读

之后共享变量 xy 读入方法的局部变量中,需要注意的是:由于 tryOptimisticRead()无锁的,所以共享变量 xy 读入方法局部变量时,这两个值可能已经被其他线程修改了,因此在最后读完之后,还需要再次验证是否存在写操作,这个验证操作通过调用 validate(stamp) 实现。如果在执行乐观读期间存在其他线程的写操作导致变量被修改,就将乐观读升级为悲观读锁 —— 这个做法很合理,否则就需要在一个循环里反复执行**乐观读——验证** 这个过程,直到在这期间没有其他线程修改变量,而循环读会浪费大量的 CPU 时间片,升级为悲观读锁,代码简练且不容易出错,实际工作中也推荐这样做。

进一步理解乐观读

StampedLock乐观读锁数据库乐观锁有异曲同工的效果。作者先接触了数据库的乐观锁,然后才接触 JDK 的 StampedLock 类,而他认为学习数据库的乐观锁对于理解 StampedLock 有很大帮助,所以下面是数据库乐观锁的介绍。

【而我对于数据库乐观锁刚好不是非常了解,学习一波。】

一个数据库乐观锁的使用场景:在 ERP 生产环境中,有多个人通过 ERP 系统提供的 UI 同时修改一条生产订单,如何保证生产订单的数据并发安全?当时作者采用的是数据库乐观锁。

乐观锁的实现很简单,在订单表 product_doc 里增加一个 数值型 的版本号字段 version,每次更新 product_doc 这个表的时候该字段加1。生产订单的 UI 在展示的时候需要查询数据库,此时将这个 version 字段与其他业务字段一起返回给生产订单 UI,假设用户查询的生产订单 id = 777,那么 SQL 语句类似下面这样:

select id,...,version from product_doc where id = 777

用户在生产订单UI 执行保存操作的时候,后台利用下面的 SQL 语句更新了生产订单,此处假设该订单的 version=9

update product_doc set version=version+1,... where id =777 and version = 9

如果这条 SQL 语句执行成功并且返回的条数等于1,说明该条数据从查询到保存期间没有其他人修改过这条数据,因为如果数据被修改过 version 一定大于9.

数据库中的乐观锁,查询的时候需要将 version 字段查出来,更新的时候利用该字段做验证,这个字段就类似 StampedLock 中的 stamp,这样对比来看,更容易理解 StampedLock 里乐观读的用法。

【确实,看完这个例子之后,感觉挺简单的,容易理解。】

StampLock 使用注意事项

对于读多写少的场景,StampedLock 的性能很好,简单应用可以基本替代 ReadWriteLock,但是 StampedLock功能仅是 ReadWriteLock 的**子集**,所以在使用时有几个需要注意的地方:

  • StampedLock 命名上没有增加 Reentrant,它是不可重入的锁,这点在使用中特别需要注意。
  • StampedLock 悲观读锁、写锁都不支持条件变量
  • 如果线程阻塞StampedLockreadLock() 或者 writeLock() 上时,此时调用阻塞线程的 interrupt() 方法,会导致 CPU 飙升

例如下面的代码中,线程 T1获取写锁后将自己阻塞,线程 T2 尝试获取悲观读锁也会阻塞。如果此时调用线程 T2interrupt() 方法来中断线程T2的话,会发现线程T2所在的 CPU 占用率会飙升到 100%

public class CPUErroDemo {
    final static StampedLock lock = new StampedLock();
  
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // 获取写锁
            lock.writeLock();
            // 永远阻塞在这里,不释放写锁
            LockSupport.park();
        });
        t1.start();

        // 保证T1 获取写锁
        Thread.sleep(100);

        Thread t2 = new Thread(() -> {
            // 阻塞在悲观读锁
            lock.readLock();
        });
        t2.start();
        // 保证 T2 阻塞在读锁
        Thread.sleep(100);

        // 中断线程T2,该操作会导致 T2 线程所在 CPU 飙升
        t2.interrupt();
        t2.join();
    }
}

所以,在使用 StampedLock 时一定不要调用中断操作,如果需要支持中断功能,需要使用可中断的悲观读锁readLockInterruptibly()写锁 writeLockInterruptibly(),这个规则需要牢记。

总结:

StampedLock 看上去复杂,但是理解乐观锁背后的原理之后,使用起来还是比较流畅的,作者建议认真揣摩官方在注释中的例子,那些基本都是最佳实践

以下是作者将官方示例精简后的代码模板,工作中尽量按照这个模板来使用 StampedLock

StampedLock 读模板

    final StampedLock sl = new StampedLock();

    // 乐观读
    void test() {
        long stamp = sl.tryOptimisticRead();

        // 将共享变量赋值给方法局部变量
        ...
        //校验stamp
        if (!sl.validate(stamp)) {
            // 升级为悲观读锁
            stamp = sl.readLock();
        }try {
            // 将共享变量赋值给方法局部变量
            ..
        }finally {
            // 释放悲观读锁
            sl.unlockRead(stamp);
        }
    }
    // 变量的获取完毕,下面就是使用这个局部变量的业务逻辑

StampedLock 写模板

long stamp = sl.writeLock();
try {
	// 写共享变量
	...
} finally {
	sl.unLockWrite(stamp);
}

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。