在之前的文章中,我们介绍了读写锁,学习完之后你应该已经知道了读写锁允许多个线程同时访问共享变量,适用于读多写少的场景。那么在读多写少的场景中还有没有更快的技术方案呢?还真有,在Java1.8这个版本里提供了一种叫StampedLock的锁,它的性能就比读写锁还要好。
下面我们介就来介绍一下StampedLock的使用方法、内部工作原理以及在使用过程中需要注意的事项。

StampedLock支持的三种模式

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

final StampedLock sl = new StampedLock();//获取/释放悲观读锁示意代码long stamp = sl.reaLock();try{//省略业务代码} finally {sl.unlockRead(stamp);}//获取/释放写锁示意代码long stamp = sl.writeLock();try{//省略相关业务代码} finally {sl.unlockWrite(stamp);}

StampedLock 的性能之所以比ReadWriteLock还要好,关键是StampedLock支持乐观读的方式。ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作也会被阻塞。而StampedLock提供的乐观读是允许一个线程获取写锁的,也就是说不是所有写操作都被阻塞的。
注意,这里我们用的是”乐观读”这个词,而不是乐观读锁,是要提醒你,乐观读操作是无锁的,所以相比较ReadWriteLock的读锁,乐观读的性能更好一点。文中下面这段代码是出自于Java SDK官方示例,并略作修改。在distanceFromRrigin()这个方法中,首先通过调用tryOptimisticRead获得了一个stamp。这里的tryOptimisticRead就是我们前面提到的乐观读。之后将共享变量X和Y读入方法的局部变量中。不过需要注意的是,由于tryOptimisticRead是无锁的,所以共享变量X和Y读入方法局部变量时,X和Y有可能被其他线程修改了,因此最后读完之后还需要再次验证一下是否存在写操作,这个操作是通过调用validate (stamp)来实现的。

class Point {private int x,y;final StampedLock sl = new StampedLock();//计算到原点的距离int distanceFromOrigin(){//乐观读long stamp = sl.tryOptimisticRead();//读入局部变量//读的过程中数据可能被修改int curX = x,curY = y;//判断执行读操作期间,是否存在写操作//如果存在,返回falseif(!sl.validate(stamp)){//升级为悲观锁stamp = sl.readLock();try{curX = x;curY = y;} finally {sl.unLockRead(stamp);}}return Math.sqrt(curX * curX + curY * curY);}}

在上面这个代码示例中,如果执行乐观读操作期间存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作期间没有写操作,只有这样才能保证X和Y的正确性和一致性。而循环读会浪费大量的CPU。升级为悲观读锁代码简练且不易出错,建议你在具体实践的时候也采用这样的方法。

进一步理解乐观读

如果你曾经用过数据库的乐观锁,你可能会发现StampLock的乐观读和数据库的乐观读锁有异曲同工之妙。的确是这样的,就我个人而言,我是先接触数据库的乐观锁,然后再接触的StampLock,我就觉得我前期数据库里的乐观锁的学习,对于后面的理解StampLock的乐观读有很大的帮助,所以这里有必要再介绍一下数据库里的乐观锁。
还记得我第一次使用数据库乐观锁的场景是这样的,在ERP的生产模块里,会有多个人通过ERP系统提供的UI同时修改同一条生产订单,那如何保证生产订单数据是并发安全的呢?我采用的方案就是乐观锁。
乐观锁的实现很简单,在生产订单的表product_doc里面增加一个数字型的版本号字段version,每次更新product_doc这个表的时候,都将version字段加1。生产订单的UI在展示的时候需要查询数据库。此时将这个version字段和其他业务字段一起返回给生产订单UI。假设用户查询的生产订单的ID=777,那么SQL语句类似于下面这样。

select id,....,versionfrom product_docwhere id = 777

用户在生产订单UI执行保存操作时候,后台利用下面的SQL语句更新生产订单,此时我们假设该条生产订单的version等于9。

update product_docset version=version+1where id=777 and versoin=9

如果这条语SQL语句成执行成功,并且返回的条数等于1,那么说明从生产订单UI执行查询操作到执行保存操作期间没有其他人修改过这条数据。因为如果这期间其他人修改过这条数据,那么版本号一定会大于9。
你会发现数据库里的乐观锁查询的时候需要把version字段查出来,更新的时候要利用version字段做校验,这个version字段就类似于StampLock里面的stamp,这样对比着看,你相信你会更容易理解StampLock里面乐观读的用法。

StampLock使用注意事项

对于读多写少的场景StampLock性能很好,简单的应用场景基本上可以替代ReadWriteLock,但是StampLock的功能仅仅是ReadWriteLock的子集,在使用的时候还是有几个需地方需要注意一下。
StampLock在命名上并没有增加Reentrant,想必你已经猜到了,StampLock应该是不可重入的,事实上的确是这样的,StampLock不支持重入,这个是在使用中必须要注意的。
另外StampLock的悲观读锁、写锁都不支持条件变量,这个你也需要注意。
还有一点需要特别注意,那就是如果线程阻塞在StampLock的readLock()上时,此时调用该阻塞线程的interrupt()方法会导致CPU飙升。
所以使用StampLock一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly(),这个规则一定要记清楚。

总结

StampLock的使用看上去有点复杂,但是如果你能理解乐观所背后的原理,使用起来还是比较流畅的。