文章目录
1 StampedLock
1.1 简介
ReadWriteLock
锁允许多个线程同时读取共享变量,但是在读取共享变量的时候,不允许另外的线程对共享变量进行写操作
,更多的适合于读多写少
的环境中。那么,在读多写少的环境中,有没有一种比ReadWriteLock
更快的锁呢?
答案当然是有!那就是——JDK1.8中新增的StampedLock
StampedLock
与ReadWriteLock
相比,在读的过程中也允许后面的一个线程获取写锁对共享变量进行写操作,为了避免读取的数据不一致,使用StampedLock
读取共享变量时,需要对共享变量进行是否有写入的检验操作,并且这种读是一种乐观读
。
总之,StampedLock
是一种在读取共享变量的过程中,允许后面的一个线程获取写锁对共享变量进行写操作,使用乐观读
避免数据不一致的问题,并且在读多写少的高并发环境下,比ReadWriteLock
更快的一种锁。
1.2 StampedLock三种锁模式
1.2.1 简介
这里,我们可以简单对比下StampedLock
与ReadWriteLock
,ReadWriteLock
支持两种锁模式:一种是读锁
,另一种是写锁
,并且ReadWriteLock
允许多个线程同时读共享变量,在读时,不允许写,在写时,不允许读,读和写是互斥的,所以,ReadWriteLock
中的读锁,更多的是指悲观读锁
。
- 写锁 (
Write Lock
)
描述: 一种独占锁(exclusive lock)
,同一时刻只允许一个线程持有写锁,其他线程(无论是读还是写)都会被阻塞。
获取方法:lockWrite()
或tryLockWrite()
。释放方法:unlockWrite(long stamp)
特点:- 类似于传统读写锁中的写锁,适用于需要修改共享资源的场景。
- 返回一个 stamp(时间戳),用于后续解锁操作。
- 悲观读锁 (
Pessimistic Read Lock
)
描述: 一种共享锁(shared lock
),允许多个线程同时持有读锁,但会阻塞写锁的获取。
获取方法: lockRead() 或 tryLockRead()。释放方法: unlockRead(long stamp)。
特点:- 类似于
ReentrantReadWriteLock
的读锁,保证读操作期间不会有写操作干扰。 - 返回一个 stamp,用于解锁或验证锁状态。
- 类似于
- 乐观读锁 (
Optimistic Read Lock
)
描述: 一种无锁(lock-free
)的读取模式,不真正加锁,而是通过验证数据是否在读取期间被修改来确保一致性。
获取方法: tryOptimisticRead()。验证方法: validate(long stamp)。
特点:- 不阻塞写操作,性能最高,但需要配合验证机制(validate)来检查数据是否被修改。
- 如果验证失败(即数据被写锁修改),通常需要回退并获取悲观读锁。
- 不需要显式释放锁。
锁模式 | 独占/共享 | 阻塞性 | 性能 | 使用场景 |
---|---|---|---|---|
写锁 | 独占 | 阻塞读和写 | 较低 | 数据写入/修改 |
悲观读锁 | 共享 | 阻塞写,不阻塞读 | 中等 | 一致性要求高的读取 |
乐观读锁 | 无锁 | 不阻塞任何操作 | 最高 | 读多写少的高性能场景 |
1.2.2 示例
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private double x, y;
private final StampedLock lock = new StampedLock();
// 写锁示例:修改坐标
public void move(double deltaX, double deltaY) {
long stamp = lock.lockWrite(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp); // 释放写锁
}
}
// 悲观读锁示例:读取坐标
public double[] getPosition() {
long stamp = lock.lockRead(); // 获取悲观读锁
try {
return new double[]{x, y};
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
//如果在执行乐观读操作时,另外的线程对共享变量进行了写操作,
//则会把`乐观读`升级为`悲观读锁`,如下代码片段所示。
// 乐观读锁示例:计算距离
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // 获取乐观读锁
double currentX = x;
double currentY = y;
//判断是否有线程对变量进行了写操作
//如果有线程对共享变量进行了写操作
//则sl.validate(stamp)会返回false
if (!lock.validate(stamp)) { // 验证数据是否被修改
stamp = lock.lockRead(); // 如果验证失败,升级为悲观读锁
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
public static void main(String[] args) {
StampedLockExample example = new StampedLockExample();
example.move(3.0, 4.0);
System.out.println("Position: " + Arrays.toString(example.getPosition()));
System.out.println("Distance from origin: " + example.distanceFromOrigin());
}
}
乐观读锁
将乐观读
升级为悲观读锁
的方式相比一直使用乐观读
的方式更加合理,如果不升级为悲观读锁,则程序会在一个循环中反复执行乐观读操作,直到乐观读操作期间没有线程执行写操作,而在循环中不断的执行乐观读会消耗大量的CPU资源,升级为悲观读锁是更加合理的一种方式。
1.3 StampedLock实现思想
StampedLock
内部是基于CLH
锁实现的,CLH
是一种自旋锁
,能够保证没有 饥饿现象
(线程长期无法获取资源,任务无法完成) 的发生,并且能够保证 FIFO
(先进先出)的服务顺序。
在CLH
中,锁维护一个等待线程队列,所有申请锁,但是没有成功的线程都会存入这个队列中,每一个节点代表一个线程,保存一个标记位(locked
),用于判断当前线程是否已经释放锁,当locked标记位为true时, 表示获取到锁,当locked
标记位为false
时,表示成功释放了锁。
当一个线程试图获得锁时,取得等待队列的尾部节点作为其前序节点,并使用类似如下代码判断前序节点是否已经成功释放锁:
while (pred.locked) {
//省略操作
}
只要前序节点(pred
)没有释放锁,则表示当前线程还不能继续执行,因此会自旋等待;反之,如果前序线程已经释放锁,则当前线程可以继续执行。
释放锁时,也遵循这个逻辑,线程会将自身节点的 locked
位置标记为false
,后续等待的线程就能继续执行了,也就是已经释放了锁。
1.4 StampedLock的注意事项
在读多写少的高并发环境下,StampedLock
的性能确实不错,但是它不能够完全取代ReadWriteLock
。在使用的时候,也需要特别注意以下几个方面。
StampedLock
不支持重入
没错,StampedLock
是不支持重入的,也就是说,在使用StampedLock
时,不能嵌套使用,这点在使用时要特别注意。StampedLock
不支持条件变量
StampedLock
不支持条件变量,无论是读锁还是写锁,都不支持条件变量。StampedLock
使用不当会导致CPU飙升
在使用时需要特别注意:如果某个线程阻塞在StampedLock
的readLock()
或者writeLock()
方法上时,此时调用阻塞线程的interrupt()
方法中断线程,会导致CPU飙升到100%。
例如,下面的代码所示。
public void testStampedLock() throws Exception{
final StampedLock lock = new StampedLock();
Thread thread01 = new Thread(()->{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
thread01.start();
// 保证thread01获取写锁
Thread.sleep(100);
Thread thread02 = new Thread(()->
//阻塞在悲观读锁
lock.readLock()
);
thread02.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程thread02
//会导致线程thread02所在CPU飙升
thread02.interrupt();
thread02.join();
}
运行上面的程序,会导致thread02线程所在的CPU飙升到100%。
这里,有很多小伙伴不太明白为啥LockSupport.park();
会导致thread01会永远阻塞。这里,冰河为你画了一张线程的生命周期图,如下所示。
这下明白了吧?在线程的生命周期中,有几个重要的状态需要说明一下。
NEW
:初始状态,线程被构建,但是还没有调用start()方法。RUNNABLE
:可运行状态,可运行状态可以包括:运行中状态和就绪状态。BLOCKED
:阻塞状态,处于这个状态的线程需要等待其他线程释放锁或者等待进入synchronized。WAITING
:表示等待状态,处于该状态的线程需要等待其他线程对其进行通知或中断等操作,进而进入下一个状态。TIME_WAITING
:超时等待状态。可以在一定的时间自行返回。TERMINATED
:终止状态,当前线程执行完毕。
所以,在使用StampedLock时,一定要注意避免线程所在的CPU飙升的问题。那如何避免呢?
那就是使用
StampedLock的readLock()
方法或者读锁和使用writeLock()
方法获取写锁时,一定不要调用线程的中断方法来中断线程,如果不可避免的要中断线程的话,一定要用StampedLock
的readLockInterruptibly()
方法获取可中断的读锁和使用StampedLock
的writeLockInterruptibly()
方法获取可中断的悲观写锁。