Java多线程学习
参考链接:本书简介 · 深入浅出Java多线程 (redspider.group)
二、java多线程入门类和接口
那么在Java中,我们是如何使用多线程的呢?
首先,我们需要有一个“线程”类。JDK提供了Thread
类和Runnable
接口来让我们实现自己的“线程”类。
- 继承
Thread
类,并重写run
方法。 - 实现
Runnable
接口的run
方法。
2.1 继承Thread类和Runnable接口
2.1.1 继承Thread类
1 | public class Demo { |
我们在程序里面调用了start()
方法后,虚拟机会先为我们创建一个线程,然后等到这个线程第一次得到时间片时再调用run()
方法。
注意不可多次调用start()
方法。在第一次调用start()
方法后,再次调用start()
方法会抛出IllegalThreadStateException
异常。
2.1.2 实现Runnable接口
1 | public class Demo { |
- 待复习知识点,内部类
2.1.3 Thread类构造方法
Thread
类是一个Runnable
接口的实现类,我们来看看Thread
类的源码。
查看Thread
类的构造方法,发现其实是简单调用一个私有的init
方法来实现初始化。init
的方法签名:
1 |
|
挨个来解释一下init
方法的这些参数:
g:线程组,指定这个线程是在哪个线程组下
target:指定要执行的任务
name:线程的名字,多个线程的名字是可以重复的。如果不指定名字, 见片段2
acc:见片段3,用于初始化私有变量
inheritedAccessControlContext
。这个变量有点神奇。它是一个私有变量,但是在
Thread
类里只有init
方法对它进行初始化,在exit
方法把它设为null
。其它没有任何地方使用它。一般我们是不会使用它的,那什么时候会使用到这个变量呢?可以参考这个stackoverflow的问题:Restrict permissions to threads which execute third party software;
inheritThreadLocals:可继承的
ThreadLocal
,见片段4,Thread
类里面有两个私有属性来支持ThreadLocal
,我们会在后面的章节介绍ThreadLocal
的概念。
2.1.4 Thread类的几个常用方法
这里介绍一下Thread类的几个常用方法:
currentThread()
:静态方法,返回当前正在执行线程对象的引用;start()
:开始执行线程的方法,java虚拟机会调用线程内的run
方法;yield()
:yield在英语里有放弃的意思,同样,这里的yield()
指的是当前线程愿意让出对当前处理器的占用。这里需要注意的是,就算当前线程调用了yield()方法,程序在调度的时候,也还有可能继续运行这个线程的;sleep()
:静态方法,使当前线程睡觉一段时间join()
:使当前线程等待另一个线程执行完毕之后再继续执行,内部调用的是Object类的wait方法实现的;
2.1.5 Thread类与Runnable接口的比较
实现一个自定义的线程类,可以有继承Thread
类或者实现Runnable
接口这两种方式,它们之间有什么优劣呢?
- 由于Java“单继承,多实现”的特性,Runnable接口使用起来比Thread更灵活。
- Runnable接口出现更符合面向对象,将线程单独进行对象的封装。
- Runnable接口出现,降低了线程对象和线程任务的耦合性。(不用在去实现一个类)
- 如果使用线程时不需要使用Thread类的诸多方法,显然使用Runnable接口更为轻量。
所以,我们通常优先使用“实现Runnable
接口”这种方式来自定义线程类。
2.1.6 Callable、Future与FutureTask
通常来说,我们使用Runnable
和Thread
来创建一个新的线程。但是它们有一个弊端,就是run
方法是没有返回值的。
而有时候我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值。
JDK提供了Callable
接口与Future
接口为我们解决这个问题,这也是所谓的“异步”模型。
- 同步:必须等对方回应我之后,我才会继续做下一件事情。
- 异步:不必等对方回应我,我继续做我的其他事情。
你可以把同步理解成一个痴情男孩,比如:痴情男孩喜欢上了一个姑娘叫刘亦菲,痴情男孩向刘亦菲表白,
此时痴情男孩就在等待刘亦菲的响应(这个响应可以是拒绝男孩,也可以是接受男孩),只有刘亦菲响应了痴情男孩,这个痴情男孩才会继续追下一个女孩(继续做下一件事情),如果刘亦菲不响应男孩,那这个痴情男孩就不会继续追下一女孩(不会继续做下一件事情),而会一直等待刘亦菲的响应,等到地老天荒。
你可以把异步理解成一个渣男,比如渣男喜欢上了一个姑娘叫林志玲,渣男向林志玲表白,此时渣男不会等待林志玲的响应(这个响应可以是拒绝渣男,也可以是接受渣男),而是会继续追求其他的女孩(继续做其他事情)。
2.2.1 Callable接口
Callable
与Runnable
类似,同样是只有一个抽象方法的函数式接口。不同的是,Callable
提供的方法是有返回值的,而且支持泛型。
Callable
一般配合线程池工具 ExecutorService
使用。
ExecutorService
可以使用 submit
方法来让一个 Callable
接口执行。它会返回一个 Future
,后续的程序可以通过这个 Future
的get
方法得到结果。
1 | public class Task implements Callable<Integer> { |
2.2.2 Future接口
Future
接口只有几个比较简单的方法:
1 | public abstract interface Future<V> { |
cancel
方法是试图取消一个线程的执行。
注意是试图取消,并不一定能取消成功。因为任务可能已完成、已取消、或者一些其它因素不能取消,存在取消失败的可能。boolean
类型的返回值是“是否取消成功”的意思。参数paramBoolean
表示是否采用中断的方式取消线程执行。
**所以有时候,为了让任务有能够取消的功能,就使用Callable
来代替Runnable
**。如果为了可取消性而使用 Future
但又不提供可用的结果,则可以声明 Future<?>
形式类型、并返回 null
作为底层任务的结果。
2.2.3 FutureTask类
上面介绍了Future
接口。这个接口有一个实现类叫FutureTask
。FutureTask
是实现的RunnableFuture
接口的,而RunnableFuture
接口同时继承了Runnable
接口和Future
接口:
1 | public interface RunnableFuture<V> extends Runnable, Future<V> { |
前面介绍的Future
只是一个接口,而它里面的 cancel
,get
,isDone
等方法要自己实现很复杂,故JDK提供了一个 FutureTask
类来供我们使用。
示例:
1 | public class Task implements Callable<Integer> { |
使用上与第一个Demo
有一点小的区别。首先,调用submit
方法是没有返回值的。
这里实际调用的是submit(Runnable task)
方法,而上面调用的是 submit(Callable<T> task)
方法。
然后,这里是使用FutureTask
直接取get
取值,而上面的Demo是通过submit
方法返回的Future
去取值。
在很多高并发的环境下,有可能Callable和FutureTask会创建多次。FutureTask能够在高并发环境下确保任务只执行一次。这块有兴趣的同学可以参看FutureTask源码。
2.2.4 FutureTask的几个状态
1 | /** |
state表示任务的运行状态,初始状态为NEW。
运行状态只会在set、setException、cancel方法中终止。
COMPLETING、INTERRUPTING是任务完成后的瞬时状态。
三、线程组和线程优先级
3.1 线程组
Java中用ThreadGroup来表示线程组,我们可以使用线程组对线程进行批量控制。
ThreadGroup和Thread的关系就如同他们的字面意思一样简单粗暴,每个Thread必然存在于一个ThreadGroup中,Thread不能独立于ThreadGroup存在。
执行main()方法线程的名字是main,如果在new Thread时没有显式指定,那么默认将父线程(当前执行new Thread的线程)线程组设置为自己的线程组。
1 | public class Demo { |
输出结果:
1 | 执行main所在线程的线程组名字:main |
ThreadGroup管理着它下面的Thread,ThreadGroup是一个标准的向下引用的树状结构,这样设计的原因是防止”上级”线程被”下级”线程引用而无法有效地被GC回收。
3.2 线程的优先级
Java中线程优先级可以指定,范围是1~10。但是并不是所有的操作系统都支持10级优先级的划分(比如有些操作系统只支持3级划分:低,中,高),Java只是给操作系统一个优先级的参考值,线程最终在操作系统的优先级是多少还是由操作系统决定。
Java默认的线程优先级为5,线程的执行顺序由调度程序来决定,线程的优先级会在线程被调用之前设定。
通常情况下,高优先级的线程将会比低优先级的线程有更高的几率得到执行。我们使用方法Thread
类的setPriority()
实例方法来设定线程的优先级。
1 | public class Demo { |
输出结果:
1 | 我是默认线程优先级:5 |
Java中的优先级来说不是特别的可靠,Java程序中对线程所设置的优先级只是给操作系统一个建议,操作系统不一定会采纳。而真正的调用顺序,是由操作系统的线程调度算法决定的。
我们通过代码来验证一下:
1 | public class Demo02 { |
输出:
1 | 当前执行的线程是:Thread-17,优先级:9 |
Java提供一个线程调度器来监视和控制处于RUNNABLE状态的线程。
线程的调度策略采用抢占式,优先级高的线程比优先级低的线程会有更大的几率优先执行。在优先级相同的情况下,按照“先到先得”的原则。每个Java程序都有一个默认的主线程,就是通过JVM启动的第一个线程main线程。
还有一种线程称为守护线程(Daemon),守护线程默认的优先级比较低。
如果某线程是守护线程,那如果所有的非守护线程都结束了,这个守护线程也会自动结束。
应用场景是:当所有非守护线程结束时,结束其余的子线程(守护线程)自动关闭,就免去了还要继续关闭子线程的麻烦。
一个线程默认是非守护线程,可以通过Thread类的setDaemon(boolean on)来设置。
在之前,我们有谈到一个线程必然存在于一个线程组中,那么当线程和线程组的优先级不一致的时候将会怎样呢?我们用下面的案例来验证一下:
1 | public class Demo03 { |
输出:
1 | 我是线程组的优先级:6 |
所以,如果某个线程优先级大于线程所在线程组的最大优先级,那么该线程的优先级将会失效,取而代之的是线程组的最大优先级。
3.3 线程组的常用方法及数据结构
3.3.1 线程组的常用方法
获取当前的线程组名字
1
Thread.currentThread().getThreadGroup().getName()
复制线程组
1
2
3
4
5// 获取当前的线程组
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
// 复制一个线程组到一个线程数组(获取Thread信息)
Thread[] threads = new Thread[threadGroup.activeCount()];
threadGroup.enumerate(threads);线程组统一异常处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class Demo {
public static void main(String[] args) {
ThreadGroup threadGroup = new ThreadGroup("group1"){
// 继承ThreadGroup并重新定义以下方法
// 在线程成员抛出unchecked exception
// 会执行此方法
public void uncaughtException(Thread t,Throwable e){
System.out.println(t.getName()+": "+e.getMessage());
}
};
Thread thread = new Thread(threadGroup, new Runnable() {
public void run() {
// 抛出uncheck Exception
throw new RuntimeException("测试异常");
}
});
thread.start();
}
}3.3.2 线程组的数据结构
线程组还可以包含其他的线程组,不仅仅是线程。
首先看看
ThreadGroup
源码中的成员变量:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public
class ThreadGroup implements Thread.UncaughtExceptionHandler {
private final ThreadGroup parent; //父线程组
String name;
int maxPriority; // 最大优先级
boolean destroyed; // 是否被销毁
boolean daemon; // 是否守护线程
boolean vmAllowSuspension; // 是否可以终端
int nUnstartedThreads = 0; // 还未启动的线程
int nthreads; // 线程组数目
Thread threads[]; // 线程组中的线程,
int ngroups; // 线程组数目
ThreadGroup groups[]; // 线程组数组然后看看构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29private ThreadGroup() { // called from C code
this.name = "system";
this.maxPriority = Thread.MAX_PRIORITY;
this.parent = null;
}
// Constructs a new thread group. The parent of this new group is
// the thread group of the currently running thread.
public ThreadGroup(String name) {
this(Thread.currentThread().getThreadGroup(), name);
}
public ThreadGroup(ThreadGroup parent, String name) {
this(checkParentAccess(parent), parent, name);
}
// 构造函数
public ThreadGroup(ThreadGroup parent, String name) {
this(checkParentAccess(parent), parent, name);
}
// 私有构造函数,主要的构造函数
private ThreadGroup(Void unused, ThreadGroup parent, String name) {
this.name = name;
this.maxPriority = parent.maxPriority;
this.daemon = parent.daemon;
this.vmAllowSuspension = parent.vmAllowSuspension;
this.parent = parent;
parent.add(this);
}第三个构造函数里调用了
checkParentAccess
方法,这里看看这个方法的源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 检查parent ThreadGroup
private static Void checkParentAccess(ThreadGroup parent) {
parent.checkAccess();
return null;
}
// Determines if the currently running thread has permission to
// modify this thread group.
public final void checkAccess() {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkAccess(this);
}
}这里涉及到
SecurityManager
这个类,它是Java的安全管理器,它允许应用程序在执行一个可能不安全或敏感的操作前确定该操作是什么,以及是否是在允许执行该操作的安全上下文中执行它。应用程序可以允许或不允许该操作。比如引入了第三方类库,但是并不能保证它的安全性。
其实Thread类也有一个checkAccess()方法,不过是用来当前运行的线程是否有权限修改被调用的这个线程实例。(Determines if the currently running thread has permission to modify this thread.)
总结来说,线程组是一个树状的结构,每个线程组下面可以有多个线程或者线程组。线程组可以起到统一控制线程的优先级和检查线程的权限的作用。
四、Java线程的状态以及主要的转化方法
4.1 操作系统中的线程状态转换
在现在的操作系统中,线程是被视为轻量级进程的,所以操作系统线程的状态其实和操作系统进程的状态是一致的。
操作系统线程主要有以下三个状态:
- 就绪状态(ready):线程正在等待使用CPU,经调度程序调用之后可进入running状态。
- 执行状态(running):线程正在使用CPU。
- 等待状态(waiting): 线程经过等待事件的调用或者正在等待其他资源(如I/O)。
4.2 Java线程的6个状态
1 | // Thread.State 源码 |
4.2.1 NEW
处于NEW状态的线程此时尚未启动。这里的尚未启动指的是还没调用Thread实例的start()方法。
1 | private void testStateNew() { |
从上面可以看出,只是创建了线程而并没有调用start()方法,此时线程处于NEW状态。
关于start()的两个引申问题
- 反复调用同一个线程的start()方法是否可行?
- 假如一个线程执行完毕(此时处于TERMINATED状态),再次调用这个线程的start()方法是否可行?
要分析这两个问题,我们先来看看start()的源码:
1 | public synchronized void start() { |
我们可以看到,在start()内部,这里有一个threadStatus的变量。如果它不等于0,调用start()是会直接抛出异常的。
我们接着往下看,有一个native的start0()
方法。这个方法里并没有对threadStatus的处理。到了这里我们仿佛就拿这个threadStatus没辙了,我们通过debug的方式再看一下:
1 |
|
我是在start()方法内部的最开始打的断点,叙述下在我这里打断点看到的结果:
- 第一次调用时threadStatus的值是0。
- 第二次调用时threadStatus的值不为0。
查看当前线程状态的源码:
1 | public State getState() { |
所以,我们结合上面的源码可以得到引申的两个问题的结果:
两个问题的答案都是不可行,在调用一次start()之后,threadStatus的值会改变(threadStatus !=0),此时再次调用start()方法会抛出IllegalThreadStateException异常。
比如,threadStatus为2代表当前线程状态为TERMINATED。
4.2.2 RUNNABLE
表示当前线程正在运行中。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待CPU分配资源。
Java中线程的RUNNABLE状态
看了操作系统线程的几个状态之后我们来看看Thread源码里对RUNNABLE状态的定义:
1 | /** |
Java线程的RUNNABLE状态其实是包括了传统操作系统线程的ready和running两个状态的。
4.2.3 BLOCKED
阻塞状态。处于BLOCKED状态的线程正等待锁的释放以进入同步区。
我们用BLOCKED状态举个生活中的例子:
假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须得等前面的人从窗口离开才行。
假设你是线程t2,你前面的那个人是线程t1。此时t1占有了锁(食堂唯一的窗口),t2正在等待锁的释放,所以此时t2就处于BLOCKED状态。
4.2.4 WAITING
等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。
调用如下3个方法会使线程进入等待状态:
- Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;
- Thread.join():等待线程执行完毕,底层调用的是Object实例的wait方法;
- LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。
我们延续上面的例子继续解释一下WAITING状态:
你等了好几分钟现在终于轮到你了,突然你们有一个“不懂事”的经理突然来了。你看到他你就有一种不祥的预感,果然,他是来找你的。
他把你拉到一旁叫你待会儿再吃饭,说他下午要去作报告,赶紧来找你了解一下项目的情况。你心里虽然有一万个不愿意但是你还是从食堂窗口走开了。
此时,假设你还是线程t2,你的经理是线程t1。虽然你此时都占有锁(窗口)了,“不速之客”来了你还是得释放掉锁。此时你t2的状态就是WAITING。然后经理t1获得锁,进入RUNNABLE状态。
要是经理t1不主动唤醒你t2(notify、notifyAll..),可以说你t2只能一直等待了。
4.2.5 TIMED_WAITING
超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。
调用如下方法会使线程进入超时等待状态:
Thread.sleep(long millis)
:使当前线程睡眠指定时间;Object.wait(long timeout)
:线程休眠指定时间,等待期间可以通过notify()/notifyAll()
唤醒;Thread.join(long millis)
:等待当前线程最多执行millis
毫秒,如果millis
为0,则会一直执行;LockSupport.parkNanos(long nanos)
: 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;LockSupport.parkUntil(long deadline)
:同上,也是禁止线程进行调度指定时间;
我们继续延续上面的例子来解释一下TIMED_WAITING
状态:
到了第二天中午,又到了饭点,你还是到了窗口前。
突然间想起你的同事叫你等他一起,他说让你等他十分钟他改个bug。
好吧,你说那你就等等吧,你就离开了窗口。很快十分钟过去了,你见他还没来,你想都等了这么久了还不来,那你还是先去吃饭好了。
这时你还是线程t1,你改bug的同事是线程t2。t2让t1等待了指定时间,此时t1等待期间就属于TIMED_WATING状态。
t1等待10分钟后,就自动唤醒,拥有了去争夺锁的资格。
4.2.6 TERMINATED
终止状态。此时线程已执行完毕。
4.3 线程的状态转换
4.3.1 BLOCKED与RUNNABLE状态的转换
我们在上面说到:处于BLOCKED状态的线程是因为在等待锁的释放。假如这里有两个线程a和b,a线程提前获得了锁并且暂未释放锁,此时b就处于BLOCKED状态。我们先来看一个例子:
1 | public class Demo { |
输出:
1 | a: TIMED_WAITING |
输出可能不固定
初看之下,大家可能会觉得线程a会先调用同步方法,同步方法内又调用了Thread.sleep()方法,必然会输出TIMED_WAITING,而线程b因为等待线程a释放锁所以必然会输出BLOCKED。
其实不然,有两点需要值得大家注意,一是在测试方法blockedTest()内还有一个main线程,二是启动线程后执行run方法还是需要消耗一定时间的。
在这个例子中两个线程的状态转换如下“
- a的状态转换过程:RUNNABLE(
a.start()
) -> TIMED_WATING(Thread.sleep()
)->RUNABLE(sleep()时间到)->BLOCKED(未抢到锁) -> TERMINATED - b的状态转换过程:RUNNABLE(
b.start()
) -> BLOCKED(未抢到锁) ->TERMINATED
斜体表示可能出现的状态, 大家可以在自己的电脑上多试几次看看输出。同样,这里的输出也可能有多钟结果。
4.3.2 WAITING状态与RUNNABLE状态的转换
根据转换图我们知道有3个方法可以使线程从RUNNABLE
状态转为WAITING
状态。我们主要介绍下**Object.wait()和Thread.join()**。
Object.wait()
调用
wait()
方法前线程必须持有对象的锁。线程调用
wait()
方法时,会释放当前的锁,直到有其他线程调用notify()/notifyAll()
方法唤醒等待锁的线程。需要注意的是,其他线程调用
notify()
方法只会唤醒单个等待锁的线程,如有有多个线程都在等待这个锁的话不一定会唤醒到之前调用wait()方法的线程。同样,调用
notifyAll()
方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。
wait
的时候放出锁,是为了让其他就绪的进程被调度。
Thread.join()
调用
join()
方法,会一直等待这个线程执行完毕(转换为TERMINATED
状态)。
我们再把上面的例子线程启动那里改变一下:
1 | a.start(); |
要是没有调用join
方法,main
线程不管a
线程是否执行完毕都会继续往下走。
a线程启动之后马上调用了join方法,这里main线程就会等到a线程执行完毕,所以这里a线程打印的状态固定是TERMINATED。
至于b线程的状态,有可能打印RUNNABLE(尚未进入同步方法),也有可能打印TIMED_WAITING(进入了同步方法)。
4.3.3 TIMED_WAITING与RUNNABLE状态转换
TIMED_WAITING与WAITING状态类似,只是TIMED_WAITING状态等待的时间是指定的。
Thread.sleep(long)
使当前线程睡眠指定时间。需要注意这里的“睡眠”只是暂时使线程停止执行,并不会释放锁。时间到后,线程会重新进入RUNNABLE状态。
Object.wait(long)
wait(long)
方法使线程进入TIMED_WAITING
状态。这里的wait(long)
方法与无参方法wait()
相同的地方是,都可以通过其他线程调用notify()
或notifyAll()
方法来唤醒。不同的地方是,有参方法
wait(long)
就算其他线程不来唤醒它,经过指定时间long之后它会自动唤醒,拥有去争夺锁的资格。
Thread.join(long)
join(long)
使当前线程执行指定时间,并且使线程进入TIMED_WAITING状态。我们再来改一改刚才的示例:
1
2
3
4
5
6 a.start();
// Thread.sleep(1000);
a.join(1000L);
b.start();
System.out.println(a.getName()+": "+a.getState());
System.out.println(b.getName()+": "+b.getState());输出:
1
2
3
4
5
6 当前线程:a
a: TIMED_WAITING
b: RUNNABLE
当前线程:a
当前线程:b
当前线程:b这里调用a.join(1000L),因为是指定了具体a线程执行的时间的,并且执行时间是小于a线程sleep的时间,所以a线程状态输出TIMED_WAITING。
b线程状态仍然不固定(RUNNABLE或BLOCKED)。
4.3.4 线程中断
在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在Java里还没有安全直接的方法来停止线程,但是Java提供了线程中断机制来处理需要中断线程的情况。
线程中断机制是一种协作机制。需要注意,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。
简单介绍下Thread类里提供的关于线程中断的几个方法:
Thread.interrupt()
:中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为true(默认是flase);Thread.interrupted()
:测试当前线程是否被中断。线程的中断状态受这个方法的影响,意思是调用一次使线程中断状态设置为true
,连续调用两次会使得这个线程的中断状态重新转为false
;Thread.isInterrupted()
:测试当前线程是否被中断。与上面方法不同的是调用这个方法并不会影响线程的中断状态。
在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己而定,可以在合适的实际处理中断请求,也可以完全不处理继续执行下去。
五、Java进程间通信
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰。但是当我们需要多个线程之间相互协作的时候,就需要我们掌握Java线程的通信方式。本文将介绍Java线程之间的几种通信原理。
5.1 锁与同步
在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。
线程和锁的关系,我们可以用婚姻关系来理解。一个锁同一时间只能被一个线程持有。也就是说,一个锁如果和一个线程“结婚”(持有),那其他线程如果需要得到这个锁,就得等这个线程和这个锁“离婚”(释放)。
在我们的线程之间,有一个同步的概念。什么是同步呢,假如我们现在有2位正在抄暑假作业答案的同学:线程A和线程B。
当他们正在抄的时候,老师突然来修改了一些答案,可能A和B最后写出的暑假作业就不一样。
我们为了A,B能写出2本相同的暑假作业,我们就需要让老师先修改答案,然后A,B同学再抄。
或者A,B同学先抄完,老师再修改答案。这就是线程A,线程B的线程同步。
可以解释为:线程同步是线程之间按照一定的顺序执行。
为了达到线程同步,我们可以使用锁来实现它。
我们先来看看一个无锁的程序:
1 | public class Demo01 { |
在这个程序中,线程A
和线程B
独立工作,输出内容为乱序(多次测试)。
现在有一个需求,需要等A执行完之后,再由B去执行,怎么办?
最简单的方式就是使用一个“对象锁”:
1 | public class Demo02 { |
这里声明了一个名字为lock
的对象锁。我们在ThreadA
和ThreadB
内需要同步的代码块里,都是用synchronized
关键字加上了同一个对象锁lock
。
上文我们说到了,根据线程和锁的关系,同一时间只有一个线程持有一个锁,那么线程B就会等线程A执行完成后释放lock
,线程B才能获得锁lock
。
5.2 等待/通知机制
上面一种基于“锁”的方式,线程需要不断地去尝试获得锁,如果失败了,再继续尝试。这可能会耗费服务器资源。
而等待/通知机制是另一种方式。
Java多线程的等待/通知机制是基于Object
类的wait()
方法和notify()
, notifyAll()
方法来实现的。
notify()方法会随机叫醒一个正在等待的线程,而notifyAll()会叫醒所有正在等待的线程。
前面讲到,一个锁同一时刻只能被一个线程持有。假如线程A持有了一个锁lock
并开始执行,它可以使用 lock.wait()
让自己进入等待状态,这个时候,lock
这个锁是被释放的了。
这时,线程B
获得了lock
这个锁并开始执行,它可以在某一时刻,使用 lock.notify()
,通知前面持有 lock
锁并进入等待状态的线程A,说“线程A你不用等了,可以往下执行了”。
需要注意的是,这个时候线程B并没有释放锁
lock
,除非线程B
这个时候使用lock.wait()
释放锁,或者线程B执行结束自行释放锁,线程A才能获得lock
锁。
代码实现如下:
1 | public class Demo03 { |
输出:
1 | ThreadA: 0 |
在这个Demo
里,线程A
和线程B
首先打印出自己需要的东西,然后使用notify()
方法叫醒另一个正在等待的线程,然后自己使用 wait()
方法陷入等待然后释放 lock
锁。
需要注意的是等待/通知机制使用的是使用同一个对象锁,如果你两个线程使用的是不同的对象锁,那它们之间是不能用等待/通知机制通信的。
5.3 信号量
JDK提供了一个类似于“信号量”功能的类Semaphore
。但本文不是要介绍这个类,而是介绍一种基于volatile
关键字的自己实现的信号量通信。
后面会有专门的章节介绍volatile
关键字,这里只是做一个简单的介绍。
volatile关键字能够保证内存的可见性,如果用volatile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。
比如我现在有一个需求,我想让线程A输出0,然后线程B输出1,再然后线程A输出2…以此类推。我应该怎样实现呢?
1 | public class Signal { |
我们可以看到,使用了一个volatile
变量signal
来实现了“信号量”的模型。这里需要注意的是,volatile
变量需要进行原子操作。
需要注意的是,signal++
并不是一个原子操作,所以我们在实际开发中,会根据需要使用synchronized
给它“上锁”,或者是使用AtomicInteger
等原子类。并且上面的程序也并不是线程安全的,因为执行while
语句后,可能当前线程就暂停等待时间片了,等线程醒来,可能signal已经大于等于5了。
这种实现方式并不一定高效,本例只是演示信号量
原子操作就是: 不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换(context switch).
信号量的应用场景:
假如在一个停车场中,车位是我们的公共资源,线程就如同车辆,而看门的管理员就是起的“信号量”的作用。
因为在这种场景下,多个线程(超过2个)需要相互合作,我们用简单的“锁”和“等待通知机制”就不那么方便了。这个时候就可以用到信号量。
其实JDK中提供的很多多线程通信工具类都是基于信号量模型的。我们会在后面第三篇的文章中介绍一些常用的通信工具类。
5.4 管道
管道是基于“管道流”的通信方式。JDK提供了PipedWriter
、 PipedReader
、 PipedOutputStream
、 PipedInputStream
。其中,前面两个是基于字符的,后面两个是基于字节流的。
这里的示例代码使用的是基于字符的:
1 | public class Demo01 { |
我们通过线程的构造函数,传入了PipedWrite
和PipedReader
对象。可以简单分析一下这个示例代码的执行流程:
- 线程ReaderThread开始执行,
- 线程ReaderThread使用管道
reader.read()
进入阻塞
。 - 线程
WriterThread
开始执行 - 线程
WriterThread
使用writer.write("test")
往管道里写入字串 - 线程
WriterThread
使用wirter.close()
结束管道写入,并执行完毕 - 线程ReaderThread接受到管道输出的字符串并打印
- 线程ReaderThread执行完毕
管道通信的应用场景
这个很好理解。使用管道多半与I/O流相关。当我们一个线程需要先另一个线程发送一个信息(比如字符串)或者文件等等时,就需要使用管道通信了。
5.5 其他通信相关
以上介绍了一些线程间通信的基本原理和方法。除此以外,还有一些与线程通信相关的知识点,这里一并介绍。
5.5.1 join方法
join()
方法是Thread
类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join
的这个线程执行完成后,再继续执行当前线程。
有时候,主线程创建并启动了子线程,如果子线程中需要进行大量的耗时运算,主线程往往将早于子线程结束之前结束。
如果主线程想等待子线程执行完毕后,获得子线程中的处理完的某个数据,就要用到join方法了。
1 | public class Join { |
注意
join()
方法有两个重载方法,一个是join(long)
,一个是join(long,int)
实际上,通过源码你会发现,join()方法及其重载方法底层都是利用了wait(long)这个方法。
对于join(long, int),通过查看源码(JDK 1.8)发现,底层并没有精确到纳秒,而是对第二个参数做了简单的判断和处理。
5.5.2 sleep方法
sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间。它有这样两个方法:
- Thread.sleep(long)
- Thread.sleep(long, int)
同样,查看源码(JDK 1.8)发现,第二个方法貌似只对第二个参数做了简单的处理,没有精确到纳秒。实际上还是调用的第一个方法。
这里需要强调一下:sleep方法是不会释放当前的锁的,而wait方法会。这也是最常见的一个多线程面试题。
它们还有这些区别:
- wait可以指定时间,也可以不指定;而sleep必须指定时间。
- wait释放cpu资源,同时释放锁;sleep释放cpu资源,但是不释放锁,所以易死锁。
- wait必须放在同步块或同步方法中,而sleep可以再任意位置
5.5.3 ThreadLocal类
ThreadLocal是一个本地线程副本变量工具类。内部是一个弱引用的Map来维护。这里不详细介绍它的原理,而是只是介绍它的使用,以后有独立章节来介绍ThreadLocal类的原理。
有些朋友称ThreadLocal为线程本地变量或线程本地存储。
严格来说,ThreadLocal类并不属于多线程间的通信,而是让每个线程有自己”独立“的变量,线程之间互不影响。
它为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。
ThreadLocal类最常用的就是set方法和get方法。示例代码:
根据线程作为
key
1 | public class ThreadLocalDemo { |
可以看到,虽然两个线程使用的同一个ThreadLocal
实例(通过构造方法传入),但是它们各自可以存取自己当前线程的一个值。
那ThreadLocal
有什么作用呢?如果只是单纯的想要线程隔离,在每个线程中声明一个私有变量就好了呀,为什么要使用ThreadLocal
?
如果开发者希望将类的某个静态变量(user ID
或者transaction ID
)与线程状态关联,则可以考虑使用ThreadLocal。
最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。数据库连接和Session管理涉及多个复杂对象的初始化和关闭。如果在每个线程中声明一些私有变量来进行操作,那这个线程就变得不那么“轻量”了,需要频繁的创建和关闭连接。
5.5.4 InheritableThreadLocal
InheritableThreadLocal
类与ThreadLocal
类稍有不同,Inheritable
是继承的意思。它不仅仅是当前线程可以存取副本值,而且它的子线程也可以存取这个副本值。
六、JAVA内存模型基础知识
6.1 并发编程模型的两个关键问题
- 线程间如何通信?即:线程之间以何种机制来交换信息
- 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序
有两种并发模型可以解决这两个问题:
- 消息传递并发模型
- 共享内存并发模型
在Java中,使用的是共享内存并发模型。
6.2 Java内存模型的抽象结构
6.2.1 运行时内存的划分
先谈一下运行时数据区,下面这张图相信大家一点都不陌生:
对于每一个线程来说,栈都是私有的,而堆是共有的。
也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。
而在堆中的变量是共享的,本文称为共享变量。
所以,内存可见性是针对的共享变量。
6.2.2 既然堆是共享的,为什么在堆中会有内存不可见问题?
这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。
线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。
本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
Java线程之间的通信由Java内存模型(简称JMM,Java Memory Model)控制,从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:
从图中可以看出:
所有的共享变量都存在主存中
每个线程都保存了一份该线程使用到的共享变量的副本
如果线程A与线程B要通信的话,需要经过下面两个步骤:
- 线程A将本地内存A中更新过的共享变量刷新到主存中
- 线程B到主存中去读取线程A之前已经更新过的共享变量
所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。
注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。
故线程B不是直接去主存中读取共享变量的值,而是先在本地内存中B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主存中读取这个共享变量的新值,并拷贝到本地内存B中,最后程序B再读取本地内存B中的新值。
那么怎么知道这个共享变量的值被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。
JMM通过控制主存于每个线程的本地内存之间的交互,来提供内存的可见性保证。
- volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序
- synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)
在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。
为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。
这里涉及到的所有内容后面都会有专门的章节介绍。
6.2.3 JMM与JAVA内存区域划分的区别与联系
上面两小节分别提到了JMM和Java运行时内存区域的划分,这两者既有差别又有联系:
区别
两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。
而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。
联系
都存在私有数据区域和共享数据区域。
。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
实际上,他们表达的是同一种含义,这里不做区分。
七、重排序与happens-before
7.1 什么是重排序?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。
因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
1 | a = b + c; |
先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
指令重排一般分为以下三种:
编译器优化重排
编译器在不改变单线程程序语义的情况下,重新安排语句执行顺序
指令并行重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
内存系统重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
7.2 顺序一致性模型与JMM的保证
顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。
7.2.1 数据竞争与顺序一致性
当程序未正确同步的时候,就可能存在数据竞争。
数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。
如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程程序能够正确同步,那么就不存在数据竞争。
Java内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性。 即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。
这里的同步包括了使用volatile
、final
、synchronized
等关键字来实现多线程下的同步。
如果程序员没有正确使用volatile
、final
、synchronized
,那么即便是使用了同步(单线程下的同步),JMM也不会有内存可见性的保证,可能会导致你的程序出错,并且具有不可重现性,很难排查。
所以如何正确使用volatile
、final
、synchronized
,是程序员应该去了解的。后面会有专门的章节介绍这几个关键字的内存语义及使用。
7.2.2 顺序一致性模型
顺序一致性内存模型是一个理想化的理论参考模型,它为程序员提供了极强的内存可见性保证。
顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即Java代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程A和B并发执行,线程A有3个操作,他们在程序中的顺序是A1->A2->A3,线程B也有3个操作,B1->B2->B3。
假设正确使用了同步,A线程的3个操作执行后释放锁,B线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。
假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:
操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。
但是JMM没有这样的保证。
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。
只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。
7.2.3 JMM中同步程序的顺序一致性效果
在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是JMM中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。
虽然线程A在临界区做了重排序,但是因为锁的特性,线程B无法观察到线程A在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
同时,JMM会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。
由此可见,JMM的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门。
7.2.4 JMM中未同步程序的顺序一致性效果
对于未同步的多线程程序,JMM只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。
为了实现这个安全性,JVM在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。
JMM没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么JMM需要禁止大量的优化,对程序的执行性能会产生很大的影响。
未同步程序在JMM和顺序一致性内存模型中的执行特性有如下差异:
- 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是JMM保证单线程下的重排序不影响执行结果)
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。(因为JMM不保证所有操作立即可见)
- 顺序一致性模型保证对所有的内存读写操作都具有原子性,而JMM不保证对64位的long型和double型变量的写操作具有原子性。
7.3 happens-before
7.3.1 什么是happens-before?
一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
而对于程序员,JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。
JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
happens-before关系的定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。
happens-before关系本质上和as-if-serial语义是一回事。
as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。
7.3.2 天然的happens-before关系
在Java中,有以下天然的happens-before关系:
- 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
举例:
1 | int a = 1; // A操作 |
根据以上介绍的happens-before规则,假如只有一个线程,那么不难得出:
1 | 1> A happens-before B |
注意,真正在执行指令的时候,其实JVM有可能对操作A & B进行重排序,因为无论先执行A还是B,他们都对对方是可见的,并且不影响执行结果。
如果这里发生了重排序,这在视觉上违背了happens-before原则,但是JMM是允许这样的重排序的。
所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。
重排序有两类,JMM对这两类重排序有不同的策略:
- 会改变程序执行结果的重排序,比如 A -> C,JMM要求编译器和处理器都禁止这种重排序。
- 不会改变程序执行结果的重排序,比如 A -> B,JMM对编译器和处理器不做要求,允许这种重排序。
八、volatile
8.1 几个基本概念
在介绍volatile之前,我们先回顾及介绍几个基本的概念。
8.1.1 内存可见性
在Java内存模型那一章我们介绍了JMM有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。
内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
8.1.2 重排序
为优化程序性能,对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。
8.1.3 happens-before规则
是一个给程序员使用的规则,只要程序员在写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。
8.2 volatile的内存语义
在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:
- 保证变量的内存可见性
- 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”)
8.2.1 内存可见性
以一段示例代码开始:
1 | public class VolatileExample { |
在这段代码里,我们使用volatile
关键字修饰了一个boolean
类型的变量flag
。
所谓内存可见性,指的是当一个线程对volatile
修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile
修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。
假设在时间线上,线程A先执行方法writer
方法,线程B后执行reader
方法。那必然会有下图:
而如果flag
变量没有用volatile
修饰,在step 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false
。
8.2.2 禁止重排序
在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。那上面的案例中,可能就会被重排序成下列时序来执行:
- 线程A写volatile变量,step 2,设置flag为true;
- 线程B读同一个volatile,step 3,读取到flag为true;
- 线程B读普通变量,step 4,读取到 a = 0;
- 线程A修改普通变量,step 1,设置 a = 1;
可见,如果volatile
变量与普通变量发生了重排序,虽然volatile
变量能保证内存可见性,也可能导致普通变量读取错误。
所以在旧的内存模型中,volatile
的写-读就不能与锁的释放-获取具有相同的内存语义了。
为了提供一种比锁更轻量级的线程间的通信机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。
编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。
什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是CPU缓存,如L1,L2等
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:
- 在每个volatile写操作前插入一个StoreStore屏障;
- 在每个volatile写操作后插入一个StoreLoad屏障;
- 在每个volatile读操作后插入一个LoadLoad屏障;
- 在每个volatile读操作后再插入一个LoadStore屏障。
再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,这个屏障会把Store1强制刷新到内存,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
对于连续多个volatile变量读或者连续多个volatile变量写,编译器做了一定的优化来提高性能,比如:
第一个volatile读;
LoadLoad屏障;
第二个volatile读;
LoadStore屏障
再介绍一下volatile与普通变量的重排序规则:
- 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
- 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
- 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序。
举个例子,我们在案例中step 1
,是普通变量的写,step 2
是volatile
变量的写,那符合第2
个规则,这两个steps
不能重排序。而step 3
是volatile变量读,step 4
是普通变量读,符合第1个规则,同样不能重排序。
但如果是下列情况:第一个操作是普通变量读,第二个操作是volatile变量读,那是可以重排序的:
1 | // 声明变量 |
8.3 volatile的用途
从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序。
在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。
但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。
在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:
在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:
1 | public class Singleton{ |
如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:
1 | instance = new Singleton(); // 第10行 |
而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
九、synchronized与锁
这篇文章我们来聊一聊Java多线程里面的“锁”。
首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。
还有一点需要注意的是,我们常听到的类锁其实也是对象锁。
Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。
9.1 synchronized关键字
说到锁,我们通常会谈到synchronized
这个关键字。它翻译成中文就是“同步”的意思。
我们通常使用synchronized
关键字来给一段代码或一个方法上锁。它通常有以下三种形式:
1 | // 关键字在实例方法上,锁为当前实例 |
我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。
在上面的例子中,如果synchronized
关键字在方法上,那临界区就是整个方法内部。
而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。
通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:
1 | // 关键字在实例方法上,锁为当前实例 |
同理,下面这两个方法也应该是等价的:
1 | // 关键字在静态方法上,锁为当前Class对象 |
9.2 几种锁
Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
关于锁降级有两点说明:
- 不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。
- 上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。
下面分别介绍这几种锁以及它们之间的升级。
9.2.1 Java对象头
前面我们提到,Java的锁都是基于对象的。首先我们来看看一个对象的“锁”的信息是存放在什么地方的。
每个Java
对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。
在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的内容如下表:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
我们主要来看看Mark Word的格式:
锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时,Mark Word
存储的是偏向的线程ID;
当状态为轻量级锁时,Mark Word
存储的是指向线程栈中Lock Record
的指针;
当状态为重量级锁时,Mark Word
为指向堆中的monitor对象的指针。
9.2.2 偏向锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。
大白话就是对锁置个变量,如果发现为true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为false,代表存在其他线程竞争资源,那么就会走后面的流程。
实现原理
一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。
如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:
- 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
- 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。
CAS: Compare and Swap
比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。 比较是否和给定的数值一致,如果一致则修改,不一致则不修改。
线程竞争偏向锁的过程如下:
图中涉及到了lock record指针指向当前堆栈中的最近一个lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。
撤销偏向锁
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:
- 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
- 唤醒被停止的线程,将当前锁升级成轻量级锁。
所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
1 | -XX:UseBiasedLocking=false。 |
下面这个经典的图总结了偏向锁的获得和撤销:
9.2.3 轻量级锁
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。
轻量级锁的加锁
JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。
如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。
然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
自旋:不断尝试去获取锁,一般用循环来实现。
自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
轻量级锁的释放:
在释放锁时,当前线程会使用CAS
操作将Displaced Mark Word
的内容复制回锁的Mark Word里面。
如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
一张图说明加锁和释放锁的过程:
9.2.4 重量级锁
重量级锁依赖于操作系统的互斥量(mutex
) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU
。
前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:
1 | Contention List:所有请求锁的线程将被首先放置到该竞争队列 |
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到Contention List的队列的队首,然后调用park
函数挂起当前线程。
当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive
即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定能获得锁。
这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。
如果线程获得锁后调用Object.wait
方法,则会将线程加入到WaitSet中,当被Object.notify
唤醒后,会将线程从WaitSet移动到Contention List或EntryList中去。
需要注意的是,当调用一个锁对象的wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
9.2.5 总结锁的升级流程
每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
第三步,两个线程都把锁对象的HashCode
复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
9.2.6 各种锁的优缺点对比
下表来自《Java并发编程的艺术》:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到的锁竞争的线程使用自旋会消耗CPU | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量。同步块时间比较长。 |
十、CAS与原子操作
10.1 乐观锁与悲观锁的概念
锁可以从不同的角度分类。其中,乐观锁和悲观锁是一种分类方式。
悲观锁:
悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
乐观锁:
乐观锁又称为 “无锁”,顾名思义,它是乐观派。乐观锁假设对共享资源的访问没有冲突,线程不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程程序的安全性。
由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说 乐观锁天生免疫死锁。
乐观锁用于“读多写少”的情况,避免频繁加锁影响性能;而悲观锁多用于“写多读少”的环境,避免频繁失败和重试影响性能。
10.2 CAS 的概念
CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
- V:要更新的变量(var)
- E:期望值(expected)(旧值)
- N:新值(new)
比较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;
如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。
所以这里的预期值E本质上指的是“旧值”。
我们以一个简单的例子来解释这个过程:
- 如果有一个多个线程共享的变量
i
原本等于5,我现在在线程A中,想把它设置为新的值6; - 我们使用CAS来做这个事情;
- 首先我们用
i
去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i
的值被设置成了6; - 如果不等于5,说明
i
被其它线程改过了(比如现在i
的值为2),那么我就什么也不做,此次CAS失败,i
的值仍然为2。
在这个例子中,i
就是V,5就是E,6就是N。
那有没有可能我在判断了i
为5之后,正准备更新它的新值的时候,被其它线程更改了i
的值呢?
不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
10.3 JAVA实现CAS的原理-Unsafe类
前面提到,CAS是一种原子操作。那么Java是怎样来使用CAS的呢?我们知道,在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现。
在Java中,有一个Unsafe
类,它在sun.misc
包中。它里面是一些native
方法,其中就有几个关于CAS的:
1 | boolean compareAndSwapObject(Object o, long offset,Object expected, Object x); |
当然,他们都是public native
的。
Unsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。
Linux的X86下主要是通过cmpxchgl
这个指令在CPU级完成CAS操作的,但在多处理器情况下必须使用lock
指令加锁来完成。当然不同的操作系统和处理器的实现会有所不同,大家可以自行了解。
当然,Unsafe类里面还有其它方法用于不同的用途。比如支持线程挂起和恢复的park
和unpark
, LockSupport类底层就是调用了这两个方法。还有支持反射操作的allocateInstance()
方法。
10.4 原子操作-AtomicInteger类源码简析
上面介绍了Unsafe类的几个支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢?
JDK提供了一些用于原子操作的类,在java.util.concurrent.atomic
包下面。在JDK 11中,有如下17个类:
10 CAS与原子操作 · 深入浅出Java多线程 (redspider.group)
10.5 CAS实现原子操作的三大问题
这里介绍一下CAS实现原子操作的三大问题及其解决方案。
10.5.1 ABA问题
所谓ABA问题,就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。
ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference
类来解决ABA问题。
这个类的compareAndSet
方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。
1 | public boolean compareAndSet(V expectedReference, |
10.5.2 循环时间长开销大
CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。
解决思路是让JVM支持处理器提供的pause指令。
pause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。
10.5.3 只能保证一个共享变量的原子操作
这个问题你可能已经知道怎么解决了。有两种解决方案:
- 使用JDK 1.5开始就提供的
AtomicReference
类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作; - 使用锁。锁内的临界区代码可以保证只有当前线程能操作。
十一、AQS
11.1 AQS简介
AQS 是 AbstractQueuedSynchronizer
的简称,既 抽象队列同步器
,从字面意思上理解:
- 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
- 队列:使用先进先出(FIFO)队列存储数据;
- 同步:实现了同步的功能。
那AQS有什么用?
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的同步器,比如前面提到的 ReentrantLock
,Semaphore
,ReentrantReadWriteLock
,SynchronousQueue
,FutureTask
等等皆是基于AQS的。
当然,我们自己也能利用AQS非常轻松地构造出符合我们自己需求的同步器,只要子类实现它的几个 protected
方法就可以了,在下文会有详细的介绍。
11.2 AQS的数据结构
AQS内部使用了一个volatile的变量state来作为资源的标识。
同时定义了几个获取和改变state
的protected
的方法,子类可以覆盖这些方法来实现自己的逻辑:
1 | getState() |
这三种叫做均是原子操作,其中compareAndSetState
的实现依赖于Unsafe
的compareAndSwapInt()
方法。
而AQS类本身实现的是一些排队和阻塞的机制,比如具体线程等待队列的维护(如获取资源失败入队/唤醒出队等)。
它内部使用了一个先进先出(FIFO)的双端队列,并使用了两个指针head和tail用于标识队列的头部和尾部。其数据结构如图:
但它并不是直接储存线程,而是储存拥有线程的Node
节点。
11.3 资源共享模式
资源有两种共享模式,或者说两种同步方式:
- 独占模式(
Exclusive
):资源是独占的,一次只能一个线程获取。如ReentrantLock
。 - 共享模式(
Share
):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch
。
一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock
。
AQS中关于这两种资源共享模式的定义源码(均在内部类Node中)。我们来看看Node的结构:
1 | static final class Node { |
注意:通过Node我们可以实现两个队列,一是通过prev和next实现CLH队列(线程同步队列,双向队列),二是nextWaiter实现Condition条件上的等待线程队列(单向队列),这个Condition主要用在ReentrantLock类中。
11.4 AQS的主要方法源码解析
AQS的设计是基于模板方法模式的,它有一些方法必须要子类去实现的,它们主要有:
isHeldExclusively()
:该线程是否正在独占资源。只有用到condition
才需要去实现它。tryAcquire(int)
:独占方式。尝试获取资源,成功则返回true
,失败则返回false
。tryRelease(int)
:独占方式。尝试释放资源,成功则返回true
,失败则返回false
。tryAcquireShared(int)
:共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。tryReleaseShared(int)
:共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true
,否则返回false
。
这些方法虽然都是protected
方法,但是它们并没有在AQS
具体实现,而是直接抛出异常(这里不使用抽象方法的目的是:避免强迫子类中把所有的抽象方法都实现一遍,减少无用功,这样子类只需要实现自己关心的抽象方法即可,比如 Semaphore
只需要实现 tryAcquire
方法而不用实现其余不需要用到的模版方法):
1 | protected boolean tryAcquire(int arg) { |
而AQS实现了一系列主要的逻辑。下面我们从源码来分析一下获取和释放资源的主要逻辑:
11.4.1 获取资源
获取资源的入口是acquire(int arg)
方法。arg是要获取的资源的个数,在独占模式下始终为1。我们先来看看这个方法的逻辑:
1 | public final void acquire(int arg) { |
首先调用tryAcquire(arg)
尝试去获取资源。前面提到了这个方法是在子类具体实现的。
如果获取资源失败,就通过addWaiter(Node.EXCLUSIVE)
方法把这个线程插入到等待队列中。其中传入的参数代表要插入的Node是独占式的。这个方法的具体实现:
1 | private Node addWaiter(Node mode) { |
同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Nodeexpect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
上面的两个函数比较好理解,就是在队列的尾部插入新的Node节点,但是需要注意的是由于AQS中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过CAS自旋的方式保证了操作的线程安全性。
OK,现在回到最开始的aquire(int arg)
方法。现在通过addWaiter
方法,已经把一个WNode
放到等待队列尾部了。而处于等待队列的结点是从头结点一个一个去获取资源的。具体的实现我们来看看acquireQueued
方法
1 | final boolean acquireQueued(final Node node, int arg) { |
这里parkAndCheckInterrupt方法内部使用到了LockSupport.park(this),顺便简单介绍一下park。
LockSupport类是Java 6 引入的一个类,提供了基本的线程同步原语。LockSupport实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数:
- park(boolean isAbsolute, long time):阻塞当前线程
- unpark(Thread jthread):使给定的线程停止阻塞
所以结点进入等待队列后,是调用park使它进入阻塞状态的。只有头结点的线程是处于活跃状态的。
当然,获取资源的方法除了acquire外,还有以下三个:
acquireInterruptibly
:申请可中断的资源(独占模式)acquireShared
:申请共享模式的资源acquireSharedInterruptibly
:申请可中断的资源(共享模式)
可中断的意思是,在线程中断时可能会抛出
InterruptedException
(60条消息) 队列同步器(AQS)详解_void-CSDN博客_aqs同步队列
11.4.2 释放资源
释放资源相比于获取资源来说,会简单许多。在AQS中只有一小段实现。源码:
1 | public final boolean release(int arg) { |
十二、线程池原理
12.1 为什么要使用线程池
使用线程池主要有以下3个原因:
- 创建/销毁线程需要消耗系统资源,线程池可以复用已创建的线程
- 控制并发的数量。并发数量过多,可能会导致资源消耗过多,从而造成服务器崩溃。(主要原因)
- 可以对线程做同一管理
12.2 线程池的原理
Java中线程池顶层接口是 Executor
接口,ThreadPoolExecutor
是这个接口的实现类。
我们先看看 THreadPoolExecutor
类
12.2.1 THreadPoolExecutor提供的构造方法
一共有四个构造方法:
1 | // 五个参数的构造函数 |
涉及到5~7个参数,我们先看看必须的5个参数是什么意思:
int corePoolSize:该线程池中核心线程数最大值
核心线程:线程池中有两类线程,核心线程和非核心线程。核心线程默认情况下会一直存在于线程池中,即使这个核心线程什么都不干(铁饭碗),而非核心线程如果长时间的闲置,就会被销毁(临时工)。
int maximumPoolSize:该线程池中线程总数最大值 。
该值等于核心线程数量 + 非核心线程数量。
long keepAliveTime:非核心线程闲置超时时长。
非核心线程如果处于闲置状态超过该值,就会被销毁。如果设置allowCoreThreadTimeOut(true),则会也作用于核心线程。
TimeUnit unit:keepAliveTime的单位。
TimeUnit
是一个枚举类型 ,包括以下属性:NANOSECONDS : 1微毫秒 = 1微秒 / 1000
MICROSECONDS : 1微秒 = 1毫秒 / 1000
MILLISECONDS : 1毫秒 = 1秒 /1000
SECONDS : 秒
MINUTES : 分
HOURS : 小时
DAYS : 天
BlockingQueue workQueue:阻塞队列,维护着等待执行的Runnable任务对象。
常用的几个阻塞队列:
LinkedBlockingQueue
链式阻塞队列,底层数据结构是链表,默认大小是
Integer.MAX_VALUE
,也可以指定大小。ArrayBlockingQueue
数组阻塞队列,底层数据结构是数组,需要指定队列的大小。
SynchronousQueue
同步队列,内部容量为0,每个put操作必须等待一个take操作,反之亦然。
DelayQueue
延迟队列,该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素 。
好了,介绍完5个必须的参数之后,还有两个非必须的参数。
ThreadFactory threadFactory
创建线程的工厂 ,用于批量创建线程,统一在创建线程时设置一些参数,如是否守护线程、线程的优先级等。如果不指定,会新建一个默认的线程工厂。
1
2
3
4
5
6
7
8
9
10
11
12
13
14static class DefaultThreadFactory implements ThreadFactory {
// 省略属性
// 构造函数
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
// 省略
}RejectedExecutionHandler handler
拒绝处理策略,线程数量大于最大线程数就会采用拒绝处理策略,四种拒绝处理的策略为 :
ThreadPoolExecutor.AbortPolicy:默认拒绝处理策略,丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:丢弃新来的任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列头部(最旧的)的任务,然后重新尝试执行程序(如果再次失败,重复此过程)。
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务。
12.2.2 ThreadPoolExecutor的策略
线程池本身有一个调度线程,这个线程就是用于管理布控整个线程池里的各种任务和事务,例如创建线程、销毁线程、任务队列管理、线程队列管理等等。
故线程池也有自己的状态。ThreadPoolExecutor
类中使用了一些final int
常量变量来表示线程池的状态 ,分别为RUNNING
、SHUTDOWN
、STOP
、TIDYING
、TERMINATED
。
1 | // runState is stored in the high-order bits |
线程池创建后处于RUNNING状态。
调用
shutdown()
方法后处于SHUTDOWN状态,线程池不能接受新的任务,清除一些空闲worker,会等待阻塞队列的任务完成。调用
shutdownNow()
方法后处于STOP状态,线程池不能接受新的任务,中断所有线程,阻塞队列中没有被执行的任务全部丢弃。此时,poolsize=0
,阻塞队列的size
也为0。当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。接着会执行
terminated()
函数。ThreadPoolExecutor中有一个控制状态的属性叫
ctl
,它是一个AtomicInteger类型的变量。线程池状态就是通过AtomicInteger类型的成员变量ctl
来获取的。获取的
ctl
值传入runStateOf
方法,与~CAPACITY
位与运算(CAPACITY
是低29位全1的int变量)。~CAPACITY
在这里相当于掩码,用来获取ctl的高3位,表示线程池状态;而另外的低29位用于表示工作线程数线程池处在TIDYING状态时,执行完terminated()方法之后,就会由 TIDYING -> TERMINATED, 线程池被设置为TERMINATED状态。
12.2.3 线程池主要的任务处理流程
处理任务的核心方法是execute
,我们看看 JDK 1.8 源码中ThreadPoolExecutor
是如何处理线程任务的:
1 | // JDK 1.8 |
ctl.get()
是获取线程池状态,用int
类型表示。第二步中,入队前进行了一次isRunning
判断,入队之后,又进行了一次isRunning
判断。
为什么要二次检查线程池的状态?
在多线程的环境下,线程池的状态是时刻发生变化的。很有可能刚获取线程池状态后线程池状态就改变了。
判断是否将command
加入workqueue
是线程池之前的状态。倘若没有二次检查,万一线程池处于非RUNNING状态(在多线程环境下很有可能发生),那么command
永远不会执行。
处理流程总结
- 线程总数量 <
corePoolSize
,无论线程是否空闲,都会新建一个核心线程执行任务(让核心线程数量快速达到corePoolSize
,在核心线程数量 <corePoolSize
时)。注意,这一步需要获得全局锁。 - 线程总数量 >=
corePoolSize
时,新来的线程任务会进入任务队列中等待,然后空闲的核心线程会依次去缓存队列中取任务来执行(体现了线程复用) - 当缓存队列满了,说明这个时候任务已经多到爆棚,需要一些“临时工”来执行这些任务。于是会创建非核心线程去执行这个任务。注意,这一步需要获得全局锁。
- 缓存队列满了,且总线程数达到了
maximumPoolSize
,则会采取上面提到的拒绝策略进行处理。
整个过程如下图所示:
12.2.4 ThreadPoolExecutor如何做到线程复用
我们知道,一个线程在创建的时候会指定一个线程任务,当执行完这个线程任务之后,线程自动销毁。
但是线程池却可以复用线程,即一个线程执行完线程任务后不销毁,继续执行另外的线程任务。那么,线程池如何做到线程复用呢?
原来,ThreadPoolExecutor
在创建线程时,会将线程封装成工作线程worker,并放入工作线程组中,然后这个worker
反复从阻塞队列中拿任务去执行。
这里的addWorker
方法是在上面提到的execute
方法里面调用的,先看看上半部分:
1 | // ThreadPoolExecutor.addWorker方法源码上半部分 |
上半部分主要是判断线程数量是否超出阈值,超过了就返回false。我们继续看下半部分:
1 | // ThreadPoolExecutor.addWorker方法源码下半部分 |
创建worker
对象,并初始化一个Thread
对象,然后启动这个线程对象。
我们接着看看Worker
类,仅展示部分源码:
1 | // Worker类部分源码 |
Worker
类实现了Runnable
接口,所以Worker
也是一个线程任务。
在构造方法中,创建了一个线程,线程的任务就是自己。
故addWorker
方法调用addWorker方法源码下半部分中的第4步t.start
,会触发Worker
类的run
方法被JVM调用。
我们再看看runWorker
的逻辑:
1 | // Worker.runWorker方法源代码 |
首先去执行创建这个 worker
时就有的任务,当执行完这个任务后,worker
的生命周期并没有结束,在 while
循环中,worker
会不断地调用 getTask
方法从 阻塞队列中获取任务然后调用 task.run()
执行任务,从而达到 复用线程的目的。只要 getTask
方法不返回 null
,此线程就不会退出。
当然,核心线程池中创建的线程想要拿到阻塞队列中的任务,先要判断线程池的状态,如果 STOP或者TERMINATED,返回 null
。
最后看看 getTask
方法的实现。
1 | // Worker.getTask方法源码 |
核心线程的会一直卡在workQueue.take
方法,被阻塞并挂起,不会占用CPU资源,直到拿到Runnable
然后返回(当然如果allowCoreThreadTimeOut设置为true
,那么核心线程就会去调用poll
方法,因为poll
可能会返回null
,所以这时候核心线程满足超时条件也会被销毁)。
非核心线程会workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS)
,如果超时还没有拿到,下一次循环判断compareAndDecrementWorkerCount就会返回null
,Worker对象的run()
方法循环体的判断为null
,任务结束,然后线程被系统回收 。
源码解析完毕,你理解的源码是否和图中的处理流程一致?如果不一致,那么就多看两遍吧,加油。
12.3 四种常见的线程池
Executors
类中提供的几个静态方法来创建线程池。大家到了这一步,如果看懂了前面讲的ThreadPoolExecutor
构造方法中各种参数的意义,那么一看到Executors
类中提供的线程池的源码就应该知道这个线程池是干嘛的。
12.3.1 newCachedThreadPool
1 | public static ExecutorService newCachedThreadPool() { |
CacheThreadPool
的运行流程如下:
- 提交任务进线程池
- 因为
corePoolSize
为0的关系,不创建核心线程,线程池最大为Integer.MAX_VALUE
。 - 尝试将任务添加到
SynchronousQueue
队列 - 如果
SynchronousQueue
入队成功,等待被当前运行的线程空闲后拉取执行。如果当前没有空闲线程,那么就创建一个非核心线程,然后从SynchronousQueue
拉取任务并在当前线程执行。 - 如果
SynchronousQueue
已有任务在等待,入队操作将会阻塞
当需要执行很多短时间的任务时,CacheThreadPool的线程复用率比较高, 会显著的提高性能。
而且线程60s后会回收,意味着即使没有任务进来,CacheThreadPool并不会占用很多资源。
12.3.2 newFixedThreadPool
1 | public static ExecutorService newFixedThreadPool(int nThreads) { |
核心线程数量和总线程数量相等,都是传入的参数nThreads
,所以只能创建核心线程,不能创建非核心线程。
因为LinkedBlockingQueue
的默认大小是Integer.MAX_VALUE,故如果核心线程空闲,则交给核心线程处理;如果核心线程不空闲,则入列等待,直到核心线程空闲。
与CachedThreadPool的区别:
- 因为
corePoolSize == maximumPoolSize
,所以FixedThreadPool
只会创建核心线程。 而CachedThreadPool
因为corePoolSize=0
,所以只会创建非核心线程。 - 在
getTask()
方法,如果队列里没有任务可取,线程会一直阻塞在LinkedBlockingQueue.take()
,线程不会被回收。CachedThreadPool
会在60s后收回。 - 由于线程不会被回收,会一直卡在阻塞,所以没有任务的情况下, FixedThreadPool占用资源更多。
- 都几乎不会触发拒绝策略,但是原理不同。
FixedThreadPool
是因为阻塞队列可以很大(最大为Integer最大值),故几乎不会触发拒绝策略;CachedThreadPool
是因为线程池很大(最大为Integer最大值),几乎不会导致线程数量大于最大线程数,故几乎不会触发拒绝策略。
12.3.3 newSingleThreadExecutor
1 | public static ExecutorService newSingleThreadExecutor() { |
有且仅有一个核心线程( corePoolSize == maximumPoolSize=1
),使用了LinkedBlockingQueue
(容量很大),所以,不会创建非核心线程。
所有任务按照先来先执行的顺序执行。如果这个唯一的线程不空闲,那么新来的任务会存储在任务队列里等待执行。
12.3.4 newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。
1 | public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { |
四种常见的线程池基本够我们使用了,但是《阿里巴巴开发手册》不建议我们直接使用Executors类中的线程池,而是通过ThreadPoolExecutor
的方式,这样的处理方式让写的同学需要更加明确线程池的运行规则,规避资源耗尽的风险。
但如果你及团队本身对线程池非常熟悉,又确定业务规模不会大到资源耗尽的程度(比如线程数量或任务队列长度可能达到Integer.MAX_VALUE)时,其实是可以使用JDK提供的这几个接口的,它能让我们的代码具有更强的可读性。
十三、阻塞队列
13.1 阻塞队列的由来
我们假设一种场景,生产者一直生产资源,消费者一直消费资源,资源存储在一个缓冲池中,生产者将生产的资源存进缓冲池中,消费者从缓冲池中拿到资源进行消费,这就是大名鼎鼎的生产者-消费者模式。
该模式能够简化开发过程,一方面消除了生产者类与消费者类之间的代码依赖性,另一方面将生产数据的过程与使用数据的过程解耦简化负载。
我们自己coding
实现这个模式的时候,因为需要让多个线程操作共享变量(即资源),所以很容易引发线程安全问题,造成重复消费和死锁,尤其是生产者和消费者存在多个的情况。
另外,当缓冲池空了,我们需要阻塞消费者,唤醒生产者;当缓冲池满了,我们需要阻塞生产者,唤醒消费者,这些个等待-唤醒逻辑都需要自己实现。
这么容易出错的事情,JDK当然帮我们做啦,这就是阻塞队列(BlockingQueue),你只管往里面存、取就行,而不用担心多线程环境下存、取共享变量的线程安全问题。
BlockingQueue是Java util.concurrent包下重要的数据结构,区别于普通的队列,BlockingQueue提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于BlockingQueue实现的。
BlockingQueue一般用于生产者-消费者模式,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。BlockingQueue就是存放元素的容器。
13.2 BlockingQueue的操作方法
阻塞队列提供了四组不同的方法用于插入、移除、检查元素:
方法\处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除方法 | remove() | poll() | take() | poll(time,unit) |
检查方法 | element() | peek() | - | - |
- 抛出异常:如果试图的操作无法立即执行,抛异常。当阻塞队列满时候,再往队列里插入元素,会抛出IllegalStateException(“Queue full”)异常。当队列为空时,从队列里获取元素时会抛出NoSuchElementException异常 。
- 返回特殊值:如果试图的操作无法立即执行,返回一个特殊值,通常是
true / false
。 - 一直阻塞:如果试图的操作无法立即执行,则一直阻塞或者响应中断。
- 超时退出:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功,通常是
true / false
。
注意之处
- 不能往阻塞队列中插入
null
,会抛出空指针异常。 - 可以访问阻塞队列中的任意元素,调用remove(o)可以将队列之中的特定对象移除,但并不高效,尽量避免使用。
13.3 BlockingQueue的实现类
13.3.1 ArrayBlockingQueue
由数组结构组成的有界阻塞队列。内部结构是数组,故具有数组的特性。
1 | public ArrayBlockingQueue(int capacity, boolean fair){ |
可以初始化队列大小, 且一旦初始化不能改变。构造方法中的fair
表示控制对象的内部锁是否采用公平锁,默认是非公平锁。
13.3.2 LinkedBlockingQueue
由链表结构组成的有界阻塞队列。内部结构是链表,具有链表的特性。默认队列的大小是Integer.MAX_VALUE
,也可以指定大小。此队列按照先进先出的原则对元素进行排序。
13.3.3 DelayQueue
1 | 该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。 |
13.3.4 PriorityBlockingQueue
1 | 基于优先级的无界阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定), |
13.3.5 SynchronousQueue
这个队列比较特殊,没有任何内部容量,甚至连一个队列的容量都没有。并且每个 put
必须等待一个 take
,反之亦然。
需要区别容量为1的ArrayBlockingQueue
、LinkedBlockingQueue
。
以下方法的返回值,可以帮助理解这个队列:
iterator()
永远返回空,因为里面没有东西peek()
永远返回null
put()
往queue
放进去一个element
以后就一直wait
直到有其他thread
进来把这个element
取走。offer()
往queue
里放一个element
后立即返回,如果碰巧这个element
被另一个thread
取走了,offer
方法返回true
,认为offer
成功;否则返回false
。take()
取出并且remove
掉queue
里的element
,取不到东西他会一直等。poll()
取出并且remove
掉queue
里的element
,只有到碰巧另外一个线程正在往queue
里offer
数据或者put
数据的时候,该方法才会取到东西。否则立即返回null
。isEmpty()
永远返回true
remove()&removeAll()
永远返回false
注意
PriorityBlockingQueue不会阻塞数据生产者(因为队列是无界的),而只会在没有可消费的数据时,阻塞数据的消费者。
因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
对于使用默认大小的LinkedBlockingQueue也是一样的。
13.5 阻塞队列的原理
阻塞队列的原理很简单,利用了Lock
锁的多条件(Condition
)阻塞控制。接下来我们分析ArrayBlockingQueue JDK 1.8
的源码。
首先是构造器,除了初始化队列的大小和是否是公平锁之外,还对同一个锁(lock
)初始化了两个监视器,分别是notEmpty
和notFull
。
这两个监视器的作用目前可以简单理解为标记分组,当该线程是put
操作时,给他加上监视器notFull
,标记这个线程是一个生产者;当线程是take
操作时,给他加上监视器notEmpty
,标记这个线程是消费者。
1 | //数据元素数组 |
put操作的源码
1 | public void put(E e) throws InterruptedException { |
总结put
的流程:
- 所有执行
put
操作的线程竞争lock
锁,拿到了lock
锁的线程进入下一步,没有拿到lock
锁的线程自旋竞争锁。 - 判断阻塞队列是否满了,如果满了,则调用
await
方法阻塞这个线程,并标记为notFull
(生产者)线程,同时释放lock
锁,等待被消费者线程唤醒。 - 如果没有满,则调用
enqueue
方法将元素put
进阻塞队列。注意这一步的线程还有一种情况是第二步中阻塞的线程被唤醒且又拿到了lock
锁的线程。 - 唤醒一个标记为
notEmpty
(消费者)的线程。
take操作的源码
1 | public E take() throws InterruptedException { |
take
操作和put
操作的流程是类似的,总结一下take
操作的流程:
put
和take
操作都需要先获取锁,没有获取到锁的线程会被挡在第一道大门之外自旋拿锁,直到获取到锁。- 就算拿到锁了之后,也不一定会顺利进行
put/take
操作,需要判断队列是否可用(是否满/空),如果不可用,则会被阻塞,并释放锁。 - 在第2点被阻塞的线程会被唤醒,但是在唤醒之后,依然需要拿到锁才能继续往下执行,否则,自旋拿锁,拿到锁了再while判断队列是否可用(这也是为什么不用if判断,而使用while判断的原因)。
13.6 示例和使用场景
13.6.1 生产者-消费者模型
1 | public class Test { |
下面是这个例子的输出片段:
1 | 从队列取走一个元素,队列剩余0个元素 |
注意,这个例子中的输出结果看起来可能有问题,比如有几行在插入一个元素之后,队列的剩余空间不变。
这是由于System.out.println语句没有锁。考虑到这样的情况:线程1在执行完put/take操作后立即失去CPU时间片,然后切换到线程2执行put/take操作,执行完毕后回到线程1的System.out.println语句并输出,发现这个时候阻塞队列的size已经被线程2改变了,所以这个时候输出的size并不是当时线程1执行完put/take操作之后阻塞队列的size,但可以确保的是size不会超过10个。实际上使用阻塞队列是没有问题的。
13.6.2 线程池中使用阻塞队列
1 | public ThreadPoolExecutor(int corePoolSize, |
Java中的线程池就是使用阻塞队列实现的,我们在了解阻塞队列之后,无论是使用Executors类中已经提供的线程池,还是自己通过ThreadPoolExecutor实现线程池,都会更加得心应手,想要了解线程池的同学,可以看第十二章:线程池原理。
注:上面提到了生产者-消费者模式,大家可以参考生产者-消费者模型,可以更好的理解阻塞队列。
十四、锁接口和类
前面我们介绍了Java原生的锁——基于对象的锁,它一般是配合synchronized
关键字来使用的。
实际上,Java在java.util.concurrent.locks
包下,还为我们提供了几个关于锁的类和接口。它们有更强大的功能或更高的性能。
14.1 synchronized
的不足之处
我们先来看看synchronized
有什么不足之处。
- 如果临界区是只读操作,其实可以多线程一起执行,但使用
synchronized
的话,同一时间只能有一个线程执行。 synchronized
无法知道线程有没有成功获取到锁- 使用
synchronized
,如果临界区因为IO或者sleep方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待。
而这些都是locks包下的锁可以解决的。
14.2 锁的几种分类
锁可以根据以下几种方式来进行分类,下面我们逐一介绍。
14.2.1 可重入锁和非可重入锁
所谓重入锁,顾名思义。就是支持重新进入的锁,也就是说这个锁支持一个线程对资源重复加锁。
synchronized
关键字就是使用的重入锁。比如说,你在一个synchronized
实例方法里面调用另一个本实例的synchronized
实例方法,它可以重新进入这个锁,不会出现任何异常。
如果我们自己在继承AQS
实现同步器的时候,没有考虑到占有锁的线程再次获取锁的场景,可能就会导致线程阻塞,那这个就是一个“非可重入锁”。
ReentrantLock
的中文意思就是可重入锁。也是本文后续要介绍的重点类。
14.2.2 公平锁与非公平锁
这里的“公平”,其实通俗意义来说就是“先来后到”,也就是FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。
一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。
ReentrantLock
支持非公平锁和公平锁两种。
14.2.3 读写锁与排它锁
我们前面讲到的synchronized
用的锁和ReentrantLock
,其实都是“排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。
而读写锁可以在同一时刻允许多个读线程访问。Java
提供了ReentrantReadWriteLock
类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。
注意,即使用读写锁,在写线程访问时,所有的读线程和其它写线程均被阻塞。
可见,只是synchronized是远远不能满足多样化的业务对锁的要求的。接下来我们介绍一下JDK中有关锁的一些接口和类。
14.3 JDK中有关锁的一些接口和类
众所周知,JDK中关于并发的类大多都在java.util.concurrent
(以下简称juc)包下。而juc.locks包看名字就知道,是提供了一些并发锁的工具类的。前面我们介绍的AQS(AbstractQueuedSynchronizer
)就是在这个包下。下面分别介绍一下这个包下的类和接口以及它们之间的关系。
14.3.1 抽象类AQS/AQLS/AOS
这三个抽象类有一定的关系,所以这里放到一起讲。
首先我们看AQS(AbstractQueuedSynchronizer),之前专门有章节介绍这个类,它是在JDK 1.5 发布的,提供了一个“队列同步器”的基本功能实现。而AQS里面的“资源”是用一个int
类型的数据来表示的,有时候我们的业务需求资源的数量超出了int
的范围,所以在JDK 1.6 中,多了一个AQLS(AbstractQueuedLongSynchronizer)。它的代码跟AQS几乎一样,只是把资源的类型变成了long
类型。
AQS和AQLS都继承了一个类叫AOS(AbstractOwnableSynchronizer
)。这个类也是在JDK 1.6 中出现的。这个类只有几行简单的代码。从源码类上的注释可以知道,它是用于表示锁与持有者之间的关系(独占模式)。可以看一下它的主要方法:
1 | // 独占模式,锁的持有者 |
14.3.2 接口Condition/Lock/ReadWriteLock
juc.locks包下共有三个接口:Condition
、Lock
、ReadWriteLock
。
其中,Lock和ReadWriteLock从名字就可以看得出来,分别是锁和读写锁的意思。
Lock接口里面有一些获取锁和释放锁的方法声明,而ReadWriteLock里面只有两个方法,分别返回“读锁”和“写锁”:
1 | public interface ReadWriteLock { |
Lock接口中有一个方法是可以获得一个Condition
:
1 | Condition newCondition(); |
之前我们提到了每个对象都可以用继承自Object
的wait/notify方法来实现等待/通知机制。而Condition接口也提供了类似Object监视器的方法,通过与Lock配合来实现等待/通知模式。
那为什么既然有Object的监视器方法了,还要用Condition呢?这里有一个二者简单的对比:
对比项 | Object监视器 | Condition |
---|---|---|
前置条件 | 获取对象的锁 | 调用Lock.lock获取锁,调用Lock.newCondition获取Condition对象 |
调用方式 | 直接调用,比如object.notify() | 直接调用,比如condition.await() |
等待队列的个数 | 一个 | 多个 |
当前线程释放锁进入等待状态 | 支持 | 支持 |
当前线程释放锁进入等待状态,在等待状态中不中断 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态直到将来的某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
Condition
和Object
的wait/notify
基本相似。其中,Condition
的await
方法对应的是Object
的wait
方法,而Condition
的signal/signalAll方法则对应Object
的notify/notifyAll()
。
但Condition类似于Object
的等待/通知机制的加强版。我们来看看主要的方法:
方法名称 | 描述 |
---|---|
await() | 当前线程进入等待状态直到被通知(signal)或者中断;当前线程进入运行状态并从await()方法返回的场景包括:(1)其他线程调用相同Condition对象的signal/signalAll方法,并且当前线程被唤醒;(2)其他线程调用interrupt方法中断当前线程; |
awaitUninterruptibly() | 当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程 |
awaitNanos(long) | 当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于0,可以认定就是超时了 |
awaitUntil(Date) | 当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回true,否则返回false |
signal() | 唤醒一个等待在Condition上的线程,被唤醒的线程在方法返回前必须获得与Condition对象关联的锁 |
signalAll() | 唤醒所有等待在Condition上的线程,能够从await()等方法返回的线程必须先获得与Condition对象关联的锁 |
14.3.3 ReentrantLock
ReentrantLock
是一个非抽象类,它是Lock接口的JDK默认实现,实现了锁的基本功能。
从名字上看,它是一个”可重入“锁,从源码上看,它内部有一个抽象类Sync
,是继承了AQS,自己实现的一个同步器。
同时,ReentrantLock内部有两个非抽象类NonfairSync
和FairSync
,它们都继承了Sync。从名字上看得出,分别是”非公平同步器“和”公平同步器“的意思。这意味着ReentrantLock可以支持”公平锁“和”非公平锁“。
通过看这两个同步器的源码可以发现,它们的实现都是”独占“的。都调用了AOS的setExclusiveOwnerThread
方法,所以ReentrantLock的锁是”独占“的,也就是说,它的锁都是”排他锁“,不能共享。
在ReentrantLock的构造方法里,可以传入一个boolean
类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过isFair()
方法来查看。
14.3.4 ReentrantReadWriteLock
这个类也是一个非抽象类,它是ReadWriteLock接口的JDK默认实现。
它与ReentrantLock的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。
ReentrantReadWriteLock内部的结构大概是这样:
1 | // 内部结构 |
可以看到,它同样是内部维护了两个同步器。且维护了两个Lock的实现类ReadLock
和WriteLock
。从源码可以发现,这两个内部类用的是外部类的同步器。
ReentrantReadWriteLock
实现了读写锁,但它有一个小弊端,就是在“写”操作的时候,其它线程不能写也不能读。我们称这种现象为“写饥饿”,将在后文的StampedLock
类继续讨论这个问题。
14.3.5 StampedLock
StampedLock
类是在Java 8 才发布的,也是Doug Lea大神所写,有人号称它为锁的性能之王。
它没有实现Lock
接口和ReadWriteLock
接口,但它其实是实现了“读写锁”的功能,并且性能比ReentrantReadWriteLock
更高。StampedLock
还把读锁分为了“乐观读锁”和“悲观读锁”两种。
前面提到了ReentrantReadWriteLock
会发生“写饥饿”的现象,但StampedLock
不会。
它是怎么做到的呢?
它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和CAS自旋的思想一样。
这种操作方式决定了StampedLock
在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。
这里篇幅有限,就不介绍StampedLock
的源码了,只是分析一下官方提供的用法(在JDK源码类声明的上方或Javadoc里可以找到)。
1 | class Point { |
乐观读锁的意思就是先假定在这个锁获取期间,共享变量不会被改变,既然假定不会被改变,那就不需要上锁。
在获取乐观读锁之后进行了一些操作,然后又调用了validate方法,这个方法就是用来验证tryOptimisticRead之后,是否有写操作执行过,如果有,则获取一个悲观读锁,这里的悲观读锁和ReentrantReadWriteLock中的读锁类似,也是个共享锁。
可以看到,StampedLock
获取锁会返回一个long
类型的变量,释放锁的时候再把这个变量传进去。简单看看源码:
1 | // 用于操作state后获取stamp的值 |
StampedLock
用这个long
类型的变量的前7位(LG_READERS
)来表示读锁,每获取一个悲观读锁,就加1(RUNIT
),每释放一个悲观读锁,就减1。
而悲观读锁最多只能装128个(7位限制),很容易溢出,所以用一个int类型的变量来存储溢出的悲观读锁。
写锁用state
变量剩下的位来表示,每次获取一个写锁,就加0000 1000 0000(WBIT)。需要注意的是,写锁在释放的时候,并不是减WBIT,而是再加WBIT。这是为了让每次写锁都留下痕迹,解决CAS中的ABA问题,也为乐观锁检查变化validate方法提供基础。
乐观读锁就比较简单了,并没有真正改变state的值,而是在获取锁的时候记录state的写状态,在操作完成后去检查state的写状态部分是否发生变化,上文提到了,每次写锁都会留下痕迹,也是为了这里乐观锁检查变化提供方便。
总的来说,StampedLock的性能是非常优异的,基本上可以取代ReentrantReadWriteLock的作用。
十五、并发容器集合
15.1 同步容器与并发容器
我们知道在java.util
包下提供了一些容器类,而Vector
和Hashtable
是线程安全的容器类,但是这些容器实现同步的方式是通过对方法加锁(sychronized)方式实现的,这样读写均需要锁操作,导致性能低下。
而即使是Vector
这样线程安全的类,在面对多线程下的复合操作的时候也是需要通过客户端加锁的方式保证原子性。如下面例子说明:
1 | public class TestVector { |
如果方法一和方法二为一个组合的话。那么当方法一获取到了vector
的size之后,方法二已经执行完毕,这样就导致程序的错误。
如果方法三与方法四组合的话。通过锁机制保证了在vector
上的操作的原子性。
并发容器是Java 5
提供的在多线程编程下用于代替同步容器,针对不同的应用场景进行设计,提高容器的并发访问性,同时定义了线程安全的复合操作。
15.2 并发容器类介绍
其中,阻塞队列(BlockingQueue)在第十三章有介绍,CopyOnWrite容器(CopyOnWritexxx)在第十六章有介绍,这里不做过多介绍。
下面分别介绍一些常用的并发容器类和接口,因篇幅原因,这里只介绍这些类的用途和基本的原理,不做过多的源码解析。
15.2.1 并发Map
ConcurrentMap接口
ConcurrentMap
接口继承了Map
接口,在Map
接口的基础上又定义了四个方法:
1 | public interface ConcurrentMap<K, V> extends Map<K, V> { |
putIfAbsent:与原有put
方法不同的是,putIfAbsent
方法中如果插入的key相同,则不替换原有的value值;
remove:与原有remove
方法不同的是,新remove
方法中增加了对value
的判断,如果要删除的key-value
不能与Map中原有的key-value
对应上,则不会删除该元素;
replace(K,V,V):增加了对value
值的判断,如果key-oldValue
能与Map中原有的key-value对应上,才进行替换操作;
replace(K,V):与上面的replace
不同的是,此replace
不会对Map中原有的key-value进行比较,如果key存在则直接替换;
ConcurrentHashMap类
ConcurrentHashMap
同HashMap
一样也是基于散列表的map
,但是它提供了一种与Hashtable
完全不同的加锁策略,提供更高效的并发性和伸缩性。
ConcurrentHashMap
在JDK 1.7 和JDK 1.8中有一些区别。这里我们分开介绍一下。
JDK 1.7
ConcurrentHashMap
在JDK 1.7中,提供了一种粒度更细的加锁机制来实现在多线程下更高的性能,这种机制叫分段锁(Lock Striping
)。
提供的优点是:在并发环境下将实现更高的吞吐量,而在单线程环境下只损失非常小的性能。
可以这样理解分段锁,就是将数据分段,对每一段数据分配一把锁。当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
有些方法需要跨段,比如size()
、isEmpty()
、containsValue()
,它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。如下图:
ConcurrentHashMap
是由Segment
数组结构和HashEntry
数组结构组成。Segment是一种可重入锁ReentrantLock,HashEntry则用于存储键值对数据。
一个ConcurrentHashMap
里包含一个Segment
数组,Segment
的结构和HashMap
类似,是一种数组和链表结构, 一个Segment
里包含一个HashEntry
数组,每个HashEntry
是一个链表结构的元素, 每个Segment
守护着一个HashEntry
数组里的元素,当对HashEntry
数组的数据进行修改时,必须首先获得它对应的Segment
锁。
JDK 1.8
而在JDK 1.8中,ConcurrentHashMap
主要做了两个优化:
- 同HashMap一样,链表也会在长度达到8的时候转化为红黑树,这样可以提升大量冲突时候的查询效率;
- 以某个位置的头结点(链表的头结点或红黑树的root结点)为锁,配合自旋+CAS避免不必要的锁开销,进一步提升并发性能。
ConcurrentNavigableMap接口与ConcurrentSkipListMap类
ConcurrentNavigableMap接口继承了NavigableMap
接口,这个接口提供了针对给定搜索目标返回最接近匹配项的导航方法。
ConcurrentNavigableMap
接口的主要实现类是ConcurrentSkipListMap
类。从名字上来看,它的底层使用的是跳表(SkipList)的数据结构。关于跳表的数据结构这里不做太多介绍,它是一种”空间换时间“的数据结构,可以使用CAS来保证并发安全性。
15.2.2 并发Queue
JDK并没有提供线程安全的List类,因为对List来说,很难去开发一个通用并且没有并发瓶颈的线程安全的List。
因为即使简单的读操作,拿contains()
这样一个操作来说,很难想到搜索的时候如何避免锁住整个list
。
所以退一步,JDK提供了对队列和双端队列的线程安全的类:ConcurrentLinkedQueue
和ConcurrentLinkedDeque
。因为队列相对于List
来说,有更多的限制。这两个类是使用CAS来实现线程安全的。
15.2.3 并发Set
JDK提供了ConcurrentSkipListSet
,是线程安全的有序的集合。底层是使用ConcurrentSkipListMap
实现。
谷歌的guava框架实现了一个线程安全的ConcurrentHashSet
:
1 | Set<String> s = Sets.newConcurrentHashSet(); |
十六、CopyOnWrite容器
16.1 什么是CopyOnWrite
容器
在说到CopyOnWrite
容器之前我们先来谈谈什么是CopyOnWrite
机制,CopyOnWrite
是计算机设计领域中的一种优化策略,也是一种在并发场景下常用的设计思想——写入时复制思想。
那什么是写入时复制思想呢?就是当有多个调用者同时去请求一个资源数据的时候,有一个调用者出于某些原因需要对当前的数据源进行修改,这个时候系统将会复制一个当前数据源的副本给调用者修改。
CopyOnWrite
容器即写入时复制的容器,当我们往一个容器中添加元素的时候,不直接往容器中添加,而是将当前容器进行copy
,复制出来一个新的容器,然后向新容器中添加我们需要的元素,最后将原容器的引用指向新容器。
这样做的好处在于,我们可以在并发的场景下对容器进行”读操作”而不需要”加锁”,从而达到读写分离的目的。
从JDK 1.5
开始Java并发包里提供了两个使用CopyOnWrite
机制实现的并发容器 ,分别是CopyOnWriteArrayList
和CopyOnWriteArraySet
。我们着重给大家介绍一下CopyOnWriteArrayList
。
16.2 CopyOnWriteArrayList
优点:CopyOnWriteArrayList经常被用于“读多写少”的并发场景,是因为CopyOnWriteArrayList无需任何同步措施,大大增强了读的性能。
在Java中遍历线程非安全的List(如:ArrayList
和 LinkedList
)的时候,若中途有别的线程对List容器进行修改,那么会抛出ConcurrentModificationException
异常。
CopyOnWriteArrayList
由于其”读写分离”,遍历和修改操作分别作用在不同的List容器,所以在使用迭代器遍历的时候,则不会抛出异常。
缺点:第一个缺点是CopyOnWriteArrayList
每次执行写操作都会将原容器进行拷贝一份,数据量大的时候,内存会存在较大的压力,可能会引起频繁Full GC(ZGC因为没有使用Full GC)。比如这些对象占用的内存200M左右,那么再写入100M数据进去,内存就会多占用300M。
第二个缺点是CopyOnWriteArrayList
由于实现的原因,写和读分别作用在不同新老容器上,在写操作执行过程中,读不会阻塞,但读取到的却是老容器的数据。
现在我们来看一下CopyOnWriteArrayList
的add
操作源码,它的逻辑很清晰,就是先把原容器进行copy
,然后在新的副本上进行“写操作”,最后再切换引用,在此过程中是加了锁的。
1 | public boolean add(E e) { |
我们再来看一下remove操作的源码,remove
的逻辑是将要remove
元素之外的其他元素拷贝到新的副本中,然后再将原容器的引用指向新的副本中,因为remove
操作也是“写操作”所以也是要加锁的。
1 | public E remove(int index) { |
我们再来看看CopyOnWriteArrayList
效率最高的读操作的源码
1 | public E get(int index) { |
由上可见“读操作”是没有加锁,直接读取。
16.3 copyOnWrite的业务中实现
接下来,我们结合具体业务场景来实现一个CopyOnWriteMap的并发容器并且使用它。
1 | import java.util.Collection; |
上面就是参考CopyOnWriteArrayList实现的CopyOnWriteMap,我们可以用这个容器来做什么呢?结合我们之前说的CopyOnWrite的复制思想,它最适用于“读多写少”的并发场景。
场景:假如我们有一个搜索的网站需要屏蔽一些“关键字”,“黑名单”每晚定时更新,每当用户搜索的时候,“黑名单”中的关键字不会出现在搜索结果当中,并且提示用户敏感字。
1 | // 黑名单服务 |
这里需要各位小伙伴特别特别注意一个问题,此处的场景是每晚凌晨“黑名单”定时更新,原因是CopyOnWrite容器有数据一致性的问题,它只能保证最终数据一致性。
所以如果我们希望写入的数据马上能准确地读取,请不要使用CopyOnWrite容器。
十七、通信工具类
JDK中提供了一些工具类以供开发者使用。这样的话我们在遇到一些常见的应用场景时就可以使用这些工具类,而不用自己再重复造轮子了。
它们都在java.util.concurrent
包下。先总体概括一下都有哪些工具类,它们有什么作用,然后再分别介绍它们的主要使用方法和原理。
类 | 作用 |
---|---|
Semaphore | 限制线程的数量 |
Exchanger | 两个线程交换数据 |
CountDownLatch | 线程等待直到计数器减为0时开始工作 |
CyclicBarrier | 作用跟CountDownLatch类似,但是可以重复使用 |
Phaser | 增强的CyclicBarrier |
下面分别介绍这几个类。
17.1 Semaphore
17.1.1 Semaphore介绍
Semaphore翻译过来是信号的意思。顾名思义,这个工具类提供的功能就是多个线程彼此“打信号”。而这个“信号”是一个int
类型的数据,也可以看成是一种“资源”。
可以在构造函数中传入初始资源总数,以及是否使用“公平”的同步器。默认情况下,是非公平的。
1 | // 默认情况下使用非公平 |
最主要的方法是acquire
方法和release
方法。acquire()
方法会申请一个permit
,而release方法会释放一个permit。当然,你也可以申请多个acquire(int permits)
或者释放多个release(int permits)
。
每次acquire,permits
就会减少一个或者多个。如果减少到了0,再有其他线程来acquire
,那就要阻塞这个线程直到有其它线程release permit
为止。
17.1.2 Semaphore案例
Semaphore
往往用于资源有限的场景中,去限制线程的数量。举个例子,我想限制同时只能有3个线程在工作:
1 | public class SemaphoreDemo { |
输出:
当前线程是1, 还剩2个资源,还有0个线程在等待
当前线程是0, 还剩1个资源,还有0个线程在等待
当前线程是6, 还剩0个资源,还有0个线程在等待
线程6释放了资源
当前线程是2, 还剩0个资源,还有6个线程在等待
线程2释放了资源
当前线程是4, 还剩0个资源,还有5个线程在等待
线程0释放了资源
当前线程是7, 还剩0个资源,还有4个线程在等待
线程1释放了资源
当前线程是8, 还剩0个资源,还有3个线程在等待
线程7释放了资源
当前线程是5, 还剩0个资源,还有2个线程在等待
线程4释放了资源
当前线程是3, 还剩0个资源,还有1个线程在等待
线程8释放了资源
当前线程是9, 还剩0个资源,还有0个线程在等待
线程9释放了资源
线程5释放了资源
线程3释放了资源
可以看到,在这次运行中,最开始是1,0,6这三个线程获得了资源,而其他线程进入了等待队列。
在某个线程释放资源后,就会有等待队列中的线程获得资源。
当然,Semphore
默认的acquire
方法是会让线程进入等待队列,且会抛出中断异常。但它还有一些方法可以忽略中断或不进入阻塞队列。
1 | // 忽略中断 |
17.1.3 Semaphore原理
Seamphore
内部有一个继承了AQS的同步器Sync
,重写了tryAcquireShared
方法。在这个方法里,会去尝试获取资源。
如果获取失败(想要的资源数量小于目前已有的资源数量),就会返回一个负数(代表尝试获取资源失败)。然后当前线程就会进入AQS的等待队列。
17.2 Exchanger
Exchanger
类用于两个线程交换数据。它支持泛型,也就是你可以在两个线程之间传送任何数据。先来一个案例看看如何使用,比如两个线程之间想要传送字符串:
1 | public class ExchangerDemo { |
输出:
1 | 这个时候线程A是阻塞的,在等待线程B的数据 |
可以看到,当一个线程调用exchange方法后,它是处于阻塞状态的,只有当另一个线程也调用了exchange方法,它才会继续向下执行。
看源码可以发现它是使用park/unpark来实现等待状态的切换的,但是在使用park/unpark方法之前,使用了CAS检查,估计是为了提高性能。
Exchanger一般用于两个线程之间更方便地在内存中交换数据,因为其支持泛型,所以我们可以传输任何的数据,比如IO流或者IO缓存。根据JDK里面的注释的说法,可以总结为一下特性:
- 此类提供对外的操作是同步的
- 用于成对出现的线程之间交换数据
- 可以视作双向的同步队列
- 可应用于基因算法、流水线设计等场景
Exchanger类还有一个有超时参数的方法,如果在指定时间内没有另一个线程调用exchange,就会抛出一个超时异常。
1 | public V exchange(V x, long timeout, TimeUnit unit) |
那么问题来了,Exchanger只能是两个线程交换数据吗?那三个调用同一个实例的exchange方法会发生什么呢?答案是只有前两个线程会交换数据,第三个线程会进入阻塞状态。
需要注意的是,exchange是可以重复使用的。也就是说。两个线程可以使用Exchanger在内存中不断地再交换数据。
17.3 CountDownLatch
17.3.1 CountDownLatch介绍
先来解读一下CountDownLatch这个类名字的意义。CountDown代表计数递减,Latch是“门闩”的意思。也有人把它称为“屏障”。而CountDownLatch这个类的作用也很贴合这个名字的意义,假设某个线程在执行任务之前,需要等待其它线程完成一些前置任务,必须等所有的前置任务都完成,才能开始执行本线程的任务。
CountDownLatch的方法也很简单,如下:
1 | // 构造方法: |
17.3.2 CountDownLatch案例
我们知道,玩游戏的时候,在游戏真正开始之前,一般会等待一些前置任务完成,比如“加载地图数据”,“加载人物模型”,“加载背景音乐”等等。只有当所有的东西都加载完成后,玩家才能真正进入游戏。下面我们就来模拟一下这个demo。
1 | public class CountDownLatchDemo { |
输出:
等待数据加载…
还有3个前置任务
加载人物模型 - 任务完成
加载背景音乐 - 任务完成
加载地图数据 - 任务完成
数据加载完成,正式开始游戏!
17.3.3 CountDownLatch原理
其实CountDownLatch类的原理挺简单的,内部同样是一个继承了AQS的实现类Sync,且实现起来还很简单,可能是JDK里面AQS的子类中最简单的实现了,有兴趣的读者可以去看看这个内部类的源码。
需要注意的是构造器中的计数值(count)实际上就是闭锁需要等待的线程数量。这个值只能被设置一次,而且CountDownLatch没有提供任何机制去重新设置这个计数值。
17.4 CyclicBarrier
17.4.1 CyclicBarrier介绍
CyclicBarrirer从名字上来理解是“循环的屏障”的意思。前面提到了CountDownLatch一旦计数值count
被降为0后,就不能再重新设置了,它只能起一次“屏障”的作用。而CyclicBarrier拥有CountDownLatch的所有功能,还可以使用reset()
方法重置屏障。
17.4.2 CyclicBarrier Barrier被破坏
如果参与者(线程)在等待的过程中,Barrier
被破坏,就会抛出BrokenBarrierException
。可以用isBroken()
方法检测Barrier是否被破坏。
- 如果有线程已经处于等待状态,调用reset方法会导致已经在等待的线程出现BrokenBarrierException异常。并且由于出现了BrokenBarrierException,将会导致始终无法等待。
- 如果在等待的过程中,线程被中断,会抛出InterruptedException异常,并且这个异常会传播到其他所有的线程。
- 如果在执行屏障操作过程中发生异常,则该异常将传播到当前线程中,其他线程会抛出BrokenBarrierException,屏障被损坏。
- 如果超出指定的等待时间,当前线程会抛出 TimeoutException 异常,其他线程会抛出BrokenBarrierException异常。
17.4.3 CyclicBarrier案例
我们同样用完游戏的例子。如果玩一个游戏有多个“关卡”,那使用CountDownLatch
显然不太合适,那需要为每个关卡都创建一个实例。我们可以使用CyclicBarrier
来实现每个关卡的数据加载等待功能。
1 | public class CyclicBarrierDemo{ |