13 | 多线程之锁优化(中):深入了解Lock 同步锁的优化方法
背景:感觉还可以,做个记录
今天这讲我们继续来聊聊锁优化。上一讲我重点介绍了在
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 的实现,
代码如下:
2. 读写锁再优化之 StampedLock
RRW 被很好地应用在了读大于写的并发场景中,然而 RRW
在性能上还有可提升的空间。在读取很多、写入很少的情况
下,RRW 会使写入线程遭遇饥饿(Starvation)问题,也
就是说写入线程会因迟迟无法竞争到锁而一直处于等待状
态。
在 JDK1.8 中,Java 提供了 StampedLock 类解决了这个问
题。StampedLock 不是基于 AQS 实现的,但实现的原理
和 AQS 是一样的,都是基于队列和锁状态实现的。与
RRW 不一样的是,StampedLock 控制锁有三种模式: 写、
悲观读以及乐观读,并且 StampedLock 在获取锁时会返回
一个票据 stamp,获取的 stamp 除了在释放锁时需要校
验,在乐观读模式下,stamp 还会作为读取共享资源后的二
次校验,后面我会讲解 stamp 的工作原理。
总结
不管使用 Synchronized 同步锁还是 Lock 同步锁,只要存
在锁竞争就会产生线程阻塞,从而导致线程之间的频繁切
换,最终增加性能消耗。因此,如何降低锁竞争,就成为了
优化锁的关键。
在 Synchronized 同步锁中,我们了解了可以通过减小锁粒
度、减少锁占用时间来降低锁的竞争。在这一讲中,我们知
道可以利用 Lock 锁的灵活性,通过锁分离的方式来降低锁
竞争。
Lock 锁实现了读写锁分离来优化读大于写的场景,从普通
的 RRW 实现到读锁和写锁,到 StampedLock 实现了乐观
读锁、悲观读锁和写锁,都是为了降低锁的竞争,促使系统
的并发性能达到最佳
思考题
StampedLock 同 RRW 一样,都适用于读大于写操作的场
景,StampedLock 青出于蓝结果却不好说,毕竟 RRW 还在被广泛应用,就说明它还有 StampedLock 无法替代的优
势。你知道 StampedLock 没有被广泛应用的原因吗?或者
说它还存在哪些缺陷导致没有被广泛应用。
StampLock不支持重入,不支持条件变量,线程被中断
时可能导致CPU暴涨