JAVA高并发——读写锁的改进:StampedLock

文章目录

StampedLock是Java 8中引入的一种新的锁机制,可以认为它是读写锁的一个改进版本。读写锁虽然分离了读和写的功能,使得读与读之间可以完全并发。但是,读和写之间依然是冲突的。读锁会完全阻塞写锁,它使用的依然是悲观的锁策略,如果有大量的读线程,它也可能引起写线程的"饥饿"。

StampedLock提供了一种乐观的读策略。这种乐观的锁非常类似于无锁的操作,使得乐观锁完全不会阻塞写线程。

1、StampedLock的使用示例

StampedLock的使用并不困难,下面是StampedLock的使用示例:

上述代码出自JDK的官方文档。它定义了一个点Point类,内部有两个元素x和y,表示点的坐标。第3行定义了StampedLock锁。第15行定义的distanceFromOrigin()方法是一个只读方法,它只会读取Point的x和y坐标。在读取时,首先使用了StampedLock.tryOptimisticRead()方法,这个方法表示试图尝试一次乐观读,它会返回一个类似于时间戳的邮戳整数stamp,这个stamp就可以作为这一次锁获取的凭证。

在第17行读取x和y的值。当然,这时我们并不确定这个x和y是否是一致的(在读取x的时候,可能其他线程改写了y的值,使得currentX和currentY处于不一致的状态),因此,我们必须在第18行使用validate()方法判断stamp是否在读取过程中被修改过。如果stamp没有被修改过,则认为这次读取是有效的,因此就可以跳转到第27行进行数据处理。反之,如果stamp是不可用的,则意味着在读取过程中,可能被其他线程改写了数据,因此有可能出现脏读。如果出现这种情况,我们可以像处理CAS操作那样在一个死循环中一直使用乐观读,直到成功为止。

也可以升级锁的级别。在本例中,我们升级乐观锁的级别,将乐观锁变为悲观锁。在第19行,当判断乐观读失败后,使用readLock()方法获得悲观的读锁,并进一步读取数据。如果当前对象正在被修改,则读锁的请求可能导致线程挂起。

写入的情况可以参考第5行定义的move()方法。使用writeLock()方法可以请求写锁,这里的含义和读写锁是类似的。

在退出临界区时,不要忘记释放写锁(第11行)或者读锁(第24行)。

可以看到,StampedLock通过引入乐观读增加了系统的并行度。

2、StampedLock的小陷阱

StampedLock实现时,使用类似于CAS操作的死循环反复尝试的策略。在它挂起线程时,使用的是Unsafe.park()方法,而park()方法在遇到线程中断时,会直接返回(注意,不同于Thread.sleep()方法,它不会抛出异常)。在StampedLock的死循环逻辑中,没有处理中断的逻辑,这就会导致阻塞在park()方法上的线程被中断后,再次进入循环。而当退出条件得不到满足时,就会发生疯狂占用CPU的情况。这一点值得我们注意,下面演示这个问题:

在上述代码中,首先开启线程占用写锁(第7行),注意,为了演示效果,这里使写线程不释放锁而一直等待。接着,开启3个读线程,让它们请求读锁。此时,由于写锁的存在,所有读线程都会被挂起。

下面是其中一个读线程挂起时的信息:

可以看到,这个线程因为park()方法的操作而进入了等待状态,这种情况是正常的。

在10秒以后(代码第17行执行了10秒等待),系统中断了这3个读线程,之后,你就会发现CPU占用率飙升。这是因为中断导致park()方法返回,使线程再次进入运行状态。下面是同一个线程在中断后的信息:

此时,这个线程的状态是RUNNABLE,这是我们不愿意看到的。它会一直存在并耗尽CPU资源,直到自己抢到了锁。

3、有关StampedLock的实现思想

StampedLock的内部实现是基于CLH锁的。CLH锁是一种自旋锁,它保证没有饥饿发生,并且可以保证FIFO(First-In-First-Out)的服务顺序。

CLH锁的基本思想如下:锁维护一个等待线程队列,所有请求锁但是没有成功的线程都记录在这个队列中。每一个节点(一个节点代表一个线程)保存一个标记位(locked),用于判断当前线程是否已经释放锁。

当一个线程试图获得锁时,取得当前等待队列的尾部节点作为其前序节点,并使用类似如下代码判断前序节点是否已经成功释放锁:

如果前序节点没有释放锁,则表示当前线程还不能继续执行,因此会自旋等待。反之,如果前序线程已经释放锁,则当前线程可以继续执行。

释放锁时,也遵循这个逻辑,如果线程将自身节点的locked位置标记为false,那么后续等待的线程就能继续执行了。

下图显示了CLH锁的基本思想:

StampedLock正是基于这种思想实现的,只是更复杂一些。

在StampedLock内部,会维护一个等待链表队列:

上述代码中,WNode为链表的基本元素,每一个WNode表示一个等待线程。字段whead和wtail分别指向等待链表的头部和尾部。

另一个重要的字段为state:

state表示当前锁的状态。它是一个long型整数,有64位,其中,倒数第8位表示写锁状态,如果该位为1,则表示当前线程由写锁占用。

对于一次乐观读,它会执行如下操作:

一次成功的乐观读必须保证当前锁没有写锁占用。其中WBIT用来获取写锁状态位,值为0x80。如果成功,则返回当前state的值(末尾7位清零,末尾7位表示当前正在读取的线程数量)。

如果在乐观读后,有线程请求了写锁,那么state的状态就会改变:

上述代码中第4行设置写锁位为1(通过加上WBIT(0x80)),这样就会改变state的取值,那么在乐观锁确认(validate)时,就会发现这个改动,从而导致乐观锁失效:

上述validate()方法会比较当前stamp和使用乐观锁时取得的stamp,如果不一致,则宣告乐观锁失败。

乐观锁失败后,可以提升锁级别,使用悲观读锁:

悲观读线程会尝试设置state状态(第4行),它会将state加1(前提是读线程数量没有溢出,对于读线程数量溢出的情况,会使用辅助的readerOverflow进行统计,我们在这里不做过于烦琐的讨论),用于统计读线程的数量。如果失败,则进入acquireRead()方法再次尝试获取锁。

在acquireRead()方法中,线程会在不同条件下进行若干次自旋,试图通过CAS操作获得锁。如果自旋失败,则会启用CLH队列,将自己加到队列中。之后再自旋,如果发现自己成功获得了读锁,则会进一步把自己的cowait队列中的读线程全部激活(使用Unsafe.unpark()方法)。如果最终依然无法成功获得读锁,则会使用Unsafe.park()方法挂起当前线程。

acquireWrite()方法和acquireRead()方法非常类似,也会进行自旋尝试、加入等待队列等操作,直至最终Unsafe.park()方法挂起线程。释放锁与加锁动作相反,以unlockWrite()方法为例:

上述代码第5行,将写标记位清零,如果state发生溢出,则退回到初始值。

接着,如果等待队列不为空,则从等待队列中激活一个线程(绝大多数情况下是第一个等待线程)继续执行(第7行)。

相关推荐
苹果醋39 分钟前
React源码02 - 基础知识 React API 一览
java·运维·spring boot·mysql·nginx
晓纪同学26 分钟前
QT-简单视觉框架代码
开发语言·qt
威桑26 分钟前
Qt SizePolicy详解:minimum 与 minimumExpanding 的区别
开发语言·qt·扩张策略
Hello.Reader29 分钟前
深入解析 Apache APISIX
java·apache
飞飞-躺着更舒服29 分钟前
【QT】实现电子飞行显示器(简易版)
开发语言·qt
明月看潮生35 分钟前
青少年编程与数学 02-004 Go语言Web编程 16课题、并发编程
开发语言·青少年编程·并发编程·编程与数学·goweb
明月看潮生38 分钟前
青少年编程与数学 02-004 Go语言Web编程 17课题、静态文件
开发语言·青少年编程·编程与数学·goweb
Java Fans40 分钟前
C# 中串口读取问题及解决方案
开发语言·c#
盛派网络小助手1 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#
菠萝蚊鸭1 小时前
Dhatim FastExcel 读写 Excel 文件
java·excel·fastexcel