深入浅出java多线程
1 进程与线程基本概念
1.1 进程产生的背景
最初的计算机只能接受一些特定的指令,用户每输入一个指令,计算机就做出一个操作。当用户在思考或者输入时,计算机就在等待。这样效率非常低下,在很多时候,计算机都处在等待状态。
批处理操作系统
后来有了批处理操作体统,把一系列需要操作的指令写下来,形成一个清单,一次性交给计算机。用户将多个需要执行的程序写在磁带上,然后交由计算机去读取并逐个执行这些程序,并将输出结果写在另一个磁带上。
批处理操作系统在一定程度上提高了计算机的效率,但是由于批处理操作系统的指令运行方式仍然是串行的,内存中始终只有一个程序在运行,后面的程序需要等待前面的程序执行完成后才能开始执行,而前面的程序有时会由于I/O操作、网络等原因阻塞,所以批处理操作效率也不高。
进程的提出
人们对于计算机的性能要求越来越高,现有的批处理操作系统并不能满足人们的需求,而批处理操作系统的瓶颈在于内存中只存在一个程序,那么内存中能不能存在多个程序呢?这是人们亟待解决的问题。
于是,科学家们提出了进程的概念。
进程就是应用程序在内存中分配的空间,也就是正在运行的程序,各个进程之间互不干扰。同时进程保存着程序每一个时刻运行的状态。
程序:用某种编程语言(java、python等)编写,能够完成一定任务或者功能的代码集合,是指令和数据的有序集合,是一段静态代码。
此时,CPU采用时间片轮转的方式运行进程:CPU为每个进程分配一个时间段,称作它的时间片。如果在时间片结束时进程还在运行,则暂停这个进程的运行,并且CPU分配给另一个进程(这个过程叫做上下文切换)。如果进程在时间片结束前阻塞或结束,则CPU立即进行切换,不用等待时间片用完。
当进程暂停时,它会保存当前进程的状态(进程标识,进程使用的资源等),在下一次切换回来时根据之前保存的状态进行恢复,接着继续执行。
使用进程+CPU时间片轮转方式的操作系统,在宏观上看起来同一时间段执行多个任务,换句话说,进程让操作体统的并发成为了可能。虽然并发从宏观上看有多个任务在执行,但在事实上,对于单核CPU来说,任意具体时刻都只有一个任务在占用CPU资源。
对操作系统的要求进一步提高
虽然进程的出现,使得操作系统的性能大大提升,但是随着时间的推移,人们并不满足一个进程在一段时间只能做一件事情,如果一个进程有多个子任务时,只能逐个得执行这些子任务,很影响效率。
比如杀毒软件在检测用户电脑时,如果在某一项检测中卡住了,那么后面的检测项也会受到影响。或者说当你使用杀毒软件中的扫描病毒功能时,在扫描病毒结束之前,无法使用杀毒软件中清理垃圾的功能,这显然无法满足人们的要求。
线程的提出
那么能不能让这些子任务同时执行呢?于是人们又提出了线程的概念,让一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。
使用线程之后,事情就变得简单多了。当用户使用扫描病毒功能时,就让扫描病毒这个线程去执行。同时,如果用户又使用清理垃圾功能,那么可以先暂停扫描病毒线程,先响应用户的清理垃圾的操作,让清理垃圾这个线程去执行。响应完后再切换回来,接着执行扫描病毒线程。
注意:操作系统是如何分配时间片给每一个线程的,涉及到线程的调度策略,有兴趣的同学可以看一下《操作系统》,本文不做深入详解。
总之,进程和线程的提出极大的提高了操作提供的性能。进程让操作系统的并发性成为了可能,而线程让进程的内部并发成为了可能。
多进程的方式也可以实现并发,为什么我们要使用多线程?
多进程方式确实可以实现并发,但使用多线程,有以下几个好处:
-
进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
-
进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。
进程和线程的区别
进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O):
-
进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
-
进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
-
进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。
另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位 。
1.2 上下文切换
上下文切换(有时也称做进程切换或任务切换)是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)。上下文是指某一时间点 CPU 寄存器和程序计数器的内容。
寄存器是cpu内部的少量的速度很快的闪存,通常存储和访问计算过程的中间值提高计算机程序的运行速度。
程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体实现依赖于特定的系统。
举例说明 线程A - B
1.先挂起线程A,将其在cpu中的状态保存在内存中。
2.在内存中检索下一个线程B的上下文并将其在 CPU 的寄存器中恢复,执行B线程。
3.当B执行完,根据程序计数器中指向的位置恢复线程A。
CPU通过为每个线程分配CPU时间片来实现多线程机制。CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。
但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的,意味着此操作会消耗大量的 CPU 时间,故线程也不是越多越好。如何减少系统中上下文切换次数,是提升多线程性能的一个重点课题。
参考资料
-
-
-
-
-
-
-
-
-
-
2 Java多线程入门类和接口
2.1 Thread类和Runnable接口
上一章我们了解了操作系统中多线程的基本概念。那么在Java中,我们是如何使用多线程的呢?
首先,我们需要有一个“线程”类。JDK提供了Thread
类和Runnalble
接口来让我们实现自己的“线程”类。
-
继承Thread
类,并重写run
方法;
-
实现Runnable
接口的run
方法;
2.1.1 继承Thread类
先学会怎么用,再学原理。首先我们来看看怎么用Thread
和Runnable
来写一个Java多线程程序。
首先是继承Thread
类:
1 |
|
注意要调用start()
方法后,该线程才算启动!
我们在程序里面调用了start()方法后,虚拟机会先为我们创建一个线程,然后等到这个线程第一次得到时间片时再调用run()方法。
注意不可多次调用start()方法。在第一次调用start()方法后,再次调用start()方法会抛出异常。
2.1.2 实现Runnable接口
接着我们来看一下Runnable
接口(JDK 1.8 +):
1 |
|
可以看到Runnable
是一个函数式接口,这意味着我们可以使用Java 8的函数式编程来简化代码。
示例代码:
1 |
|
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
的概念。
实际情况下,我们大多是直接调用下面两个构造方法:
1 |
|
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.2 Callable、Future与FutureTask
通常来说,我们使用Runnable
和Thread
来创建一个新的线程。但是它们有一个弊端,就是run
方法是没有返回值的。而有时候我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值。
JDK提供了Callable
接口与Future
类为我们解决这个问题,这也是所谓的“异步”模型。
2.2.1 Callable接口
Callable
与Runnable
类似,同样是只有一个抽象方法的函数式接口。不同的是,Callable
提供的方法是有返回值的,而且支持泛型。
1 |
|
那一般是怎么使用Callable
的呢?Callable
一般是配合线程池工具ExecutorService
来使用的。我们会在后续章节解释线程池的使用。这里只介绍ExecutorService
可以使用submit
方法来让一个Callable
接口执行。它会返回一个Future
,我们后续的程序可以通过这个Future
的get
方法得到结果。
这里可以看一个简单的使用demo:
1 |
|
cancel
方法是试图取消一个线程的执行。
注意是试图取消,并不一定能取消成功。因为任务可能已完成、已取消、或者一些其它因素不能取消,存在取消失败的可能。boolean
类型的返回值是“是否取消成功”的意思。参数paramBoolean
表示是否采用中断的方式取消线程执行。
所以有时候,为了让任务有能够取消的功能,就使用Callable
来代替Runnable
。如果为了可取消性而使用 Future
但又不提供可用的结果,则可以声明 Future<?>
形式类型、并返回 null
作为底层任务的结果。
2.2.3 FutureTask类
上面介绍了Future
接口。这个接口有一个实现类叫FutureTask
。FutureTask
是实现的RunnableFuture
接口的,而RunnableFuture
接口同时继承了Runnable
接口和Future
接口:
1 |
|
那FutureTask
类有什么用?为什么要有一个FutureTask
类?前面说到了Future
只是一个接口,而它里面的cancel
,get
,isDone
等方法要自己实现起来都是非常复杂的。所以JDK提供了一个FutureTask
类来供我们使用。
示例代码:
- ```java
/**state可能的状态转变路径如下:
NEW -> COMPLETING -> NORMAL
NEW -> COMPLETING -> EXCEPTIONAL
NEW -> CANCELLED
NEW -> INTERRUPTING -> INTERRUPTED
*/
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
> state表示任务的运行状态,初始状态为NEW。运行状态只会在set、setException、cancel方法中终止。COMPLETING、INTERRUPTING是任务完成后的瞬时状态。
以上就是Java多线程几个基本的类和接口的介绍。可以打开JDK看看源码,体会这几个类的设计思路和用途吧!
**参考资料**
-
[Java语言定义的线程状态分析](https://www.cnblogs.com/trust-freedom/p/6606594.html)
-
[Java线程状态分析](https://fangjian0423.github.io/2016/06/04/java-thread-state/)
-
[FutureTask源码分析](https://my.oschina.net/7001/blog/875658)
# 3 线程组和线程优先级
## 3.1 线程组(ThreadGroup)
Java中用ThreadGroup来表示线程组,我们可以使用线程组对线程进行批量控制。
ThreadGroup和Thread的关系就如同他们的字面意思一样简单粗暴,每个Thread必然存在于一个ThreadGroup中,Thread不能独立于ThreadGroup存在。执行main()方法线程的名字是main,如果在new Thread时没有显式指定,那么默认将父线程(当前执行new Thread的线程)线程组设置为自己的线程组。
示例代码:
```java
public class Demo {
public static void main(String[] args) {
Thread testThread = new Thread(() -> {
System.out.println("testThread当前线程组名字:" +
Thread.currentThread().getThreadGroup().getName());
System.out.println("testThread线程名字:" +
Thread.currentThread().getName());
});
testThread.start();
System.out.println("执行main方法线程名字:" + Thread.currentThread().getName());
}
}
输出结果:
1 |
|
ThreadGroup管理着它下面的Thread,ThreadGroup是一个标准的向下引用的树状结构,这样设计的原因是防止”上级”线程被”下级”线程引用而无法有效地被GC回收。
3.2 线程的优先级
Java中线程优先级可以指定,范围是1~10。但是并不是所有的操作系统都支持10级优先级的划分(比如有些操作系统只支持3级划分:低,中,高),Java只是给操作系统一个优先级的参考值,线程最终在操作系统的优先级是多少还是由操作系统决定。
Java默认的线程优先级为5,线程的执行顺序由调度程序来决定,线程的优先级会在线程被调用之前设定。
通常情况下,高优先级的线程将会比低优先级的线程有更高的几率得到执行。我们使用方法Thread
类的setPriority()
实例方法来设定线程的优先级。
1 |
|
输出结果:
1 |
|
既然有1-10的级别来设定了线程的优先级,这时候可能有些读者会问,那么我是不是可以在业务实现的时候,采用这种方法来指定一些线程执行的先后顺序?
对于这个问题,我们的答案是:No!
Java中的优先级来说不是特别的可靠,Java程序中对线程所设置的优先级只是给操作系统一个建议,操作系统不一定会采纳。而真正的调用顺序,是由操作系统的线程调度算法决定的。
我们通过代码来验证一下:
1 |
|
某次输出:
1 |
|
Java提供一个线程调度器来监视和控制处于RUNNABLE状态的线程。线程的调度策略采用抢占式,优先级高的线程比优先级低的线程会有更大的几率优先执行。在优先级相同的情况下,按照“先到先得”的原则。每个Java程序都有一个默认的主线程,就是通过JVM启动的第一个线程main线程。
还有一种线程称为守护线程(Daemon),守护线程默认的优先级比较低。
如果某线程是守护线程,那如果所以的非守护线程结束,这个守护线程也会自动结束。
应用场景是:当所有非守护线程结束时,结束其余的子线程(守护线程)自动关闭,就免去了还要继续关闭子线程的麻烦。
一个线程默认是非守护线程,可以通过Thread类的setDaemon(boolean on)来设置。
在之前,我们有谈到一个线程必然存在于一个线程组中,那么当线程和线程组的优先级不一致的时候将会怎样呢?我们用下面的案例来验证一下:
1 |
|
输出:
我是线程组的优先级6 我是线程的优先级6
所以,如果某个线程优先级大于线程所在线程组的最大优先级,那么该线程的优先级将会失效,取而代之的是线程组的最大优先级。
3.3 线程组的常用方法及数据结构
3.3.1 线程组的常用方法
获取当前的线程组名字
1 |
|
复制线程组
1 |
|
线程组统一异常处理
1 |
|
3.3.2 线程组的数据结构
线程组还可以包含其他的线程组,不仅仅是线程。
首先看看 ThreadGroup
源码中的成员变量
1 |
|
然后看看构造函数:
1 |
|
第三个构造函数里调用了checkParentAccess
方法,这里看看这个方法的源码:
1 |
|
这里涉及到
SecurityManager
这个类,它是Java的安全管理器,它允许应用程序在执行一个可能不安全或敏感的操作前确定该操作是什么,以及是否是在允许执行该操作的安全上下文中执行它。应用程序可以允许或不允许该操作。比如引入了第三方类库,但是并不能保证它的安全性。
其实Thread类也有一个checkAccess()方法,不过是用来当前运行的线程是否有权限修改被调用的这个线程实例。(Determines if the currently running thread has permission to modify this thread.)
总结来说,线程组是一个树状的结构,每个线程组下面可以有多个线程或者线程组。线程组可以起到统一控制线程的优先级和检查线程的权限的作用。
参考资料
-
https://blog.csdn.net/Evankaka/article/details/51627380
-
《Java并发编程实践》
-
《Java多线程编程核心技术》
4 Java线程的状态及主要转化方法
4 Java线程的状态及主要转化方法
4.1 操作系统中的线程状态转换
首先我们来看看操作系统中的线程状态转换。
在现在的操作系统中,线程是被视为轻量级进程的,所以操作系统线程的状态其实和操作系统进程的状态是一致的。
系统进程/线程转换图
操作系统线程主要有以下三个状态:
-
就绪状态(ready):线程正在等待使用CPU,经调度程序调用之后可进入running状态。
-
执行状态(running):线程正在使用CPU。
-
等待状态(waiting): 线程经过等待事件的调用或者正在等待其他资源(如I/O)。
-
4.2 Java线程的6个状态
1 |
|
4.2.1 NEW
处于NEW状态的线程此时尚未启动。这里的尚未启动指的是还没调用Thread实例的start()方法。
1 |
|
从上面可以看出,只是创建了线程而并没有调用start()方法,此时线程处于NEW状态。
关于start()的两个引申问题
反复调用同一个线程的start()方法是否可行?
假如一个线程执行完毕(此时处于TERMINATED状态),再次调用这个线程的start()方法是否可行?
要分析这两个问题,我们先来看看start()的源码:
1 |
|
我们可以看到,在start()内部,这里有一个threadStatus的变量。如果它不等于0,调用start()是会直接抛出异常的。
我们接着往下看,有一个native的start0()
方法。这个方法里并没有对threadStatus的处理。到了这里我们仿佛就拿这个threadStatus没辙了,我们通过debug的方式再看一下:
1 |
|
我是在start()方法内部的最开始打的断点,叙述下在我这里打断点看到的结果:
-
第一次调用时threadStatus的值是0。
-
第二次调用时threadStatus的值不为0。
查看当前线程状态的源码:
1 |
|
所以,我们结合上面的源码可以得到引申的两个问题的结果:
两个问题的答案都是不可行,在调用一次start()之后,threadStatus的值会改变(threadStatus !=0),此时再次调用start()方法会抛出IllegalThreadStateException异常。
比如,threadStatus为2代表当前线程状态为TERMINATED。
4.2.2 RUNNABLE
表示当前线程正在运行中。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待其他系统资源(比如I/O)。
Java中线程的RUNNABLE状态
看了操作系统线程的几个状态之后我们来看看Thread源码里对RUNNABLE状态的定义:
/**
Thread state for a runnable thread. A thread in the runnable
state is executing in the Java virtual machine but it may
be waiting for other resources from the operating system
such as processor.
*/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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
> 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先主动释放了锁。此时t1等待期间就属于TIMED_WATING状态。
>
> t1等待10分钟后,就自动唤醒,拥有了去争夺锁的资格。
## 4.2.6 TERMINATED
终止状态。此时线程已执行完毕。
# 4.3 线程状态的转换
根据上面关于线程状态的介绍我们可以得到下面的**线程状态转换图**: 
## 4.3.1 BLOCKED与RUNNABLE状态的转换
我们在上面说到:处于BLOCKED状态的线程是因为在等待锁的释放。假如这里有两个线程a和b,a线程提前获得了锁并且暂未释放锁,此时b就处于BLOCKED状态。我们先来看一个例子:
```java
@Test
public void blockedTest() {
Thread a = new Thread(new Runnable() {
@Override
public void run() {
testMethod();
}
}, "a");
Thread b = new Thread(new Runnable() {
@Override
public void run() {
testMethod();
}
}, "b");
a.start();
b.start();
System.out.println(a.getName() + ":" + a.getState()); // 输出?
System.out.println(b.getName() + ":" + b.getState()); // 输出?
}
// 同步方法争夺锁
private synchronized void testMethod() {
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
初看之下,大家可能会觉得线程a会先调用同步方法,同步方法内又调用了Thread.sleep()方法,必然会输出TIMED_WAITING,而线程b因为等待线程a释放锁所以必然会输出BLOCKED。
其实不然,有两点需要值得大家注意,一是在测试方法blockedTest()内还有一个main线程,二是启动线程后执行run方法还是需要消耗一定时间的。不打断点的情况下,上面代码中都应该输出RUNNABLE。
测试方法的main线程只保证了a,b两个线程调用start()方法(转化为RUNNABLE状态),还没等两个线程真正开始争夺锁,就已经打印此时两个线程的状态(RUNNABLE)了。
这时你可能又会问了,要是我想要打印出BLOCKED状态我该怎么处理呢?其实就处理下测试方法里的main线程就可以了,你让它“休息一会儿”,打断点或者调用Thread.sleep方法就行。
这里需要注意的是main线程休息的时间,要保证在线程争夺锁的时间内,不要等到前一个线程锁都释放了你再去争夺锁,此时还是得不到BLOCKED状态的。
我们把上面的测试方法blockedTest()改动一下:
1 |
|
在这个例子中,由于main线程休眠,所以线程a的run()方法跟着执行,线程b再接着执行。
在线程a执行run()调用testMethod()之后,线程a休眠了2000ms(注意这里是没有释放锁的),main线程休眠完毕,接着b线程执行的时候是争夺不到锁的,所以这里输出:
1 |
|
4.3.2 WAITING状态与RUNNABLE状态的转换
根据转换图我们知道有3个方法可以使线程从RUNNABLE状态转为WAITING状态。我们主要介绍下**Object.wait()和Thread.join()**。 Object.wait()
调用wait()方法前线程必须持有对象的锁。
线程调用wait()方法时,会释放当前的锁,直到有其他线程调用notify()/notifyAll()方法唤醒等待锁的线程。
需要注意的是,其他线程调用notify()方法只会唤醒单个等待锁的线程,如有有多个线程都在等待这个锁的话不一定会唤醒到之前调用wait()方法的线程。
同样,调用notifyAll()方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。
Thread.join()
调用join()方法不会释放锁,会一直等待当前线程执行完毕(转换为TERMINATED状态)。
我们再把上面的例子线程启动那里改变一下:
1 |
|
要是没有调用join方法,main线程不管a线程是否执行完毕都会继续往下走。
a线程启动之后马上调用了join方法,这里main线程就会等到a线程执行完毕,所以这里a线程打印的状态固定是TERMIATED。
至于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
7
8
public void blockedTest() {
······
a.start();
a.join(1000L);
b.start();
System.out.println(a.getName() + ":" + a.getState()); // 输出 TIEMD_WAITING
System.out.println(b.getName() + ":" + b.getState());
}这里调用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,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己而定,可以在合适的实际处理中断请求,也可以完全不处理继续执行下去。
参考资料
-
JDK 1.8 源码
-
-
-
-
-
5 Java线程间的通信
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰。但是当我们需要多个线程之间相互协作的时候,就需要我们掌握Java线程的通信方式。本文将介绍Java线程之间的几种通信原理。
5.1 锁与同步
在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。线程和锁的关系,我们可以用婚姻关系来理解。一个锁同一时间只能被一个线程持有。也就是说,一个锁如果和一个线程“结婚”(持有),那其他线程如果需要得到这个锁,就得等这个线程和这个锁“离婚”(释放)。
在我们的线程之间,有一个同步的概念。什么是同步呢,假如我们现在有2位正在抄暑假作业答案的同学:线程A和线程B。当他们正在抄的时候,老师突然来修改了一些答案,可能A和B最后写出的暑假作业就不一样。我们为了A,B能写出2本相同的暑假作业,我们就需要让老师先修改答案,然后A,B同学再抄。或者A,B同学先抄完,老师再修改答案。这就是线程A,线程B的线程同步。
可以以解释为:线程同步是线程之间按照一定的顺序执行。
为了达到线程同步,我们可以使用锁来实现它。
我们先来看看一个无锁的程序:
1 |
|
执行这个程序,你会在控制台看到,线程A和线程B各自独立工作,输出自己的打印值。如下是我的电脑上某一次运行的结果。每一次运行结果都会不一样。
1 |
|
那我现在有一个需求,我想等A先执行完之后,再由B去执行,怎么办呢?最简单的方式就是使用一个“对象锁”:
1 |
|
这里声明了一个名字为lock
的对象锁。我们在ThreadA
和ThreadB
内需要同步的代码块里,都是用synchronized
关键字加上了同一个对象锁lock
。
上文我们说到了,根据线程和锁的关系,同一时间只有一个线程持有一个锁,那么线程B就会等线程A执行完成后释放lock
,线程B才能获得锁lock
。
这里在主线程里使用sleep方法睡眠了10毫秒,是为了防止线程B先得到锁。因为如果同时start,线程A和线程B都是出于就绪状态,操作系统可能会先让B运行。这样就会先输出B的内容,然后B执行完成之后自动释放锁,线程A再执行。
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 |
|
在这个Demo里,线程A和线程B首先打印出自己需要的东西,然后使用notify()
方法叫醒另一个正在等待的线程,然后自己使用wait()
方法陷入等待并释放lock
锁。
需要注意的是等待/通知机制使用的是使用同一个对象锁,如果你两个线程使用的是不同的对象锁,那它们之间是不能用等待/通知机制通信的。
5.3 信号量
JDK提供了一个类似于“信号量”功能的类Semaphore
。但本文不是要介绍这个类,而是介绍一种基于volatile
关键字的自己实现的信号量通信。
后面会有专门的章节介绍volatile
关键字,这里只是做一个简单的介绍。
volitile关键字能够保证内存的可见性,如果用volitile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。
比如我现在有一个需求,我想让线程A输出0,然后线程B输出1,再然后线程A输出2…以此类推。我应该怎样实现呢?
代码:
1 |
|
我们可以看到,使用了一个volatile
变量signal
来实现了“信号量”的模型。这里需要注意的是,volatile
变量需要进行原子操作。signal++
并不是一个原子操作,所以我们需要使用synchronized
给它“上锁”。
这种实现方式并不一定高效,本例只是演示信号量
信号量的应用场景:
假如在一个停车场中,车位是我们的公共资源,线程就如同车辆,而看门的管理员就是起的“信号量”的作用。
因为在这种场景下,多个线程(超过2个)需要相互合作,我们用简单的“锁”和“等待通知机制”就不那么方便了。这个时候就可以用到信号量。
其实JDK中提供的很多多线程通信工具类都是基于信号量模型的。我们会在后面第三篇的文章中介绍一些常用的通信工具类。
5.4 管道
管道是基于“管道流”的通信方式。JDK提供了PipedWriter
、 PipedReader
、 PipedOutputStream
、 PipedInputStream
。其中,前面两个是基于字符的,后面两个是基于字节流的。
这里的示例代码使用的是基于字符的:
1 |
|
我们通过线程的构造函数,传入了PipedWrite
和PipedReader
对象。可以简单分析一下这个示例代码的执行流程:
1.
线程ReaderThread开始执行,
2.
线程ReaderThread使用管道reader.read()进入”阻塞“,
3.
线程WriterThread开始执行,
4.
线程WriterThread用writer.write(“test”)往管道写入字符串,
5.
线程WriterThread使用writer.close()结束管道写入,并执行完毕,
6.
线程ReaderThread接受到管道输出的字符串并打印,
7.
线程ReaderThread执行完毕。
管道通信的应用场景:
这个很好理解。使用管道多半与I/O流相关。当我们一个线程需要先另一个线程发送一个信息(比如字符串)或者文件等等时,就需要使用管道通信了。
5.5 其它通信相关
以上介绍了一些线程间通信的基本原理和方法。除此以外,还有一些与线程通信相关的知识点,这里一并介绍。
5.5.1 join方法
join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。
有时候,主线程创建并启动了子线程,如果子线程中需要进行大量的耗时运算,主线程往往将早于子线程结束之前结束。
如果主线程想等待子线程执行完毕后,获得子线程中的处理完的某个数据,就要用到join方法了。
示例代码:
1 |
|
注意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方法。示例代码:
1 |
|
可以看到,虽然两个线程使用的同一个ThreadLocal实例(通过构造方法传入),但是它们各自可以存取自己当前线程的一个值。
那ThreadLocal有什么作用呢?如果只是单纯的想要线程隔离,在每个线程中声明一个私有变量就好了呀,为什么要使用ThreadLocal?
如果开发者希望将类的某个静态变量(user ID或者transaction ID)与线程状态关联,则可以考虑使用ThreadLocal。
最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。数据库连接和Session管理涉及多个复杂对象的初始化和关闭。如果在每个线程中声明一些私有变量来进行操作,那这个线程就变得不那么“轻量”了,需要频繁的创建和关闭连接。
5.5.4 InheritableThreadLocal
InheritableThreadLocal类与ThreadLocal类稍有不同,Inheritable是继承的意思。它不仅仅是当前线程可以存取副本值,而且它的子线程也可以存取这个副本值。
参考资料
-
JDK 1.8 源码
-
-
-
6 Java内存模型基础知识
6.1 并发编程模型的两个关键问题
线程间如何通信?即:线程之间以何种机制来交换信息
线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序
有两种并发模型可以解决这两个问题:
消息传递并发模型
共享内存并发模型
这两种模型之间的区别如下表所示:
在Java中,使用的是共享内存并发模型。
6.2 Java内存模型的抽象结构
6.2.1 运行时内存的划分
先谈一下运行时数据区,下面这张图相信大家一点都不陌生:
对于每一个线程来说,栈都是私有的,而堆是共有的。
也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。
所以,内存可见性是针对的共享变量。
6.2.2 既然堆是共享的,为什么在堆中会有内存不可见问题?
这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。
线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:
image
从图中可以看出: 1. 所有的共享变量都存在主内存中。 2. 每个线程都保存了一份该线程使用到的共享变量的副本。 3. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤: 1. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。 2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。
所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。
注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。
所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。
那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。
Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。这里涉及到的所有内容后面都会有专门的章节介绍。
6.2.3 JMM与Java内存区域划分的区别与联系
上面两小节分别提到了JMM和Java运行时内存区域的划分,这两者既有差别又有联系:
-
区别
两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。
-
联系
都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
实际上,他们表达的是同一种含义,这里不做区分。
参考资料
- 《Java并发编程的艺术》
- 《实战Java高并发程序设计》
7 重排序与happens-before
7.1 什么是重排序?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
我们分析一下下面这个代码的执行情况:
a = b + c;
d = e - f ;
先加载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和顺序一致性内存模型中的执行特性有如下差异: 1. 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是JMM保证单线程下的重排序不影响执行结果) 2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。(因为JMM不保证所有操作立即可见) 3. 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关系的定义如下: 1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 2. 两个操作之间存在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 |
|
根据以上介绍的happens-before规则,假如只有一个线程,那么不难得出:1
1 |
|
Copied!
注意,真正在执行指令的时候,其实JVM有可能对操作A & B进行重排序,因为无论先执行A还是B,他们都对对方是可见的,并且不影响执行结果。
如果这里发生了重排序,这在视觉上违背了happens-before原则,但是JMM是允许这样的重排序的。
所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。
重排序有两类,JMM对这两类重排序有不同的策略:
- 会改变程序执行结果的重排序,比如 A -> C,JMM要求编译器和处理器都不许禁止这种重排序。
- 不会改变程序执行结果的重排序,比如 A -> B,JMM对编译器和处理器不做要求,允许这种重排序。
参考资料
- 《Java并发编程的艺术》
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 |
|
在这段代码里,我们使用volatile
关键字修饰了一个boolean
类型的变量flag
。
所谓内存可见性,指的是当一个线程对volatile
修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile
修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。
假设在时间线上,线程A先自行方法writer
方法,线程B后执行reader
方法。那必然会有下图:
Could not load image
volatile内存示意图
而如果flag
变量没有用volatile
修饰,在step 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false
。
8.2.1 禁止重排序
在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的写入操作对其它处理器可见。 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也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:
1 |
|
如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:
1 |
|
而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候线程A执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
所以JSR-133对volatile做了增强后,volatile的禁止重排序功能还是非常有用的。
参考资料
- happens-before规则和as-if-serial语义
- volatile关键字详解
- Java可见性机制的原理
- Volatile关键字介绍
- Java中Volatile关键字详解
- JVM(十一)Java指令重排序
- 深入理解JVM(二)——内存模型、可见性、指令重排序
- JMM-volatile与内存屏障
- 并发关键字volatile(重排序和内存屏障)
9 synchronized与锁
这篇文章我们来聊一聊Java多线程里面的“锁”。
首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。
还有一点需要注意的是,我们常听到的类锁其实也是对象锁。
Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。
9.1 Synchronized关键字
说到锁,我们通常会谈到synchronized
这个关键字。它翻译成中文就是“同步”的意思。
我们通常使用synchronized
关键字来给一段代码或一个方法上锁。它通常有以下三种形式:
1 |
|
我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized
关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。
通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:
1 |
|
同理,下面这两个方法也应该是等价的:
// 关键字在静态方法上,锁为当前Class对象
1 |
|
9.2 几种锁
Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。
几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。
关于锁降级有两点说明:
1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。
2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。
下面分别介绍这几种锁以及它们之间的升级。
9.2.1 Java对象头
前面我们提到,Java的锁都是基于对象的。首先我们来看看一个对象的“锁”的信息是存放在什么地方的。
每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位。对象头的内容如下表:
我们主要来看看Mark Word的格式:
可以看到,当对象状态为偏向锁时,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 |
|
Copied!
下面这个经典的图总结了偏向锁的获得和撤销:
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 |
|
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个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并发编程的艺术》:
参考文章
- Java锁优化–JVM锁降级
- Java中的锁机制
- 死磕Synchronized底层实现
- 《Java并发编程的艺术》
10 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 |
|
当然,他们都是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个类:
原子类
从名字就可以看得出来这些类大概的用途:
- 原子更新基本类型
- 原子更新数组
- 原子更新引用
- 原子更新字段(属性)
这里我们以AtomicInteger
类的getAndAdd(int delta)
方法为例,来看看Java是如何实现原子操作的。
先看看这个方法的源码:
1 |
|
这里的U其实就是一个Unsafe
对象:
1 |
|
所以其实AtomicInteger
类的getAndAdd(int delta)
方法是调用Unsafe
类的方法来实现的:
1 |
|
注:这个方法是在JDK 1.8才新增的。在JDK1.8之前,
AtomicInteger
源码实现有所不同,是基于for死循环的,有兴趣的读者可以自行了解一下。
我们来一步步解析这段源码。首先,对象o
是this
,也就是一个AtomicInteger
对象。然后offset
是一个常量VALUE
。这个常量是在AtomicInteger
类中声明的:
1 |
|
同样是调用的Unsafe
的方法。从方法名字上来看,是得到了一个对象字段偏移量。
用于获取某个字段相对Java对象的“起始地址”的偏移量。
一个java对象可以看成是一段内存,各个字段都得按照一定的顺序放在这段内存里,同时考虑到对齐要求,可能这些字段不是连续放置的,
用这个方法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象又没什么太大关系,跟class的定义和虚拟机的内存模型的实现细节更相关。
继续看源码。前面我们讲到,CAS是“无锁”的基础,它允许更新失败。所以经常会与while循环搭配,在失败后不断去重试。
这里声明了一个v,也就是要返回的值。从getAndAddInt
来看,它返回的应该是原来的值,而新的值的v + delta
。
这里使用的是do-while循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证return 的值v
是我们期望的值。
循环体的条件是一个CAS方法:
1 |
|
可以看到,最终其实是调用的我们之前说到了CAS native
方法。那为什么要经过一层weakCompareAndSetInt
呢?从JDK源码上看不出来什么。在JDK 8及之前的版本,这两个方法是一样的。
而在JDK 9开始,这两个方法上面增加了@HotSpotIntrinsicCandidate注解。这个注解允许HotSpot VM自己来写汇编或IR编译器来实现该方法以提供性能。也就是说虽然外面看到的在JDK9中weakCompareAndSet和compareAndSet底层依旧是调用了一样的代码,但是不排除HotSpot VM会手动来实现weakCompareAndSet真正含义的功能的可能性。
根据本文第一篇参考文章(文末链接),它跟volitile
有关。
简单来说,weakCompareAndSet
操作仅保留了volatile
自身变量的特性,而出去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet
无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。这在一定程度上可以提高性能。
再回到循环条件上来,可以看到它是在不断尝试去用CAS更新。如果更新失败,就继续重试。那为什么要把获取“旧值”v的操作放到循环体内呢?其实这也很好理解。前面我们说了,CAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内。
10.5 CAS实现原子操作的三大问题
这里介绍一下CAS实现原子操作的三大问题及其解决方案。
10.5.1 ABA问题
所谓ABA问题,就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。
ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference
类来解决ABA问题。
这个类的compareAndSet
方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。
1 |
|
10.5.2 循环时间长开销大
CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。
解决思路是让JVM支持处理器提供的pause指令。
pause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。
10.5.3 只能保证一个共享变量的原子操作
这个问题你可能已经知道怎么解决了。有两种解决方案:
- 使用JDK 1.5开始就提供的
AtomicReference
类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作; - 使用锁。锁内的临界区代码可以保证只有当前线程能操作。
参考资料
- 对 volatile、compareAndSet、weakCompareAndSet 的一些思考
- 《Java 并发编程的艺术》