本文旨在探讨通用的锁机制实现逻辑,以Java中常见的锁实现为例。
本文提到的锁,是指通过限制并发/并行访问所添加的安全措施,本质上是通过限制线程/进程同时更改数据或是读取数据与写入数据产生时序差从而造成数据问题。
特性列举
锁机制中,有一些常见特性:
可重入性。指同一线程/进程携带相同的标识可以反复多次加锁,每次加锁和释放锁对应的重入次数+1/-1;
读写锁/独享共享。是锁的不同运作模式,分为读写锁,读锁与写锁、写锁与写锁是互斥的,但多个线程/进程可以同时对一个逻辑添加读锁,独享共享是另一种叫法。
公平性。锁分为
公平锁和非 公平锁, 公平锁指锁释放和获取的顺序严格按照索取的顺序,非 公平锁则是等待锁的对象共同进行锁释放机会的争抢。在长租塞、争抢激烈的场景下,非 公平锁可能会造成一些很糟糕的影响,比如有些线程可能因为等待锁挂起/阻塞很久。 乐观/悲观。针对锁具体的行为可以分为乐观锁和悲观锁,悲观锁是广义普通的锁机制。乐观锁旨在无锁化的场景下,假定不存在冲突,写线程会将数据的检查和更新定义为一个原子化操作(CAS),低冲突场景下配合自旋,乐观锁代价和性能开销小很多;高频争抢的场景下则刚好相反,容易造成大范围阻塞。
核心设计(基于悲观锁)
此处让我们先来讨论悲观锁的核心设计思想,主要有几点前提:
共享变量,锁冲突的场景下,多个线程需要共享变量去判断当前锁的状态及能否获取到锁,例如Java中synchronized修饰的对象/class类/字符串、Lock对象、使用Redis分布式锁中的Redis key;
标识位,以共享变量为前提,记录当前的锁是否有被占用,部分锁逻辑还需要记录当前锁的状态(即不只有1和0,可能还会有类似synchronized的锁升级过程);
信息记录,以共享变量为前提,记录一些锁的配置(例如:当前锁的重入次数)和当前占有锁的线程标识;
阻塞队列(可选),锁如果要记录当前在等待锁的线程列表,就需要一个FIFO的队列实现;
定义锁冲突等待行为,当锁发生冲突时,应该有对应的逻辑,比如阻塞、自旋、挂起等待唤醒、直接返回。自旋行为能够使得等待线程及时反馈,相应地性能代价高一些;
暂且不讨论获取锁失败当次直接放弃的场景,在尝试获取锁的行为中,最常见的操作是:
1. 自旋,简单理解就是假定锁会很快释放,快速反复重新尝试获取锁,例如在下面的代码中,会循环compareAndSet逻辑直到获取锁:
private AtomicReference<Thread> cas = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); while (!cas.compareAndSet(null, current)) { //nothing } }
自旋操作的处理本身是不公平的,因为获取锁的请求在由争抢线程本身不断发出,Java的synchronized轻量级锁中使用自旋逻辑。这一逻辑使得线程本身不会进入WAIT状态。在cpu中衍生了一种自适应自旋的逻辑,含义是根据自旋次数适当的调整自旋时间,但是免于代码中的线程阻塞操作,避免因为长代码损耗性能。
2. 阻塞唤醒,基于阻塞唤醒的实现,一旦发现锁冲突将先行阻塞让出时间片,直到延迟一定时间或是被锁被释放唤醒之后继续执行操作,Java中的synchronized升级到重量级锁和Lock类便是使用这一逻辑。
阻塞唤醒开销小,能减少过度争抢反复自旋带来的cpu消耗,但这种异步化的行为也导致等待锁的线程获取锁的时间不及时,阻塞和唤醒的操作也有额外的开销,如果锁内逻辑很短、锁冲突不严重,使用阻塞唤醒模式反倒不是最佳选择。
加餐-synchronized锁的升级
偏向锁是一种特殊的无锁结构,它假定同步块不会出现冲突,锁一直由原线程获取和持有,因维护成本高,较高版本的Java(15)已经取消了偏向锁