简介
本系列为《Java并发编程的艺术》读书笔记。在原本的内容的基础上加入了自己的理解和笔记,欢迎交流!
Chapter 5:Java中的锁
这章我觉得是本书的核心,他介绍了不是如何使用锁,而是如何设计锁,在设计锁的过程中,不断加深对锁的理解。
1. Lock锁
lock锁提供了和synchronized相似的锁功能,但是其使用必须是需要显式的获取和释放锁(finally块中)。这样做虽然和synchronized的隐式获取和释放比起来比较麻烦,但是更加灵活,程序员可以在任意位置获取和释放锁,可以应对更多复杂的场景。
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
1.1 Lock锁的特点:
- 会尝试非阻塞的获取锁(tryLock):使用tryLock方式获取锁,如果当前线程没有拿到锁,那么就暂时不获取锁,去执行下面的程序;
- 能被中断地获取锁:获取到锁的线程可以相应中断,同时抛出异常并释放锁;
- 可以超时获取锁:在指定的时间内获取锁,如果超时则返回。
1.2 常用API
2. 队列同步器(AbstractQueuedSynchronizer)
锁是为编程人员在多线程并发环境下确保线程间可以正确同步而产生的。而队列同步器是用来构建锁和其他同步组件的基础框架。
通常编写将静态内部类来继承同步器(我们常用的ReentrantLock和读写锁都是的)。
队列同步器,顾名思义就是一个队列,使用一个volatile变量state(int类型)来记录同步状态。对state的操作包括:
- getState
- setState
- comparedAndSetState:使用原子性的CAS操作来修改state值。
同步器的模式有两种:
- 独占式:一个时刻只能有一个线程获取到锁,其他线程没有争取到锁会被放入同步队列;
- **共享式:**共享锁最典型的就是读锁。读可以并发,即多个线程同时读。
2.1 自定义同步器需要重写的方法
主要分成独占式和共享式:
2.2 AQS提供的模板方法
主要分成三类:
- 独占式获取和释放同步状态;
- 共享式获取和释放同步状态;
- 查询同步队列中等待线程。
利用AQS实现一个简单的锁:
public class Mutex implements Lock {
private final Sync sync = new Sync();
//定义了一个静态内部类
private static class Sync extends AbstractQueuedSynchronizer {
//检测是否队列被占用
protected boolean isHeldExclusively() {
return super.getState() == 1;
}
@Override
protected boolean tryAcquire(int arg) {
// 使用原子性的操作来修改state
if (super.compareAndSetState(0,1)) {
super.setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
if (super.getState() == 0) throw new IllegalMonitorStateException();
super.setState(0);
setExclusiveOwnerThread(null);
return true;
}
public Condition getCondition() {
return new ConditionObject();
}
}
@Override
public void lock() {
sync.acquire(1);
}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
public void unlock() {
sync.tryRelease(1);
}
@Override
public Condition newCondition() {
return sync.getCondition();
}
}
3. AQS原理分析
3.1 同步队列
同步队列是一个FIFO的数据结构。当一个线程获取锁失败时,当前线程和其等待的状态信息会被封装放入Node中。
在AQS的源码中定义了一个静态内部类Node,是一个双端链表的结构。本书中针对了几个关键的变量进行了诠释:
3.2 独占模式下的数据结构和模板方法
理解独占模式下的工作原理是理解共享模式的基础。
在获取同步状态时,同步器维护一个同步队列,获取同步状态失败的线程都会被加入到队列中并在队列中进行自旋(死循环)。
移出队列 (或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步 器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。
1. 获取同步状态
节点首先要做的事情就是获取同步状态:acquire(int arg)
:
// 注意,tryAcquire是继承AQS的类(锁)应该覆盖的方法,如果不覆盖这个方法就无法正常使用AQS
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
public final void acquire(int arg) {
// tryAcquire尝试获取同步状态
//如果获取不到,那么就调用acquireQueued
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
2. 加入同步队列
当无法获取到同步状态时,就会被加入队列,此时需要构造Node对象和加入队列:addWaiter(Node mode)
和enq(final Node node)
。
// addWaiter会构造出一个Node,并设置模式为Node.EXCLUSIVE
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 第一次首先尝试添加到队列尾部
Node pred = tail;
if (pred != null) {
node.prev = pred;
// 使用CAS的方式来修改尾结点
// 因为Node本质是一个LinkedList,不是线程安全的
// 当多个线程同时获取尾结点并尝试修改尾结点,会导致错误
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果获取不到,那么就用enq进行自旋式的尝试加入队列
enq(node);
return node;
}
private Node enq(final Node node) {
// 自选式尝试加入同步队列
for (;;) {
Node t = tail; // op0
if (t == null) {
// 如果队列是空的,那么就尝试CAS的方式来设置头
if (compareAndSetHead(new Node()))
// 如果头部设置成功,那么下一次将走else分支
tail = head;
} else {
// 这里和addWaiter里面操作一致,都是尝试将自身加入到队列
node.prev = t; // op1
if (compareAndSetTail(t, node)) {
// op2
t.next = node; // op3
return t;
}
}
}
}
3. 移除队列
每个在队列中的node本质上都是在不断地自旋,即不断审查自己是否可以获取到同步状态当某个结点变成了头结点。
一旦某个节点成为了头结点,那么他就可能成为获取同步状态的节点,head节点只有在释放了同步状态之后,才会从AQS中移除:acquireQueued(final Node node, int arg)
。
// 需要继承AQS的类重写该方法
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 获取当前node的前驱节点
final Node p = node.predecessor();
// 如果p的前驱是一个头结点,即当前Node为N1
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
4. 补充
为什么要这样的方式添加到队列中
2. 共享模式下的数据结构和模板方法
共享锁和独占锁的区别在于,同一时刻是否有多个线程可以持有共享状态。
其实本质上都是修改state
,共享模式下的判断阻塞的条件的state超过了某个阈值而已。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 获取前驱节点
final Node p = node.predecessor();
if (p == head) {
// 尝试共享地获取同步状态,应该继承AQS的类重写该方法
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted) selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed) cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
// 释放锁
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
3. 自定义锁
自定义锁:我们只需要关注如何变化同步状态即可,其他关于队列的操作,由AQS完成。
定义一个容量为2的共享锁:TwinsLock
public class TwinsLock implements Lock {
private final Sync sync = new Sync(2);
static class Sync extends AbstractQueuedSynchronizer {
public Sync(int threadNum) {
if (threadNum <= 0) {
throw new IllegalArgumentException("参数异常,共享线程数应该是一个正数");
}
setState(threadNum);
}
@Override
protected int tryAcquireShared(int arg) {
for (;;) {
int curStatus = getState();
int newStatus = curStatus - arg;
if (newStatus < 0 || compareAndSetState(curStatus, newStatus)) {
return newStatus;
}
}
}
@Override
protected boolean tryReleaseShared(int arg) {
for (;;) {
int curStatus = getState();
int newStatus = curStatus + arg;
if (compareAndSetState(curStatus, newStatus)) {
return true;
}
}
}
public Condition getCondition() {
return new AbstractQueuedSynchronizer.ConditionObject();
}
}
@Override
public void lock() {
sync.acquireShared(1); }
@Override
public boolean tryLock() {
return sync.tryAcquireShared(1) > 0;}
@Override
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(time)); }
@Override
public void unlock() {
sync.releaseShared(1); }
@Override
public Condition newCondition() {
return sync.getCondition(); }
}
测试类:
public class Worker2 implements Runnable{
private static final Lock lock = new TwinsLock();
@Override
public void run() {
while (true) {
lock.lock();
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("延时错误");
} finally {
lock.unlock();
}
}
}
}
// 测试类
public classTest {
public static void main(String[] args) {
for(inti = 0; i < 10; i++) {
Worker2 w =newWorker2();
Thread thread =newThread(w);
thread.setDaemon(true);
thread.start();
}
for(inti = 0; i < 10; i++) {
try{
Thread.sleep(1000);
System.out.println();
}catch(InterruptedException e) {
System.out.println("延时错误");
}
}
}
}
4. 排它锁——可重入锁
可重入的意思是:某个线程在获取到了一个锁之后,再次获取到这个锁时不会阻塞,而是获取成功(防止死锁)。
ReentrantLock同时支持公平式和非公平式(默认)的访问。
公平和非公平的概念之前提到过,关于到底要不要使用公平锁,可以有以下考虑:
- 公平锁解决了线程饥饿问题,等待时间长的线程会优先执行;
- 这种公平机制带来了效率上的降低,书中举的例子表明:公平锁的无效操作(判断和线程切换)远远大于非公平锁,在使用公平锁时要考虑这种牺牲是否值得。
ok,回到ReentrantLock,他实现了Lock接口,那么它是一个需要显示使用的锁,其次在其内部也定义了一个继承了AQS的静态内部类。
4.1 实现可重入
实现可重入从代码的角度来说要解决:
- 如何判断是持有锁的线程再次获取到锁;
- 如何修改state;
- 如何释放锁。
以非公平锁的TryAcquire方法(继承AQS需要自己覆盖的方法)为例:
非公平锁的释放:
4.2 公平锁
理解非公平锁是理解公平锁的关键,在非公平锁的代码基础上,实现公平锁即在锁获取的时候判断自己是否是head指针指向的节点对应的线程。
5. 读写锁ReentrantReadWriteLock
读写锁的特点是:
- 允许多个线程同时读,此时会阻塞写操作,写操作必须等待所有读线程执行释放完锁才可以执行;
- 当写线程获取到时间片,会阻塞所有线程。
可想而知ReentrantReadWriteLock在内部实现了两个锁,ReadLock和WriteLock
,在读写时分别获取对应的锁即可。
5.1 读写状态的设计
锁的状态依赖于AQS中的state变量,由于对state的操作应该是原子性,但是读锁和写锁确实是两个不同的需求,所以我们需要在一个变量上维护多种状态,就一定需要按位切割使用这个变量。
读写锁将变量切分成了两个部分,高16位表示读,低16位表示写,通过使用掩码的方式来获取读写状态。
当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16),也就是 S+0x00010000。
5.2 写锁的获取和释放
写锁是一个支持重进入的排它锁。
1. 获取锁
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对 后续读写线程可见。
5.3 读锁的获取和释放
读锁是一个支持重进入的共享锁。在了解了写锁的获取后,不难猜出,在ReentrantLock的基础上,读锁的获取需要判读当前是否有写锁。
5.4 锁降级
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
这个过程是为了保持数据的修改对当前线程的可见性:在持有读锁的情况下,释放了写锁之后别的线程可以读到当前线程的写操作,而此时至少有一个线程还持有读锁(当前线程),别的线程无法修改数据。
RentrantReadWriteLock
不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了 数据,则其更新对其他获取到读锁的线程是不可见的。
6. Condition接口
之前提到了Monitor方法
每个Object都有,所以每个对象都带有wait(), notify(), notifyAll()
方法。我们只需要new Object(),将其作为锁对象即可。
那么Condition可以理解为在此基础再次封装了一下,从而可以获取更加复杂的功能。
通过调用lock的方法来获取一个条件:
Condition condition = lock.newCondition();
常见的API有:
6.1 实现分析
这和我们之前提到的等待队列和同步队列有很大关系。
- 一个锁只有一个唯一的同步队列;
- 使用Condition的方式等待,会为每个等待对象创建一个等待队列;而使用监视器的方式,一个同步队列只有一个等待队列。
- 多个等待队列与一个同步队列关联,每次从等待队列队头中移除后放入同步队列队尾;
Condition的signalAll()
方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。