本文同享自华为云社区《一文彻底了解并发编程中非常重要的收据锁——StampedLock》,作者:冰 河 。
什么是StampedLock?
ReadWriteLock锁答应多个线程一起读取同享变量,可是在读取同享变量的时分,不答应别的的线程多同享变量进行写操作,更多的适合于读多写少的环境中。那么,在读多写少的环境中,有没有一种比ReadWriteLock更快的锁呢?
答案当然是有!那便是咱们今日要介绍的主角——JDK1.8中新增的StampedLock!没错,便是它!
StampedLock与ReadWriteLock相比,在读的过程中也答应后边的一个线程获取写锁对同享变量进行写操作,为了防止读取的数据不一致,运用StampedLock读取同享变量时,需求对同享变量进行是否有写入的检验操作,而且这种读是一种达观读。
总归,StampedLock是一种在读取同享变量的过程中,答应后边的一个线程获取写锁对同享变量进行写操作,运用达观读防止数据不一致的问题,而且在读多写少的高并发环境下,比ReadWriteLock更快的一种锁。
StampedLock三种锁模式
这儿,咱们能够简单对比下StampedLock与ReadWriteLock,ReadWriteLock支撑两种锁模式:一种是读锁,另一种是写锁,而且ReadWriteLock答应多个线程一起读同享变量,在读时,不答应写,在写时,不答应读,读和写是互斥的,所以,ReadWriteLock中的读锁,更多的是指失望读锁。
StampedLock支撑三种锁模式:写锁、读锁(这儿的读锁指的是失望读锁)和达观读(很多资料和书本写的是达观读锁,这儿我个人觉得更准确的是达观读,为啥呢?咱们持续往下看啊)。其中,写锁和读锁与ReadWriteLock中的语义类似,答应多个线程一起获取读锁,可是只答应一个线程获取写锁,写锁和读锁也是互斥的。
另一个与ReadWriteLock不同的地方在于:StampedLock在获取读锁或许写锁成功后,都会回来一个Long类型的变量,之后在开释锁时,需求传入这个Long类型的变量。例如,下面的伪代码所示的逻辑演示了StampedLock如何获取锁和开释锁。
public class StampedLockDemo{
//创建StampedLock锁对象
public StampedLock stampedLock = new StampedLock();
//获取、开释读锁
public void testGetAndReleaseReadLock(){
long stamp = stampedLock.readLock();
try{
//履行获取读锁后的业务逻辑
}finally{
//开释锁
stampedLock.unlockRead(stamp);
}
}
//获取、开释写锁
public void testGetAndReleaseWriteLock(){
long stamp = stampedLock.writeLock();
try{
//履行获取写锁后的业务逻辑。
}finally{
//开释锁
stampedLock.unlockWrite(stamp);
}
}
}
StampedLock支撑达观读,这是它比ReadWriteLock性能要好的关键地点。 ReadWriteLock在读取同享变量时,一切对同享变量的写操作都会被堵塞。而StampedLock供给的达观读,在多个线程读取同享变量时,答应一个线程对同享变量进行写操作。
咱们再来看一下JDK官方给出的StampedLock示例,如下所示。
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double deltaX, double deltaY) { // an exclusively locked method
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
double distanceFromOrigin() { // A read-only method
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
void moveIfAtOrigin(double newX, double newY) { // upgrade
// Could instead start with optimistic, not read mode
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
stamp = ws;
x = newX;
y = newY;
break;
}
else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
}
}
在上述代码中,假如在履行达观读操作时,别的的线程对同享变量进行了写操作,则会把达观读晋级为失望读锁,如下代码片段所示。
double distanceFromOrigin() { // A read-only method
//达观读
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
//判别是否有线程对变量进行了写操作
//假如有线程对同享变量进行了写操作
//则sl.validate(stamp)会回来false
if (!sl.validate(stamp)) {
//将达观读晋级为失望读锁
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
//开释失望锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
这种将达观读晋级为失望读锁的办法相比一直运用达观读的办法愈加合理,假如不晋级为失望读锁,则程序会在一个循环中反复履行达观读操作,直到达观读操作期间没有线程履行写操作,而在循环中不断的履行达观读会消耗很多的CPU资源,晋级为失望读锁是愈加合理的一种办法。
StampedLock实现思想
StampedLock内部是根据CLH锁实现的,CLH是一种自旋锁,能够确保没有“饥饿现象”的发生,而且能够确保FIFO(先进先出)的服务顺序。
在CLH中,锁维护一个等候线程行列,一切请求锁,可是没有成功的线程都会存入这个行列中,每一个节点代表一个线程,保存一个符号位(locked),用于判别当时线程是否现已开释锁,当locked符号位为true时, 表明获取到锁,当locked符号位为false时,表明成功开释了锁。
当一个线程试图获得锁时,获得等候行列的尾部节点作为其前序节点,并运用类似如下代码判别前序节点是否现已成功开释锁:
while (pred.locked) {
//省掉操作
}
只要前序节点(pred)没有开释锁,则表明当时线程还不能持续履行,因此会自旋等候;反之,假如前序线程现已开释锁,则当时线程能够持续履行。
开释锁时,也遵从这个逻辑,线程会将自身节点的locked方位符号为false,后续等候的线程就能持续履行了,也便是现已开释了锁。
StampedLock的实现思想总体来说,仍是比较简单的,这儿就不打开讲了。
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:停止状况,当时线程履行完毕。
看完这个线程的生命周期图,知道为啥调用LockSupport.park();
会使thread02堵塞了吧?
所以,在运用StampedLock时,必定要留意防止线程地点的CPU飙升的问题。那如何防止呢?
那便是运用StampedLock的readLock()办法或许读锁和运用writeLock()办法获取写锁时,必定不要调用线程的中止办法来中止线程,假如不可防止的要中止线程的话,必定要用StampedLock的readLockInterruptibly()办法获取可中止的读锁和运用StampedLock的writeLockInterruptibly()办法获取可中止的失望写锁。
最后,关于StampedLock的运用,JDK官方给出的StampedLock示例自身便是一个最佳实践了,小伙伴们能够多看看JDK官方给出的StampedLock示例,多多体会下StampedLock的运用办法和背后原理与核心思想。
点击关注,第一时间了解华为云新鲜技能~