并发编程——同步器AQS
0.1. 内容概要
0.2. 学习目标
- 理解AQS的概念和原理
- 能够描述出AQS的工作流程和工作原理
- 能够自定义同步器并正确使用
- 能够描述出常用同步器的底层实现及彼此的区别
- 理解AQS的数据结构
- 深入理解Lock框架,能够熟练使用Lock框架的常用类
1. AQS的概念和原理
1.0. Java并发核心组件
1.1. AQS的概念
AQS(AbstractQueuedSynchronizer),字面意思:抽象队列同步器。
- abstract:抽象的,要使用这个类,需要创建它的子类对象;
- queued:队列,说明该类是基于队列这样的数据结构来实现的;
- synchronizer:同步器,即用于实现线程同步的对象。
AQS是Java并发包中的一个框架,用于实现阻塞锁和与之相关的依赖于先进先出(FIFO,First Input First Output)等待队列的同步器(比如Semaphore、CountDownLatch)。说白了,就是Java中除了synchronized关键字之外另一种实现锁的方式。JSR166基于AQS类建立了一个框架,这个框架为构造同步器提供一种通用的实现机制,并且被java.util.concurrent包中大部分类使用,同时,我们也可以用它来定义自己的同步器,这就是作者Doug Lea大神的初衷。我们会对比两种锁机制的实现方式来学习AQS,同时也让你加深对synchronized关键字以及锁机制的认识。
1.2. AQS的工作原理
在JDK5之前,我们使用synchronized关键字实现同步,由于在语言层面提供了内部的支持,我们通常将这种锁称为内部对象锁。在JDK5之后,我们有了新的选择:同步器AQS。要注意的是,AQS并不是作为内部对象锁的替代,而是当内部锁被证明受到局限时,提供可选择的高级特性。
此类默认支持两种模式:
-
排他模式(Exclusive mode):也叫独占模式,相当于互斥锁。当一个线程以独占模式成功获取锁,其它线程获取锁的尝试都将失败,就像synchronized那样。
-
共享模式(Shared mode):多个线程可同时获取锁,用于控制一定量的线程并发执行。设计者建议共享模式下的同步状态支持0,小于0和大于0三种情况,以便在某种情况下和独占模式兼容。在此模式下,同步状态大于、等于0都代表获取锁成功。
接下来,我们回顾并对比synchronized关键字实现同步的流程,来分析一下AQS的工作原理、数据结构和它的设计模式,然后尝试自己定义一个同步器。
1.2.1. 对象锁的工作流程
先花一点时间来回顾下synchronized关键字实现同步的工作流程。
前面提到过,使用synchronized关键字实现同步,我们使用对象(类锁也是对象锁)作为监视器,而锁的本质是内存中对象头部的一部分数据。线程能否通过锁对象访问监视区域,取决于这部分数据所代表的锁的状态,当状态发生改变,别的线程无法通过锁,直到锁被完全释放。同样的,使用AQS来实现锁的思路也大体相同,区别在于,synchronized关键字是一种Java原生语法,而AQS只是普通的Java类。
锁机制的核心操作是:获取锁、释放锁。来看下面这段代码:
public void method() {
synchronized (this) {
System.out.println("synchronized block");
}
}
通过javap命令反编译之后,得到的结果是是这样的:
public void method();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 进入监视器
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String synchronized block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit // 退出监视器
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // 程序发生异常,确保退出监视器
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
请重点关注序号为3、13和19的三行指令。synchronized关键字基于monitorenter和monitorexit两个指令来实现锁的获取和释放过程。还记得下面这张图吗:
- 进入并持有监视器:线程到达监视区域,并进入监视器处于入口区。它会尝试获取锁,由于现在没有其它线程持有监视器,所以该线程会立即持有监视器,并执行监视区域中的代码。线程进入监视区域,执行的就是JVM的
monitorenter指令。 - 其它线程阻塞:新的线程到达监视区域后,也会尝试获取监视器,但它此时必须在入口区等待,因为第一个线程还在执行监视区域的代码。后续的线程也会在入口区等待(准确地说是阻塞状态),直到该监视器当前的持有者(第一个线程)完全释放。
- 关于等待区和退出:监视器的持有者,即活动线程会通过两种途径释放监视器:完成监视区域的代码或者执行一个等待命令。如果它执行完监视区域,会从最右边的出口处释放并退出监视器,进入监视器的JVM指令是
monitorenter,退出的指令是monitorexit(第二次退出的含义:在程序发生异常时也要确保退出监视器)。如果活动线程执行了等待(wait)命令,它就会释放监视器并进入等待区(wait set),注意,此时它并没有退出监视区域。 - 条件对象:synchronized关键字有个内置的条件对象,它是有JVM原生支持的,我们不需要/也不能对它进行操作,但是你要知道,它确实是存在的。
- 同步状态与可重入性:对象锁是可重入的,持有该监视器的线程每进入一次由该监视器锁保护的同步块/方法,锁的状态(计数器)就会+1,同样的,每退出一次,锁的状态就会-1,直到锁状态变成默认状态0,锁被完全释放,该线程就退出了监视器。
使用synchronized关键字实现同步,Java程序员不需要自己手动加锁、释放锁,也不需要关心同步状态被加/减了多少次,因为对象锁是在JVM内部使用的,这一切操作都在JVM内部完成——被JVM指令集支持。我们只需要编写同步语句或者同步方法来保护一个代码片段,程序运行时,JVM每一次进入监视区域都会自动锁上对象或者类。
1.2.2. AQS的工作流程
同步器AQS的工作流程与内部对象锁大致相同,它的核心操作也是获取锁、释放锁,但是现在,需要我们自己操作线程的同步状态了。为了实现与synchronized关键字同样的功能,需要考虑三个基本组件的相互协作:
- 同步状态的原子性管理;
- 锁的获取与释放(线程的阻塞与激活);
- 入口区和等待区线程的管理——队列;
接下来梳理一下AQS类的结构,摸清楚这些组件在独占模式和共享模式下分别是怎么实现的。
-
同步状态(state属性):
/** * The synchronization state. */ private volatile int state; protected final int getState() { return state; } protected final void setState(int newState) { state = newState; } protected final boolean compareAndSetState(int expect, int update) { return STATE.compareAndSet(this, expect, update); }在AQS类中定义了
private volatile修饰的int型属性state,代表同步状态,加锁与释放锁的操作,本质上是操作state的结果,这与内部对象锁的实现如出一辙。为了保证同步,对state的操作必须是原子的,而且该属性不能被继承,所有同步机制的实现均依赖于对该变量的原子操作。volatile和CAS操作确保了同步状态的原子性管理。 -
获取锁(acquire方法):
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } /** * 尝试获取独占锁,需要子类重写 */ protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } /** * 获取独占锁,支持响应线程中断 */ public final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (!tryAcquire(arg)) doAcquireInterruptibly(arg); } /** * 执行获取独占锁之后的工作 * 1.将线程添加到同步队列,并标记为独占模式 */ private Node addWaiter(Node mode) { Node node = new Node(mode); for (;;) { Node oldTail = tail; if (oldTail != null) { node.setPrevRelaxed(oldTail); if (compareAndSetTail(oldTail, node)) { oldTail.next = node; return node; } } else { initializeSyncQueue(); } } } /** * 执行获取独占锁之后的工作 * 2.阻塞添加到队列中的线程,并在该线程被激活时尝试获取锁 */ final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC return interrupted; } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { cancelAcquire(node); if (interrupted) selfInterrupt(); throw t; } }acquire方法用于以独占模式获取锁,如果获取失败,则将此线程添加到队列(如果不存在则创建)并阻塞此线程——底层调用工具类java.util.concurrent.locks.LockSupport的静态方法park()来实现。 此方法是独占模式下获取锁(state)的顶层入口,它的调用流程如下:- tryAcquire:尝试获取共享资源(state),获取成功后,线程就会进入临界区执行相关的代码并导致acquire方法直接返回。该方法的默认实现只是单纯的抛出
UnsupportedOperationException异常,设计者这样做的目的,就是要让我们自己去重写该方法,并定义获取共享资源的具体算法。 - addWaiter:尝试获取共享资源失败,才会执行这个方法。它的作用是将此线程加入同步队列(尾部),并标记为“独占模式”。
- acquireQueued:线程进入等待队列之后,会执行此方法。该方法的主要作用就是调用
LockSupport.park()方法阻塞当前线程。为了提高效率,线程进入阻塞队列之后,会检查前一个节点是否为头节点,若是,则再进行一次尝试获取锁,一旦成功则直接返回。当阻塞的线程被激活(unpark),会再次尝试获取资源,获取成功则返回,如果失败,则以自旋的方式执行上述操作,直到成功后返回。在此过程中如果发生异常,则取消获取共享资源的动作——这与内置锁是不同的;如果该线程接收到中断请求,并不会立即响应,而是在获取锁成功之后,将中断状态返回,再由acquire方法响应中断。如果想要在线程获取锁的过程中支持响应中断,可以调用acquire的兄弟方法:acquireInterruptibly,这是与内置锁另一个不同之处。
- tryAcquire:尝试获取共享资源(state),获取成功后,线程就会进入临界区执行相关的代码并导致acquire方法直接返回。该方法的默认实现只是单纯的抛出
-
释放锁(release方法):
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } /** * 尝试释放独占锁,需要子类重写 */ protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }release方法用于释放独占模式 的同步锁,底层调用工具类java.util.concurrent.locks.LockSupport的静态方法unpark()来实现,如果释放失败,则直接返回。此方法是独占模式下释放共享资源的顶层入口。类似的,此方法中调用了tryRelease并根据其返回值来判断是否完成了释放锁的操作——一个默认实现也是抛出UnsupportedOperationException异常的方法,目的也是为了让我们自己去重写。在独占模式下,线程释放资源之前,必定已经预先拿到了资源,所以重写的方法中只需要减掉相应的资源量即可,不需要过多考虑线程安全问题。当前线程释放完成后,会通过
unparkSuccessor方法激活队列中等待的下一个线程。一般被激活的线程就是当前线程的“next”节点,但如果该线程由于超时或者被中断而已经被激活,则(从队列尾部倒序)查找距离当前线程最近的可激活线程,用LockSupport.unpark()方法激活它。private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) node.compareAndSetWaitStatus(ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node p = tail; p != node && p != null; p = p.prev) if (p.waitStatus <= 0) s = p; } if (s != null) LockSupport.unpark(s.thread); } -
共享模式获取锁、释放锁的顶层方法命名规则和实现逻辑与独占模式几乎相同,请看下面的方法列表:
/** * 获取共享锁,共享模式获取锁的顶层入口 */ public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); } /** * 获取共享锁,支持响应中断 */ public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); } /** * 释放共享锁,共享模式释放锁的顶层入口 */ public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } /** * 尝试获取共享锁,需要子类重写 */ protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } /** * 尝试释放共享锁,需要子类重写 */ protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); } /** * 执行获取共享锁之后的工作 * 1.将线程添加到同步队列,并标记为共享模式 * 2.阻塞此线程,并在它被激活时尝试获取锁 */ private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); boolean interrupted = false; try { for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; // help GC return; } } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { cancelAcquire(node); throw t; } finally { if (interrupted) selfInterrupt(); } }相对于独占模式,共享模式获取锁、释放锁的基本流程没有任何变化。对锁的获取操作都应该被子类重写:独占模式:tryAcquire方法,共享模式:tryAcquireShared方法;同样的,释放锁的具体操作也是在子类中重新定义:独占模式:tryRelease方法,共享模式:tryReleaseShared方法。
不同的地方是,获取锁操作,在独占模式下,队列中的线程获取锁成功之后,将当前节点设置为头结点,仅仅是将节点对象关联的前后节点重新赋值:
private void setHead(Node node) { head = node; node.thread = null; node.prev = null; }而共享模式下,第一个线程获取锁后,如果共享资源仍然可以使用(state >= 0),则会唤醒当前节点的后续节点——多个线程是可以同时获取锁。所以,队列中的线程重新被激活之后,除了设置当前节点为头节点,还 需要/可能需要 进行后续节点的唤醒:
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(); // 继续唤醒后续节点 } }释放锁操作,共享模式下,释放掉共享资源(state)之后,唤醒后续节点。独占模式下的
tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程(可重入性);而共享模式下的releaseShared 则没有这种要求,共享模式的实质是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点:private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; if (ws == Node.SIGNAL) { if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0)) continue; // loop to recheck cases unparkSuccessor(h); } else if (ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)) continue; // loop on failed CAS } if (h == head) // loop if head changed break; } } -
关于等待队列
当线程进入临界区,发现必须满足某个/些 条件才能继续,则该线程将在该条件对象上等待,并进入等待区(wait set)。为什么需要Condition对象呢?这是因为在多线程并发的环境中,我们能确定哪个线程先执行,哪个后执行吗?不能。通过设置Condition对象让进入临界区却不满足条件的线程等待,并在条件满足时继续执行,这样可以确保程序以设计的顺序执行。这就是条件对象的本质。一个锁可以管理多个条件对象,一个条件对象上可能会有多个线程处于等待状态。
AQS类中定义了一个ConditionObject类,它实现了
java.util.concurrent.locks.Condition接口,并提供如await、signal和signalAll操作,还扩展了带有超时、检测和监控的方法。ConditionObject类有效地将条件与其它同步操作结合到了一起。这里要注意,当且仅当一个线程持有锁且要操作的条件对象属于该锁时,条件操作才是合法的。这样,一个条件对象(ConditionObject)关联到一个锁对象(同步器的实例)上就表现出跟synchronized对象锁一样的行为了。 -
AQS的工作流程
2. AQS的数据结构
AQS使用队列来管理入口区和等待区的线程:
入口区:同步队列
等待区:等待队列
两个队列的本质相同,它们都是先进先出的链表结构,而且使用的是同一个内部类Node的实例最为节点。不同的是,同步队列是一个双向队列,而等待队列是一个单向队列。
2.1. AQS的节点和队列
2.1.1. AQS的节点
AQS使用Node内部类作为节点,封装线程及相关同步信息,从而构成一个链式结构的队列。
同步队列使用:prev、next两个指针分别指向前一个、后一个节点,使用waitStatus表示节点的同步状态,同时封装了当前thread对象;
-
volatile Tread thread 线程对象
-
volatile Node next 下一个节点
-
volatile Node prev 前一个节点
等待队列使用:nextWaiter一个指针指向后一个节点,使用waitStatus表示节点的同步状态,同时封装了当前thread对象;
-
volatile Node nextWaiter 等待队列中的下一个节点
-
volatile int waitStatus **:**节点等待状态,默认0。当前节点的值表示后续节点的状态。
CANCELLED = 1 取消状态,唯一大于0的值,当线程等待超时、发生异常或中断,节点会变成取消状态
SIGNAL = -1 一般正常的节点状态,代表后一个节点需要被唤醒
CONDITION = -2 在条件对象上等待的节点处于此状态,与SIGNAL唯一的区别就是数值不同,从而表示处于同步队列还是条件队列
PROPAGATE = -3 传播,共享模式的节点处于此状态,表示当前节点的行为会向后续节点传播,即后续节点与当前节点行为相同。事实上,很少/很短时间有节点会处于此状态。
2.1.2. AQS的同步队列
获取锁失败的线程,被封装成Node节点加入同步队列的尾部。同步队列有个头指示器(head)和尾指示器(tail)分别指向首节点和尾节点。值得注意的是,头节点的状态为-1,即代表后续节点需要被唤醒。头结点(head)中并未封装线程实例,而且,前一个节点的状态其实代表的时候后一个节点的行为,而最后一个节点的状态,是默认值0。
2.1.2. AQS的等待(条件)队列
获取锁成功后,线程进入监视区域,但是如果线程不满足某些条件,就不得不再次进行等待。不满足条件对象的线程会被封装成节点,进入等待状态并加入等待队列的尾部。等待队列有个首节点指示器firstWaiter和尾节点指示器lastWaiter,与同步队列不同的是,firstWaiter指向的首节点是封装了线程实例的,同步队列的首节点是一个标志位,是一个具有特殊功能的火车头,而等待队列只需要封装线程和它的同步信息即可。
2.2. 同步队列节点的入队和出队
2.2.1. 同步队列节点的入队
2.2.2. 同步队列节点的出队
2.3. 等待队列节点的入队和出队
2.3.1. 等待队列节点的入队
2.3.2. 等待队列节点的出队
值得注意的一点,等待队列的节点出队之后,并不是直接将线程从节点中移除,因为条件对象满足之后,就一定可以立即执行吗?很明显,答案是否定的,线程必须再次检测并尝试获取锁,才能确定资源是否还存在。所以,等待队列的节点移出之后,被转移到了同步队列,直到下次排队到当前线程被唤醒。
2.4. 共享模式下节点的入队与出队
2.5. 关于节点的取消状态
当线程等待超时、被中断或者发生异常时,会执行取消获取锁的方法:cancelAcquire,并且将节点状态置为1。
-
cancelAcquire(Node node):取消获取锁
node.thread = null;
node.waitStatus = Node.CANCELLED;
node == tail,node脱钩;
node为中间节点,将前驱的next指向后继节点
否则,唤醒后继节点
-
Ø中断
①acquireQueued(Node node, int arg)
②doAcquireShared(int arg)
③doAcquireInterruptibly(int arg)
④doAcquireSharedInterruptibly(int arg)
-
超时
①doAcquireNanos(int arg, long nanosTimeout)
②doAcquireSharedNanos(int arg, long nanosTimeout)
3. AQS的使用方式
3.1. AQS的设计模式——模板方法
-
概述:定义一个算法的骨架,而将一些步骤延迟到子类中实现
-
好处:子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤
-
适用性
①一次性实现算法不变的部分,将可变的部分交给子类来实现
②将子类公共部分提取出来以避免代码重复,将代码不同之处分离为新的操作,最后,用一个调用这些新
操作的模板方法替换这些不同的代码
③控制子类扩展
/**
* 抽象父类:模板类
* 实现一个模板方法,定义算法的骨架
*
* 好处:
* 1.一次性实现算法不变的部分,将可变的部分交给子类来实现
* 2.将子类公共的部分提取出来,避免代码重复,将代码不同之处分离成新的操作
* 最后,用一个调用这些新操作的模板方法来替换这些不同的代码
* 3.控制子类扩展
*/
public abstract class Template {
/**
* 模板方法,执行数据更新操作,并打印相关信息
*/
public void update() {
System.out.println("开始打印");
for (int i = 0; i < 10; i++) {
print();
}
System.out.println("打印结束");
}
/**
* 钩子方法
*/
public abstract void print();
}
/**
* 具体子类,实现算法的一部分
*/
public class TemplateConcrete extends Template {
@Override
public void print() {
System.out.println("在控制台打印信息");
}
}
public class Test {
public static void main(String[] args) {
Template temp = new TemplateConcrete();
temp.update();
}
}
3.2. 自定义同步器的实现步骤
- 继承AQS
- 重写钩子方法
- 独占锁实现
- 共享锁实现
package com.boxuegu.aqs.selflock;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/**
* 自定义一个互斥锁
*
* 1.继承自AQS类
* 2.重写钩子方法
* 获取锁
* 释放锁
*
* 设置状态1为获取锁:
* 线程尝试获取锁时,如果state的值为0,则可以获取锁,并设置为1
* 设置状态0为释放锁:
* 把同步状态设置为0
*
*/
public class Mutex {
class Sync extends AbstractQueuedSynchronizer {
/**
* 重写钩子方法:尝试获取锁
* @param arg
* @return
*/
public boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
/**
* 重写钩子方法:尝试释放锁
* @param arg
* @return
*/
protected boolean tryRelease(int arg) {
setState(0);
return true;
}
}
private final Sync sync = new Sync();
public void lock() {
sync.acquire(0);
}
public void unlock() {
sync.release(0);
}
}
package com.boxuegu.aqs.selflock;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/**
* 支持可重入的自定义同步器
* 1.获取锁
* 判断当前线程是否为锁的持有者
* 是:允许进入监视区域
* 否:返回;或者判断是否为第一次进入锁,若是,则设置当前线程为锁的持有者
* 2.释放锁
* 设置新的同步状态
* 当前锁的持有者,置空
*
*/
public class ReentrantMutex {
class Sync extends AbstractQueuedSynchronizer {
/**
* 重写钩子方法:尝试获取锁
* @param arg
* @return
*/
public boolean tryAcquire(int arg) {
if (getState() == 0) { // 当前锁没有被任何线程持有
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread()); // 设置锁的持有者为当前线程
return true;
}
} else if (Thread.currentThread() == getExclusiveOwnerThread()) {
return true;
}
return false;
}
/**
* 重写钩子方法:尝试释放锁
* @param arg
* @return
*/
protected boolean tryRelease(int arg) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}
}
private final Sync sync = new Sync();
public void lock() {
sync.acquire(0);
}
public void unlock() {
sync.release(0);
}
}
package com.boxuegu.aqs.selflock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 使用Lock框架实现同步机制的标准格式
* Lock lock = new 子类(); // 锁对象
* lock.lock(); // 在监视区域开始的时候,加锁
* try{
* // 监视区域,临界区
* } finally {
* lock.unlock(); // 在监视区域结束时,释放锁
* }
*/
public class TicketSellerReentrantLock {
static int tickets = 100; // 某趟列出的剩余车票数
public static void main(String[] args) {
// Lock lock = new ReentrantLock(); // 锁对象
// Mutex lock = new Mutex(); // 锁对象
ReentrantMutex lock = new ReentrantMutex(); // 锁对象
Runnable seller = () -> {
while (true) {
lock.lock(); // 加锁
try {
if (tickets <= 0 )
break;
Thread.sleep(10); // 不会释放锁
System.out.println(Thread.currentThread().getName() +
"正在售卖第" + tickets-- + "张票");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 释放锁
}
}
};
for (int i = 0; i < 4; i++) {
new Thread(seller, "窗口" + (i + 1)).start();
}
}
}
4. Lock框架再认识
J.U.C包中并没有对同步器的API做一个统一的定义。因此,有一些类定义了通用的接口(如Lock),而另外一些则定义了其专有的版本。因此在不同的类中,acquire和release操作的名字和形式会各有不同。例如:Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,在这个框架里,这些方法都是acquire操作。但是,J.U.C为支持一系列常见的使用选项,在类间都有个一致约定。在有意义的情况下,每一个同步器都支持下面的操作:
- 阻塞和非阻塞(例如tryLock)同步。
- 可选的超时设置,让调用者可以放弃等待
- 通过中断实现的任务取消,通常是分为两个版本,一个acquire可取消,而另一个不可以。
同步器的实现根据其状态是否独占而有所不同。独占状态的同步器,在同一时间只有一个线程可以通过阻塞点,而共享状态的同步器可以同时有多个线程在执行。一般锁的实现类往往只维护独占状态,但是,例如计数信号量在数量许可的情况下,允许多个线程同时执行。为了使框架能得到广泛应用,这两种模式都要支持。
j.u.c包里还定义了Condition接口,用于支持管程形式的await/signal操作,这些操作与独占模式的Lock类有关,且Condition的实现天生就和与其关联的Lock类紧密相关。
4.1. Lock接口的核心方法
4.1.1. 方法列表。。。
void lock(); // 加锁
void lockInterruptibly() throws InterruptedException; // 加锁,支持响应中断
boolean tryLock(); // 尝试加锁
// 尝试加锁,支持响应中断
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock(); // 释放锁
Condition newCondition(); // 条件对象
4.1.2. Lock接口与synchronized的比较
1.优先选择synchronized关键字,因为效率已经与Lock相当
2.Lock支持获取锁被中断、超时等情况
3.Lock支持尝试获取锁(tryLock),支持闯入策略
4.支持公平和非公平策略
4.2. 读写锁ReadWriteLock
实现类:ReentrantReadWriteLock
同一时刻,支持任意数量的线程读数据 但是,如果写线程访问是,有且只有一个写线程可以执行(也没有读线程) ReadLock --> lock,用于读操作 lock():共享模式 WriteLock --> lock,用于写操作 lock():独占模式
适用性:读多写少
注意事项:
1.与互斥锁相比,读写锁效率提升在于: 读与写的相对频率,读越多,写越少,效率越高,反之越低(生产者-消费者模式) 读与写的持续时间 数据的争用 同时尝试读、写的线程数量
2.阅读源码要注意的点: 写入器释放锁之后,此时,写线程和读线程都在等待获取锁,把锁给谁? 锁的重入性:读锁本身是否可重入;写锁本身是否可重入;持有写锁的线程能够再持有写锁? 写锁是否可以降级为读锁,反之如何?如何实现?
public class TestRW {
// 读商品数据线程
private static class ReadThread implements Runnable {
private GoodsService goodsService;
public ReadThread(GoodsService goodsService) {
this.goodsService = goodsService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
goodsService.getNum();
}
System.out.println(Thread.currentThread().getName() + "读取商品数据耗时:"
+ (System.currentTimeMillis() - start) + "ms");
}
}
// 写商品数据线程
private static class WriteThread implements Runnable {
private GoodsService goodsService;
public WriteThread(GoodsService goodsService) {
this.goodsService = goodsService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
Random r = new Random();
for (int i = 0; i < 10; i++) {
goodsService.setNum(r.nextInt(10));
Sleep.millis(50);
}
System.err.println(Thread.currentThread().getName()
+ "写商品数据耗时:" + (System.currentTimeMillis() - start) + "ms");
}
}
public static void main(String[] args) throws InterruptedException {
Goods goods = new Goods(10);
// GoodsService goodsService = new SyncLock(goods); // synchronized实现
GoodsService goodsService = new RWLock(goods); // 读写锁实现
for (int i = 0; i < 3; i++) { // 写线程数量
Thread setT = new Thread(new WriteThread(goodsService));
for (int j = 0; j < 10; j++) { // 读线程数量
Thread getT = new Thread(new ReadThread(goodsService));
getT.start();
}
Sleep.millis(100);
setT.start();
}
}
}
/**
* 用内置锁来实现商品服务接口
*/
public class SyncLock implements GoodsService {
private Goods goods;
public SyncLock(Goods goods) {
this.goods = goods;
}
@Override
public synchronized int getNum() {
Sleep.millis(5);
return 10;
}
@Override
public synchronized void setNum(int number) {
Sleep.millis(5);
goods.setStoreNumber(number);
}
}
/**
* 读写锁实现读多写少的效果
*
*/
public class RWLock implements GoodsService {
private Goods goods;
public RWLock(Goods goods) {
this.goods = goods;
}
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock getLock = lock.readLock(); // 读锁
private final Lock setLock = lock.writeLock(); // 写锁
@Override
public int getNum() {
getLock.lock();
try {
Sleep.millis(5);
return this.goods.getStoreNumber();
} finally {
getLock.unlock();
}
}
@Override
public void setNum(int number) {
setLock.lock();
try {
Sleep.millis(5);
goods.setStoreNumber(number);
} finally {
setLock.unlock();
}
}
}
/**
* 商品服务接口
*/
public interface GoodsService {
int getNum(); // 获得商品库存数量
void setNum(int number);// 设置商品库存数量
}
/**
*
* 商品实体类
*/
public class Goods {
private int storeNumber; // 库存数量
public Goods(int storeNumber) {
this.storeNumber = storeNumber;
}
public int getStoreNumber() { // 读库存:上货
return storeNumber;
}
public void setStoreNumber(int storeNumber) { // 写库存:进货
this.storeNumber = storeNumber;
}
}
4.3. 回顾Callable、Future和FutureTask
略。
4.4. Fork-Join框架
4.4.1. 什么是Fork/Join
规模为N的问题,N<阈值,直接解决,N>阈值,将N分解为K个小规模子问题,子问题互相对立,与原问题形式相同,将子问题的解合并得到原问题的解
与动态规范的区别:
分割的子任务之间是否有联系
4.4.2 工作秘取
当一个线程去完成自己的任务队列后,为了防止CPU空转,让线程去别的任务队列的尾部“偷取(steeling)”一个任务去执行,执行完成再将结果放回对应的位置,即工作秘取:work steeling。