前序
CPU、内存、IO设备三者速度差异巨大,为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU增加了缓存,以均衡与内存的速度差异(引发出内存可见性问题)
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异(引发出原子性问题)
- 编译程序优化指令执行顺序,是的缓存能够更加合理利用(引发出有序性问题)
并发编程的BUG源头即是上述三大问题
Java解决可见性和有序性问题
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则;
volatile
volatile申明的变量是告诉编译器禁用CPU缓存,必须从内存中读取或写入;(JDK1.5版本对volatile进行语义增强,加入Happens-Before中的传递性规则)
Happens-Before规则
原译为:前面一个操作的结果对后续操作是可见的。Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。
- 程序的顺序性规则。在一个线程中,按照程序的顺序,前面的操作Happens-Before于后续的任意操作
- volatile变量规则。对一个volatile变量的写操作Happens-Before于后续对这个volatile变量的操作
- 传递性。如果A Happens-Before B,且B Happens-Before C,那么 A Happens-Before C
- 管程中锁的规则。对一个锁的解锁Happens-Before于后续对这个锁的加锁
线程start()规则。主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作
Thread B = new Thread(()->{
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
线程join规则。这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66
final
volatile 为的是禁用缓存以及编译优化,final修饰的变量是为了告诉编译器这个变量生而不变,可以可劲儿的优化。
要避免”逸出问题”,demo如下(有可能通过global.obj 可能访问到还没有初始化的this对象,将this赋值给global.obj时,this还没有初始化完):final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
死锁
一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
死锁条件
只有以下这四个条件都发生时才会出现死锁
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;(一次性申请所有资源即可破坏该条件)
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;(Java Synchronized 无法实现,SDK层可以解决)
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待;(对资源进行排序,然后按序申请资源可破坏该条件)
理解阻塞非阻塞与同步异步的区别
- 同步和异步关注的是消息通信机制;
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。
- 阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态
- 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
管程(Monitor):并发编程万能钥匙
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
Java 在 1.5 之前仅仅提供了 synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法。操作系统原理课程告诉我,用信号量能解决所有并发问题,但是Java没有提供信号量这种编程原语,Java采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,管程能实现信号量,信号量也能实现管程,但管程更容易使用;
MESA模型
管程发展史上先后出现过三种不同的管程,分别是Hasen模型、Hoare模型和MESA模型,JAVA管程参考的是MESA模型;
并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的
MESA模型互斥问题解决方案
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
利用管程,可以快速实现这个直观的想法。在下图中,管程 X 将共享变量 queue 这个线程不安全的队列和相关的操作入队操作 enq()、出队操作 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。
MESA模型同步问题解决方案
任何线程想要访问该资源,就要排队进入监控范围(即图中入口等待队列),进入之后,是否满足资源要求,不满足进入等待队列(对应的A/B条件变量等待队列),资源要求满足后,位于条件等待队列中的线程重新回到入口等待队列等待;
1.管程是一种概念,任何语言都可以通用。 2.在java中,每个加锁的对象都绑定着一个管程(监视器) 3.线程访问加锁对象,就是去拥有一个监视器的过程。如一个病人去门诊室看医生,医生是共享资源,门锁锁定医生,病人去看医生,就是访问医生这个共享资源,门诊室其实是监视器(管程)。 4.所有线程访问共享资源,都需要先拥有监视器。就像所有病人看病都需要先拥有进入门诊室的资格。 5.监视器至少有两个等待队列。一个是进入监视器的等待队列一个是条件变量对应的等待队列。后者可以有多个。就像一个病人进入门诊室诊断后,需要去验血,那么它需要去抽血室排队等待。另外一个病人心脏不舒服,需要去拍胸片,去拍摄室等待。 6.监视器要求的条件满足后,位于条件变量下等待的线程需要重新在门诊室门外排队,等待进入监视器。就像抽血的那位,抽完后,拿到了化验单,然后,重新回到门诊室等待,然后进入看病,然后退出,医生通知下一位进入。
总结起来就是,管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。
wait()
有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。
while(条件不满足) {
wait();
}
为啥用while?
当线程被唤醒后,是从wait命令后开始执行的(不是从头开始执行该方法),而执行时间点往往跟唤醒时间点不一致,所以条件变量此时不一定满足了,所以通过while循环可以再验证,而if条件却做不到,它只能从wait命令后开始执行,所以要用while
Synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。具体如下图所示