当前位置: 首页 > 科技观察

StampedLock

时间:2023-03-17 00:01:26 科技观察

,高性能解决线程饥饿的利器概述StampedLock是在JDK1.8中引入的,可以理解为ReentrantReadWriteLock在某些方面的增强。在原有的读写锁的基础上,新增了一种叫做乐观读(OptimisticReading)。模式。这种模式不加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。带着《码哥字节跳动》的疑问来看看StampedLock给我们带来了什么……有了ReentrantReadWriteLock,为什么还要引入StampedLock?什么是乐观阅读?获取锁难的线程“饥饿”问题?使用什么样的场景?实现原理分析,是通过AQS实现还是其他?特点最初设计为内部工具类,用于开发其他线程安全组件,提高系统性能,编程模型比ReentrantReadWriteLock复杂,如果不使用很容易出现死锁或线程安全等莫名其妙的问题使用得当。三种访问数据方式:Writing(独占写锁):writeLock方法会阻塞线程等待独占访问,可以类比ReentrantReadWriteLock的写锁方式。同时只有一个写线程获取锁资源;读(悲观读锁):readLock方法允许多个线程同时获取悲观读锁。悲观读锁和独占写锁互斥,与乐观读共享。OptimisticReading(乐观阅读):这里需要注意的是,是没有加锁的乐观阅读。即不会有CAS机制,不会有阻塞线程。只有当它当前不处于Writing模式时,tryOptimisticRead才会返回一个非零邮票(Stamp)。如果没有写模式线程在获取乐观读后获取锁,则在方法validate中返回true,允许多个线程获取乐观读和读锁。还允许编写器线程获取写锁。支持读写锁相互转换ReentrantReadWriteLock当线程获得写锁时,可以降级为读锁,反之则不行。StampedLock提供了读锁和写锁之间转换的功能,使得该类支持更多的应用场景。注意事项StampedLock是一个不可重入的锁。如果当前线程已经获取了写锁,重复获取就会死锁;不支持Conditon等待线程的条件;StampedLock的写锁和悲观读锁加锁成功后,都会返回一个stamp;解锁时需要传入这个戳记。详细讲解乐观读带来的性能提升,那么为什么StampedLock性能比ReentrantReadWriteLock更好呢?关键在于StampedLock提供的乐观读数。我们知道ReentrantReadWriteLock支持多个线程同时获取读锁,但是当多个线程同时读时,所有的写线程都会被阻塞。StampedLock的乐观读允许一个写线程获取写锁,所以不会造成所有写线程阻塞,即读多写少时,写线程有机会获取写锁,减少了threadstarvation问题,大大提高吞吐量。这里大家可能会有疑惑,即使允许多个乐观读和一个线程优先线程同时进入临界资源操作,如果读到的数据有可能出错怎么办?是的,乐观读不能保证读到的数据是正确的。最新的,所以在读取数据到局部变量时,需要通过lock.validate(stamp)验证是否被写线程修改过。如果已经被修改,需要加悲观读锁,然后重新读取数据到局部变量。同时,由于乐观读不是锁,不存在线程唤醒和阻塞导致的上下文切换,性能更好。其实它类似于数据库的“乐观锁”,其实现思路非常简单。让我们以数据库为例。生产订单的表product_doc中添加了一个数字版本号字段version。product_doc表每更新一次,version字段加1。selectid,...,versionfromproduct_docwhereid=123只有匹配version时才会更新。updateproduct_docsetversion=version+1,...whereid=123andversion=5数据库的乐观锁就是查询的时候查出版本,更新的时候用version字段校验。如果相等,说明数据没有被修改,读取的数据是安全的。这里的版本类似于StampedLock的Stamp。用例子仿写一个用户id和用户名数据存储在共享变量idMap中,提供put方法添加数据,get方法获取数据,putIfNotExist先从map中获取数据,如果没有则模拟从数据库中查询数据并将其放入地图中。publicclassCacheStampedLock{/***共享变量数据*/privatefinalMapidMap=newHashMap<>();privatefinalStampedLocklock=newStampedLock();/***添加数据,独占模式*/publicvoidput(Integerkey,Stringvalue){longstamp=lock.writeLock();try{idMap.put(key,value);}finally{lock.unlockWrite(stamp);}}/***读取数据,只读方法*/publicStringget(Integerkey){//1.尝试通过乐观读方式读取数据,非阻塞longstamp=lock.tryOptimisticRead();//2.读取数据到当前线程栈StringcurrentValue=idMap.get(key);//3.检查是否被其他线程修改过,true表示没有修改过,否则需要加悲观读锁if(!lock.validate(stamp)){//4。应用悲观读锁,重新读取数据到当前线程局部变量stamp=lock.readLock();try{currentValue=idMap.get(key);}finally{lock.unlockRead(stamp);}}//5.如果校验通过,则直接返回数据returncurrentValue;}/***如果数据不存在则从数据库中读取并添加到map中,锁升级使用*@paramkey*@paramvalue可以理解为读取数据从数据库中,假设不会为null*@return*/publicStringputIfNotExist(Integerkey,Stringvalue){//获取读Lock,也可以直接调用get方法使用乐观读longstamp=lock.readLock();StringcurrentValue=idMap.get(key);//如果缓存为空,尝试写锁从数据库中读取数据并写入缓存try{while(Objects.isNull(currentValue)){//尝试升级写锁longwl=lock.tryConvertToWriteLock(stamp);//成功将写锁升级为0if(wl!=0L){//模拟从数据库读取数据写入缓存stamp=wl;currentValue=value;idMap.put(key,currentValue);break;}else{//升级失败,释放之前添加的读锁和写锁,通过循环重试lock.unlockRead(stamp);stamp=lock.writeLock();}}}finally{//release最后添加的锁lock.unlock(stamp);}returncurrentValue;}}在上面的使用例子中,值得注意的是get()和putIfNotExist()方法,第一个使用了乐观读,这样读写可以并发执行,二是使用将读锁转换为写锁的编程模型,先查询缓存,不存在时,从数据库中读取数据加入缓存使用乐观阅读时,一定要按照固定的模板来写,否则容易出bug。总结一下乐观读编程模型的模板:publicvoidoptimisticRead(){//1。非阻塞乐观读方式获取版本信息longstamp=lock.tryOptimisticRead();//2.将共享数据复制到线程本地栈copyVaraibale2ThreadMemory();//3.验证乐观读模式读取的数据是否被修改if(!lock.validate(stamp)){//3.1验证失败,读锁stamp=lock.readLock();try{//3.2复制共享变量数据到本地variablecopyVaraibale2ThreadMemory();}finally{//释放读锁lock.unlockRead(stamp);}}//3.3验证通过,线程本地栈的数据用于逻辑操作useThreadMemoryVarables();}使用场景以及注意事项对于读多写少的高并发场景,StampedLock有很好的表现,乐观读模式很好的解决了写的问题。对于线程“饿死”的问题,我们可以使用StampedLock代替ReentrantReadWriteLock,但是需要注意的是,StampedLock的功能只是ReadWriteLock的一个子集,使用时还有几个地方需要注意它。StampedLock是不可重入锁,使用时一定要注意;悲观读写锁不支持条件变量Conditon,需要使用时需要注意;如果线程阻塞在StampedLock的readLock()或writeLock()上,此时调用阻塞线程的interrupt()方法会导致CPU飙升。因此,在使用StampedLock时,一定不要调用中断操作。如果需要支持中断功能,必须使用可中断悲观读锁readLockInterruptibly()和写锁writeLockInterruptibly()。必须清楚地记住这条规则。对StapedLock局部变量原理分析,我们发现它并没有像其他锁一样通过定义一个内部类继承AbstractQueuedSynchronizer抽象类,然后子类实现模板方法来实现同步逻辑。不过实现思路还是差不多的。CLH队列仍然用于管理线程,锁的状态由同步状态值state标识。里面定义了很多变量。这些变量的用途和ReentrantReadWriteLock一样,都是把状态分成位,通过位操作对状态变量进行操作,以区分同步状态。比如写锁的第8位是1,表示写锁,读锁使用0-7位,所以一般情况下,获取读锁的线程数是1-126个。超过后,会使用readerOverflowint变量保存超出的Threads。自旋优化也在一定程度上优化了多核CPU。NCPU获取核数。当核数超过1时,线程获取锁的重试和队列钱的重试都有自旋操作。主要通过内部定义的一些变量来判断,如图。等待队列queue的节点由WNode定义,如上图所示。等待队列的节点比AQS简单,只有三种状态:0:初始状态;-1:等待;取消;还有一个字段cowait,通过这个字段指向一个栈,用来保存读线程。如结构体所示,WNode同时定义了两个指向头节点和尾节点的变量。/**HeadofCLHqueue*/privatetransientvolatileWNodewhead;/**Tail(last)ofCLHqueue*/privatetransientvolatileWNodewtail;还有一点需要注意的是cowait,它保存了所有读取的节点数据,使用了head插值的方式。当读写线程竞争形成等待队列时,数据如下图:QueueacquireswritelockpubliclongwriteLock(){longs,next;//bypassacquireWriteinfullyunlockedcaseonlyreturn((((s=state)&ABITS)==0L&&U.compareAndSwapLong(this,STATE,s,next=s+WBIT))?next:acquireWrite(false,0L));}获取写锁。如果获取失败,则将构建节点放入队列,同时阻塞线程。需要注意的时候,这个方法是不响应中断的,比如需要中断需要调用writeLockInterruptibly()。否则会导致CPU使用率过高的问题。(s=state)&ABITS表示不使用读锁和写锁,则直接执行U.compareAndSwapLong(this,STATE,s,next=s+WBIT))CAS操作将第八位设置为1,说明写锁被占用成功。如果CAS失败,则调用acquireWrite(false,0L)加入等待队列,同时阻塞线程。另外,acquireWrite(false,0L)方法非常复杂,使用了很多自旋操作,比如自旋入队列。获取读锁publiclongreadLock(){longs=state,next;//绕过acquireReadoncommonuncontendedcasereturn((whead==wtail&&(s&ABITS)