背景:感觉还可以,做个记录
今天这讲我们继续来聊聊锁优化。上一讲我重点介绍了在
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暴涨