13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图113讲多线程之锁优化(中):深⼊了解Lock同步锁的优化⽅法

你好,我是刘超。

今天这讲我们继续来聊聊锁优化。上⼀讲我重点介绍了在JVM层实现的Synchronized同步锁的优化⽅法,除此之外,在
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图2JDK1.5之后,Java还提供了Lock同步锁。那么它有什么优势呢?

相对于需要JVM隐式获取和释放锁的Synchronized同步锁,Lock同步锁(以下简称Lock锁)需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性。Lock锁的基本操作是通过乐观锁来实现的,但由于Lock锁也会在阻塞时被挂起,因此它依然属于悲观锁。我们可以通过⼀张图来简单对⽐下两个同步锁,了解下各⾃的特点:
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图3

从性能⽅⾯上来说,在并发量不⾼、竞争不激烈的情况下,Synchronized同步锁由于具有分级锁的优势,性能上与Lock锁差
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图4不多;但在⾼负载、⾼并发的情况下,Synchronized同步锁由于竞争激烈会升级到重量级锁,性能则没有Lock锁稳定。我们可以通过⼀组简单的性能测试,直观地对⽐下两种锁的性能,结果⻅下⽅,代码可以在Github上下载查看。
通过以上数据,我们可以发现:Lock锁的性能相对来说更加稳定。那它与上⼀讲的Synchronized同步锁相⽐,实现原理⼜是怎样的呢?

Lock锁的实现原理

Lock锁是基于Java实现的锁,Lock是⼀个接⼝类,常⽤的实现类有ReentrantLock、ReentrantReadWriteLock(RRW),它们都是依赖AbstractQueuedSynchronizer(AQS)类实现的。

AQS类结构中包含⼀个基于链表实现的等待队列(CLH队列),⽤于存储所有阻塞的线程,AQS中还有⼀个state变量,该变

量对ReentrantLock来说表示加锁状态。

该队列的操作均通过CAS操作实现,我们可以通过⼀张图来看下整个获取锁的流程。
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图5

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图6

锁分离优化Lock同步锁

虽然Lock锁的性能稳定,但也并不是所有的场景下都默认使⽤ReentrantLock独占锁来实现线程同步。

我们知道,对于同⼀份数据进⾏读写,如果⼀个线程在读数据,⽽另⼀个线程在写数据,那么读到的数据和最终的数据就会不
⼀致;如果⼀个线程在写数据,⽽另⼀个线程也在写数据,那么线程前后看到的数据也会不⼀致。这个时候我们可以在读写⽅法中加⼊互斥锁,来保证任何时候只能有⼀个线程进⾏读或写操作。

在⼤部分业务场景中,读业务操作要远远⼤于写业务操作。⽽在多线程编程中,读操作并不会修改共享资源的数据,如果多个线程仅仅是读取共享资源,那么这种情况下其实没有必要对资源进⾏加锁。如果使⽤互斥锁,反倒会影响业务的并发性能,那么在这种场景下,有没有什么办法可以优化下锁的实现⽅式呢?

1.读写锁ReentrantReadWriteLock

针对这种读多写少的场景,Java提供了另外⼀个实现Lock接⼝的读写锁RRW。我们已知ReentrantLock是⼀个独占锁,同⼀时间只允许⼀个线程访问,⽽RRW允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁,⼀个是⽤于读操作的ReadLock,⼀个是⽤于写操作的WriteLock。

那读写锁⼜是如何实现锁分离来保证共享资源的原⼦性呢?

RRW也是基于AQS实现的,它的⾃定义同步器(继承AQS)需要在同步状态state上维护多个读线程和⼀个写线程的状态,该状态的设计成为实现读写锁的关键。RRW很好地使⽤了⾼低位,来实现⼀个整型控制两种状态的功能,读写锁将变量切分成了两个部分,⾼16位表示读,低16位表示写。

⼀个线程尝试获取写锁时,会先判断同步状态state是否为0。如果state等于0,说明暂时没有其它线程获取锁;如果state不等于0,则说明有其它线程获取了锁。

此时再判断同步状态state的低16位(w)是否为0,如果w为0,则说明其它线程获取了读锁,此时进⼊CLH队列进⾏阻塞等 待;如果w不为0,则说明其它线程获取了写锁,此时要判断获取了写锁的是不是当前线程,若不是就进⼊CLH队列进⾏阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最⼤次数,若超过,抛异常,反之更新同步状态。
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图7

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图8

⼀个线程尝试获取读锁时,同样会先判断同步状态state是否为0。如果state等于0,说明暂时没有其它线程获取锁,此时判断
是否需要阻塞,如果需要阻塞,则进⼊CLH队列进⾏阻塞等待;如果不需要阻塞,则CAS更新同步状态为读状态。

如果state不等于0,会判断同步状态低16位,如果存在写锁,则获取读锁失败,进⼊CLH阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试CAS同步状态,获取成功更新同步锁为读状态。
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图9

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图10

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图11

下⾯我们通过⼀个求平⽅的例⼦,来感受下RRW的实现,代码如下:

public class TestRTTLock {

private double x, y;

private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 读 锁
private Lock readLock = lock.readLock();
// 写 锁
private Lock writeLock = lock.writeLock();

public double read() {
//获取读锁readLock.lock(); try {
return Math.sqrt(x x + y y);
} finally {
//释放读锁readLock.unlock();
}
}

public void move(double deltaX, double deltaY) {
//获取写锁writeLock.lock(); try {
x += deltaX; y += deltaY;
} finally {
//释放写锁writeLock.unlock();
}
}

}

2.读写锁再优化之StampedLock

RRW被很好地应⽤在了读⼤于写的并发场景中,然⽽RRW在性能上还有可提升的空间。在读取很多、写⼊很少的情况

下,RRW会使写⼊线程遭遇饥饿(Starvation)问题,也就是说写⼊线程会因迟迟⽆法竞争到锁⽽⼀直处于等待状态。

在JDK1.8中,Java提供了StampedLock类解决了这个问题。StampedLock不是基于AQS实现的,但实现的原理和AQS是⼀样的,都是基于队列和锁状态实现的。与RRW不⼀样的是,StampedLock控制锁有三种模式: 写、悲观读以及乐观读,并且
StampedLock在获取锁时会返回⼀个票据stamp,获取的stamp除了在释放锁时需要校验,在乐观读模式下,stamp还会作为读取共享资源后的⼆次校验,后⾯我会讲解stamp的⼯作原理。

我们先通过⼀个官⽅的例⼦来了解下StampedLock是如何使⽤的,代码如下:

public class Point { private double x, y;
private final StampedLock s1 = new StampedLock();

void move(double deltaX, double deltaY) {
//获取写锁
long stamp = s1.writeLock(); try {
x += deltaX; y += deltaY;
} finally {
//释放写锁s1.unlockWrite(stamp);
}
}

double distanceFormOrigin() {
//乐观读操作
long stamp = s1.tryOptimisticRead();
//拷⻉变量
double currentX = x, currentY = y;
//判断读期间是否有写操作
if (!s1.validate(stamp)) {
//升级为悲观读
stamp = s1.readLock(); try {
currentX = x; currentY = y;
} finally {
s1.unlockRead(stamp);
}
}
return Math.sqrt(currentX currentX + currentY currentY);
}
}
我们可以发现:⼀个写线程获取写锁的过程中,⾸先是通过WriteLock获取⼀个票据stamp,WriteLock是⼀个独占锁,同时只
有⼀个线程可以获取该锁,当⼀个线程获取该锁后,其它请求的线程必须等待,当没有线程持有读锁或者写锁的时候才可以获取到该锁。请求该锁成功后会返回⼀个stamp票据变量,⽤来表示该锁的版本,当释放该锁的时候,需要unlockWrite并传递参数stamp。

接下来就是⼀个读线程获取锁的过程。⾸先线程会通过乐观锁tryOptimisticRead操作获取票据stamp ,如果当前没有线程持有写锁,则返回⼀个⾮0的stamp版本信息。线程获取该stamp后,将会拷⻉⼀份共享资源到⽅法栈,在这之前具体的操作都是基于⽅法栈的拷⻉数据。

之后⽅法还需要调⽤validate,验证之前调⽤tryOptimisticRead返回的stamp在当前是否有其它线程持有了写锁,如果是,那么
validate会返回0,升级为悲观锁;否则就可以使⽤该stamp版本的锁对数据进⾏操作。

相⽐于RRW,StampedLock获取读锁只是使⽤与或操作进⾏检验,不涉及CAS操作,即使第⼀次乐观锁获取失败,也会⻢上升级⾄悲观锁,这样就可以避免⼀直进⾏CAS操作带来的CPU占⽤性能的问题,因此StampedLock的效率更⾼。

总结

不管使⽤Synchronized同步锁还是Lock同步锁,只要存在锁竞争就会产⽣线程阻塞,从⽽导致线程之间的频繁切换,最终增加性能消耗。因此,如何降低锁竞争,就成为了优化锁的关键。

在Synchronized同步锁中,我们了解了可以通过减⼩锁粒度、减少锁占⽤时间来降低锁的竞争。在这⼀讲中,我们知道可以利
⽤Lock锁的灵活性,通过锁分离的⽅式来降低锁竞争。

Lock锁实现了读写锁分离来优化读⼤于写的场景,从普通的RRW实现到读锁和写锁,到StampedLock实现了乐观读锁、悲观读锁和写锁,都是为了降低锁的竞争,促使系统的并发性能达到最佳。

思考题

StampedLock同RRW⼀样,都适⽤于读⼤于写操作的场景,StampedLock⻘出于蓝结果却不好说,毕竟RRW还在被⼴泛应
⽤,就说明它还有StampedLock⽆法替代的优势。你知道StampedLock没有被⼴泛应⽤的原因吗?或者说它还存在哪些缺陷导致没有被⼴泛应⽤。

期待在留⾔区看到你的⻅解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起学习。

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图12

  1. 精选留⾔

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图13-W.LI-
⽼师好!读写锁那个流程图看不太明⽩,没有写线程的时候,判断不是当前线程在读就会进⼊CLF阻塞等待。问题1:不是可以并发读的嘛?按这图读线程也要阻塞等待的意思么?
问题⼆:CLF阻塞队列⾥是读写线程公⽤的么?队列⾥,读写交替出现。那不就没法并发读了么?
2019-06-18 09:38
作者回复
第⼀个问题,这⾥有⼀个公平锁和⾮公平锁的情况,如果是公平锁,即使⽆锁竞争的情况下,也会进⼊阻塞队列中排队获取锁
;否则,会⽴即CAS获取到读锁。

第⼆个问题,是公⽤的,这⾥同样涉及到了公平锁和⾮公平锁,读写线程对于程序来说都是⼀样的。如果是⾮公平锁,如果没有锁竞争的情况下CAS获取锁成功,是⽆需进⼊阻塞队列。如果是公平锁,都会进⼊阻塞队列。
2019-06-18 10:26

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图14Liam
StampLock不⽀持重⼊,不⽀持条件变量,线程被中断时可能导致CPU暴涨
2019-06-18 09:02
作者回复
回答很全⾯
2019-06-19 09:14

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图15英⻓
希望⽼师能多结合实践讲讲应⽤场景
2019-06-19 15:10
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图16趙衍
谢谢⽼师的回复!关于StampedLock,我的理解是乐观读的时候,线程把stamp的值读出来,通过与运算来判断当前是否存在写操作。这个过程是不涉及CAS操作的。可是如果有线程需要修改当前的资源,要加写锁,那么就需要使⽤CAS操作修改stam
p的值。不知道这样理解是否准确。
此外,前排@-W.LI-同学提出的那个问题,并发读的时候也需要按照是否是公平锁进⼊CLH队列进⾏阻塞我还不是很明⽩,既然⼤家都是读操作,互相之间没有冲突,我每个线程都直接⽤CAS操作获取锁不就⾏了吗,为什么还要进队列阻塞等待呢?
2019-06-19 11:13
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图17QQ怪
⽼师这篇⼲货很多,看了2~3遍,⼤体理解了底层AQS锁原理,期待⽼师多多分享更多相关的⽂章
2019-06-18 22:24

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图18-W.LI-
StampedLock在写多读少的时候性能会很差吧
2019-06-18 09:28
作者回复
是的,写多读少的性能没有优势。
2019-06-19 09:15

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图19密码123456
为什么?因为锁不可重⼊?
2019-06-18 08:21
作者回复
是的,StampedLock不⽀持可重⼊。如果在⼀些需要重⼊的代码中使⽤StampedLock,会导致死锁、饿死等情况出现。
2019-06-18 10:12

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图20我知道了嗯
可重⼊锁是什么?另外什么场景下会使⽤到?
2019-06-20 07:43
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图21趙衍
⽼师我有⼏个问题:
1.在ReentrantLock中,state这个变量,为0的时候表示当前的锁是没有被占⽤的。这个时候线程应该⽤CAS尝试修改state变量的值对锁进⾏抢占才对呀,为什么在您的图⾥当state=0的时候还需要判断是否为当前线程呢?
2.⽼师提到读写锁在读多写少的情况下会使得写线程遭遇饥饿问题,那我是不是只需要将锁设置为公平锁,这样先申请写锁的线程就可以先获得锁,从⽽避免饥饿问题呢?
3.StampedLock中引⼊了⼀个stamp版本对版本进⾏控制,那么对这个stamp变量进⾏写⼊的时候是否需要使⽤CAS操作?如果不是,那如何保证对stamp变量的读写是线程安全的呢?
谢谢⽼师!

2019-06-18 15:56
作者回复
第⼀个问题,是⽼师笔误,搞错⽅向了,现在已更正。

第⼆个问题,如果读多写少的情况下,即使是公平锁,也是需要⻓时间等待,不是想获取时就能⽴即获取到锁。StampedLock 如果是处于乐观读时,写锁是可以随时获取到锁。

第三个问题,StampedLock源码中存在⼤量compareAndSwapObject操作来保证原⼦性。
2019-06-19 10:02

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图22QQ怪
刚想反馈图⽚⼀个字⺟写反了,刷新⼀下,⽴⻢被修复了,厉害厉害,佩服⽼师的效率
2019-06-18 12:28
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图23张学磊
⽼师,tryOptimisticRead操作获取的不应该叫乐观读锁,应该是乐观读,是⽆锁的;StampedLock名字中没有Reentrant,所以不⽀持重⼊;StampedLock也不⽀持条件变量。
2019-06-18 07:02
作者回复
这就是⼀种按版本号实现的读乐观锁,我们经常会在数据库更新操作时⽤到这种基于版本号实现的写乐观锁。

对的,StampedLock不⽀持重⼊。
2019-06-18 10:33

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图24
⼀直不明⽩⽼师说的CAS是啥东东。具体描述下
2019-07-17 09:58
作者回复
你可以⾃⼰搜索了解下,极客时间其他专栏中也有介绍,App中搜索可以找到相关资料。
2019-07-17 10:18

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图25嘉嘉
好的,谢谢⽼师,就是看到图和⽂字是不⼀样的,确认⼀下
2019-07-09 11:44
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图26嘉嘉
⽼师好,请问
获取写锁的流程,⽂字描述和流程图,有个地⽅不太⼀样,判断“获取写锁的次数”还是“获取读锁的次数”?? 谢谢⽼师
2019-07-08 20:50
作者回复
获取写锁的次数,也是独占的数量
2019-07-09 09:46

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图27任鹏斌
⽼师好,1. 读写锁 ReentrantReadWriteLock获取写锁的时候也需要等待读锁释放后才能真正获取到是吗?
2019-07-05 19:40
作者回复
是的
2019-07-07 09:57

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图28KingSwim
重复是学习最好的⽅式——没有之⼀。虽然好⼏个Java专栏都会讲到锁的问题。但是,每次看完都是只能懂⼀部分。但是,每看完⼀个专栏就清晰⼀点,只有不断的重复,才能掌握好⼀个知识点。感觉复习同⼀个也会有效果(部分专栏看过 2 边,感觉有点耽误时间,专栏太多)。但是还是不如看新的专栏,因为同时还有其他知识点的收获。现在对温故⽽知新的”故“有了新的理解。另外,⽼师的 lock 是我看过专栏⾥⾯讲得最清晰的。
2019-07-04 16:37
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图29张艳军
⽼师,读锁是⼀个共享锁,⽽写锁是⼀个独占锁,这个在⽂章中哪⼀部分有讲解呢?看了两遍不是很理解。
2019-07-03 13:21
作者回复
这⾥没有具体讲解共享锁和独占锁的实现,后续我在这⾥补充下
2019-07-04 10:35

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图30Geek_ebda96
⽼师,RRW锁的读锁在获取锁的时候如果没有写锁,直接就可以获取到锁,只不过获取锁的过程中要⽤到CAS操作,相⽐于st
ampedlock,这个的乐观锁操作其实没⽤到任何锁操作,try的过程只是判断有没有写锁,没有则把共享变量的值拷⻉到栈⾥⾯
,后⾯的validate操作,也是再判断有没有写锁,没有则继续操作,这样理解对吗?那思考题⾥的问题,是因为乐观锁的过程除了try和validate操作判断有没有写锁,实际更新共享变量的值过程中没有cas和锁的操作,乐观锁的过程中其他线程还是可以获取到写锁,没法操作结果⼀定正确

最后还有⼀个问题这两种锁的读锁的cas操作只是在保证获取锁的过程和更新锁状态的过程吧,加锁的过程本身是要把内存中共享变量的值更新到栈中,共享变量本身不⽤volatie修饰?
2019-06-28 08:11
作者回复
RRW的读也是有锁的,所以不需要volatie修饰。
2019-06-28 09:09

13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图31Geek_ebda96
⽼师好,请问⼀下为什么rrw获取锁的时候,state状态为0还是需要把当前线程先加⼊clh等待队列,不直接去cas更新状态获取锁,为了公平性吗,是根据参数设置的值判断的吗
2019-06-27 08:15
作者回复
这⾥纠正⼀下,应该要先去判断是否是公平锁,如果是,则进⼊到CLH队列中,否则直接CAS获取锁。
2019-06-28 12:02
13讲多线程之锁优化(中):深入了解Lock同步锁的优化方法 - 图32余冲
⽼师,你那个rrw获取写锁时的图,第⼀个判断及其后⾯的判断,直线的逻辑,应该是:是。否才加⼊clh队列。
2019-06-25 21:57
作者回复
没有错,如果state为0,则表示没有被其他线程占⽤锁资源,进⼊CAS获取锁;否则,则需要继续判断⾼低位状态。
2019-06-26 12:36