第五章 java中的锁

本章将介绍Java并发包中与锁相关的API和组件,以及这些API和组件的使用方式和实现细节

内容主要包括:

  • 使用
  • 实现

Lock接口

Lock和synchronized对比

Lock接口 synchronized
显式获取和释放锁 隐式获取和释放
锁的可操作性 锁的获取和释放固化
可中断的获取锁、超时获取锁等特性 便捷性,可扩展性不够好

如何使用lock? 伪代码如下:

Lock lock = new ReentrantLock();  
lock.lock();// 获取锁不能放在try块中
try {
    ....
} finally {
    lock.unlock();
}

Lock接口提供的synchronized关键字所不具备的主要特性如下:

Java中的锁_读锁

Lock接口的基本API

Java中的锁_读写锁_02

Lock接口的实现基本都是通过聚合了一个同步器的子类来完成线程访问控制的。

队列同步器

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

「jdk描述」:AbstractQueuedSynchronizer 提供一个框架,用于实现依赖先进先出(FIFO)等待队列的阻塞锁和相关同步器(信号量,事件等)。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,子类推荐被定义为自定义同步组件的「静态内部类」。

同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。锁是面向使用者的,同步器面向的是锁的实现者。锁和同步器很好地隔离了使用者和实现者所需关注的领域。

队列同步器的接口与示例

同步器的设计是基于「模板方法模式」的。

重写同步器指定的方法时,需要使用同步器提供的如下3个方法来访问或修改同步状态

  • getState():获取当前同步状态。
  • setState(int newState):设置当前同步状态。
  • compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。
同步器可重写的方法
方法名称 描述
protected boolean tryAcquire(int arg) 独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后进行CAS设置同步状态
protected boolean tryRelease(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态。
protected int tryAcquireShared(int arg) 共享式获取同步状态,返回大于等于0的值,表示获取成功,反之获取失败
protected boolean tryReleaseShared(int arg) 共享式释放同步状态
protected boolean isHeldExclusively() 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程独占。

实现自定义同步组件时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述如下表所示:

Java中的锁_读写锁_03

同步器提供的模板方法分为三类:

  • 独占式获取与释放同步状态;
  • 共享式获取与释放同步状态;
  • 查询同步队列中的等待线程情况。

???? 独占锁示例:Mutex

用户只需要实现tryAcquire、tryRelease、isHeldExclusively方法即可实现一个Sync内部类,其他的工作通过调用该静态类的模板方法就可以实现一个自定义同步器组件了。

队列同步器的实现分析

接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心「数据结构与模板方法」。

同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列尾部,同时会阻塞当前线程,当同步状态释放时,会把节点中的线程唤醒,使其再次尝试获取同步状态。

独占式同步状态获取与释放

通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出.

Java中的锁_读写锁_04
独占式同步状态获取流程

分析了独占式同步状态获取和释放过程后,适当做个总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

共享式同步状态获取与释放

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。写操作要求对资源的独占式访问,而读操作可以是共享式访问,两种不同的访问模式在同一时刻对文件或资源的访问情况,如图所示。

Java中的锁_Java 并发_05

与独占式一样,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态, 该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点。对于支持多个线程同时访问的并发组件, 和独占式的区别是在tryReleaseShared(int arg)方法中要保证线程安全,同步状态的值更新通过循环和CAS来保证。

独占式超时获取同步状态

通过调用同步器的doAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。(与传统同步操作不同的特性)

超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。

针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,nanosTimeout计算公式为:nanosTimeout-=now-lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒,反之,表示已经超时,返回false;

独占式超时获取同步状态的流程
Java中的锁_读写锁_06

自定义同步组件——TwinsLock

本节通过编写一个自定义同步组件来加深对同步器的理解。

设计一个同步工具:该工具在同一时刻,只允许至多两个线程同时访问,超过两个线程的访问将被阻塞,我们将这个同步工具命名为TwinsLock。

同步器作为一个桥梁,连接线程访问以及同步状态控制等底层技术与不同并发组件(比如Lock、CountDownLatch等)的接口语义。

重入锁

重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择

特性 描述
重进入 已经获取到锁的线程,能够再次调用lock()方法获取锁而不阻塞
公平性问题 如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。
关于公平锁和非公平锁 事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以TPS作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

1.实现可重进入

重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞, 实现方面需要解决两个问题:

  1. 线程再次去获取锁. 识别是否为当前占据锁的线程
  2. 线程最终释放. 对加锁次数进行递增计数, 对释放锁次数进行递减计数.

查看 ReentrantLock的nonfairTryAcquire方法 和 ReentrantLock的tryRelease方法.

2.公平和非公平的区别

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO

ReentrantLock 的tryAcquire方法 与nonfairTryAcquire(int acquires)比较,唯一不同的位置为判断条件多了hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获锁。

⌛ FairAndUnfairTest 测试使用公平锁和非公平锁的区别

公平性锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。

为什么会出现线程连续获取锁的情况呢?回顾nonfairTryAcquire(int acquires)方法,当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。

非公平性锁可能使线程“饥饿”,为什么它又被设定成默认的实现呢? 非公平锁的线程上下文切换开销更小.

实验表明: 公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

读写锁

锁(如Mutex和ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。

除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。

一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。Java并发包提供读写锁的实现是ReentrantReadWriteLock.

特性 说明
公平性选择 支持非公平和公平选择,默认非公平.
重进入 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁.
锁降级 遵循读取写锁, 获取读锁再释放写锁的次序, 写锁能够降级成为读锁.

读写锁的接口与示例

ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()方法和writeLock()方法;

ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法, 方法描述如下:

Java中的锁_等待状态_07

???? 读写锁的使用, Cache

读写锁的实现分析

接下来分析ReentrantReadWriteLock的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级.

读写状态的设计

读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。

如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图所示。

Java中的锁_Java 并发_08

读写锁状态的划分方式

写锁的获取与释放

写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态

???? ReentrantReadWriteLock的tryAcquire方法

读锁的获取与释放

读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。

锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

????️ 锁降级的示例: 当线程T对数据进行修改时, 先获取写锁, 然后获取读锁, 然后释放写锁, 然后释放读锁这个过程是必要的!在释放写锁后的过程中如果另一个线程对数据进行写操作,那么此时当前线程无法感知线程T的数据更新.

RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性.

LoadSupport工具

当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。LockSupport定义了一组的公共静态方法,这些方法提供了 最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具

LockSupport提供的阻塞和唤醒方法
方法名称 描述
park() 阻塞当前线程, 如果调用unpark()方法或线程被中断,才能返回.
parkNanos(long nanos) 阻塞当前线程不超过 nanos纳秒, 增加超时返回功能.
parkUtils(long deadline) 阻塞当前线程,直到deadline时间
unpark(Thread t) 唤醒处于阻塞状态的线程t
以下为java5以后
park(Object blocker) parkNanos(Object blocker,long nanos) parkUntil(Object blocker,long deadline) 用于实现阻塞当前线程的功能,其中参数blocker是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控。

Condition接口

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。

Condition接口提供了类似object的监视器方法, 与Lock配合可以实现等待通知模式,但两者在使用方式以及功能特性上还是有差别的.

以下是两者的对比:

对比项 Object Moniter Methods Condition
前置条件 获取对象的锁 调用Lock.lock()获取锁,调用Lock.newCondition()获取Condition对象
调用方式 直接调用 直接调用, 如condition.await()
等待队列个数 一个 多个
当前线程释放锁并进入等待状态 支持 支持
当前线程释放锁并进入等待状态,在等待状态不响应中断 不支持 支持
当前线程释放锁并进入超时等待状态 支持 支持
当前线程释放锁并进入等待状态到将来某个时间 不支持 支持
唤醒等待队列中的一个线程 支持 支持
唤醒等待队列中的全部线程 支持 支持

Condition接口与示例

Condition是依赖Lock对象的。

一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

Condition的(部分)方法以及描述
方法名称 描述
await() 当前线程进入等待状态直到被通知( signal)或中断,当前线程将进入运行状态且从 await()方法返回的情况,包括: 其他线程调用该 Condition的 signal()或 signalAll()方法,而当前线程被选中唤醒:1) 其他线程(调用 interrupt方法)中断当前线程; 2)如果当前等待线程从await()方法返回,那么表明该线程已经获取了 Condition对象所对应的锁.
awaitUninterruptibly() 当前线程进入等待状态直到被通知,从方法名称上可以看出该方法对中断不敏感
awaitNanos(long nanosTimeout) 当前线程进入等待状态直到被通知、中断或者超时。返回值表示剩余的时间,如 果在 manoslieout纳秒之前被唤醒,那么返回值就是( anostimeout-实际耗时)。 如果返回值是0或者负数,那么可以认定已经超时了
boolean awaitUntil(Date deadline) 当前线程进入等待状态直到被通知、中断或者到某个时间。如果没有到指定时间 就被通知,方法返回true,否则,表示到了指定时间,方法返回 false
void signal(); 唤醒一个等待在 Condition上的线程,该线程从等待方法返回前必须获得与Condition相关的锁
void signalAll() 唤醒所有等待在 Condition上的线程,能够从等待方法返回的线程必须获得与Condition相关联的锁

???? 有界队列, BoundedQueue

Condition的实现分析

ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键。

等待队列

等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态.

同步队列和等待队列都是用的AQS的静态内部类 AbstractQueuedSynchronizer.Node.

在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列, 对应关系如下图:

Java中的锁_等待队列_09
同步队列和等待队列

等待

调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。

如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中, 当然,节点会被重新构造.

???? ConditionObject的await方法

唤醒方式 结果
其他线程调用Condition.signal()方法唤醒 加入同步队列
对等待线程进行中断 抛出 InterruptedException

通知

调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中.

???? ConditionObject的signal方法

  1. 检查是否获取了锁
  2. 获取等待队列首节点;
  3. 将其移动到同步队列并使用LockSupport唤醒节点中的线程, 加入同步队列尾部.

注意: 被唤醒的线程通过调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。如果获得锁后将从先前调用的await()方法返回.

Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。

本章小结

本章介绍了Java并发包中与锁相关的API和组件,通过示例讲述了这些API和组件的使用方式以及需要注意的地方,并在此基础上详细地剖析了队列同步器、重入锁、读写锁以及Condition等API和组件的实现细节,只有理解这些API和组件的实现细节才能够更加准确地运用它们。