极客时间已完结课程限时免费阅读

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

13 | 多线程之锁优化(中):深入了解Lock同步锁的优化方法-极客时间

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

讲述:李良

时长09:42大小8.87M

你好,我是刘超。
今天这讲我们继续来聊聊锁优化。上一讲我重点介绍了在 JVM 层实现的 Synchronized 同步锁的优化方法,除此之外,在 JDK1.5 之后,Java 还提供了 Lock 同步锁。那么它有什么优势呢?
相对于需要 JVM 隐式获取和释放锁的 Synchronized 同步锁,Lock 同步锁(以下简称 Lock 锁)需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性。Lock 锁的基本操作是通过乐观锁来实现的,但由于 Lock 锁也会在阻塞时被挂起,因此它依然属于悲观锁。我们可以通过一张图来简单对比下两个同步锁,了解下各自的特点:
从性能方面上来说,在并发量不高、竞争不激烈的情况下,Synchronized 同步锁由于具有分级锁的优势,性能上与 Lock 锁差不多;但在高负载、高并发的情况下,Synchronized 同步锁由于竞争激烈会升级到重量级锁,性能则没有 Lock 锁稳定。
我们可以通过一组简单的性能测试,直观地对比下两种锁的性能,结果见下方,代码可以在Github上下载查看。
通过以上数据,我们可以发现:Lock 锁的性能相对来说更加稳定。那它与上一讲的 Synchronized 同步锁相比,实现原理又是怎样的呢?

Lock 锁的实现原理

Lock 锁是基于 Java 实现的锁,Lock 是一个接口类,常用的实现类有 ReentrantLock、ReentrantReadWriteLock(RRW),它们都是依赖 AbstractQueuedSynchronizer(AQS)类实现的。
AQS 类结构中包含一个基于链表实现的等待队列(CLH 队列),用于存储所有阻塞的线程,AQS 中还有一个 state 变量,该变量对 ReentrantLock 来说表示加锁状态。
该队列的操作均通过 CAS 操作实现,我们可以通过一张图来看下整个获取锁的流程。

锁分离优化 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 队列进行阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最大次数,若超过,抛异常,反之更新同步状态。
一个线程尝试获取读锁时,同样会先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁,此时判断是否需要阻塞,如果需要阻塞,则进入 CLH 队列进行阻塞等待;如果不需要阻塞,则 CAS 更新同步状态为读状态。
如果 state 不等于 0,会判断同步状态低 16 位,如果存在写锁,则获取读锁失败,进入 CLH 阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,获取成功更新同步锁为读状态。
下面我们通过一个求平方的例子,来感受下 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 没有被广泛应用的原因吗?或者说它还存在哪些缺陷导致没有被广泛应用。
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 13

提建议

上一篇
12 | 多线程之锁优化(上):深入了解Synchronized同步锁的优化方法
下一篇
14 | 多线程之锁优化(下):使用乐观锁优化并行操作
unpreview
 写留言

精选留言(60)

  • Liam
    2019-06-18
    StampLock不支持重入,不支持条件变量,线程被中断时可能导致CPU暴涨

    作者回复: 回答很全面

    共 3 条评论
    65
  • KingSwim
    2019-07-04
    重复是学习最好的方式——没有之一。虽然好几个Java专栏都会讲到锁的问题。但是,每次看完都是只能懂一部分。但是,每看完一个专栏就清晰一点,只有不断的重复,才能掌握好一个知识点。感觉复习同一个也会有效果(部分专栏看过 2 边,感觉有点耽误时间,专栏太多)。但是还是不如看新的专栏,因为同时还有其他知识点的收获。现在对温故而知新的”故“有了新的理解。另外,老师的 lock 是我看过专栏里面讲得最清晰的。
    46
  • 我知道了嗯
    2019-06-20
    可重入锁是什么?另外什么场景下会使用到?

    作者回复: 可重入锁是指在同一个线程在前面方法中已获取锁了,再进入该线程的其他方法获取锁,此时不会因为之前获取锁而阻塞。 平时我们很少遇到这种情况,例如在A方法中使用了对象锁,B方法中也使用了该对象锁,平时一般都是分别调用A方法和B方法,而后面由于业务需求刚好需要在A方法中调用B方法,此时就会需要锁支持可重入性。

    共 2 条评论
    23
  • -W.LI-
    2019-06-18
    老师好!读写锁那个流程图看不太明白,没有写线程的时候,判断不是当前线程在读就会进入CLF阻塞等待。 问题1:不是可以并发读的嘛?按这图读线程也要阻塞等待的意思么? 问题二:CLF阻塞队列里是读写线程公用的么?队列里,读写交替出现。那不就没法并发读了么?

    作者回复: 第一个问题,这里有一个公平锁和非公平锁的情况,如果是公平锁,即使无锁竞争的情况下,也会进入阻塞队列中排队获取锁;否则,会立即CAS获取到读锁。 第二个问题,是公用的,这里同样涉及到了公平锁和非公平锁,读写线程对于程序来说都是一样的。如果是非公平锁,如果没有锁竞争的情况下CAS获取锁成功,是无需进入阻塞队列。如果是公平锁,都会进入阻塞队列。

    17
  • 密码123456
    2019-06-18
    为什么?因为锁不可重入?

    作者回复: 是的,StampedLock不支持可重入。如果在一些需要重入的代码中使用StampedLock,会导致死锁、饿死等情况出现。

    11
  • 英长
    2019-06-19
    希望老师能多结合实践讲讲应用场景
    10
  • 码农Kevin亮
    2020-03-18
    请问老师,在读写锁的场景中,我在读操作时为什么还要加锁?直接读不就可以了?如果担心数据不刷新,那在变量加volatile是不是就可以满足?请解惑

    作者回复: 在某些场景中,ReentrantLock这种读写锁能保证数据的强一致性。假设我们有两个对象x,y被volatile修饰,在A线程调用写入方法,x被写入及时更新到缓存中,而y没有,此时B线程刚好读取x,y的值,此时读取的x值是被修改过的,而y值还是原来的值,即x,y存在数据不一致的可能。

    共 4 条评论
    7
  • 王圣军
    2019-12-27
    老师这里说的公平锁和非公平锁让我想起两者是获取方式不同,非公平锁是首先就CAS来获取一次,成功就拿到锁,失败就放入队列;公平锁不会有这步操作,直接放入队列

    作者回复: 是的

    8
  • 张三丰
    2020-04-10
    获取读锁的流程图有问题吧,应该是判断写锁是否为当前线程,而不是判断读锁。

    作者回复: 对的,理解很到位,发现了问题

    5
  • -W.LI-
    2019-06-18
    StampedLock在写多读少的时候性能会很差吧

    作者回复: 是的,写多读少的性能没有优势。

    5
  • QQ怪
    2019-06-18
    老师这篇干货很多,看了2~3遍,大体理解了底层AQS锁原理,期待老师多多分享更多相关的文章
    4
  • 你好旅行者
    2019-06-18
    老师我有几个问题: 1.在ReentrantLock中,state这个变量,为0的时候表示当前的锁是没有被占用的。这个时候线程应该用CAS尝试修改state变量的值对锁进行抢占才对呀,为什么在您的图里当state=0的时候还需要判断是否为当前线程呢? 2.老师提到读写锁在读多写少的情况下会使得写线程遭遇饥饿问题,那我是不是只需要将锁设置为公平锁,这样先申请写锁的线程就可以先获得锁,从而避免饥饿问题呢? 3.StampedLock中引入了一个stamp版本对版本进行控制,那么对这个stamp变量进行写入的时候是否需要使用CAS操作?如果不是,那如何保证对stamp变量的读写是线程安全的呢? 谢谢老师!
    展开

    作者回复: 第一个问题,是老师笔误,搞错方向了,现在已更正。 第二个问题,如果读多写少的情况下,即使是公平锁,也是需要长时间等待,不是想获取时就能立即获取到锁。StampedLock如果是处于乐观读时,写锁是可以随时获取到锁。 第三个问题,StampedLock源码中存在大量compareAndSwapObject操作来保证原子性。

    4
  • 奋斗的小白鼠
    2019-11-28
    老师,lock锁中的线程阻塞进行的上下文切换会设计系统内核态和用户态的转换吗?啥时候会引起系统内核态和用户态转换成啊?.io流编程中会出现吗

    作者回复: lock锁阻塞不会带来进程间的上下文切换,IO流存在的,在09讲中讲到了

    3
  • 欧星星
    2019-06-21
    sync使用的是操作系统的Mutex Lock来实现的锁,Lock是使用线程等待来实现锁的,线程也会存在用户态内核态的切换,这样理解对吗?

    作者回复: 对的。进程上下文切换,是指用户态和内核态的来回切换。我们知道,如果一旦Synchronized锁资源竞争激烈,线程将会被阻塞,阻塞的线程将会从用户态调用内核态,尝试获取mutex,这个过程就是进程上下文切换。

    3
  • Wheat_Liu
    2020-08-27
    老师您好,RRW真的会导致写饥饿吗?您讲了获取读锁时会先判断需不需要阻塞。我看RRW的源码发现,在新来的线程尝试获取读锁的时候,会先判断阻塞队列中下一个准备获取锁的节点是否尝试获取写锁,如果下个节点尝试获取写锁,这个新来的线程是不会抢锁的
    2
  • 天天向上
    2020-08-11
    老师好,Lock的加锁和解锁,最终会通过调用LockSupport的方法进而调用 UNSAFE.park,或者UNSAFE.park方法,我想知道UNSAFE的这些方法会有内核态和用户态之间的切换吗
    共 1 条评论
    2
  • Gavin
    2020-07-25
    例子中,乐观读时,x和y不是volatile,读的是本地内存的,有可能不是最新值,这个是不是有问题呀?
    2
  • 又双叒叕是一年啊
    2019-11-10
    RRW 加写锁 和 读锁 都需要判断低16位? 这块写锁是不是应该判读的是高16位有没有读锁,从而判断有没有冲突?

    作者回复: 从源码分析,都会进行高低位的判断,获取写锁时,如果state!=0 and w==0,就可以判断了此锁r!=0,也就是判断了高16位有没有读锁了。

    2
  • 你好旅行者
    2019-06-19
    谢谢老师的回复!关于StampedLock,我的理解是乐观读的时候,线程把stamp的值读出来,通过与运算来判断当前是否存在写操作。这个过程是不涉及CAS操作的。可是如果有线程需要修改当前的资源,要加写锁,那么就需要使用CAS操作修改stamp的值。不知道这样理解是否准确。 此外,前排@-W.LI-同学提出的那个问题,并发读的时候也需要按照是否是公平锁进入CLH队列进行阻塞我还不是很明白,既然大家都是读操作,互相之间没有冲突,我每个线程都直接用CAS操作获取锁不就行了吗,为什么还要进队列阻塞等待呢?
    展开
    2
  • Bumblebee
    2022-05-22
    今日思考 StampedLock相比于ReentrantReadWriteLock的优点之一,在读多写少的场景下,写线程可能会长时间获取不到写锁,StampedLock利用乐观读(无锁化操作)当写线程能申请锁时能及时获取锁,不会像读写锁那样可能长时间获取不到锁。 我的意思大家应该明白了吧😂(说的不对的大家多多指正)
    展开
    1