StampedLock介绍

前言

ReadWriteLock适用于读多写少的场景,允许多个线程同时读取共享变量。但在读多写少的场景中,还有更快的技术方案。在jdk8以后,java提供了一个性能更优越的读写锁并发类StampedLock,该类的设计初衷是作为一个内部工具类,用于辅助开发其它线程安全组件,用得好,该类可以提升系统性能,用不好,容易产生死锁和其它莫名其妙的问题。本文主要和大家一起学习下StampedLock的功能和使用。

一、StampedLock概述

StampedLock 是读写锁的实现,对比 ReentrantReadWriteLock 主要不同是该锁不允许重入,多了乐观读的功能,使用上会更加复杂一些,但是具有更好的性能表现。

StampedLock 的状态由版本和读写锁持有计数组成。 获取锁方法返回一个邮戳,表示和控制与锁状态相关的访问; 这些方法的“尝试”版本可能会返回特殊值 0 来表示获取锁失败。 锁释放和转换方法需要邮戳作为参数,如果它们与锁的状态不匹配则失败。

二、StampedLock支持的三种锁模式

ReadWriteLock支持两种访问模式:读锁和写锁,而StampedLock支持三种访问模式:写锁、悲观读锁和乐观读。

其中写锁和悲观读锁的语义与ReadWriteLock中的写锁和读锁语义类似,允许多个线程同时获取悲观读锁,只允许一个线程获取写锁。与ReadWriteLock不同的是,StampedLock中的写锁和悲观读锁加锁成功之后,都会返回一个stamp标记,然后解锁的时候需要传入这个stamp。

相关示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final StampedLock sl = new StampedLock();
// 获取/释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
} finally {
sl.unlockRead(stamp);
}
// 获取/释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
sl.unlockWrite(stamp);
}

StampedLock的性能之所以比ReadWriteLock好,其关键在于StampedLock支持乐观读ReadWriteLock支持多个线程同时读,当多个线程同时读的时候,所有的写操作都会被阻塞。但是,StampedLock提供了乐观读,当有多个线程同时读共享变量允许一个线程获取写锁,也就是说不是所有写操作都会被阻塞。

需要注意,StampedLock提供的是“乐观读”而不是“乐观读锁”,这表示乐观读是无锁的,这也是其比ReadWriteLock读锁性能好的原因。

乐观读的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Point{
private int x, y;
final StampedLock sl = new StampedLock();
// 计算到原点的距离
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); //乐观读
//读取全局变量存储到局部变量中 在读入的过程中,数据可能被修改
int curX = x;
int curY = y;
//判断进行读操作期间,是否存在写操作,如果存在,则sl.validate(stamp)返回false
if(!sl.validate(stamp)) {
stamp = sl.readLock(); //升级为悲观读锁 一切的写操作都会被阻塞
try {
curX = x;
curY = y;
}finally {
sl.unlockRead(stamp); //释放悲观读锁
}
}
return Math.sqrt(curX*curX + curY*curY);
}
}

我们将共享变量x,y读入方法的局部变量中,因为tryOptimisticRead()是无锁的,所以,共享变量x和y读入方法局部变量时,x和y有可能被其他线程修改了。

因此,最后读完之后,还需要再次验证一下在读入过程中是否存在写操作,这个验证操作是通过调用validate(stamp)来实现的。

如果在执行乐观读操作期间,存在写操作,会把乐观读升级为悲观读锁。

如果不使用这种做法,那么就可能需要使用循环来执行反复读,直到执行乐观读操作的期间没有写操作,但是循环会浪费大量的CPU。

所以,升级为悲观读锁,代码简练且不易出错。

三、StampedLock乐观读的理解

数据库中的乐观锁StampedLock中的乐观读有着异曲同工之妙。

通过下面这个例子来理解

在ERP的生产模块中,会有多个人通过ERP系统提供的UI同时修改同一条生产订单,那如何保证生产订单数据是并发安全的?

一种解决方案是采用乐观锁。

在生产订单的表product_doc里面增加了一个数据型版本号字段vresion,每次更新product_doc这个表的时候,都将version字段加1。生产订单的UI在展示的时候,需要查询数据库,此时将这个version字段和其他业务字段一起返回给生产订单UI。

假设用户查询的生产订单的id=777,那么SQL语句类似如下:

1
2
3
select id, ..., version
from product_doc
where id=777

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

1
2
3
update product_doc
set version=version+1,...
where id=777 and version=4

如果这条SQL语句执行成功并且返回条数等于1,那么说明从生产订单UI执行查询操作到执行保存期间,没有其他人修改过这条数据。因为如果这期间有人修改过这条数据,那么版本号字段一定会大于4。

数据库中的乐观锁,查询的时候,需要把version字段查出来,更新的时候要利用version字段做验证StampedLock里面的stamp就类似于这个version字段。

四、StampedLock相关方法

4.1 写模式

获取写锁,它是独占的,当锁处于写模式时,无法获得读锁,所有乐观读验证都将失败。

方法 说明
writeLock() 阻塞等待独占获取锁,返回一个戳, 如果是0表示获取失败
tryWriteLock() 尝试获取一个写锁,返回一个戳, 如果是0表示获取失败
long tryWriteLock(long time, TimeUnit unit) 尝试获取一个独占写锁,可以等待一段事件,返回一个戳, 如果是0表示获取失败
long writeLockInterruptibly() 尝试获取一个独占写锁,可以被中断,返回一个戳, 如果是0表示获取失败
unlockWrite(long stamp) 释放独占写锁,传入之前获取的戳
tryUnlockWrite() 如果持有写锁,则释放该锁,而不需要戳值。这种方法可能对错误后的恢复很有用

语法

1
2
3
4
5
6
long stamp = lock.writeLock();
try {
....
} finally {
lock.unlockWrite(stamp);
}

4.2 读模式

悲观的方式后去非独占读锁。

方法 说明
readLock() 阻塞等待获取非独占的读锁,返回一个戳, 如果是0表示获取失败
tryReadLock() 尝试获取一个读锁,返回一个戳, 如果是0表示获取失败
long tryReadLock(long time, TimeUnit unit) 尝试获取一个读锁,可以等待一段事件,返回一个戳, 如果是0表示获取失败
long readLockInterruptibly() 阻塞等待获取非独占的读锁,可以被中断,返回一个戳, 如果是0表示获取失败
unlockRead(long stamp) 释放非独占的读锁,传入之前获取的戳
tryUnlockRead() 如果读锁被持有,则释放一次持有,而不需要戳值。这种方法可能对错误后的恢复很有用

语法

1
2
3
4
5
6
long stamp = lock.readLock();
try {
....
} finally {
lock.unlockRead(stamp);
}

4.3 乐观读模式

StampedLock支持 tryOptimisticRead()方法,读取完毕后做一次戳校验,如果校验通过,表示这期间没有其他线程的写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据一致性。

方法 说明
tryOptimisticRead() 返回稍后可以验证的戳记,如果独占锁定则返回零
boolean validate(long stamp) 如果自给定戳记发行以来锁还没有被独占获取,则返回true

语法

1
2
3
4
5
long stamp = lock.tryOptimisticRead();
// 验戳
if(!lock.validate(stamp)){
// 锁升级
}

此外,StampedLock 提供了api实现下面3种方式进行转换:

  • long tryConvertToWriteLock(long stamp)

    验证当前锁版本和锁持有状态和给定的邮戳是否匹配,如果不匹配、邮戳的锁状态有误或当前持有多个共享锁则返回 0。匹配时则分三种情况,当前未持有锁则获取独占锁,当前持有独占锁则不进行操作,当前仅持有一个共享锁则释放共享锁获取独占锁,最终返回独占锁的邮戳。

  • long tryConvertToReadLock(long stamp)

    验证当前锁版本和锁持有状态和给定的邮戳是否匹配,如果不匹配或者邮戳的锁状态有误则返回 0。匹配时则分三种情况,当前未持有锁则获取共享锁,当前持有独占锁则释放独占锁获取共享锁,当前持有共享锁则不进行操作,最终返回共享锁的邮戳。

  • long tryConvertToOptimisticRead(long stamp)

    验证当前锁版本和锁持有状态和给定的邮戳是否匹配,如果匹配则进行一次锁释放,如果不匹配或者邮戳的锁状态有误则返回 0。该方法的逻辑和 unlock 方法的逻辑相似,如果当前未持有锁就直接返回锁版本,如果持有锁则进行一次锁释放,再返回锁版本。

五、读写案示例

提供一个数据容器类内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Slf4j(topic = "c.DataContainerStamped")
class DataContainerStamped {
//数据
private int data;
//StampedLock 锁
private final StampedLock lock = new StampedLock();

public DataContainerStamped(int data) {
this.data = data;
}
//读取操作
public int read(int readTime) {
//首先获取stamp
long stamp = lock.tryOptimisticRead();
log.debug("optimistic read locking...{}", stamp);
sleep(readTime);
//验证如果是有效的,证明这期间没有写操作,直接返回即可,这时还是乐观锁
if (lock.validate(stamp)) {
//就可以读到数据
log.debug("read finish...{}, data:{}", stamp, data);
return data;
}
// 否则证明已经有写锁修改过了,这里需要再次获取读锁,升级为真正的读锁
// 锁升级 - 读锁
log.debug("updating to read lock... {}", stamp);
try {
//获取stamp
stamp = lock.readLock();
log.debug("read lock {}", stamp);
sleep(readTime);
log.debug("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.debug("read unlock {}", stamp);
lock.unlockRead(stamp);
}
}

public void write(int newData) {
//获取戳
long stamp = lock.writeLock();
log.debug("write lock {}", stamp);
try {
sleep(2);
this.data = newData;
} finally {
log.debug("write unlock {}", stamp);
lock.unlockWrite(stamp);
}
}
}

测试 读-读 可以优化

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}

运行结果

1
2
3
4
22:16:39.577 [t1] DEBUG c.DataContainerStamped - optimistic read locking...256
22:16:40.082 [t2] DEBUG c.DataContainerStamped - optimistic read locking...256
22:16:40.082 [t2] DEBUG c.DataContainerStamped - read finish...256, data:1
22:16:40.582 [t1] DEBUG c.DataContainerStamped - read finish...256, data:1

结果分析

从结果中可以看到两个线程同时获取读锁并执行读操作,没有先后的关系。

测试 读-写 时优化读补加读锁

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.write(0);
}, "t2").start();
}

运行结果

1
2
3
4
5
6
7
22:18:14.893 [t1] DEBUG c.DataContainerStamped - optimistic read locking...256
22:18:15.392 [t2] DEBUG c.DataContainerStamped - write lock 384
22:18:15.899 [t1] DEBUG c.DataContainerStamped - updating to read lock... 256
22:18:17.393 [t2] DEBUG c.DataContainerStamped - write unlock 384
22:18:17.393 [t1] DEBUG c.DataContainerStamped - read lock 513
22:18:18.393 [t1] DEBUG c.DataContainerStamped - read finish...513, data:0
22:18:18.393 [t1] DEBUG c.DataContainerStamped - read unlock 513

结果分析

一开始是读操作先睡眠一秒,在睡眠之前已经获取了戳了,在 t1 线程睡眠期间 t2 线程获取到了写锁,并将数据修改,而且戳也改成了384。

此时 t1 线程醒过来校验发现戳已经被修改了,所以这时候 t1 线程会等待 t2 线程释放写锁之后去获取读锁。完成从乐观读 -> 读锁 的升级。

注意

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入

StampedLock介绍
http://example.com/2023/03/31/StampedLock介绍/
作者
程序员小魏
发布于
2023年3月31日
许可协议