01 | 可见性、原子性和有序性问题:并发编程Bug的源头
由于CPU、内存、磁盘三者速度差异,程序执行时根据木桶理论,取决于最慢的操作。为了利用cpu的高性能,平衡三者速度差异,提高计算机整体吞吐量。计算机体系结构、操作系统、编译程序都做了贡献。
- cpu增加了缓存,平衡与内存的速度差异(cpu缓存比内存速度读取更快,但也导致了可见性问题)
- 操作系统设计线程、进程,分时复用cpu,均衡cpu与io设备之间速度差异(线程切换带来原子性问题)
我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
- 编译程序优化指令顺序,使得缓存能够更加合理使用(导致有序性问题) ```java
public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }
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 的成员变量就可能触发空指针异常
<a name="tiyhv"></a>
## 课后问题
> <a name="5ab43669"></a>
## 在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?
> long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题
<a name="KHkxT"></a>
# 02 | Java内存模型:看Java如何解决可见性和有序性问题
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。
<a name="JjT5C"></a>
## volatile
volatile 关键字的意义就是禁用 CPU 缓存、禁止指令重排。
<a name="cYRyw"></a>
## final
final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
<a name="2xAAX"></a>
## Happens-Before 规则
Happens-Before表达的是:前面一个操作的结果对后续操作是可见的 关于可见性的
1. 程序的顺序性规则,一个线程执行过程中,前面的操作 Happens-Before 于后续的任意操作。
1. volatile变量规则: 对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见
1. 传递性 A Happens-Before B B Happens-Before C 那么A Happens-Before C (这就是 1.5 版本对 volatile 语义的增强)
1. 管程中锁的规则 线程A获得锁之后,对共享变量的操作对后来再获得锁的其他线程来说是可见的
1. 线程start规则 线程A启动线程B,那么线程B能够看到线程A在启动它之前的操作
1. 线程join规则 线程A等待线程B完成(调用B的join方法),那么线程A是能够看到线程B对共享变量的操作的
> 逸出:
> 逸出 指的是对封装性的破坏。比如对一个对象的操作,通过将这个对象的this赋值给一个外部全局变量,使得这个全局变量可以绕过对象的封装接口直接访问对象中的成员,这就是逸出。
```java
// 以下代码来源于【参考1】
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
03 | 互斥锁(上):解决原子性问题
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
加锁模型
Java 语言提供的锁技术:synchronized
加锁本质就是在锁对象的对象头中写入当前线程id
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()。
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X; 当修饰非静态方法的时候,锁定的是当前实例对象 this。
锁和受保护资源的关系
受保护资源和锁之间的关联关系是 N:1 的关系(对象头里只有一个线程id占位符)
04 | 互斥锁(下):如何用一把锁保护多个资源?
单用synchronize来实现的话,以账户转账为例
- 每个账户传入一个相同变量
- 用Account.class作为加锁粒度
05 | 一不小心就死锁了,怎么办?
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
死锁条件
只有以下这四个条件都发生时才会出现死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
避免死锁
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 将所有申请的资源封装到一个功能块(类)中
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);
}
}
- 将所有申请的资源封装到一个功能块(类)中
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
- 在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
- 以转账为例,先锁定自身账户再锁定目标账户,如果刚好相互转账则形成死锁,将账户排序按序锁定则不会出现上述情况 ```java
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; } } } } }
<a name="MNEem"></a>
# 06 | 用“等待-通知”机制优化循环等待
流程机制:
1. 线程获取到互斥锁
1. 线程要求的条件没有满足
1. 线程释放互斥锁,进入条件等待队列
1. 线程满足条件后,再次从头竞争互斥锁
用synchronized实现等待-通知机制,可以配合java内置的wait()、notify()、notifyAll()来实现<br />wait()原理<br />notify()原理,当条件满足时调用 notify(),会通知等待队列(**互斥锁的等待队列**)中的线程,告诉它条件**曾经**满足过。<br />所以存在以下编程范式:
```java
while(条件不满足) {
wait();
}
课后问题
notify()和notifyAll()区别
老师notify和notifyAll那块的区别,我相信有很多人和我一样有疑惑,在经过细想之后我终于搞明白了,这里简单举个例子来说明老师的意思:有两个顾客要买水果,但同时只能有一个人进店里买(也就是只有有抢到锁的人才能进去买水果),顾客A想要买橘子,顾客B想要买苹果,但是目前店里什么都没有,那么A和B都在while循环里面调wait方法进行阻塞等待(这时候锁已经释放),然后店员C去进货进了苹果,然后开始通知大家可以来买水果了(也就是调用锁的notify方法),这里notify方法随机唤醒一个顾客,假设唤醒了顾客B,顾客B拿到锁之后发现要的橘子还是没有(对应while循环的条件还是没满足)又调了wait进行阻塞等待,结果这样就导致明明有苹果,但是A还是等在死那。但如果是notifyAll方法的话,那么就同时通知A和B(唤醒A和B),这时两个顾客竞争锁,假设拿到锁的还是B,他发现没有橘子于是接着wait释放锁,这时候A就能拿到B释放的锁,然后就可以买到想要的苹果了,这样就不会出现上面发生的死等现象。
wait()和sleep()区别
不同点:
- sleep是Thread的方法
- wait只能在同步方法方法/块内调用
- wait会释放锁资源,sleep不会
- wait()方法会释放对象的“锁标志”
相同点:
- 都会挂起当前线程,让渡cpu执行时间
07 | 安全性、活跃性以及性能问题
安全性问题
线程安全问题本质是因为存在共享变量且该数据会发生变化,通俗的讲就是多个线程会同时写同一个数据。
解决办法第一类就是不共享或者让数据不发生变化,比如线程本地存储、不变模式等,或者直面问题使用锁。
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) //add10k()方法线程不安全,虽然get、set加了锁,但是在这个方法内部同时get到旧值再做加法,存在竞态条件
}
}
}
所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序
活跃性问题
所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。活锁是多个线程类似死锁的情况下,同时释放掉自己已经获取的资源,然后同时获取另外一种资源,又形成依赖循环,导致都不能执行下去。可以设置一个随机等待时间解决。
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。解决方法1.保证资源充足 2.尽量避免单个线程长时间占用锁 3.公平分配。 其中3最靠谱,并发编程中具体实现即是公平锁。
性能问题
“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
课后问题
Java 语言提供的 Vector 是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?
void addIfNotExist(Vector v, Object o){
if(!v.contains(o)) {
v.add(o);
}
}
vector是线程安全,指的是它方法单独执行的时候没有并发正确性问题,并不代表把它的操作组合在一起问木有,而这个程序显然有老师讲的竞态条件问题。
08 | 管程:并发编程的万能钥匙
所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。
MESA 模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
互斥
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。
同步
- 最开始线程在入口等待队列中竞争锁资源
- 竞争到的线程发现条件变量不满足,加入到相应的条件变量等待队列中(wait)
- 接收到条件变量满足(notify)后,重新到入口队列,进行锁资源竞争
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。