一.StampedLock是什么
StampedLock类,在JDK1.8时引入,是对读写锁ReentrantReadWriteLock的增强,该类提供了一些功能,优化了读锁、写锁的访问,同时使读写锁之间可以互相转换,更细粒度控制并发。
该类的设计初衷是作为一个内部工具类,用于辅助开发其它线程安全组件,用得好,该类可以提升系统性能,用不好,容易产生死锁和其它莫名其妙的问题。
在API上增加了stamp的入参和返回值,不支持重入。
二.StampedLock能做什么
synchronized不可中断,ReentrantLock 未能读写分离实现,ReentrantReadWriteLock使得多个读线程同时持有读锁(只要写锁未被占用),而写锁是独占的。
但是,读写锁如果使用不当,很容易产生“饥饿”问题:
比如在读线程非常多,写线程很少的情况下,很容易导致写线程“饥饿”,虽然使用“公平”策略可以一定程度上缓解这个问题,但是“公平”策略是以牺牲系统吞吐量为代价的。
饥饿问题:ReentrantReadWriteLock实现了读写分离,想要获取读锁就必须确保当前没有其他任何读写锁了,但是一旦读操作比较多的时候,
想要获取写锁就变得比较困难了,因为当前有可能会一直存在读锁。而无法获得写锁。
三.StampedLock原理
1、StampedLock的实现
(1)基于CLH锁->一种自旋锁,保证没有饥饿发生,FIFO顺序【先进先出】;
(2)维护一个线程队列,申请不成功的记录在此,每个节点保存一个标记位【locked】,用来判断当前线程是否释放锁;
(3)当线程试图获取锁,取得当前队列的尾部节点作为其前序节点,并使用类似while(pred.locked){}判断前序节点是否已经成功释放锁;
(4)只要前序节点没有释放锁,则表示当前线程还不能继续执行,自旋等待。
(5)如果前序线程已经释放,则当前线程可以继续执行;
(6)释放锁时线程将自己的节点标记位置为false,那么后续等待的线程就能继续执行了。
2、StampedLock的内部常量
StampedLock内部定义了很多常量,定义这些常量的根本目的还是和ReentrantReadWriteLock一样,对同步状态值按位切分,以通过位运算对State进行操作:
对于StampedLock来说,写锁被占用的标志是第8位为1,读锁使用0-7位,正常情况下读锁数目为1-126,超过126时,使用一个名为readerOverflow
的int整型保存超出数。
StampedLock虽然不像其它锁一样定义了内部类来实现AQS框架,但是StampedLock的基本实现思路还是利用CLH队列进行线程的管理,通过同步状态值来表示锁的状态和类型。
StampedLock的内部实现是基于CLH锁的,CLH锁是一种自旋锁,它保证没有饥饿的发生,并且可以保证FIFO(先进先出)的服务顺序。
CLH锁的基本思想如下:
锁维护一个等待线程队列,所有申请锁,但是没有成功的线程都记录在这个队列中,每一个节点代表一个线程,保存一个标记位(locked),
用于判断当前线程是否已经释放锁,locked=true 表示获取到锁,false 表示成功释放了锁。
当一个线程试图获得锁时,取得等待队列的尾部节点作为其前序节点,并使用类似如下代码判断前序节点是否已经成功释放锁:while (pred.locked) { ... }
只要前序节点(pred)没有释放锁,则表示当前线程还不能继续执行,因此会自旋等待;反之,如果前序线程已经释放锁,则当前线程可以继续执行。
释放锁时,也遵循这个逻辑,线程会将自身节点的locked位置标记位false,那么后续等待的线程就能继续执行了。
3、StampedLock控制锁有三种模式(写,读,乐观读)
(1)写入(Writing):独占写模式
writeLock是一个独占锁,也是一个悲观锁。writeLock方法可能会在获取共享状态时阻塞,如果成功获取锁,返回一个stamp,它可以作为参数被用在unlockWrite方法中以释放写锁。
tryWriteLock的超时与非超时版本都被提供使用。当写锁被获取,那么没有读锁能够被获取并且所有的乐观读锁验证都会失败。
(2)读取(Reading):悲观读模式
readLock这时候是一个悲观锁。readLock方法可能会在获取共享状态时阻塞,如果成功获取锁,返回一个stamp,它可以作为参数被用在unlockRead方法中以释放读锁。
tryReadLock的超时与非超时版本都被提供使用。
(3)乐观读取(Optimistic Reading):乐观读模式。
提供了tryOptimisticRead方法返回一个非0的stamp,只有当前同步状态没有被写模式所占有是才能获取到。
乐观读取模式仅用于短时间读取操作时经常能够降低竞争和提高吞吐量。同时使用的时候一般需要读取并存储到另外一个副本,以用做对比使用。
tryOptimisticRead方法只有当写锁没有被获取时会返回一个非0的stamp。
在获取这个stamp后直到调用validate方法这段时间,如果写锁没有被获取,那么validate方法将会返回true。
这个模式可以被认为是读锁的一个弱化版本,因为它的状态可能随时被写锁破坏。这个乐观模式的主要是为一些很短的只读代码块的使用设计,它可以降低竞争并且提高吞吐量。
但是,它的使用本质上是很脆弱的。乐观读的代码区域应当只读取共享数据并将它们储存在局部变量中以待后来使用,当然在使用前要先验证这些数据是否过期,这可以使用前面提到的validate方法。
在乐观读模式下的数据读取可能是非常不一致的过程,因此只有当你对数据的表示很熟悉并且重复调用validate方法来检查数据的一致性时使用此模式。
例如,当先读取一个对象或者数组引用,然后访问它的字段、元素或者方法之一时上面的步骤都是需要的。
这个类还提供了在三种模式之间转换的辅助方法。
例如,tryConvertToWriteLock方法尝试"提升"一个模式,如果已经获取了读锁并且此时没有其他线程获取读锁,那么这个方法返回一个合法的写stamp。
这些方法被设计来帮助减少以“重试为主”设计时发生的代码代码膨胀。
四.StampedLock使用
1、对于悲观读和悲观写的方法与ReentranReadWriteLock读写锁效果一样
下面是StampedLock的悲观读、写锁的实现:
static ExecutorService service = Executors.newFixedThreadPool(10); static StampedLock lock = new StampedLock(); static long milli = 5000; static int count = 0;
private static long writeLock() { long stamp = lock.writeLock(); //获取排他写锁 count+=1; lock.unlockWrite(stamp); //释放写锁 System.out.println("数据写入完成"); return System.currentTimeMillis(); }
private static void readLock() {//悲观读锁 service.submit(() -> { int currentCount = 0; long stamp = lock.readLock(); //获取悲观读锁 try { currentCount = count; //获取变量值 try { TimeUnit.MILLISECONDS.sleep(milli);//模拟读取需要花费20秒 } catch (InterruptedException e) { e.printStackTrace(); } } finally { lock.unlockRead(stamp); //释放读锁 } System.out.println("readLock==" + currentCount); //显示最新的变量值 }); try { TimeUnit.MILLISECONDS.sleep(500);//要等一等读锁先获得 } catch (InterruptedException e) { e.printStackTrace(); } }
测试一下效果:
public static void main(String[] args) { long l1 = System.currentTimeMillis(); readLock(); long l2 = writeLock(); System.out.println(l2-l1); }
因为对悲观读操作进行了5秒的数据读取延迟,所以写操作要等5秒后读锁释放才能写入数据
输出结果:
数据写入完成的时间比获取读锁晚5043ms
读到的数据仍然是写入前的0。
2、对于乐观读(如果没有进入写模式)可以减少一次读锁的性能消耗,并且不会阻塞写入的操作
添加了一个乐观读的方法,这个方法仍然模拟读取延迟5秒,乐观读实现需要参考下面的编程模式(获取乐观锁、校验是否进入写模式)。
private static void optimisticRead() { service.submit(() -> { long stamp = lock.tryOptimisticRead(); //尝试获取乐观读锁 int currentCount = count; //获取变量值 if (!lock.validate(stamp)) { //判断count是否进入写模式 stamp = lock.readLock(); //已经进入写模式,没办法只能老老实实的获取读锁 try { currentCount = count; //成功获取到读锁,并重新获取最新的变量值 } finally { lock.unlockRead(stamp); //释放读锁 } } try { TimeUnit.MILLISECONDS.sleep(milli);//模拟读取需要花费20秒 } catch (InterruptedException e) { e.printStackTrace(); } //走到这里,说明count还没有被写,那么可以不用加读锁,减少了读锁的开销 System.out.println("optimisticRead==" + currentCount); //显示最新的变量值 }); try { TimeUnit.MILLISECONDS.sleep(500);//要等一等读锁先获得 } catch (InterruptedException e) { e.printStackTrace(); } }
测试一下效果:
public static void main(String[] args) { long l1 = System.currentTimeMillis(); optimisticRead(); long l2 = writeLock(); System.out.println(l2-l1); }
直接看一下输出结果:
数据写入完成的时间比获取读锁晚543ms(说明乐观读并没有阻塞写操作)
5秒后读到的数据仍然是写入前的0
总结
可以看到相比直接用悲观读锁,乐观读锁可以:
(1)进入悲观读锁前先看下有没有进入写模式(说白了就是有没有已经获取了悲观写锁);
(2)如果其他线程已经获取了悲观写锁,那么就只能老老实实的获取悲观读锁(这种情况相当于退化成了读写锁);
(3)如果其他线程没有获取悲观写锁,那么就不用获取悲观读锁了,减少了一次获取悲观读锁的消耗和避免了因为读锁导致写锁阻塞的问题,
直接返回读的数据即可(必须再tryOptimisticRead和validate之间获取好数据,否则数据可能会不一致了,试想如果过了validate再获取数据,这时数据可能被修改并且读操作也没有任何保护措施)
3、其他例子
public class StampedLockDemo1 { // Java8引入,StampedLock可以认为是读写锁的改进版本,采用乐观加锁机制 private final static StampedLock s1 = new StampedLock(); private static Point point = new Point(); public static void main(String[] args) { // 写入线程 Runnable mRunnable = new Runnable() { @Override public void run() { point.move(new Random().nextInt(100), new Random().nextInt(100)); } }; // 读取线程 Runnable rRunnable = new Runnable() { @Override public void run() { point.distanceFromOrigin(); } }; // 写入 for (int i = 0; i < 20; i++) { new Thread(mRunnable).start(); } // 读取 for (int i = 0; i < 20; i++) { new Thread(rRunnable).start(); } } // 来自JDK文档 public static class Point { private double x, y; public void move(double deltaX, double deltaY) { // 上一个排他锁 long stamp = s1.writeLock(); try { x = deltaX; y = deltaY; } finally { s1.unlockWrite(stamp); } } public void distanceFromOrigin() { // tryOptimisticRead方法尝试一个乐观读,返回一个邮戳,作为这一次锁获取的凭证 long stamp = s1.tryOptimisticRead(); // 将全部变量拷贝到方法体栈内 double currentX = x, currentY = y; // 判断stamp是否在读过程发生期间被修改 // 如果没有被更改,则读取有效 // 如果stamp是不可用的,可以如CAS操作一样,循环使用乐观读 // 或者升级锁的级别,升级为悲观锁 if (!s1.validate(stamp)) { //重点 // 获取悲观的读锁,进一步读取数据,此时线程可能被挂起【挂起使用的是Unsafe.park()方法】 // park方法遇到线程中断会直接返回。可能存在park的线程再次进入循环,如果不能退出,将占用大量CPU资源 stamp = s1.readLock(); try { currentX = x; currentY = y; } finally { s1.unlockRead(stamp); } } System.out.println(currentX * currentX + currentY * currentY); } } }
(1)StampedLock 对现存的锁实现有巨大的改进,特别是在读线程越来越多的场景下:
(2)StampedLock有一个复杂的API,对于加锁操作,很容易误用其他方法;
(3)当只有2个竞争者的时候,Synchronized是一个很好的通用的锁实现;
(4)当线程增长能够预估,ReentrantLock是一个很好的通用的锁实现;
(5)选择使用ReentrantReadWriteLock时,必须经过小心的适度的测试;所有重大的决定,必须在基于测试数据的基础上做决定;
(6)无锁的实现比基于锁的算法有更好短吞吐量;
五.总结
(1)synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定;
(2)ReentrantLock、ReentrantReadWriteLock,、StampedLock都是对象层面的锁定,要保证锁定一定会被释放,就必须将unLock()放到finally{}中;
(3)StampedLock 对吞吐量有巨大的改进,特别是在读线程越来越多的场景下;
(4)StampedLock有一个复杂的API,对于加锁操作,很容易误用其他方法;
(5)当只有少量竞争者的时候,synchronized是一个很好的通用的锁实现;
(6)当线程增长能够预估,ReentrantLock是一个很好的通用的锁实现;
StampedLock 可以说是Lock的一个很好的补充,吞吐量以及性能上的提升足以打动很多人了,但并不是说要替代之前Lock的东西。