Java并发学习笔记——第五章 Java中的锁
本章主要围绕Java并发包中与锁相关的API组件的两方面介绍:使用与实现
Lock接口
Java SE5 之后并发包中新增了Lock
接口,使用Lock
可以更灵活、显示地管理锁的获取和释放。
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock(); //保证释放锁
}
注:不要将获取锁的过程写在try块中,因为若获取(自定义锁的实现)锁时发生了异常,异常抛出的同时也会导致锁无故释放。
Lock
提供的synchronized
关键字不具备的特性:
-
尝试非阻塞地获取锁:当前线程尝试获取锁(
tryLock()
),若此时锁没有被其他线程获取到,则成功获取并持有锁。 -
能被中断地获取锁:与
synchronized
不同,Lock
获取到锁的线程能够响应中断。当线程中断时,异常将被抛出,锁将被释放。 - 超时获取锁:若在指定的截止时间前仍无法获取锁,则返回。
Lock
的API:
void lock():获取锁,当锁获得后,该方法会返回。
void lockInterruptibly() throws InterruptedException:可中断地获取锁,和
lock()
不同在于该方法会响应中断。-
boolean tryLock():尝试非阻塞获取锁,调用该方法会立即返回,若能获取则返回true;否则返回false。
Lock lock = new ReentrantLock(); if(lock.tryLock()) { try { //处理任务 } catch(Exception e) { } } finally { lock.unlock(); //保证释放锁 } } else { //若未能获得锁,则执行其他逻辑 }
-
boolean tryLock(long time, TimeUnit unit) throws InterruptedException:超时获取锁,以下三种情况会返回:
- 当前线程在截止时间内获得锁
- 当前线程在截止时间内被中断
- 截止时间结束仍未获得锁,返回false
void unlock():释放锁
Condition newCondition():获取等待通知组件,该组件只和当前锁绑定。当前线程只有获得了锁,才能调用该组件的
wait()
方法,调用wait()
后,当前线程将释放锁。
队列同步器
AbstractQueuedSynchronizer
简称同步器,用来构建锁或者其他同步组件的基础框架。
同步器使用一个int
成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
同步器是实现锁(或任意同步组件)的关键,锁的实现中聚合了同步器,利用同步器实现锁的语义。
锁与同步器的关系可理解为:
- 锁面向使用者,它定义了使用者与锁交互的接口,隐藏了实现细节。
- 同步器面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。
同步器提供的模板方法基本分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态以及查询同步队列中的等待线程情况。
实现一个自定义同步组件与自定义同步器的关系
实现一个自定义同步组件时,只需要在自定义同步组件内部建立一个静态内部的自定义同步器,对自定义同步器的部分可重写方法进行重写、调用同步器提供的模板方法。将自定义同步组件的操作代理到自定义同步器的重写方法或模板方法上即可。
书中P123举了实现一个独占锁的例子。
队列同步器的实现分析
同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)完成同步状态的管理。当前线程获得同步状态失败时,同步器会将其以及等待信息构造成一个节点(Node)并将节点加入同步队列的尾部,然后将该线程阻塞。当同步状态释放时,会把同步队列首节点中的线程唤醒,使其尝试再次获得同步状态。
同步器拥有两个指向同步队列的引用。一个指向头结点,一个指向尾结点。
为了保证加入队列的过程是线程安全的,同步器提供了一个基于CAS的添加尾结点的方法,需要传入当前线程“认为”的尾结点与当前节点,CAS成功后才将当前节点设置为尾结点。
头结点释放同步状态时,将唤醒后继节点,且后继节点在成功获取同步状态时将把自己设置为头结点。设置头结点是由获取同步状态成功的线程完成的,因此不需要CAS。
独占式同步状态获取与释放
线程获取同步状态的主要逻辑为:
- 调用同步器的
tryAcquire(int arg)
方法获取同步状态。 - 若获取同步状态失败,则构造节点并通过
addWaiter(Node node)
将该节点加入同步队列队尾。(自旋添加到队尾,使用CAS保证线程安全) - 调用
acquireQueued(Node node, int arg)
使该节点以自旋方式获取同步状态。
在自旋获取同步状态时,只有前驱结点为头节点的节点线程能尝试获得同步状态。由于头节点释放同步状态后会通知后继节点,以此维护FIFO原则。
线程释放同步状态主要逻辑为:
- 调用同步器的
release(int arg)
,该方法在释放同步状态后会使用LockSupport.unparkSuccessor(Node node)
唤醒后续节点。
共享式同步状态获取与释放
主要逻辑为:1-n个共享式访问资源时会阻塞独占式,1个独占式访问资源时会阻塞共享式和其他独占式。
共享式访问时主要通过CAS保证同步状态的线程安全。
详情参考下文读写锁的实现。
独占式超时获取同步状态
在独占式同步状态获取的第三步自旋中,若未获取到同步状态后重新计算输入的超时间隔(输入nanosTimeout
,用 nanosTimeout-= nowTime - lastTime
更新),当超时间隔小于等于0则表示已超时返回;若未超时,则让当前线程等待nanosTimeout
纳秒。之后再次获取同步状态。
重入锁
ReentrantLock
,它支持一个线程对资源重复加锁,还支持获取锁时的公平和非公平性选择。
公平锁没有非公平锁高效,但公平锁能减少“饥饿”发生的概率。
实现重进入
在组合自定义同步器的时候增加判断当前线程是否为获取锁的线程的逻辑,并通过一个计数器计数获取锁的线程的重入次数。
公平锁的实现
公平锁多了一个判断当前节点是否有前驱节点,即使用了同步队列维护线程间的时间顺序。
由于非公平锁情况下,刚释放锁的线程再次获取同步状态的概率很大,因此造成不同线程获取锁的切换次数更少,吞吐量更高。
读写锁
之前的ReentratLock
为排他锁,排他锁在同一时刻只允许一个线程进行访问。而读写锁同一时刻(一段时间内)允许多线程访问,但在写线程访问时,所有读线程和其他写线程均被阻塞。
读写锁维护了一个读锁和一个写锁,分离读锁和写锁有利于并发性的提升。当写锁被获取时,后续其他线程的读写操作都会被阻塞,写锁释放后,所有操作继续执行。编程方式上类似于等待通知机制。
一般情况下,在读多于写的场景下,读写锁性能都比排他锁好。Java并发包提供读写锁的实现是ReentrantReadWriteLock
。
读写锁特性:
- 支持选择是否公平性。
- 可重入。
- 锁降级:遵循获取写锁、获取读锁再释放写锁的次序时,写锁能降级成读锁
ReentrantReadWriteLock
提供的方法
readLock()
、writeLock()
:获取读写锁getReadLockCount()
:返回当前读锁被获取的次数(不等于获取读锁的线程数)。getReadHoldCount()
:返回当前线程获取读锁的次数。由ThreadLocal
实现。isWriteLocked()
:判断写锁是否被获取。getWriteHoldCount()
:返回当前写锁被获取的次数。
读写锁的实现
关于ReentrantReadWriteLock
的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放、锁降级。
读写状态的设计
读写锁设计建立于自定义同步器上,读写状态就是同步器的同步状态。同步器用一个整型变量维护同步状态,读写锁则需要利用这个整型变量维护多个线程和一个写线程的状态。
读写锁将该整型变量“按位切割使用”,高16位表示读,低16位表示写。且读写锁通过位运算判断读和写各自的状态。
写锁的获取与释放
写锁是一个可重入的排他锁。
两个情况当前线程无法获取写锁需要等待:
- 当前线程不是已经获取了锁的线程(写锁被其他线程获取)。
- 读锁已被获取。
- 读写锁需要保证写锁的操作对读锁可见。
读锁的获取与释放
读锁是一个可重入的共享锁。
当前线程仅在读写锁写状态不为0时需要等待。
当前线程获取读锁仅会更改读写锁的读状态(同步状态高16位),通过CAS保证多线程对同步状态修改的线程安全。
锁降级
指拥有写锁,再获取到读锁,随后释放写锁的过程。
public void processData() {
readLock.lock(); //获取读锁
if(!update) { //update为volatile的布尔类型。当数据更改时,update为false。
readLock.unlock(); //必须先释放读锁否则无法获得写锁
writeLock.lock(); //锁降级从获取到写锁开始
try {
if(!update) {
//准备数据的过程,略
update = true; //数据准备完成
}
readLock.lock(); //获取读锁,防止其他线程获得写锁
} finally {
writeLock.unlock(); //锁降级结束
}
}
try {
//使用数据
} finally {
readLock.unlock();
}
}
LockSupport工具类
LockSupport
类 定义了一组公共静态方法,提供了基本的线程阻塞和唤醒功能,该工具类也成为构建同步组件的基础工具。
Java 6 新增了三个方法,目的是为了能传入Lock
对象以让dump提供阻塞对象的信息。
LockSupport
提供的方法
-
park(Object blocker)
:阻塞当前线程,调用unpark(Thread thread)
或当前线程被中断,才从该处返回。 -
parkNanos(Object blocker, long nanos)
:阻塞当前线程,最长不超过nanos纳秒。 -
parkUntil(Object blocker, long deadline)
:阻塞当前线程,知道deadline时间。 -
unpark(Thread thread)
:唤醒thread。
Conditon接口
Object
对象拥有一组监视器方法如wait()
、notify()
等与synchronized
关键字配合,可实现等待/通知模式。
Condition
接口提供了类似Object
的监视器方法,与Lock
配合实现等待/通知模式,但两者在使用方式和功能特性上存在差别。
Object监视器方法与Condition接口对比
对比项 | Object Monitor Methods | Condition |
---|---|---|
前置条件 | 获取对象的锁 | 调用Lock.lock()获取锁,调用Lock.newCondition() 获取Condition对象 |
调用方式 | 直接调用 | 直接调用 |
等待队列个数 | 一个 | 多个 |
当前线程释放锁并进入等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态,在等待状态中不响应中断 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态到将来某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
Condition接口部分方法与示例
Condition
对象是由Lock
对象创建的。
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void exampleWait() throws InterruptedException {
lock.lock();
try {
condition.await();
} finally {
lock.unlock();
}
}
public void exampleSignal() throws InterruptedException {
lock.lock();
try {
condition.signal();
} finally {
lock.unlock(); //在释放锁前,调用exampleWait()的线程无法获得锁并进入运行状态
}
Condition
部分方法:
方法名称 | 描述 |
---|---|
void await() throws InterruptedException | 当前线程释放锁并在此等待,直到被通知或中断。 |
void awaitUninterruptibly() | 当前线程释放锁并进入等待状态直到被通知,对中断不敏感 |
long awaitNanos(long nanosTimeout) throws InterruptedException | 当前线程释放锁并进入等待状态直到被通知、中断或超时。返回值表示剩余时间(nanosTimeout - 实际用时),若返回0或负数则表示超时了。 |
boolean awaitUntil(Date deadline) throws InterruptedException | 当前线程释放锁并进入等待状态知道被通知、中断或到某个时间点。 |
void signal() | 唤醒一个等待在Condition 上的线程,该线程从等待方法返回前必须获得与Condition 相关联的锁。 |
void signalAll() | 唤醒所有等待在Condition 上的线程,能够从等待返回的线程必须获得与Condition 相关联的锁。 |
Condition的实现
ConditionObject
是同步器AbstractQueuedSynchronizer
的内部类,每个Condition
对象都包含着一个等待队列,该队列是实现等待/通知的关键。
等待队列
等待队列是一个FIFO队列,该队列的节点定义复用了队列同步器中同步队列的节点定义。因此,同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node
。
当一个线程调用了Condition.await()
时,该线程将释放锁、构造节点加入等待队列(尾插法)并进入等待状态。尾插时没有使用CAS保证线程安全,因为调用Condition.await()
时需要线程获取当前Condition
相关联的锁。因此线程安全是由锁来保证的。
在Object
监视器模型上,一个对象拥有一个同步队列和一个等待队列;而并发包的Lock
(准确地说是同步器)拥有一个同步队列和多个等待队列。
等待
从两个队列的角度看,当一个线程调用await()
方法时,相当于同步队列的头节点移动到Condition的等待方式中。(实际上是调用addConditionWaiter()
把当前线程构造成一个新的节点并加入等待队列)
通知
当前线程调用Condition.signal()
方法后,将会唤醒该Condition
对应的等待队列中等待时间最长(头节点)的线程(称为线程A)。通过调用同步器的enq(Node node)
方法将等待队列中的头节点安全移动到同步队列中,然后当前线程再使用LockSupport
唤醒线程A。A被唤醒后将从await()
中的等待循环中退出并调用同步器的acquireQueued()
加入到获取同步状态的竞争中。在线程A获取到同步状态后,将从await()
返回。
调用Condition.signalAll()
相当于对等待队列中的所有节点调用Condition.signal()
。
调用该方法前,当前线程必须获取了锁。
总结
通过第五章的学习,归纳一些特性的实现:
- 可重入:
- 通过同步器检测 已拥有同步资源线程 和 当前线程 是否相同。
- 用计数器记录重入次数。
- 公平锁的实现:
- 使用同步队列记录线程申请获取同步状态的时间顺序。
- 使用CAS保证线程节点加入同步队列的线程安全。
- 共享锁:
- 将同步器的同步状态分为高低位分配给读锁、写锁。
- 使用位运算记录同步状态。读锁需要使用CAS。
- 写锁阻塞其他锁;读锁阻塞写锁。
- 等待/通知模式:
- 等待队列。
- 等待队列与同步队列的协作。