mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
del
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<audio id="audio" title="01 | 可见性、原子性和有序性问题:并发编程Bug的源头" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/4e/0d7670ed5a5b2b087dbe7516b2338a4e.mp3"></audio>
|
||||
|
||||
如果你细心观察的话,你会发现,不管是哪一门编程语言,并发类的知识都是在高级篇里。换句话说,这块知识点其实对于程序员来说,是比较进阶的知识。我自己这么多年学习过来,也确实觉得并发是比较难的,因为它会涉及到很多的底层知识,比如若你对操作系统相关的知识一无所知的话,那去理解一些原理就会费些力气。这是我们整个专栏的第一篇文章,我说这些话的意思是如果你在中间遇到自己没想通的问题,可以去查阅资料,也可以在评论区找我,以保证你能够跟上学习进度。
|
||||
|
||||
你我都知道,编写正确的并发程序是一件极困难的事情,并发程序的Bug往往会诡异地出现,然后又诡异地消失,很难重现,也很难追踪,很多时候都让人很抓狂。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些Bug的源头在哪里。
|
||||
|
||||
那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些Bug的源头。
|
||||
|
||||
## 并发程序幕后的故事
|
||||
|
||||
这些年,我们的CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个**核心矛盾一直存在,就是这三者的速度差异**。CPU和内存的速度差异可以形象地描述为:CPU是天上一天,内存是地上一年(假设CPU执行一条普通指令需要一天,那么CPU读写内存得等待一年的时间)。内存和I/O设备的速度差异就更大了,内存是天上一天,I/O设备是地上十年。
|
||||
|
||||
程序里大部分语句都要访问内存,有些还要访问I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写I/O设备,也就是说单方面提高CPU性能是无效的。
|
||||
|
||||
为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
|
||||
|
||||
1. CPU增加了缓存,以均衡与内存的速度差异;
|
||||
1. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
|
||||
1. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
|
||||
|
||||
现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。
|
||||
|
||||
## 源头之一:缓存导致的可见性问题
|
||||
|
||||
在单核时代,所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程A和线程B都是操作同一个CPU里面的缓存,所以线程A更新了变量V的值,那么线程B之后再访问变量V,得到的一定是V的最新值(线程A写过的值)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/da/a07e8182819e2b260ce85b2167d446da.png" alt="">
|
||||
|
||||
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为**可见性**。
|
||||
|
||||
多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。比如下图中,线程A操作的是CPU-1上的缓存,而线程B操作的是CPU-2上的缓存,很明显,这个时候线程A对变量V的操作对于线程B而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/ea/e2aa76928b2bc135e08e7590ca36e0ea.png" alt="">
|
||||
|
||||
下面我们再用一段代码来验证一下多核场景下的可见性问题。下面的代码,每执行一次add10K()方法,都会循环10000次count+=1操作。在calc()方法中我们创建了两个线程,每个线程调用一次add10K()方法,我们来想一想执行calc()方法得到的结果应该是多少呢?
|
||||
|
||||
```
|
||||
public class Test {
|
||||
private long count = 0;
|
||||
private void add10K() {
|
||||
int idx = 0;
|
||||
while(idx++ < 10000) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
public static long calc() {
|
||||
final Test test = new Test();
|
||||
// 创建两个线程,执行add()操作
|
||||
Thread th1 = new Thread(()->{
|
||||
test.add10K();
|
||||
});
|
||||
Thread th2 = new Thread(()->{
|
||||
test.add10K();
|
||||
});
|
||||
// 启动两个线程
|
||||
th1.start();
|
||||
th2.start();
|
||||
// 等待两个线程执行结束
|
||||
th1.join();
|
||||
th2.join();
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
直觉告诉我们应该是20000,因为在单线程里调用两次add10K()方法,count的值就是20000,但实际上calc()的执行结果是个10000到20000之间的随机数。为什么呢?
|
||||
|
||||
我们假设线程A和线程B同时开始执行,那么第一次都会将 count=0 读到各自的CPU缓存里,执行完 count+=1 之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发现内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的 count 值来计算,所以导致最终count的值都是小于20000的。这就是缓存的可见性问题。
|
||||
|
||||
循环10000次count+=1操作如果改为循环1亿次,你会发现效果更明显,最终count的值接近1亿,而不是2亿。如果循环10000次,count的值接近20000,原因是两个线程不是同时启动的,有一个时差。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/79/ec6743e74ccf9a3c6d6c819a41e52279.png" alt="">
|
||||
|
||||
## 源头之二:线程切换带来的原子性问题
|
||||
|
||||
由于IO太慢,早期的操作系统就发明了多进程,即便在单核的CPU上我们也可以一边听着歌,一边写Bug,这个就是多进程的功劳。
|
||||
|
||||
操作系统允许某个进程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个50毫秒称为“**时间片**”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/fb/254b129b145d80e9bb74123d6e620efb.png" alt="">
|
||||
|
||||
在一个时间片内,如果一个进程进行一个IO操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了。
|
||||
|
||||
这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来CPU的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。
|
||||
|
||||
是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix就是因为解决了这个问题而名噪天下的。
|
||||
|
||||
早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
|
||||
|
||||
Java并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如上面代码中的`count += 1`,至少需要三条CPU指令。
|
||||
|
||||
- 指令1:首先,需要把变量count从内存加载到CPU的寄存器;
|
||||
- 指令2:之后,在寄存器中执行+1操作;
|
||||
- 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
|
||||
|
||||
操作系统做任务切换,可以发生在任何一条**CPU指令**执行完,是的,是CPU指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/63/33777c468872cb9a99b3cdc1ff597063.png" alt="">
|
||||
|
||||
我们潜意识里面觉得count+=1这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在count+=1之前,也可以发生在count+=1之后,但就是不会发生在中间。**我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性**。CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
|
||||
|
||||
## 源头之三:编译优化带来的有序性问题
|
||||
|
||||
那并发编程里还有没有其他有违直觉容易导致诡异Bug的技术呢?有的,就是有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。
|
||||
|
||||
在Java领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance()的方法中,我们首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。
|
||||
|
||||
```
|
||||
public class Singleton {
|
||||
static Singleton instance;
|
||||
static Singleton getInstance(){
|
||||
if (instance == null) {
|
||||
synchronized(Singleton.class) {
|
||||
if (instance == null)
|
||||
instance = new Singleton();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设有两个线程A、B同时调用getInstance()方法,他们会同时发现 `instance == null` ,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程则会处于等待状态(假设是线程B);线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程B检查 `instance == null` 时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。
|
||||
|
||||
这看上去一切都很完美,无懈可击,但实际上这个getInstance()方法并不完美。问题出在哪里呢?出在new操作上,我们以为的new操作应该是:
|
||||
|
||||
1. 分配一块内存M;
|
||||
1. 在内存M上初始化Singleton对象;
|
||||
1. 然后M的地址赋值给instance变量。
|
||||
|
||||
但是实际上优化后的执行路径却是这样的:
|
||||
|
||||
1. 分配一块内存M;
|
||||
1. 将M的地址赋值给instance变量;
|
||||
1. 最后在内存M上初始化Singleton对象。
|
||||
|
||||
优化后会导致什么问题呢?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现 `instance != null` ,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/d8/64c955c65010aae3902ec918412827d8.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
要写好并发程序,首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,**只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以理解、可以诊断的**。
|
||||
|
||||
在介绍可见性、原子性、有序性的时候,特意提到**缓存**导致的可见性问题,**线程切换**带来的原子性问题,**编译优化**带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以**在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避**。
|
||||
|
||||
我们这个专栏在讲解每项技术的时候,都会尽量将每项技术解决的问题以及产生的问题讲清楚,也希望你能够在这方面多思考、多总结。
|
||||
|
||||
## 课后思考
|
||||
|
||||
常听人说,在32位的机器上对long型变量进行加减操作存在并发隐患,到底是不是这样呢?现在相信你一定能分析出来。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<audio id="audio" title="02 | Java内存模型:看Java如何解决可见性和有序性问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/a9/4e47fabe747154115d55f37975b4bea9.mp3"></audio>
|
||||
|
||||
上一期我们讲到在并发场景中,因可见性、原子性、有序性导致的问题常常会违背我们的直觉,从而成为并发编程的Bug之源。这三者在编程领域属于共性问题,所有的编程语言都会遇到,Java在诞生之初就支持多线程,自然也有针对这三者的技术方案,而且在编程语言领域处于领先地位。理解Java解决并发问题的解决方案,对于理解其他语言的解决方案有触类旁通的效果。
|
||||
|
||||
那我们就先来聊聊如何解决其中的可见性和有序性导致的问题,这也就引出来了今天的主角——**Java内存模型**。
|
||||
|
||||
Java内存模型这个概念,在职场的很多面试中都会考核到,是一个热门的考点,也是一个人并发水平的具体体现。原因是当并发程序出问题时,需要一行一行地检查代码,这个时候,只有掌握Java内存模型,才能慧眼如炬地发现问题。
|
||||
|
||||
## 什么是Java内存模型?
|
||||
|
||||
你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是**禁用缓存和编译优化**,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
|
||||
|
||||
合理的方案应该是**按需禁用缓存以及编译优化**。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
|
||||
|
||||
Java内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 **volatile**、**synchronized** 和 **final **三个关键字,以及六项 **Happens-Before 规则**,这也正是本期的重点内容。
|
||||
|
||||
## 使用volatile的困惑
|
||||
|
||||
volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。
|
||||
|
||||
例如,我们声明一个volatile变量 `volatile int x = 0`,它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
|
||||
|
||||
例如下面的示例代码,假设线程A执行writer()方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程B执行reader()方法,同样按照 volatile 语义,线程B会从内存中读取变量v,如果线程B看到 “v == true” 时,那么线程B看到的变量x是多少呢?
|
||||
|
||||
直觉上看,应该是42,那实际应该是多少呢?这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;如果在1.5以上的版本上运行,x就是等于42。
|
||||
|
||||
```
|
||||
// 以下代码来源于【参考1】
|
||||
class VolatileExample {
|
||||
int x = 0;
|
||||
volatile boolean v = false;
|
||||
public void writer() {
|
||||
x = 42;
|
||||
v = true;
|
||||
}
|
||||
public void reader() {
|
||||
if (v == true) {
|
||||
// 这里x会是多少呢?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
分析一下,为什么1.5以前的版本会出现x = 0的情况呢?我相信你一定想到了,变量x可能被CPU缓存而导致可见性问题。这个问题在1.5版本已经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。
|
||||
|
||||
## Happens-Before 规则
|
||||
|
||||
如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成“先行发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:**前面一个操作的结果对后续操作是可见的**。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。所以比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
|
||||
|
||||
Happens-Before 规则应该是Java内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。
|
||||
|
||||
恰好前面示例代码涉及到这六项规则中的前三项,为便于你理解,我也会分析上面的示例代码,来看看规则1、2和3到底该如何理解。至于其他三项,我也会结合其他例子作以说明。
|
||||
|
||||
### 1. 程序的顺序性规则
|
||||
|
||||
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第6行代码 “x = 42;” Happens-Before 于第7行代码 “v = true;”,这就是规则1的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。
|
||||
|
||||
(为方便你查看,我将那段示例代码在这儿再呈现一遍)
|
||||
|
||||
```
|
||||
// 以下代码来源于【参考1】
|
||||
class VolatileExample {
|
||||
int x = 0;
|
||||
volatile boolean v = false;
|
||||
public void writer() {
|
||||
x = 42;
|
||||
v = true;
|
||||
}
|
||||
public void reader() {
|
||||
if (v == true) {
|
||||
// 这里x会是多少呢?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2. volatile变量规则
|
||||
|
||||
这条规则是指对一个volatile变量的写操作, Happens-Before 于后续对这个volatile变量的读操作。
|
||||
|
||||
这个就有点费解了,对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和1.5版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则3,就有点不一样的感觉了。
|
||||
|
||||
### 3. 传递性
|
||||
|
||||
这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
|
||||
|
||||
我们将规则3的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/e1/b1fa541e98c74bc2a033d9ac5ae7fbe1.png" alt="">
|
||||
|
||||
从图中,我们可以看到:
|
||||
|
||||
1. “x=42” Happens-Before 写变量 “v=true” ,这是规则1的内容;
|
||||
1. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则2的内容 。
|
||||
|
||||
再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?
|
||||
|
||||
如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。也就是说,线程B能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是1.5版本对volatile语义的增强,这个增强意义重大,1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在后面的内容中会详细介绍。
|
||||
|
||||
### 4. 管程中锁的规则
|
||||
|
||||
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
|
||||
|
||||
要理解这个规则,就首先要了解“管程指的是什么”。**管程**是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。
|
||||
|
||||
管程中的锁在Java里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
|
||||
|
||||
```
|
||||
synchronized (this) { //此处自动加锁
|
||||
// x是共享变量,初始值=10
|
||||
if (this.x < 12) {
|
||||
this.x = 12;
|
||||
}
|
||||
} //此处自动解锁
|
||||
|
||||
```
|
||||
|
||||
所以结合规则4——管程中锁的规则,可以这样理解:假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。这个也是符合我们直觉的,应该不难理解。
|
||||
|
||||
### 5. 线程 start() 规则
|
||||
|
||||
这条是关于线程启动的。它是指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
|
||||
|
||||
换句话说就是,如果线程A调用线程B的 start() 方法(即在线程A中启动线程B),那么该start()操作 Happens-Before 于线程B中的任意操作。具体可参考下面示例代码。
|
||||
|
||||
```
|
||||
Thread B = new Thread(()->{
|
||||
// 主线程调用B.start()之前
|
||||
// 所有对共享变量的修改,此处皆可见
|
||||
// 此例中,var==77
|
||||
});
|
||||
// 此处对共享变量var修改
|
||||
var = 77;
|
||||
// 主线程启动子线程
|
||||
B.start();
|
||||
|
||||
```
|
||||
|
||||
### 6. 线程 join() 规则
|
||||
|
||||
这条是关于线程等待的。它是指主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对**共享变量**的操作。
|
||||
|
||||
换句话说就是,如果在线程A中,调用线程B的 join() 并成功返回,那么线程B中的任意操作Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。
|
||||
|
||||
```
|
||||
Thread B = new Thread(()->{
|
||||
// 此处对共享变量var修改
|
||||
var = 66;
|
||||
});
|
||||
// 例如此处对共享变量修改,
|
||||
// 则这个修改结果对线程B可见
|
||||
// 主线程启动子线程
|
||||
B.start();
|
||||
B.join()
|
||||
// 子线程所有对共享变量的修改
|
||||
// 在主线程调用B.join()之后皆可见
|
||||
// 此例中,var==66
|
||||
|
||||
```
|
||||
|
||||
## 被我们忽视的final
|
||||
|
||||
前面我们讲volatile为的是禁用缓存以及编译优化,我们再从另外一个方面来看,有没有办法告诉编译器优化得更好一点呢?这个可以有,就是**final关键字**。
|
||||
|
||||
**final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。**Java编译器在1.5以前的版本的确优化得很努力,以至于都优化错了。
|
||||
|
||||
问题类似于上一期提到的利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到final变量的值会变化。详细的案例可以参考[这个文档](http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong)。
|
||||
|
||||
当然了,在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
|
||||
|
||||
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因此我们一定要避免“逸出”。
|
||||
|
||||
```
|
||||
// 以下代码来源于【参考1】
|
||||
final int x;
|
||||
// 错误的构造函数
|
||||
public FinalFieldExample() {
|
||||
x = 3;
|
||||
y = 4;
|
||||
// 此处就是讲this逸出,
|
||||
global.obj = this;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Java的内存模型是并发编程领域的一次重要创新,之后C++、C#、Golang等高级语言都开始支持内存模型。Java内存模型里面,最晦涩的部分就是Happens-Before规则了,Happens-Before规则最初是在一篇叫做**Time, Clocks, and the Ordering of Events in a Distributed System**的论文中提出来的,在这篇论文中,Happens-Before的语义是一种因果关系。在现实世界里,如果A事件是导致B事件的起因,那么A事件一定是先于(Happens-Before)B事件发生的,这个就是Happens-Before语义的现实理解。
|
||||
|
||||
在Java语言里面,Happens-Before的语义本质上是一种可见性,A Happens-Before B 意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上,B事件发生在线程2上,Happens-Before规则保证线程2上也能看到A事件的发生。
|
||||
|
||||
Java内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向JVM的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是Happens-Before规则。相信经过本章的介绍,你应该对这部分内容已经有了深入的认识。
|
||||
|
||||
## 课后思考
|
||||
|
||||
有一个共享变量 abc,在一个线程里设置了abc的值 `abc=3`,你思考一下,有哪些办法可以让其他线程能够看到`abc==3`?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
## 参考
|
||||
|
||||
1. [JSR 133 (Java Memory Model) FAQ](http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html)
|
||||
1. [Java内存模型FAQ](http://ifeve.com/jmm-faq/)
|
||||
1. [JSR-133: Java<sup>TM</sup> Memory Model and Thread Specification](https://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf)
|
||||
|
||||
|
||||
195
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/03 | 互斥锁(上):解决原子性问题.md
Normal file
195
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/03 | 互斥锁(上):解决原子性问题.md
Normal file
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="03 | 互斥锁(上):解决原子性问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/c3/c3944240aa84b97548046226397980c3.mp3"></audio>
|
||||
|
||||
在[第一篇文章](https://time.geekbang.org/column/article/83682)中我们提到,一个或者多个操作在CPU执行的过程中不被中断的特性,称为“原子性”。理解这个特性有助于你分析并发编程Bug出现的原因,例如利用它可以分析出long型变量在32位机器上读写可能出现的诡异Bug,明明已经把变量成功写入内存,重新读出来却不是自己写入的。
|
||||
|
||||
**那原子性问题到底该如何解决呢?**
|
||||
|
||||
你已经知道,原子性问题的源头是**线程切换**,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。
|
||||
|
||||
在早期单核CPU时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。这里我们以32位CPU上执行long型变量的写操作为例来说明这个问题,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/28/381b657801c48b3399f19d946bad9e28.png" alt="">
|
||||
|
||||
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
|
||||
|
||||
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
|
||||
|
||||
“**同一时刻只有一个线程执行**”这个条件非常重要,我们称之为**互斥**。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。
|
||||
|
||||
## 简易锁模型
|
||||
|
||||
当谈到互斥,相信聪明的你一定想到了那个杀手级解决方案:锁。同时大脑中还会出现以下模型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/a2/3df991e7de14a788b220468836cd48a2.png" alt="">
|
||||
|
||||
我们把一段需要互斥执行的代码称为**临界区**。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。
|
||||
|
||||
这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。很长时间里,我也是这么理解的。这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
|
||||
|
||||
## 改进后的锁模型
|
||||
|
||||
我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/2f/287008c8137a43fa032e68a0c23c172f.png" alt="">
|
||||
|
||||
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后,针对这把锁LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁LR和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的Bug非常不好诊断,因为潜意识里我们认为已经正确加锁了。
|
||||
|
||||
## Java语言提供的锁技术:synchronized
|
||||
|
||||
锁是一种通用的技术方案,Java语言提供的synchronized关键字,就是锁的一种实现。synchronized关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:
|
||||
|
||||
```
|
||||
class X {
|
||||
// 修饰非静态方法
|
||||
synchronized void foo() {
|
||||
// 临界区
|
||||
}
|
||||
// 修饰静态方法
|
||||
synchronized static void bar() {
|
||||
// 临界区
|
||||
}
|
||||
// 修饰代码块
|
||||
Object obj = new Object();
|
||||
void baz() {
|
||||
synchronized(obj) {
|
||||
// 临界区
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁lock()和解锁unlock()在哪里呢?其实这两个操作都是有的,只是这两个操作是被Java默默加上的,Java编译器会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock(),这样做的好处就是加锁lock()和解锁unlock()一定是成对出现的,毕竟忘记解锁unlock()可是个致命的Bug(意味着其他线程只能死等下去了)。
|
||||
|
||||
那synchronized里的加锁lock()和解锁unlock()锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个obj对象,那修饰方法的时候锁定的是什么呢?这个也是Java的一条隐式规则:
|
||||
|
||||
>
|
||||
<p>当修饰静态方法的时候,锁定的是当前类的Class对象,在上面的例子中就是Class X;<br>
|
||||
当修饰非静态方法的时候,锁定的是当前实例对象this。</p>
|
||||
|
||||
|
||||
对于上面的例子,synchronized修饰静态方法相当于:
|
||||
|
||||
```
|
||||
class X {
|
||||
// 修饰静态方法
|
||||
synchronized(X.class) static void bar() {
|
||||
// 临界区
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
修饰非静态方法,相当于:
|
||||
|
||||
```
|
||||
class X {
|
||||
// 修饰非静态方法
|
||||
synchronized(this) void foo() {
|
||||
// 临界区
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 用synchronized解决count+=1问题
|
||||
|
||||
相信你一定记得我们前面文章中提到过的count+=1存在的并发问题,现在我们可以尝试用synchronized来小试牛刀一把,代码如下所示。SafeCalc这个类有两个方法:一个是get()方法,用来获得value的值;另一个是addOne()方法,用来给value加1,并且addOne()方法我们用synchronized修饰。那么我们使用的这两个方法有没有并发问题呢?
|
||||
|
||||
```
|
||||
class SafeCalc {
|
||||
long value = 0L;
|
||||
long get() {
|
||||
return value;
|
||||
}
|
||||
synchronized void addOne() {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们先来看看addOne()方法,首先可以肯定,被synchronized修饰后,无论是单核CPU还是多核CPU,只有一个线程能够执行addOne()方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下[上一篇文章](https://time.geekbang.org/column/article/84017)中提到的**管程中锁的规则**。
|
||||
|
||||
>
|
||||
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
|
||||
|
||||
|
||||
管程,就是我们这里的synchronized(至于为什么叫管程,我们后面介绍),我们知道synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
|
||||
|
||||
按照这个规则,如果多个线程同时执行addOne()方法,可见性是可以保证的,也就说如果有1000个线程执行addOne()方法,最终结果一定是value的值增加了1000。看到这个结果,我们长出一口气,问题终于解决了。
|
||||
|
||||
但也许,你一不小心就忽视了get()方法。执行addOne()方法后,value的值对get()方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而get()方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是get()方法也synchronized一下,完整的代码如下所示。
|
||||
|
||||
```
|
||||
class SafeCalc {
|
||||
long value = 0L;
|
||||
synchronized long get() {
|
||||
return value;
|
||||
}
|
||||
synchronized void addOne() {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get()方法和addOne()方法都需要访问value这个受保护的资源,这个资源用this这把锁来保护。线程要进入临界区get()和addOne(),必须先获得this这把锁,这样get()和addOne()也是互斥的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/f6/26a84ffe2b4a6ae67c8093d29473e1f6.png" alt="">
|
||||
|
||||
这个模型更像现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是Java类里的方法,而门票就是用来保护资源的“锁”,Java里的检票工作是由synchronized解决的。
|
||||
|
||||
## 锁和受保护资源的关系
|
||||
|
||||
我们前面提到,受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:**受保护资源和锁之间的关联关系是N:1的关系**。还拿前面球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的,并发领域的锁和现实世界的锁不是完全匹配的。不过倒是可以用同一把锁来保护多个资源,这个对应到现实世界就是我们所谓的“包场”了。
|
||||
|
||||
上面那个例子我稍作改动,把value改成静态变量,把addOne()方法改成静态方法,此时get()方法和addOne()方法是否存在并发问题呢?
|
||||
|
||||
```
|
||||
class SafeCalc {
|
||||
static long value = 0L;
|
||||
synchronized long get() {
|
||||
return value;
|
||||
}
|
||||
synchronized static void addOne() {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量value,两个锁分别是this和SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区get()和addOne()是用两个锁保护的,因此这两个临界区没有互斥关系,临界区addOne()对value的修改对临界区get()也没有可见性保证,这就导致并发问题了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/be/60551e006fca96f581f3dc25424226be.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
|
||||
|
||||
synchronized是Java在语言层面提供的互斥原语,其实Java里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁/解锁,就属于设计层面的事情了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
下面的代码用synchronized修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?
|
||||
|
||||
```
|
||||
class SafeCalc {
|
||||
long value = 0L;
|
||||
long get() {
|
||||
synchronized (new Object()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
void addOne() {
|
||||
synchronized (new Object()) {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
178
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/04 | 互斥锁(下):如何用一把锁保护多个资源?.md
Normal file
178
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/04 | 互斥锁(下):如何用一把锁保护多个资源?.md
Normal file
@@ -0,0 +1,178 @@
|
||||
<audio id="audio" title="04 | 互斥锁(下):如何用一把锁保护多个资源?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/d1/c8c4d60e8cd63aee893dcb395ef08dd1.mp3"></audio>
|
||||
|
||||
在上一篇文章中,我们提到**受保护资源和锁之间合理的关联关系应该是N:1的关系**,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,并且结合文中示例,我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源,我们今天就来聊聊。
|
||||
|
||||
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。
|
||||
|
||||
## 保护没有关联关系的多个资源
|
||||
|
||||
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
|
||||
|
||||
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
|
||||
|
||||
相关的示例代码如下,账户类Account有两个成员变量,分别是账户余额balance和账户密码password。取款withdraw()和查看余额getBalance()操作会访问账户余额balance,我们创建一个final对象balLock作为锁(类比球赛门票);而更改密码updatePassword()和查看密码getPassword()操作会修改账户密码password,我们创建一个final对象pwLock作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的,很简单。
|
||||
|
||||
```
|
||||
class Account {
|
||||
// 锁:保护账户余额
|
||||
private final Object balLock
|
||||
= new Object();
|
||||
// 账户余额
|
||||
private Integer balance;
|
||||
// 锁:保护账户密码
|
||||
private final Object pwLock
|
||||
= new Object();
|
||||
// 账户密码
|
||||
private String password;
|
||||
|
||||
// 取款
|
||||
void withdraw(Integer amt) {
|
||||
synchronized(balLock) {
|
||||
if (this.balance > amt){
|
||||
this.balance -= amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 查看余额
|
||||
Integer getBalance() {
|
||||
synchronized(balLock) {
|
||||
return balance;
|
||||
}
|
||||
}
|
||||
|
||||
// 更改密码
|
||||
void updatePassword(String pw){
|
||||
synchronized(pwLock) {
|
||||
this.password = pw;
|
||||
}
|
||||
}
|
||||
// 查看密码
|
||||
String getPassword() {
|
||||
synchronized(pwLock) {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用this这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字synchronized就可以了,这里我就不一一展示了。
|
||||
|
||||
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。**用不同的锁对受保护资源进行精细化管理,能够提升性能**。这种锁还有个名字,叫**细粒度锁**。
|
||||
|
||||
## 保护有关联关系的多个资源
|
||||
|
||||
如果多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户A减少100元,账户B增加100元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?先把这个问题代码化。我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个用于转账的方法:transfer(),然后怎么保证转账操作transfer()没有并发问题呢?
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(
|
||||
Account target, int amt){
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相信你的直觉会告诉你这样的解决方案:用户synchronized关键字修饰一下transfer()方法就可以了,于是你很快就完成了相关的代码,如下所示。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
synchronized void transfer(
|
||||
Account target, int amt){
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,临界区内有两个资源,分别是转出账户的余额this.balance和转入账户的余额target.balance,并且用的是一把锁this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
|
||||
|
||||
问题就出在this这把锁上,this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/d8/1ba92a09d1a55a6a1636318f30c155d8.png" alt="">
|
||||
|
||||
下面我们具体分析一下,假设有A、B、C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作:账户A转给账户B 100 元,账户B转给账户C 100 元,最后我们期望的结果应该是账户A的余额是100元,账户B的余额是200元, 账户C的余额是300元。
|
||||
|
||||
我们假设线程1执行账户A转账户B的操作,线程2执行账户B转账户C的操作。这两个线程分别在两颗CPU上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程1锁定的是账户A的实例(A.this),而线程2锁定的是账户B的实例(B.this),所以这两个线程可以同时进入临界区transfer()。同时进入临界区的结果是什么呢?线程1和线程2都会读到账户B的余额为200,导致最终账户B的余额可能是300(线程1后于线程2写B.balance,线程2写的B.balance值被线程1覆盖),可能是100(线程1先于线程2写B.balance,线程1写的B.balance值被线程2覆盖),就是不可能是200。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/27/a46b4a1e73671d6e6f1bdb26f6c87627.png" alt="">
|
||||
|
||||
## 使用锁的正确姿势
|
||||
|
||||
在上一篇文章中,我们提到用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的**锁能覆盖所有受保护资源**就可以了。在上面的例子中,this是对象级别的锁,所以A对象和B对象都有自己的锁,如何让A对象和B对象共享一把锁呢?
|
||||
|
||||
稍微开动脑筋,你会发现其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建Account时传入。方案有了,完成代码就简单了。示例代码如下,我们把Account默认构造函数变为private,同时增加一个带Object lock参数的构造函数,创建Account对象时,传入相同的lock,这样所有的Account对象都会共享这个lock了。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private Object lock;
|
||||
private int balance;
|
||||
private Account();
|
||||
// 创建Account时传入同一个lock对象
|
||||
public Account(Object lock) {
|
||||
this.lock = lock;
|
||||
}
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
// 此处检查所有对象共享的锁
|
||||
synchronized(lock) {
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建Account对象的时候必须传入同一个对象,如果创建Account对象时,传入的lock不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建Account对象的代码很可能分散在多个工程中,传入共享的lock真的很难。
|
||||
|
||||
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是**用Account.class作为共享的锁**。Account.class是所有Account对象共享的,而且这个对象是Java虚拟机在加载Account类的时候创建的,所以我们不用担心它的唯一性。使用Account.class作为共享的锁,我们就无需在创建Account对象时传入了,代码更简单。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
synchronized(Account.class) {
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面这幅图很直观地展示了我们是如何使用共享的锁Account.class来保护不同对象的临界区的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/7c/527cd65f747abac3f23390663748da7c.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
|
||||
|
||||
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向CPU指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
|
||||
|
||||
**“原子性”的本质**是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,**操作的中间状态对外不可见**。例如,在32位的机器上写long型变量有中间状态(只写了64位中的32位),在银行转账的操作中也有中间状态(账户A减少了100,账户B还没来得及发生变化)。所以**解决原子性问题,是要保证中间状态对外不可见**。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:`private final Object xxxLock = new Object();`,如果账户余额用 this.balance 作为互斥锁,账户密码用this.password作为互斥锁,你觉得是否可以呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
225
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/05 | 一不小心就死锁了,怎么办?.md
Normal file
225
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/05 | 一不小心就死锁了,怎么办?.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<audio id="audio" title="05 | 一不小心就死锁了,怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/82/16/8288a6fae8f67799fe9e032e9a423f16.mp3"></audio>
|
||||
|
||||
在上一篇文章中,我们用Account.class作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户A 转账户B、账户C 转账户D这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。
|
||||
|
||||
试想互联网支付盛行的当下,8亿网民每人每天一笔交易,每天就是8亿笔交易;每笔交易都对应着一次转账操作,8亿笔交易就是8亿次转账操作,也就是说平均到每秒就是近1万次转账操作,若所有的转账操作都串行,性能完全不能接受。
|
||||
|
||||
那下面我们就尝试着把性能提升一下。
|
||||
|
||||
## 向现实世界要答案
|
||||
|
||||
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
|
||||
|
||||
我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
|
||||
|
||||
1. 文件架上恰好有转出账本和转入账本,那就同时拿走;
|
||||
1. 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
|
||||
1. 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
|
||||
|
||||
上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在transfer()方法内部,我们首先尝试锁定转出账户this(先把转出账本拿到手),然后尝试锁定转入账户target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/55/cb18e672732ab76fc61d60bdf66bf855.png" alt="">
|
||||
|
||||
而至于详细的代码实现,如下所示。经过这样的优化后,账户A 转账户B和账户C 转账户D这两个转账操作就可以并行了。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
// 锁定转出账户
|
||||
synchronized(this) {
|
||||
// 锁定转入账户
|
||||
synchronized(target) {
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 没有免费的午餐
|
||||
|
||||
上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用Account.class作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,上一章我们介绍过,叫**细粒度锁**。**使用细粒度锁可以提高并行度,是性能优化的一个重要手段**。
|
||||
|
||||
这个时候可能你已经开始警觉了,使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?编写并发程序就需要这样时时刻刻保持谨慎。
|
||||
|
||||
**的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。**
|
||||
|
||||
在详细介绍死锁之前,我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户A 转账户B 100元,此时另一个客户找柜员李四也做个转账业务:账户B 转账户A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本A,李四拿到了账本B。张三拿到账本A后就等着账本B(账本B已经被李四拿走),而李四拿到账本B后就等着账本A(账本A已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本A送回去,李四也不会把账本B送回去。我们姑且称为死等吧。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/88/f293dc0d92b7c8255bd0bc790fc2a088.png" alt="">
|
||||
|
||||
现实世界里的死等,就是编程领域的死锁了。**死锁**的一个比较专业的定义是:**一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象**。
|
||||
|
||||
上面转账的代码是怎么发生死锁的呢?我们假设线程T1执行账户A转账户B的操作,账户A.transfer(账户B);同时线程T2执行账户B转账户A的操作,账户B.transfer(账户A)。当T1和T2同时执行完①处的代码时,T1获得了账户A的锁(对于T1,this是账户A),而T2获得了账户B的锁(对于T2,this是账户B)。之后T1和T2在执行②处的代码时,T1试图获取账户B的锁时,发现账户B已经被锁定(被T2锁定),所以T1开始等待;T2则试图获取账户A的锁时,发现账户A已经被锁定(被T1锁定),所以T2也开始等待。于是T1和T2会无期限地等待下去,也就是我们所说的死锁了。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
// 锁定转出账户
|
||||
synchronized(this){ ①
|
||||
// 锁定转入账户
|
||||
synchronized(target){ ②
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于这种现象,我们还可以借助资源分配图来可视化锁的占用情况(资源分配图是个有向图,它可以描述资源和线程的状态)。其中,资源用方形节点表示,线程用圆形节点表示;资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到。转账发生死锁时的资源分配图就如下图所示,一个“各据山头死等”的尴尬局面。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/1c/829d69c7d32c3ad1b89d89fc56017d1c.png" alt="">
|
||||
|
||||
## 如何预防死锁
|
||||
|
||||
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
|
||||
|
||||
那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫Coffman的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
|
||||
|
||||
>
|
||||
|
||||
1. 互斥,共享资源X和Y只能被一个线程占用;
|
||||
1. 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
|
||||
1. 不可抢占,其他线程不能强行抢占线程T1占有的资源;
|
||||
1. 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
|
||||
|
||||
反过来分析,**也就是说只要我们破坏其中一个,就可以成功避免死锁的发生**。
|
||||
|
||||
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
|
||||
|
||||
1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
|
||||
1. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
|
||||
1. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
|
||||
|
||||
我们已经从理论上解决了如何预防死锁,那具体如何体现在代码上呢?下面我们就来尝试用代码实践一下这些理论。
|
||||
|
||||
### 1. 破坏占用且等待条件
|
||||
|
||||
从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?
|
||||
|
||||
可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本A和B,账本管理员如果发现文件架上只有账本A,这个时候账本管理员是不会把账本A拿下来给张三的,只有账本A和B都在的时候才会给张三。这样就保证了“一次性申请所有资源”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/db/273af8c2ee60bd659f18673d2af005db.png" alt="">
|
||||
|
||||
对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java里面的类)来管理这个临界区,我们就把这个角色定为Allocator。它有两个重要功能,分别是:同时申请资源apply()和同时释放资源free()。账户Account 类里面持有一个Allocator的单例(必须是单例,只能由一个人来分配资源)。当账户Account在执行转账操作的时候,首先向Allocator同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知Allocator同时释放转出账户和转入账户这两个资源。具体的代码实现如下。
|
||||
|
||||
```
|
||||
class Allocator {
|
||||
private List<Object> als =
|
||||
new ArrayList<>();
|
||||
// 一次性申请所有资源
|
||||
synchronized boolean apply(
|
||||
Object from, Object to){
|
||||
if(als.contains(from) ||
|
||||
als.contains(to)){
|
||||
return false;
|
||||
} else {
|
||||
als.add(from);
|
||||
als.add(to);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 归还资源
|
||||
synchronized void free(
|
||||
Object from, Object to){
|
||||
als.remove(from);
|
||||
als.remove(to);
|
||||
}
|
||||
}
|
||||
|
||||
class Account {
|
||||
// actr应该为单例
|
||||
private Allocator actr;
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
// 一次性申请转出账户和转入账户,直到成功
|
||||
while(!actr.apply(this, target))
|
||||
;
|
||||
try{
|
||||
// 锁定转出账户
|
||||
synchronized(this){
|
||||
// 锁定转入账户
|
||||
synchronized(target){
|
||||
if (this.balance > amt){
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
actr.free(this, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2. 破坏不可抢占条件
|
||||
|
||||
破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点synchronized是做不到的。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
|
||||
|
||||
你可能会质疑,“Java作为排行榜第一的语言,这都解决不了?”你的怀疑很有道理,Java在语言层次确实没有解决这个问题,不过在SDK层面还是解决了的,java.util.concurrent这个包下面提供的Lock是可以轻松解决这个问题的。关于这个话题,咱们后面会详细讲。
|
||||
|
||||
### 3. 破坏循环等待条件
|
||||
|
||||
破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int id;
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(Account target, int amt){
|
||||
Account left = this ①
|
||||
Account right = target; ②
|
||||
if (this.id > target.id) { ③
|
||||
left = target; ④
|
||||
right = this; ⑤
|
||||
} ⑥
|
||||
// 锁定序号小的账户
|
||||
synchronized(left){
|
||||
// 锁定序号大的账户
|
||||
synchronized(right){
|
||||
if (this.balance > amt){
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
当我们在编程世界里遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,**利用现实世界的模型来构思解决方案**,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。
|
||||
|
||||
但是现实世界的模型有些细节往往会被我们忽视。因为在现实世界里,人太智能了,以致有些细节实在是显得太不重要了。在转账的模型中,我们为什么会忽视死锁问题呢?原因主要是在现实世界,我们会交流,并且会很智能地交流。而编程世界里,两个线程是不会智能地交流的。所以在利用现实模型建模的时候,我们还要仔细对比现实世界和编程世界里的各角色之间的差异。
|
||||
|
||||
我们今天这一篇文章主要讲了**用细粒度锁来锁定多个资源时,要注意死锁的问题**。这个就需要你能把它强化为一个思维定势,遇到这种场景,马上想到可能存在死锁问题。当你知道风险之后,才有机会谈如何预防和避免,因此,**识别出风险很重要**。
|
||||
|
||||
预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转账那个例子,我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 `while(!actr.apply(this, target));`方法,不过好在apply()这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。
|
||||
|
||||
所以我们在选择具体方案的时候,还需要**评估一下操作成本,从中选择一个成本最低的方案**。
|
||||
|
||||
## 课后思考
|
||||
|
||||
我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 `while(!actr.apply(this, target));`这个方法,那它比synchronized(Account.class)有没有性能优势呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
129
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/06 | 用“等待-通知”机制优化循环等待.md
Normal file
129
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/06 | 用“等待-通知”机制优化循环等待.md
Normal file
@@ -0,0 +1,129 @@
|
||||
<audio id="audio" title="06 | 用“等待-通知”机制优化循环等待" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/5b/2e5a654a57a3a1d8761333348a692d5b.mp3"></audio>
|
||||
|
||||
由上一篇文章你应该已经知道,在**破坏占用且等待条件**的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待,核心代码如下:
|
||||
|
||||
```
|
||||
// 一次性申请转出账户和转入账户,直到成功
|
||||
while(!actr.apply(this, target))
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
如果apply()操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。但是如果apply()操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下,可能要循环上万次才能获取到锁,太消耗CPU了。
|
||||
|
||||
其实在这种场景下,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入**等待**状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,**通知**等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗CPU的问题。
|
||||
|
||||
那Java语言是否支持这种**等待-通知机制**呢?答案是:一定支持(毕竟占据排行榜第一那么久)。下面我们就来看看Java语言是如何支持**等待-通知机制**的。
|
||||
|
||||
## 完美的就医流程
|
||||
|
||||
在介绍Java语言如何支持等待-通知机制之前,我们先看一个现实世界里面的就医流程,因为它有着完善的等待-通知机制,所以对比就医流程,我们就能更好地理解和应用并发编程中的等待-通知机制。
|
||||
|
||||
就医流程基本上是这样:
|
||||
|
||||
1. 患者先去挂号,然后到就诊门口分诊,等待叫号;
|
||||
1. 当叫到自己的号时,患者就可以找大夫就诊了;
|
||||
1. 就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
|
||||
1. 当患者做完检查后,拿检测报告重新分诊,等待叫号;
|
||||
1. 当大夫再次叫到自己的号时,患者再去找大夫就诊。
|
||||
|
||||
或许你已经发现了,这个有着完美等待-通知机制的就医流程,不仅能够保证同一时刻大夫只为一个患者服务,而且还能够保证大夫和患者的效率。与此同时你可能也会有疑问,“这个就医流程很复杂呀,我们前面描述的等待-通知机制相较而言是不是太简单了?”那这个复杂度是否是必须的呢?这个是必须的,我们不能忽视等待-通知机制中的一些细节。
|
||||
|
||||
下面我们来对比看一下前面都忽视了哪些细节。
|
||||
|
||||
1. 患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了。
|
||||
1. 大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足。
|
||||
1. 患者去做检查,类似于线程进入等待状态;然后**大夫叫下一个患者,这个步骤我们在前面的等待-通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁**。
|
||||
1. 患者做完检查,类似于线程要求的条件已经满足;**患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待-通知机制中也忽视了**。
|
||||
|
||||
所以加上这些至关重要的细节,综合一下,就可以得出**一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁**。
|
||||
|
||||
## 用synchronized实现等待-通知机制
|
||||
|
||||
在Java语言里,等待-通知机制可以有多种实现方式,比如Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法就能轻松实现。
|
||||
|
||||
如何用synchronized实现互斥锁,你应该已经很熟悉了。在下面这个图里,左边有一个等待队列,同一时刻,只允许一个线程进入synchronized保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。**这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/d0/c6640129fde927be8882ca90981613d0.png" alt="">
|
||||
|
||||
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java对象的wait()方法就能够满足这种需求。如上图所示,当调用wait()方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,**这个等待队列也是互斥锁的等待队列**。 线程在进入等待队列的同时,**会释放持有的互斥锁**,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
|
||||
|
||||
那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是Java对象的notify()和notifyAll()方法。我在下面这个图里为你大致描述了这个过程,当条件满足时调用notify(),会通知等待队列(**互斥锁的等待队列**)中的线程,告诉它**条件曾经满足过**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/8c/1b3e999c300166a84f2e8cc7a4b8f78c.png" alt="">
|
||||
|
||||
为什么说是曾经满足过呢?因为**notify()只能保证在通知时间点,条件是满足的**。而被通知线程的**执行时间点和通知的时间点**基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。
|
||||
|
||||
除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用wait()时已经释放了)。
|
||||
|
||||
上面我们一直强调wait()、notify()、notifyAll()方法操作的等待队列是互斥锁的等待队列,所以如果synchronized锁定的是this,那么对应的一定是this.wait()、this.notify()、this.notifyAll();如果synchronized锁定的是target,那么对应的一定是target.wait()、target.notify()、target.notifyAll() 。而且wait()、notify()、notifyAll()这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现wait()、notify()、notifyAll()都是在synchronized{}内部被调用的。如果在synchronized{}外部调用,或者锁定的this,而用target.wait()调用的话,JVM会抛出一个运行时异常:`java.lang.IllegalMonitorStateException`。
|
||||
|
||||
## 小试牛刀:一个更好地资源分配器
|
||||
|
||||
等待-通知机制的基本原理搞清楚后,我们就来看看它如何解决一次性申请转出账户和转入账户的问题吧。在这个等待-通知机制中,我们需要考虑以下四个要素。
|
||||
|
||||
1. 互斥锁:上一篇文章我们提到Allocator需要是单例的,所以我们可以用this作为互斥锁。
|
||||
1. 线程要求的条件:转出账户和转入账户都没有被分配过。
|
||||
1. 何时等待:线程要求的条件不满足就等待。
|
||||
1. 何时通知:当有线程释放账户时就通知。
|
||||
|
||||
将上面几个问题考虑清楚,可以快速完成下面的代码。需要注意的是我们使用了:
|
||||
|
||||
```
|
||||
while(条件不满足) {
|
||||
wait();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
利用这种范式可以解决上面提到的**条件曾经满足过**这个问题。因为当wait()返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。后面在介绍“管程”的时候,我会详细介绍这个经典做法的前世今生。
|
||||
|
||||
```
|
||||
class Allocator {
|
||||
private List<Object> als;
|
||||
// 一次性申请所有资源
|
||||
synchronized void apply(
|
||||
Object from, Object to){
|
||||
// 经典写法
|
||||
while(als.contains(from) ||
|
||||
als.contains(to)){
|
||||
try{
|
||||
wait();
|
||||
}catch(Exception e){
|
||||
}
|
||||
}
|
||||
als.add(from);
|
||||
als.add(to);
|
||||
}
|
||||
// 归还资源
|
||||
synchronized void free(
|
||||
Object from, Object to){
|
||||
als.remove(from);
|
||||
als.remove(to);
|
||||
notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 尽量使用notifyAll()
|
||||
|
||||
在上面的代码中,我用的是notifyAll()来实现通知机制,为什么不使用notify()呢?这二者是有区别的,**notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程**。从感觉上来讲,应该是notify()更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
|
||||
|
||||
假设我们有资源A、B、C、D,线程1申请到了AB,线程2申请到了CD,此时线程3申请AB,会进入等待队列(AB分配给线程1,线程3要求的条件不满足),线程4申请CD也会进入等待队列。我们再假设之后线程1归还了资源AB,如果使用notify()来通知等待队列中的线程,有可能被通知的是线程4,但线程4申请的是CD,所以此时线程4还是会继续等待,而真正该唤醒的线程3就再也没有机会被唤醒了。
|
||||
|
||||
所以除非经过深思熟虑,否则尽量使用notifyAll()。
|
||||
|
||||
## 总结
|
||||
|
||||
等待-通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待-通知机制来优化。Java语言内置的synchronized配合wait()、notify()、notifyAll()这三个方法可以快速实现这种机制,但是它们的使用看上去还是有点复杂,所以你需要认真理解等待队列和wait()、notify()、notifyAll()的关系。最好用现实世界做个类比,这样有助于你的理解。
|
||||
|
||||
Java语言的这种实现,背后的理论模型其实是管程,这个很重要,不过你不用担心,后面会有专门的一章来介绍管程。现在你只需要能够熟练使用就可以了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
很多面试都会问到,wait()方法和sleep()方法都能让当前线程挂起一段时间,那它们的区别是什么?现在你也试着回答一下吧。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
153
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/07 | 安全性、活跃性以及性能问题.md
Normal file
153
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/07 | 安全性、活跃性以及性能问题.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="07 | 安全性、活跃性以及性能问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/42/39df59b09aee5af28b7677f991914142.mp3"></audio>
|
||||
|
||||
通过前面六篇文章,我们开启了一个简单的并发旅程,相信现在你对并发编程需要注意的问题已经有了更深入的理解,这是一个很大的进步,正所谓只有发现问题,才能解决问题。但是前面六篇文章的知识点可能还是有点分散,所以是时候将其总结一下了。
|
||||
|
||||
并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:**安全性问题、活跃性问题和性能问题**。下面我就来一一介绍这些问题。
|
||||
|
||||
## 安全性问题
|
||||
|
||||
相信你一定听说过类似这样的描述:这个方法不是线程安全的,这个类不是线程安全的,等等。
|
||||
|
||||
那什么是线程安全呢?其实本质上就是正确性,而正确性的含义就是**程序按照我们期望的执行**,不要让我们感到意外。在[第一篇《可见性、原子性和有序性问题:并发编程Bug的源头》](https://time.geekbang.org/column/article/83682)中,我们已经见识过很多诡异的Bug,都是出乎我们预料的,它们都没有按照我们**期望**的执行。
|
||||
|
||||
那如何才能写出线程安全的程序呢?[第一篇文章](https://time.geekbang.org/column/article/83682)中已经介绍了并发Bug的三个主要源头:原子性问题、可见性问题和有序性问题。也就是说,理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
|
||||
|
||||
那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?当然不是,其实只有一种情况需要:**存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据**。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛。有不少技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等等,后面我会详细介绍相关的技术方案是如何在Java语言中实现的。
|
||||
|
||||
但是,现实生活中,**必须共享会发生变化的数据**,这样的应用场景还是很多的。
|
||||
|
||||
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做**数据竞争**(Data Race)。比如,前面[第一篇文章](https://time.geekbang.org/column/article/83682)里有个add10K()的方法,当多个线程调用时候就会发生**数据竞争**,如下所示。
|
||||
|
||||
```
|
||||
public class Test {
|
||||
private long count = 0;
|
||||
void add10K() {
|
||||
int idx = 0;
|
||||
while(idx++ < 10000) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那是不是在访问数据的地方,我们加个锁保护一下就能解决所有的并发问题了呢?显然没有这么简单。例如,对于上面示例,我们稍作修改,增加两个被 synchronized 修饰的get()和set()方法, add10K()方法里面通过get()和set()方法来访问value变量,修改后的代码如下所示。对于修改后的代码,所有访问共享变量value的地方,我们都增加了互斥锁,此时是不存在数据竞争的。但很显然修改后的add10K()方法并不是线程安全的。
|
||||
|
||||
```
|
||||
public class Test {
|
||||
private long count = 0;
|
||||
synchronized long get(){
|
||||
return count;
|
||||
}
|
||||
synchronized void set(long v){
|
||||
count = v;
|
||||
}
|
||||
void add10K() {
|
||||
int idx = 0;
|
||||
while(idx++ < 10000) {
|
||||
set(get()+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设count=0,当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get()+1操作,结果都是1,之后两个线程再将结果1写入了内存。你本来期望的是2,而结果却是1。
|
||||
|
||||
这种问题,有个官方的称呼,叫**竞态条件**(Race Condition)。所谓**竞态条件,指的是程序的执行结果依赖线程执行的顺序**。例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
|
||||
|
||||
下面再结合一个例子来说明下**竞态条件**,就是前面文章中提到的转账操作。转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。假设账户A有余额200,线程1和线程2都要从账户A转出150,在下面的代码里,有可能线程1和线程2同时执行到第6行,这样线程1和线程2都会发现转出金额150小于账户余额200,于是就会发生超额转出的情况。
|
||||
|
||||
```
|
||||
class Account {
|
||||
private int balance;
|
||||
// 转账
|
||||
void transfer(
|
||||
Account target, int amt){
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以你也可以按照下面这样来理解**竞态条件**。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
|
||||
|
||||
```
|
||||
if (状态变量 满足 执行条件) {
|
||||
执行操作
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。当然很多场景下,这个条件不是显式的,例如前面addOne的例子中,set(get()+1)这个复合操作,其实就隐式依赖get()的结果。
|
||||
|
||||
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用**互斥**这个技术方案,而实现**互斥**的方案有很多,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API。从逻辑上来看,我们可以统一归为:**锁**。前面几章我们也粗略地介绍了如何使用锁,相信你已经胸中有丘壑了,这里就不再赘述了,你可以结合前面的文章温故知新。
|
||||
|
||||
## 活跃性问题
|
||||
|
||||
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然**除了死锁外,还有两种情况,分别是“活锁”和“饥饿”**。
|
||||
|
||||
通过前面的学习你已经知道,发生“死锁”后线程会互相等待,而且会一直等待下去,在技术上的表现形式是线程永久地“阻塞”了。
|
||||
|
||||
但**有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”**。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。
|
||||
|
||||
解决“**活锁**”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft这样知名的分布式一致性算法中也用到了它。
|
||||
|
||||
那“**饥饿**”该怎么去理解呢?**所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况**。“不患寡,而患不均”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
|
||||
|
||||
解决“**饥饿**”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
|
||||
|
||||
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
|
||||
|
||||
## 性能问题
|
||||
|
||||
使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
|
||||
|
||||
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?
|
||||
|
||||
有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
|
||||
|
||||
公式里的n可以理解为CPU的核数,p可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的5%。我们再假设CPU的核数(也就是n)无穷大,那加速比S的极限就是20。也就是说,如果我们的串行率是5%,那么我们无论采用什么技术,最高也就只能提高20倍的性能。
|
||||
|
||||
所以使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,**Java SDK并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能**。
|
||||
|
||||
不过从方案层面,我们可以这样来解决这个问题。
|
||||
|
||||
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储(Thread Local Storage, TLS)、写入时复制(Copy-on-write)、乐观锁等;Java并发包里面的原子类也是一种无锁的数据结构;Disruptor则是一个无锁的内存队列,性能都非常好……
|
||||
|
||||
第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是Java并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
|
||||
|
||||
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
|
||||
|
||||
1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
|
||||
1. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
|
||||
1. 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是1000的时候,延迟是50毫秒。
|
||||
|
||||
## 总结
|
||||
|
||||
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
|
||||
|
||||
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
|
||||
|
||||
要解决问题,首先要把问题分析清楚。同样,要写好并发程序,首先要了解并发程序相关的问题,经过这7章的内容,相信你一定对并发程序相关的问题有了深入的理解,同时对并发程序也一定心存敬畏,因为一不小心就出问题了。不过这恰恰也是一个很好的开始,因为你已经学会了分析并发问题,然后解决并发问题也就不远了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
Java语言提供的Vector是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?
|
||||
|
||||
```
|
||||
void addIfNotExist(Vector v,
|
||||
Object o){
|
||||
if(!v.contains(o)) {
|
||||
v.add(o);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
168
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/08 | 管程:并发编程的万能钥匙.md
Normal file
168
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/08 | 管程:并发编程的万能钥匙.md
Normal file
@@ -0,0 +1,168 @@
|
||||
<audio id="audio" title="08 | 管程:并发编程的万能钥匙" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b0/9e/b0acfe19055882b9f9931274f4c7759e.mp3"></audio>
|
||||
|
||||
并发编程这个技术领域已经发展了半个世纪了,相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢?这个问题如果让我选择,我一定会选择**管程技术**。Java语言在1.5之前,提供的唯一的并发原语就是管程,而且1.5之后提供的SDK并发包,也是以管程技术为基础的。除此之外,C/C++、C#等高级语言也都支持管程。
|
||||
|
||||
可以这么说,管程就是一把解决并发问题的万能钥匙。
|
||||
|
||||
## 什么是管程
|
||||
|
||||
不知道你是否曾思考过这个问题:为什么Java在1.5之前仅仅提供了synchronized关键字及wait()、notify()、notifyAll()这三个看似从天而降的方法?在刚接触Java的时候,我以为它会提供信号量这种编程原语,因为操作系统原理课程告诉我,用信号量能解决所有并发问题,结果我发现不是。后来我找到了原因:Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而**管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。**但是管程更容易使用,所以Java选择了管程。
|
||||
|
||||
管程,对应的英文是Monitor,很多Java领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管程”。
|
||||
|
||||
所谓**管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发**。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?
|
||||
|
||||
## MESA模型
|
||||
|
||||
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型。其中,现在广泛应用的是MESA模型,并且Java管程的实现参考的也是MESA模型。所以今天我们重点介绍一下MESA模型。
|
||||
|
||||
在并发编程领域,有两大核心问题:一个是**互斥**,即同一时刻只允许一个线程访问共享资源;另一个是**同步**,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
|
||||
|
||||
我们先来看看管程是如何解决**互斥**问题的。
|
||||
|
||||
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
|
||||
|
||||
利用管程,可以快速实现这个直观的想法。在下图中,管程X将共享变量queue这个线程不安全的队列和相关的操作入队操作enq()、出队操作deq()都封装起来了;线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保证互斥性,只允许一个线程进入管程。
|
||||
|
||||
不知你有没有发现,管程模型和面向对象高度契合的。估计这也是Java选择管程的原因吧。而我在前面章节介绍的互斥锁用法,其背后的模型其实就是它。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/c4/592e33c4339c443728cdf82ab3d318c4.png" alt="" title="管程模型的代码化语义">
|
||||
|
||||
那管程如何解决线程间的**同步**问题呢?
|
||||
|
||||
这个就比较复杂了,不过你可以借鉴一下我们曾经提到过的就医流程,它可以帮助你快速地理解这个问题。为进一步便于你理解,在下面,我展示了一幅MESA管程模型示意图,它详细描述了MESA模型的主要组成部分。
|
||||
|
||||
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。
|
||||
|
||||
管程里还引入了条件变量的概念,而且**每个条件变量都对应有一个等待队列,**如下图,条件变量A和条件变量B分别都有自己的等待队列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/65/839377608f47e7b3b9c79b8fad144065.png" alt="" title="MESA管程模型">
|
||||
|
||||
那**条件变量**和**条件变量等待队列**的作用是什么呢?其实就是解决线程同步问题。你可以结合上面提到的阻塞队列的例子加深一下理解(阻塞队列的例子,是用管程来实现线程安全的阻塞队列,这个阻塞队列和管程内部的等待队列没有关系,本文中**一定要注意阻塞队列和等待队列是不同的**)。
|
||||
|
||||
假设有个线程T1执行阻塞队列的出队操作,执行出队操作,需要注意有个前提条件,就是阻塞队列不能是空的(空队列只能出Null值,是不允许的),**阻塞队列不空**这个前提条件对应的就是管程里的条件变量。 如果线程T1进入管程后恰好发现阻塞队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的**等待队列**里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程T1进入条件变量的等待队列后,是允许其他线程进入管程的。这和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。
|
||||
|
||||
再假设之后另外一个线程T2执行阻塞队列的入队操作,入队操作执行成功之后,**“阻塞队列不空”<strong>这个条件对于线程T1来说已经满足了,此时线程T2要通知T1,告诉它需要的条件已经满足了。当线程T1得到通知后,会从**等待队列</strong>里面出来,但是出来之后不是马上执行,而是重新进入到**入口等待队列**里面。这个过程类似你验血完,回来找大夫,需要重新分诊。
|
||||
|
||||
条件变量及其等待队列我们讲清楚了,下面再说说wait()、notify()、notifyAll()这三个操作。前面提到线程T1发现“阻塞队列不空”这个条件不满足,需要进到对应的**等待队列**里等待。这个过程就是通过调用wait()来实现的。如果我们用对象A代表“阻塞队列不空”这个条件,那么线程T1需要调用A.wait()。同理当“阻塞队列不空”这个条件满足时,线程T2需要调用A.notify()来通知A等待队列中的一个线程,此时这个等待队列里面只有线程T1。至于notifyAll()这个方法,它可以通知等待队列中的所有线程。
|
||||
|
||||
这里我还是来一段代码再次说明一下吧。下面的代码用管程实现了一个线程安全的阻塞队列(再次强调:这个阻塞队列和管程内部的等待队列没关系,示例代码只是用管程来实现阻塞队列,而不是解释管程内部等待队列的实现原理)。阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
|
||||
|
||||
1. 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了`notFull.await();`。
|
||||
1. 对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了`notEmpty.await();`。
|
||||
1. 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空`notEmpty`对应的等待队列。
|
||||
1. 如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满`notFull`对应的等待队列。
|
||||
|
||||
```
|
||||
public class BlockedQueue<T>{
|
||||
final Lock lock =
|
||||
new ReentrantLock();
|
||||
// 条件变量:队列不满
|
||||
final Condition notFull =
|
||||
lock.newCondition();
|
||||
// 条件变量:队列不空
|
||||
final Condition notEmpty =
|
||||
lock.newCondition();
|
||||
|
||||
// 入队
|
||||
void enq(T x) {
|
||||
lock.lock();
|
||||
try {
|
||||
while (队列已满){
|
||||
// 等待队列不满
|
||||
notFull.await();
|
||||
}
|
||||
// 省略入队操作...
|
||||
//入队后,通知可出队
|
||||
notEmpty.signal();
|
||||
}finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
// 出队
|
||||
void deq(){
|
||||
lock.lock();
|
||||
try {
|
||||
while (队列已空){
|
||||
// 等待队列不空
|
||||
notEmpty.await();
|
||||
}
|
||||
// 省略出队操作...
|
||||
//出队后,通知可入队
|
||||
notFull.signal();
|
||||
}finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段示例代码中,我们用了Java并发包里面的Lock和Condition,如果你看着吃力,也没关系,后面我们还会详细介绍,这个例子只是先让你明白条件变量及其等待队列是怎么回事。需要注意的是:**await()和前面我们提到的wait()语义是一样的;signal()和前面我们提到的notify()语义是一样的**。
|
||||
|
||||
## wait()的正确姿势
|
||||
|
||||
但是有一点,需要再次提醒,对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。**这个是MESA管程特有的**。
|
||||
|
||||
```
|
||||
|
||||
while(条件不满足) {
|
||||
wait();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
|
||||
|
||||
1. Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
|
||||
1. Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。但是相比Hasen模型,T2多了一次阻塞唤醒操作。
|
||||
1. MESA管程里面,T2通知完T1后,T2还是会接着执行,T1并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是notify()不用放到代码的最后,T2也没有多余的阻塞唤醒操作。但是也有个副作用,就是当T1再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
|
||||
|
||||
## notify()何时可以使用
|
||||
|
||||
还有一个需要注意的地方,就是notify()和notifyAll()的使用,前面章节,我曾经介绍过,**除非经过深思熟虑,否则尽量使用notifyAll()**。那什么时候可以使用notify()呢?需要满足以下三个条件:
|
||||
|
||||
1. 所有等待线程拥有相同的等待条件;
|
||||
1. 所有等待线程被唤醒后,执行相同的操作;
|
||||
1. 只需要唤醒一个线程。
|
||||
|
||||
比如上面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是下面这3行代码。对所有等待线程来说,都是执行这3行代码,**重点是 while 里面的等待条件是完全相同的**。
|
||||
|
||||
```
|
||||
|
||||
while (阻塞队列已满){
|
||||
// 等待队列不满
|
||||
notFull.await();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:
|
||||
|
||||
```
|
||||
|
||||
// 省略入队操作...
|
||||
// 入队后,通知可出队
|
||||
notEmpty.signal();
|
||||
|
||||
```
|
||||
|
||||
同时也满足第3条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用signal()是可以的。
|
||||
|
||||
## 总结
|
||||
|
||||
管程是一个解决并发问题的模型,你可以参考医院就医的流程来加深理解。理解这个模型的重点在于理解条件变量及其等待队列的工作原理。
|
||||
|
||||
Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。具体如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/fa/57e4d94e90226b70be3d57024f5333fa.png" alt="" title="Java中的管程示意图">
|
||||
|
||||
Java内置的管程方案(synchronized)使用简单,synchronized关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
|
||||
|
||||
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。
|
||||
|
||||
## 课后思考
|
||||
|
||||
wait()方法,在Hasen模型和Hoare模型里面,都是没有参数的,而在MESA模型里面,增加了超时参数,你觉得这个参数有必要吗?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
166
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/09 | Java线程(上):Java线程的生命周期.md
Normal file
166
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/09 | Java线程(上):Java线程的生命周期.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="09 | Java线程(上):Java线程的生命周期" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/0d/fac0721d577fefc74de980b361682b0d.mp3"></audio>
|
||||
|
||||
在Java领域,实现并发程序的主要手段就是多线程。线程是操作系统里的一个概念,虽然各种不同的开发语言如Java、C#等都对其进行了封装,但是万变不离操作系统。Java语言里的线程本质上就是操作系统的线程,它们是一一对应的。
|
||||
|
||||
在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂**生命周期中各个节点的状态转换机制**就可以了。
|
||||
|
||||
虽然不同的开发语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的。所以,我们可以先来了解一下通用的线程生命周期模型,这部分内容也适用于很多其他编程语言;然后再详细有针对性地学习一下Java中线程的生命周期。
|
||||
|
||||
## 通用的线程生命周期
|
||||
|
||||
通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:**初始状态、可运行状态、运行状态、休眠状态**和**终止状态**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/e5/9bbc6fa7fb4d631484aa953626cf6ae5.png" alt="">
|
||||
|
||||
这“五态模型”的详细情况如下所示。
|
||||
|
||||
1. **初始状态**,指的是线程已经被创建,但是还不允许分配CPU执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
|
||||
1. **可运行状态**,指的是线程可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU执行。
|
||||
1. 当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了**运行状态**。
|
||||
1. 运行状态的线程如果调用一个阻塞的API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到**休眠状态**,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
|
||||
1. 线程执行完或者出现异常就会进入**终止状态**,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
|
||||
|
||||
这五种状态在不同编程语言里会有简化合并。例如,C语言的POSIX Threads规范,就把初始状态和可运行状态合并了;Java语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为JVM把线程调度交给操作系统处理了。
|
||||
|
||||
除了简化合并,这五种状态也有可能被细化,比如,Java语言里就细化了休眠状态(这个下面我们会详细讲解)。
|
||||
|
||||
## Java中线程的生命周期
|
||||
|
||||
介绍完通用的线程生命周期模型,想必你已经对线程的“生老病死”有了一个大致的了解。那接下来我们就来详细看看Java语言里的线程生命周期是什么样的。
|
||||
|
||||
Java语言中线程共有六种状态,分别是:
|
||||
|
||||
1. NEW(初始化状态)
|
||||
1. RUNNABLE(可运行/运行状态)
|
||||
1. BLOCKED(阻塞状态)
|
||||
1. WAITING(无时限等待)
|
||||
1. TIMED_WAITING(有时限等待)
|
||||
1. TERMINATED(终止状态)
|
||||
|
||||
这看上去挺复杂的,状态类型也比较多。但其实在操作系统层面,Java线程中的BLOCKED、WAITING、TIMED_WAITING是一种状态,即前面我们提到的休眠状态。也就是说**只要Java线程处于这三种状态之一,那么这个线程就永远没有CPU的使用权**。
|
||||
|
||||
所以Java线程的生命周期可以简化为下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/8c/3f6c6bf95a6e8627bdf3cb621bbb7f8c.png" alt="">
|
||||
|
||||
其中,BLOCKED、WAITING、TIMED_WAITING可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从RUNNABLE状态转换到这三种状态呢?而这三种状态又是何时转换回RUNNABLE的呢?以及NEW、TERMINATED和RUNNABLE状态是如何转换的?
|
||||
|
||||
### 1. RUNNABLE与BLOCKED的状态转换
|
||||
|
||||
只有一种场景会触发这种转换,就是线程等待synchronized的隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从RUNNABLE转换到BLOCKED状态。而当等待的线程获得synchronized隐式锁时,就又会从BLOCKED转换到RUNNABLE状态。
|
||||
|
||||
如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式API时,是否会转换到BLOCKED状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在JVM层面,Java线程的状态不会发生变化,也就是说Java线程的状态会依然保持RUNNABLE状态。**JVM层面并不关心操作系统调度相关的状态**,因为在JVM看来,等待CPU使用权(操作系统层面此时处于可执行状态)与等待I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了RUNNABLE状态。
|
||||
|
||||
而我们平时所谓的Java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是Java线程的状态。
|
||||
|
||||
### 2. RUNNABLE与WAITING的状态转换
|
||||
|
||||
总体来说,有三种场景会触发这种转换。
|
||||
|
||||
第一种场景,获得synchronized隐式锁的线程,调用无参数的Object.wait()方法。其中,wait()方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
|
||||
|
||||
第二种场景,调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如有一个线程对象thread A,当调用A.join()的时候,执行这条语句的线程会等待thread A执行完,而等待中的这个线程,其状态会从RUNNABLE转换到WAITING。当线程thread A执行完,原来等待它的线程又会从WAITING状态转换到RUNNABLE。
|
||||
|
||||
第三种场景,调用LockSupport.park()方法。其中的LockSupport对象,也许你有点陌生,其实Java并发包中的锁,都是基于它实现的。调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。
|
||||
|
||||
### 3. RUNNABLE与TIMED_WAITING的状态转换
|
||||
|
||||
有五种场景会触发这种转换:
|
||||
|
||||
1. 调用**带超时参数**的Thread.sleep(long millis)方法;
|
||||
1. 获得synchronized隐式锁的线程,调用**带超时参数**的Object.wait(long timeout)方法;
|
||||
1. 调用**带超时参数**的Thread.join(long millis)方法;
|
||||
1. 调用**带超时参数**的LockSupport.parkNanos(Object blocker, long deadline)方法;
|
||||
1. 调用**带超时参数**的LockSupport.parkUntil(long deadline)方法。
|
||||
|
||||
这里你会发现TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了**超时参数**。
|
||||
|
||||
### 4. 从NEW到RUNNABLE状态
|
||||
|
||||
Java刚创建出来的Thread对象就是NEW状态,而创建Thread对象主要有两种方法。一种是继承Thread对象,重写run()方法。示例代码如下:
|
||||
|
||||
```
|
||||
// 自定义线程对象
|
||||
class MyThread extends Thread {
|
||||
public void run() {
|
||||
// 线程需要执行的代码
|
||||
......
|
||||
}
|
||||
}
|
||||
// 创建线程对象
|
||||
MyThread myThread = new MyThread();
|
||||
|
||||
```
|
||||
|
||||
另一种是实现Runnable接口,重写run()方法,并将该实现类作为创建Thread对象的参数。示例代码如下:
|
||||
|
||||
```
|
||||
// 实现Runnable接口
|
||||
class Runner implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
// 线程需要执行的代码
|
||||
......
|
||||
}
|
||||
}
|
||||
// 创建线程对象
|
||||
Thread thread = new Thread(new Runner());
|
||||
|
||||
```
|
||||
|
||||
NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。从NEW状态转换到RUNNABLE状态很简单,只要调用线程对象的start()方法就可以了,示例代码如下:
|
||||
|
||||
```
|
||||
MyThread myThread = new MyThread();
|
||||
// 从NEW状态转换到RUNNABLE状态
|
||||
myThread.start();
|
||||
|
||||
```
|
||||
|
||||
### 5. 从RUNNABLE到TERMINATED状态
|
||||
|
||||
线程执行完 run() 方法后,会自动转换到TERMINATED状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断run()方法的执行,例如 run()方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java的Thread类里面倒是有个stop()方法,不过已经标记为@Deprecated,所以不建议使用了。正确的姿势其实是调用interrupt()方法。
|
||||
|
||||
**那stop()和interrupt()方法的主要区别是什么呢?**
|
||||
|
||||
stop()方法会真的杀死线程,不给线程喘息的机会,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unlock()去释放锁,那其他线程就再也没机会获得ReentrantLock锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有suspend() 和 resume()方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。
|
||||
|
||||
而interrupt()方法就温柔多了,interrupt()方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被interrupt的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
|
||||
|
||||
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。
|
||||
|
||||
当线程A处于RUNNABLE状态时,并且阻塞在java.nio.channels.InterruptibleChannel上时,如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
|
||||
|
||||
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,例如中断计算圆周率的线程A,这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
|
||||
|
||||
## 总结
|
||||
|
||||
理解Java线程的各种状态以及生命周期对于诊断多线程Bug非常有帮助,多线程程序很难调试,出了Bug基本上都是靠日志,靠线程dump来跟踪问题,分析线程dump的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。同时,本文介绍的线程生命周期具备很强的通用性,对于学习其他语言的多线程编程也有很大的帮助。
|
||||
|
||||
你可以通过 `jstack` 命令或者`Java VisualVM`这个可视化工具将JVM所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。例如,我曾经写过一个死锁的程序,导出的线程栈明确告诉我发生了死锁,并且将死锁线程的调用栈信息清晰地显示出来了(如下图)。导出线程栈,分析线程状态是诊断并发问题的一个重要工具。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/be/67734e1a062adc7cf7baac7d6c17ddbe.png" alt="">
|
||||
|
||||
## 课后思考
|
||||
|
||||
下面代码的本意是当前线程被中断之后,退出`while(true)`,你觉得这段代码是否正确呢?
|
||||
|
||||
```
|
||||
Thread th = Thread.currentThread();
|
||||
while(true) {
|
||||
if(th.isInterrupted()) {
|
||||
break;
|
||||
}
|
||||
// 省略业务代码无数
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
}catch (InterruptedException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<audio id="audio" title="10 | Java线程(中):创建多少线程才是合适的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/93/b7454829f207e4fce7fc42b40ae66b93.mp3"></audio>
|
||||
|
||||
在Java领域,实现并发程序的主要手段就是多线程,使用多线程还是比较简单的,但是使用多少个线程却是个困难的问题。工作中,经常有人问,“各种线程池的线程数量调整成多少是合适的?”或者“Tomcat的线程数、Jdbc连接池的连接数是多少?”等等。那我们应该如何设置合适的线程数呢?
|
||||
|
||||
要解决这个问题,首先要分析以下两个问题:
|
||||
|
||||
1. 为什么要使用多线程?
|
||||
1. 多线程的应用场景有哪些?
|
||||
|
||||
## 为什么要使用多线程?
|
||||
|
||||
使用多线程,本质上就是提升程序性能。不过此刻谈到的性能,可能在你脑海里还是比较笼统的,基本上就是快、快、快,这种无法度量的感性认识很不科学,所以在提升性能之前,首要问题是:如何度量性能。
|
||||
|
||||
度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。**延迟**指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。 **吞吐量**指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。
|
||||
|
||||
我们所谓提升性能,从度量的角度,主要是**降低延迟,提高吞吐量**。这也是我们使用多线程的主要目的。那我们该怎么降低延迟,提高吞吐量呢?这个就要从多线程的应用场景说起了。
|
||||
|
||||
## 多线程的应用场景
|
||||
|
||||
要想“降低延迟,提高吞吐量”,对应的方法呢,基本上有两个方向,一个方向是**优化算法**,另一个方向是**将硬件的性能发挥到极致**。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是I/O,一个是CPU。简言之,**在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率**。
|
||||
|
||||
估计这个时候你会有个疑问,操作系统不是已经解决了硬件的利用率问题了吗?的确是这样,例如操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免CPU轮询I/O状态,也提升了CPU的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们的并发程序,往往需要CPU和I/O设备相互配合工作,也就是说,**我们需要解决CPU和I/O设备综合利用率的问题**。关于这个综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。
|
||||
|
||||
下面我们用一个简单的示例来说明:如何利用多线程来提升CPU和I/O设备的利用率?假设程序按照CPU计算和I/O操作交叉执行的方式运行,而且CPU计算和I/O操作的耗时是1:1。
|
||||
|
||||
如下图所示,如果只有一个线程,执行CPU计算的时候,I/O设备空闲;执行I/O操作的时候,CPU空闲,所以CPU的利用率和I/O设备的利用率都是50%。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/22/d1d7dfa1d574356cc5cb1019a4b7ca22.png" alt="">
|
||||
|
||||
如果有两个线程,如下图所示,当线程A执行CPU计算的时候,线程B执行I/O操作;当线程A执行I/O操作的时候,线程B执行CPU计算,这样CPU的利用率和I/O设备的利用率就都达到了100%。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/2c/68a415b31b72844eb81889e9f0eb3f2c.png" alt="">
|
||||
|
||||
我们将CPU的利用率和I/O设备的利用率都提升到了100%,会对性能产生了哪些影响呢?通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了1倍。此时可以逆向思维一下,**如果CPU和I/O设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量**。
|
||||
|
||||
在单核时代,多线程主要就是用来平衡CPU和I/O设备的。如果程序只有CPU计算,而没有I/O操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
|
||||
|
||||
为便于你理解,这里我举个简单的例子说明一下:计算1+2+… … +100亿的值,如果在4核的CPU上利用4个线程执行,线程A计算[1,25亿),线程B计算[25亿,50亿),线程C计算[50,75亿),线程D计算[75亿,100亿],之后汇总,那么理论上应该比一个线程计算[1,100亿]快将近4倍,响应时间能够降到25%。一个线程,对于4核的CPU,CPU的利用率只有25%,而4个线程,则能够将CPU的利用率提高到100%。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/8c/95367d49f55e0dfd099f2749c532098c.png" alt="">
|
||||
|
||||
## 创建多少线程合适?
|
||||
|
||||
创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是CPU计算和I/O操作交叉执行的,由于I/O设备的速度相对于CPU来说都很慢,所以大部分情况下,I/O操作执行的时间相对于CPU计算来说都非常长,这种场景我们一般都称为I/O密集型计算;和I/O密集型计算相对的就是CPU密集型计算了,CPU密集型计算大部分场景下都是纯CPU计算。I/O密集型程序和CPU密集型程序,计算最佳线程数的方法是不同的。
|
||||
|
||||
下面我们对这两个场景分别说明。
|
||||
|
||||
对于CPU密集型计算,多线程本质上是提升多核CPU的利用率,所以对于一个4核的CPU,每个核一个线程,理论上创建4个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,**对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的**。不过在工程上,**线程的数量一般会设置为“CPU核数+1”**,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。
|
||||
|
||||
对于I/O密集型的计算场景,比如前面我们的例子中,如果CPU计算和I/O操作的耗时是1:1,那么2个线程是最合适的。如果CPU计算和I/O操作的耗时是1:2,那多少个线程合适呢?是3个线程,如下图所示:CPU在A、B、C三个线程之间切换,对于线程A,当CPU从B、C切换回来时,线程A正好执行完I/O操作。这样CPU和I/O设备的利用率都达到了100%。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/cb/98b71b72f01baf5f0968c7c3a2102fcb.png" alt="">
|
||||
|
||||
通过上面这个例子,我们会发现,对于I/O密集型计算场景,最佳的线程数是与程序中CPU计算和I/O操作的耗时比相关的,我们可以总结出这样一个公式:
|
||||
|
||||
>
|
||||
最佳线程数=1 +(I/O耗时 / CPU耗时)
|
||||
|
||||
|
||||
我们令R=I/O耗时 / CPU耗时,综合上图,可以这样理解:当线程A执行IO操作时,另外R个线程正好执行完各自的CPU计算。这样CPU的利用率就达到了100%。
|
||||
|
||||
不过上面这个公式是针对单核CPU的,至于多核CPU,也很简单,只需要等比扩大就可以了,计算公式如下:
|
||||
|
||||
>
|
||||
最佳线程数=CPU核数 * [ 1 +(I/O耗时 / CPU耗时)]
|
||||
|
||||
|
||||
## 总结
|
||||
|
||||
很多人都知道线程数不是越多越好,但是设置多少是合适的,却又拿不定主意。其实只要把握住一条原则就可以了,这条原则就是**将硬件的性能发挥到极致**。上面我们针对CPU密集型和I/O密集型计算场景都给出了理论上的最佳公式,这些公式背后的目标其实就是**将硬件的性能发挥到极致**。
|
||||
|
||||
对于I/O密集型计算场景,I/O耗时和CPU耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。不过工程上,原则还是**将硬件的性能发挥到极致**,所以压测时,我们需要重点关注CPU、I/O设备的利用率和性能指标(响应时间、吞吐量)之间的关系。
|
||||
|
||||
## 课后思考
|
||||
|
||||
有些同学对于最佳线程数的设置积累了一些经验值,认为对于I/O密集型应用,最佳线程数应该为:2 * CPU的核数 + 1,你觉得这个经验值合理吗?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<audio id="audio" title="11 | Java线程(下):为什么局部变量是线程安全的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/db/29/db68790283e75fd25877579cad982129.mp3"></audio>
|
||||
|
||||
我们一遍一遍重复再重复地讲到,多个线程同时访问共享变量的时候,会导致并发问题。那在Java语言里,是不是所有变量都是共享变量呢?工作中我发现不少同学会给方法里面的局部变量设置同步,显然这些同学并没有把共享变量搞清楚。那Java方法里面的局部变量是否存在并发问题呢?下面我们就先结合一个例子剖析下这个问题。
|
||||
|
||||
比如,下面代码里的 fibonacci() 这个方法,会根据传入的参数 n ,返回 1 到 n 的斐波那契数列,斐波那契数列类似这样: 1、1、2、3、5、8、13、21、34……第1项和第2项是1,从第3项开始,每一项都等于前两项之和。在这个方法里面,有个局部变量:数组 r 用来保存数列的结果,每次计算完一项,都会更新数组 r 对应位置中的值。你可以思考这样一个问题,当多个线程调用 fibonacci() 这个方法的时候,数组 r 是否存在数据竞争(Data Race)呢?
|
||||
|
||||
```
|
||||
// 返回斐波那契数列
|
||||
int[] fibonacci(int n) {
|
||||
// 创建结果数组
|
||||
int[] r = new int[n];
|
||||
// 初始化第一、第二个数
|
||||
r[0] = r[1] = 1; // ①
|
||||
// 计算2..n
|
||||
for(int i = 2; i < n; i++) {
|
||||
r[i] = r[i-2] + r[i-1];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你自己可以在大脑里模拟一下多个线程调用 fibonacci() 方法的情景,假设多个线程执行到 ① 处,多个线程都要对数组r的第1项和第2项赋值,这里看上去感觉是存在数据竞争的,不过感觉再次欺骗了你。
|
||||
|
||||
其实很多人也是知道局部变量不存在数据竞争的,但是至于原因嘛,就说不清楚了。
|
||||
|
||||
那它背后的原因到底是怎样的呢?要弄清楚这个,你需要一点编译原理的知识。你知道在CPU层面,是没有方法概念的,CPU的眼里,只有一条条的指令。编译程序,负责把高级语言里的方法转换成一条条的指令。所以你可以站在编译器实现者的角度来思考“怎么完成方法到指令的转换”。
|
||||
|
||||
## 方法是如何被执行的
|
||||
|
||||
高级语言里的普通语句,例如上面的`r[i] = r[i-2] + r[i-1];`翻译成CPU的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第1行,声明一个int变量a;第2行,调用方法 fibonacci(a);第3行,将b赋值给c。
|
||||
|
||||
```
|
||||
int a = 7;
|
||||
int[] b = fibonacci(a);
|
||||
int[] c = b;
|
||||
|
||||
```
|
||||
|
||||
当你调用fibonacci(a)的时候,CPU要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后CPU执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是`int[] c=b;`的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/1f/9bd881b545e1c67142486f6594dc9d1f.png" alt="">
|
||||
|
||||
到这里,方法调用的过程想必你已经清楚了,但是还有一个很重要的问题,“CPU去哪里找到调用方法的参数和返回地址?”如果你熟悉CPU的工作原理,你应该会立刻想到:**通过CPU的堆栈寄存器**。CPU支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为**调用栈**。
|
||||
|
||||
例如,有三个方法A、B、C,他们的调用关系是A->B->C(A调用B,B调用C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为**栈帧**,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,**栈帧和方法是同生共死的**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/c7/674bb47feccbf55cf0b6acc5c92e4fc7.png" alt="">
|
||||
|
||||
利用栈结构来支持方法调用这个方案非常普遍,以至于CPU里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是**靠栈结构解决**的。Java语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
|
||||
|
||||
## 局部变量存哪里?
|
||||
|
||||
我们已经知道了方法间的调用在CPU眼里是怎么执行的,但还有一个关键问题:方法内的局部变量存哪里?
|
||||
|
||||
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,**局部变量就是放到了调用栈里**。于是调用栈的结构就变成了下图这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/9c/ece8c32d23e4777c370f594c97762a9c.png" alt="">
|
||||
|
||||
这个结论相信很多人都知道,因为学Java语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
|
||||
|
||||
## 调用栈与线程
|
||||
|
||||
两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:**每个线程都有自己独立的调用栈**。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程A、B、C每个线程都有自己独立的调用栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/1a/840cb955e521bd51776dbcdad3dba11a.png" alt="">
|
||||
|
||||
现在,让我们回过头来再看篇首的问题:Java方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
|
||||
|
||||
## 线程封闭
|
||||
|
||||
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做**线程封闭**,比较官方的解释是:**仅在单线程内访问数据**。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
|
||||
|
||||
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接Connection,在JDBC规范里并没有要求这个Connection必须是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。
|
||||
|
||||
## 总结
|
||||
|
||||
调用栈是一个通用的计算机概念,所有的编程语言都会涉及到,Java调用栈相关的知识,我并没有花费很大的力气去深究,但是靠着那点C语言的知识,稍微思考一下,基本上也就推断出来了。工作了十几年,我发现最近几年和前些年最大的区别是:很多技术的实现原理我都是靠推断,然后看源码验证,而不是像以前一样纯粹靠看源码来总结了。
|
||||
|
||||
建议你也多研究原理性的东西、通用的东西,有这些东西之后再学具体的技术就快多了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
128
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/12 | 如何用面向对象思想写好并发程序?.md
Normal file
128
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/12 | 如何用面向对象思想写好并发程序?.md
Normal file
@@ -0,0 +1,128 @@
|
||||
<audio id="audio" title="12 | 如何用面向对象思想写好并发程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/36/42b535904316f092b21379de2ab9c936.mp3"></audio>
|
||||
|
||||
在工作中,我发现很多同学在设计之初都是直接按照单线程的思路来写程序的,而忽略了本应该重视的并发问题;等上线后的某天,突然发现诡异的Bug,再历经千辛万苦终于定位到问题所在,却发现对于如何解决已经没有了思路。
|
||||
|
||||
关于这个问题,我觉得咱们今天很有必要好好聊聊“如何用面向对象思想写好并发程序”这个话题。
|
||||
|
||||
面向对象思想与并发编程有关系吗?本来是没关系的,它们分属两个不同的领域,但是在Java语言里,这两个领域被无情地融合在一起了,好在融合的效果还是不错的:**在Java语言里,面向对象思想能够让并发编程变得更简单**。
|
||||
|
||||
那如何才能用面向对象思想写好并发程序呢?结合我自己的工作经验来看,我觉得你可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。
|
||||
|
||||
## 一、封装共享变量
|
||||
|
||||
并发程序,我们关注的一个核心问题,不过是解决多线程同时访问共享变量的问题。在[《03 | 互斥锁(上):解决原子性问题》](https://time.geekbang.org/column/article/84344)中,我们类比过球场门票的管理,现实世界里门票管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。在编程世界这个问题也很重要,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。好在有了面向对象思想,对共享变量的访问路径可以轻松把控。
|
||||
|
||||
面向对象思想里面有一个很重要的特性是**封装**,封装的通俗解释就是**将属性和实现细节封装在对象内部**,外界对象**只能通过**目标对象提供的**公共方法来间接访问**这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。我们把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。
|
||||
|
||||
利用面向对象思想写并发程序的思路,其实就这么简单:**将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略**。就拿很多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是value,我们把它作为Counter类的属性,并且将两个公共方法get()和addOne()声明为同步方法,这样Counter类就成为一个线程安全的类了。
|
||||
|
||||
```
|
||||
public class Counter {
|
||||
private long value;
|
||||
synchronized long get(){
|
||||
return value;
|
||||
}
|
||||
synchronized long addOne(){
|
||||
return ++value;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,实际工作中,很多的场景都不会像计数器这么简单,经常要面临的情况往往是有很多的共享变量,例如,信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量,如果每一个都考虑它的并发安全问题,那我们就累死了。但其实仔细观察,你会发现,很多共享变量的值是不会变的,例如信用卡账户的卡号、姓名、身份证。**对于这些不会发生变化的共享变量,建议你用final关键字来修饰**。这样既能避免并发问题,也能很明了地表明你的设计意图,让后面接手你程序的兄弟知道,你已经考虑过这些共享变量的并发安全问题了。
|
||||
|
||||
## 二、识别共享变量间的约束条件
|
||||
|
||||
识别共享变量间的约束条件非常重要。因为**这些约束条件,决定了并发访问策略**。例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类SafeWM中,声明了两个成员变量upper和lower,分别代表库存上限和库存下限,这两个变量用了AtomicLong这个原子类,原子类是线程安全的,所以这两个成员变量的set方法就不需要同步了。
|
||||
|
||||
```
|
||||
public class SafeWM {
|
||||
// 库存上限
|
||||
private final AtomicLong upper =
|
||||
new AtomicLong(0);
|
||||
// 库存下限
|
||||
private final AtomicLong lower =
|
||||
new AtomicLong(0);
|
||||
// 设置库存上限
|
||||
void setUpper(long v){
|
||||
upper.set(v);
|
||||
}
|
||||
// 设置库存下限
|
||||
void setLower(long v){
|
||||
lower.set(v);
|
||||
}
|
||||
// 省略其他业务代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽说上面的代码是没有问题的,但是忽视了一个约束条件,就是**库存下限要小于库存上限**,这个约束条件能够直接加到上面的set方法上吗?我们先直接加一下看看效果(如下面代码所示)。我们在setUpper()和setLower()中增加了参数校验,这乍看上去好像是对的,但其实存在并发问题,问题在于存在竞态条件。这里我顺便插一句,其实当你看到代码里出现if语句的时候,就应该立刻意识到可能存在竞态条件。
|
||||
|
||||
我们假设库存的下限和上限分别是(2,10),线程A调用setUpper(5)将上限设置为5,线程B调用setLower(7)将下限设置为7,如果线程A和线程B完全同时执行,你会发现线程A能够通过参数校验,因为这个时候,下限还没有被线程B设置,还是2,而5>2;线程B也能够通过参数校验,因为这个时候,上限还没有被线程A设置,还是10,而7<10。当线程A和线程B都通过参数校验后,就把库存的下限和上限设置成(7, 5)了,显然此时的结果是不符合**库存下限要小于库存上限**这个约束条件的。
|
||||
|
||||
```
|
||||
public class SafeWM {
|
||||
// 库存上限
|
||||
private final AtomicLong upper =
|
||||
new AtomicLong(0);
|
||||
// 库存下限
|
||||
private final AtomicLong lower =
|
||||
new AtomicLong(0);
|
||||
// 设置库存上限
|
||||
void setUpper(long v){
|
||||
// 检查参数合法性
|
||||
if (v < lower.get()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
upper.set(v);
|
||||
}
|
||||
// 设置库存下限
|
||||
void setLower(long v){
|
||||
// 检查参数合法性
|
||||
if (v > upper.get()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
lower.set(v);
|
||||
}
|
||||
// 省略其他业务代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在没有识别出**库存下限要小于库存上限**这个约束条件之前,我们制定的并发访问策略是利用原子类,但是这个策略,完全不能保证**库存下限要小于库存上限**这个约束条件。所以说,在设计阶段,我们**一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙**。
|
||||
|
||||
共享变量之间的约束条件,反映在代码里,基本上都会有if语句,所以,一定要特别注意竞态条件。
|
||||
|
||||
## 三、制定并发访问策略
|
||||
|
||||
制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。
|
||||
|
||||
1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
|
||||
1. 不变模式:这个在Java领域应用的很少,但在其他领域却有着广泛的应用,例如Actor模式、CSP模式以及函数式编程的基础都是不变模式。
|
||||
1. 管程及其他同步工具:Java领域万能的解决方案是管程,但是对于很多特定场景,使用Java并发包提供的读写锁、并发容器等同步工具会更好。
|
||||
|
||||
接下来在咱们专栏的第二模块我会仔细讲解Java并发工具类以及他们的应用场景,在第三模块我还会讲解并发编程的设计模式,这些都是和制定并发访问策略有关的。
|
||||
|
||||
除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。
|
||||
|
||||
1. 优先使用成熟的工具类:Java SDK并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
|
||||
1. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是synchronized、Lock、Semaphore等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
|
||||
1. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。
|
||||
|
||||
## 总结
|
||||
|
||||
利用面向对象思想编写并发程序,一个关键点就是利用面向对象里的封装特性,由于篇幅原因,这里我只做了简单介绍,详细的你可以借助相关资料定向学习。而对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面,比如在[《02 | Java内存模型:看Java如何解决可见性和有序性问题》](https://time.geekbang.org/column/article/84017)那一篇我们已经讲过构造函数里的this“逸出”。这些都是必须要避免的。
|
||||
|
||||
这是我们专栏并发理论基础的最后一部分内容,这一部分内容主要是让你对并发编程有一个全面的认识,让你了解并发编程里的各种概念,以及它们之间的关系,当然终极目标是让你知道遇到并发问题该怎么思考。这部分的内容还是有点烧脑的,但专栏后面几个模块的内容都是具体的实践部分,相对来说就容易多了。我们一起坚持吧!
|
||||
|
||||
## 课后思考
|
||||
|
||||
本期示例代码中,类SafeWM不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
关于这部分的内容,如果你觉得还不“过瘾”,这里我再给你推荐一本书吧——[《Java并发编程实战》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw),这本书的第三章《对象的共享》、第四章《对象的组合》全面地介绍了如何构建线程安全的对象,你可以拿来深入地学习。
|
||||
|
||||
|
||||
209
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/13 | 理论基础模块热点问题答疑.md
Normal file
209
极客时间专栏/geek/Java并发编程实战/第一部分:并发理论基础/13 | 理论基础模块热点问题答疑.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="13 | 理论基础模块热点问题答疑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/b7/06ef485902909fe06a58ec768154c0b7.mp3"></audio>
|
||||
|
||||
到这里,专栏的第一模块——并发编程的理论基础,我们已经讲解完了,总共12篇,不算少,但“跳出来,看全景”你会发现这12篇的内容基本上是一个“串行的故事”。所以,在学习过程中,建议你从一个个单一的知识和技术中“跳出来”,看全局,搭建自己的并发编程知识体系。
|
||||
|
||||
为了便于你更好地学习和理解,下面我会先将这些知识点再简单地为你“串”一下,咱们一起复习下;然后就每篇文章的课后思考题、留言区的热门评论,我也集中总结和回复一下。
|
||||
|
||||
**那这个“串行的故事”是怎样的呢?**
|
||||
|
||||
起源是一个硬件的核心矛盾:CPU与内存、I/O的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的Bug之源。这,就是[01](https://time.geekbang.org/column/article/83682)的内容。
|
||||
|
||||
那如何解决这三个问题呢?Java语言自然有招儿,它提供了Java内存模型和互斥锁方案。所以,在[02](https://time.geekbang.org/column/article/84017)我们介绍了Java内存模型,以应对可见性和有序性问题;那另一个原子性问题该如何解决?多方考量用好互斥锁才是关键,这就是[03](https://time.geekbang.org/column/article/84344)和[04](https://time.geekbang.org/column/article/84601)的内容。
|
||||
|
||||
虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,所以[05](https://time.geekbang.org/column/article/85001)就介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,这也就引出了[06](https://time.geekbang.org/column/article/85241)这篇文章的内容,介绍线程间的协作机制:等待-通知。
|
||||
|
||||
你应该也看出来了,前六篇文章,我们更多地是站在微观的角度看待并发问题。而[07](https://time.geekbang.org/column/article/85702)则是换一个角度,站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺。
|
||||
|
||||
[08](https://time.geekbang.org/column/article/86089)介绍的管程,是Java并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。所以,学好管程,就相当于掌握了一把并发编程的万能钥匙。
|
||||
|
||||
至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。
|
||||
|
||||
而后在[09](https://time.geekbang.org/column/article/86366)、[10](https://time.geekbang.org/column/article/86666)和[11](https://time.geekbang.org/column/article/86695)我们又介绍了线程相关的知识,毕竟Java并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。
|
||||
|
||||
最后,在[12](https://time.geekbang.org/column/article/87365)我们还介绍了如何用面向对象思想写好并发程序,因为在Java语言里,面向对象思想能够让并发编程变得更简单。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/8e/7fed6a485a694c794ee205c346b5338e.png" alt="">
|
||||
|
||||
经过这样一个简要的总结,相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验,还是需要你“钻进去,看本质”,加深对技术本身的认识,拓展知识深度和广度。
|
||||
|
||||
另外,在每篇文章的最后,我都附上了一个思考题,这些思考题虽然大部分都很简单,但是隐藏的问题却很容易让人忽略,从而不经意间就引发了Bug;再加上留言区的一些热门评论,所以我想着**将这些隐藏的问题或者易混淆的问题,做一个总结也是很有必要的**。
|
||||
|
||||
## 1. 用锁的最佳实践
|
||||
|
||||
例如,在[《03 | 互斥锁(上):解决原子性问题》](https://time.geekbang.org/column/article/84344)和[《04 | 互斥锁(下):如何用一把锁保护多个资源?》](https://time.geekbang.org/column/article/84601)这两篇文章中,我们的思考题都是关于如何创建正确的锁,而思考题里的做法都是错误的。
|
||||
|
||||
[03](https://time.geekbang.org/column/article/84344)的思考题的示例代码如下,`synchronized (new Object())` 这行代码很多同学已经分析出来了,每次调用方法get()、addOne()都创建了不同的锁,相当于无锁。这里需要你再次加深一下记忆,“**一个合理的受保护资源与锁之间的关联关系应该是N:1**”。只有共享一把锁才能起到互斥的作用。
|
||||
|
||||
另外,很多同学也提到,JVM开启逃逸分析之后,`synchronized (new Object())` 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程,如果你对“逃逸分析”感兴趣,可以参考一些JVM相关的资料。
|
||||
|
||||
```
|
||||
class SafeCalc {
|
||||
long value = 0L;
|
||||
long get() {
|
||||
synchronized (new Object()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
void addOne() {
|
||||
synchronized (new Object()) {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
[04](https://time.geekbang.org/column/article/84601)的思考题转换成代码,是下面这个样子。它的核心问题有两点:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在JVM里面是可能被重用的,除此之外,JVM里可能被重用的对象还有Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 `synchronized(你的锁)`,而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
|
||||
|
||||
```
|
||||
class Account {
|
||||
// 账户余额
|
||||
private Integer balance;
|
||||
// 账户密码
|
||||
private String password;
|
||||
// 取款
|
||||
void withdraw(Integer amt) {
|
||||
synchronized(balance) {
|
||||
if (this.balance > amt){
|
||||
this.balance -= amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更改密码
|
||||
void updatePassword(String pw){
|
||||
synchronized(password) {
|
||||
this.password = pw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过这两个反例,我们可以总结出这样一个基本的原则:**锁,应是私有的、不可变的、不可重用的**。我们经常看到别人家的锁,都长成下面示例代码这样,这种写法貌不惊人,却能避免各种意想不到的坑,这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java安全编码标准》这本书,研读里面的每一条规则都会让你受益匪浅。
|
||||
|
||||
```
|
||||
// 普通对象锁
|
||||
private final Object
|
||||
lock = new Object();
|
||||
// 静态对象锁
|
||||
private static final Object
|
||||
lock = new Object();
|
||||
|
||||
```
|
||||
|
||||
## 2. 锁的性能要看场景
|
||||
|
||||
[《05 | 一不小心就死锁了,怎么办?》](https://time.geekbang.org/column/article/85001)的思考题是比较`while(!actr.apply(this, target));`这个方法和`synchronized(Account.class)`的性能哪个更好。
|
||||
|
||||
这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许A->B、C->D这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。
|
||||
|
||||
## 3. 竞态条件需要格外关注
|
||||
|
||||
[《07 | 安全性、活跃性以及性能问题》](https://time.geekbang.org/column/article/85702)里的思考题是一种典型的竞态条件问题(如下所示)。竞态条件问题非常容易被忽略,contains()和add()方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。
|
||||
|
||||
```
|
||||
void addIfNotExist(Vector v,
|
||||
Object o){
|
||||
if(!v.contains(o)) {
|
||||
v.add(o);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这道思考题的解决方法,可以参考[《12 | 如何用面向对象思想写好并发程序?》](https://time.geekbang.org/column/article/87365),你需要将共享变量v封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对Vector v变量的滥用,从而导致并发问题。你可以参考下面的示例代码来加深理解。
|
||||
|
||||
```
|
||||
class SafeVector{
|
||||
private Vector v;
|
||||
// 所有公共方法增加同步控制
|
||||
synchronized
|
||||
void addIfNotExist(Object o){
|
||||
if(!v.contains(o)) {
|
||||
v.add(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 4. 方法调用是先计算参数
|
||||
|
||||
不过,还有同学对[07](https://time.geekbang.org/column/article/85702)文中所举的例子有疑议,认为`set(get()+1);`这条语句是进入set()方法之后才执行get()方法,其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体,方法调用的过程在[11](https://time.geekbang.org/column/article/86695)这篇文章中我们已经做了详细的介绍,你可以再次重温一下。
|
||||
|
||||
```
|
||||
while(idx++ < 10000) {
|
||||
set(get()+1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
先计算参数这个事情也是容易被忽视的细节。例如,下面写日志的代码,如果日志级别设置为INFO,虽然这行代码不会写日志,但是会计算`"The var1:" + var1 + ", var2:" + var2`的值,因为方法调用前会先计算参数。
|
||||
|
||||
```
|
||||
logger.debug("The var1:" +
|
||||
var1 + ", var2:" + var2);
|
||||
|
||||
```
|
||||
|
||||
更好地写法应该是下面这样,这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。
|
||||
|
||||
```
|
||||
logger.debug("The var1:{}, var2:{}",
|
||||
var1, var2);
|
||||
|
||||
```
|
||||
|
||||
## 5. InterruptedException异常处理需小心
|
||||
|
||||
[《 09 | Java线程(上):Java线程的生命周期》](https://time.geekbang.org/column/article/86366)的思考题主要是希望你能够注意InterruptedException的处理方式。当你调用Java对象的wait()方法或者线程的sleep()方法时,需要捕获并处理InterruptedException异常,在思考题里面(如下所示),本意是通过isInterrupted()检查线程是否被中断了,如果中断了就退出while循环。当其他线程通过调用`th.interrupt().`来中断th线程时,会设置th线程的中断标志位,从而使`th.isInterrupted()`返回true,这样就能退出while循环了。
|
||||
|
||||
```
|
||||
Thread th = Thread.currentThread();
|
||||
while(true) {
|
||||
if(th.isInterrupted()) {
|
||||
break;
|
||||
}
|
||||
// 省略业务代码无数
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
}catch (InterruptedException e){
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在sleep(100)上,当其他线程通过调用`th.interrupt().`来中断th线程时,大概率地会触发InterruptedException 异常,**在触发InterruptedException 异常的同时,JVM会同时把线程的中断标志位清除**,所以这个时候`th.isInterrupted()`返回的是false。
|
||||
|
||||
正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:
|
||||
|
||||
```
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
}catch(InterruptedException e){
|
||||
// 重新设置中断标志位
|
||||
th.interrupt();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 6. 理论值 or 经验值
|
||||
|
||||
[《10 | Java线程(中):创建多少线程才是合适的?》](https://time.geekbang.org/column/article/86666)的思考题是:经验值为“最佳线程=2 * CPU的核数 + 1”,是否合理?
|
||||
|
||||
从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O耗时 / CPU耗时”不太容易确定的系统来说,却是一个很好到初始值。
|
||||
|
||||
我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O耗时 / CPU耗时”往往都大于1,所以基本上都是在这个**初始值的基础上增加**。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。
|
||||
|
||||
实际工作中,不同的I/O模型对最佳线程数的影响非常大,例如大名鼎鼎的Nginx用的是非阻塞I/O,采用的是多进程单线程结构,Nginx本来是一个I/O密集型系统,但是最佳进程数设置的却是CPU的核数,完全参考的是CPU密集型的算法。所以,理论我们还是要活学活用。
|
||||
|
||||
## 总结
|
||||
|
||||
这个模块,内容主要聚焦在并发编程相关的理论上,但是思考题则是聚焦在细节上,我们经常说细节决定成败,在并发编程领域尤其如此。理论主要用来给我们提供解决问题的思路和方法,但在具体实践的时候,还必须重点关注每一个细节,哪怕有一个细节没有处理好,都会导致并发问题。这方面推荐你认真阅读《Java安全编码标准》这本书,如果你英文足够好,也可以参考[这份文档](https://wiki.sei.cmu.edu/confluence/display/java/2+Rules)。
|
||||
|
||||
最后总结一句,学好理论有思路,关注细节定成败。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user