01 | 可见性、原子性和有序性问题:并发编程Bug的源头

由于CPU、内存、磁盘三者速度差异,程序执行时根据木桶理论,取决于最慢的操作。为了利用cpu的高性能,平衡三者速度差异,提高计算机整体吞吐量。计算机体系结构、操作系统、编译程序都做了贡献。

  1. cpu增加了缓存,平衡与内存的速度差异(cpu缓存比内存速度读取更快,但也导致了可见性问题)

image.png

  1. 操作系统设计线程、进程,分时复用cpu,均衡cpu与io设备之间速度差异(线程切换带来原子性问题)

image.png
我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

  1. 编译程序优化指令顺序,使得缓存能够更加合理使用(导致有序性问题) ```java

public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }

  1. new 操作应该是:
  2. 1. 分配一块内存 M
  3. 1. 在内存 M 上初始化 Singleton 对象;
  4. 1. 然后 M 的地址赋值给 instance 变量。
  5. 但是实际上优化后的执行路径却是这样的:
  6. 1. 分配一块内存 M
  7. 1. M 的地址赋值给 instance 变量;
  8. 1. 最后在内存 M 上初始化 Singleton 对象。
  9. 优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常
  10. <a name="tiyhv"></a>
  11. ## 课后问题
  12. > <a name="5ab43669"></a>
  13. ## 在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?
  14. > long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题
  15. <a name="KHkxT"></a>
  16. # 02 | Java内存模型:看Java如何解决可见性和有序性问题
  17. Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatilesynchronized final 三个关键字,以及六项 Happens-Before 规则。
  18. <a name="JjT5C"></a>
  19. ## volatile
  20. volatile 关键字的意义就是禁用 CPU 缓存、禁止指令重排。
  21. <a name="cYRyw"></a>
  22. ## final
  23. final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
  24. <a name="2xAAX"></a>
  25. ## Happens-Before 规则
  26. Happens-Before表达的是:前面一个操作的结果对后续操作是可见的 关于可见性的
  27. 1. 程序的顺序性规则,一个线程执行过程中,前面的操作 Happens-Before 于后续的任意操作。
  28. 1. volatile变量规则: 对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见
  29. 1. 传递性 A Happens-Before B B Happens-Before C 那么A Happens-Before C (这就是 1.5 版本对 volatile 语义的增强)
  30. 1. 管程中锁的规则 线程A获得锁之后,对共享变量的操作对后来再获得锁的其他线程来说是可见的
  31. 1. 线程start规则 线程A启动线程B,那么线程B能够看到线程A在启动它之前的操作
  32. 1. 线程join规则 线程A等待线程B完成(调用Bjoin方法),那么线程A是能够看到线程B对共享变量的操作的
  33. > 逸出:
  34. > 逸出 指的是对封装性的破坏。比如对一个对象的操作,通过将这个对象的this赋值给一个外部全局变量,使得这个全局变量可以绕过对象的封装接口直接访问对象中的成员,这就是逸出。
  35. ```java
  36. // 以下代码来源于【参考1】
  37. final int x;
  38. // 错误的构造函数
  39. public FinalFieldExample() {
  40. x = 3;
  41. y = 4;
  42. // 此处就是讲this逸出,
  43. global.obj = this;
  44. }

03 | 互斥锁(上):解决原子性问题

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
加锁模型
image.png

Java 语言提供的锁技术:synchronized

加锁本质就是在锁对象的对象头中写入当前线程id

  1. class X {
  2. // 修饰非静态方法
  3. synchronized void foo() {
  4. // 临界区
  5. }
  6. // 修饰静态方法
  7. synchronized static void bar() {
  8. // 临界区
  9. }
  10. // 修饰代码块
  11. Object obj = new Object();
  12. void baz() {
  13. synchronized(obj) {
  14. // 临界区
  15. }
  16. }
  17. }

Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()。
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X; 当修饰非静态方法的时候,锁定的是当前实例对象 this。

锁和受保护资源的关系

受保护资源和锁之间的关联关系是 N:1 的关系(对象头里只有一个线程id占位符)

04 | 互斥锁(下):如何用一把锁保护多个资源?

单用synchronize来实现的话,以账户转账为例

  1. 每个账户传入一个相同变量
  2. 用Account.class作为加锁粒度

05 | 一不小心就死锁了,怎么办?

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

死锁条件

只有以下这四个条件都发生时才会出现死锁:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

    避免死锁

    其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  5. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

    1. 将所有申请的资源封装到一个功能块(类)中
      1. class Allocator {
      2. private List<Object> als =
      3. new ArrayList<>();
      4. // 一次性申请所有资源
      5. synchronized boolean apply(
      6. Object from, Object to){
      7. if(als.contains(from) ||
      8. als.contains(to)){
      9. return false;
      10. } else {
      11. als.add(from);
      12. als.add(to);
      13. }
      14. return true;
      15. }
      16. // 归还资源
      17. synchronized void free(
      18. Object from, Object to){
      19. als.remove(from);
      20. als.remove(to);
      21. }
      22. }
  6. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

    1. 这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
    2. 在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的
  7. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
    1. 以转账为例,先锁定自身账户再锁定目标账户,如果刚好相互转账则形成死锁,将账户排序按序锁定则不会出现上述情况 ```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; } } } } }

  1. <a name="MNEem"></a>
  2. # 06 | 用“等待-通知”机制优化循环等待
  3. 流程机制:
  4. 1. 线程获取到互斥锁
  5. 1. 线程要求的条件没有满足
  6. 1. 线程释放互斥锁,进入条件等待队列
  7. 1. 线程满足条件后,再次从头竞争互斥锁
  8. 用synchronized实现等待-通知机制,可以配合java内置的wait()、notify()、notifyAll()来实现<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/2109626/1613717231603-52609b73-e7f7-4e9a-8ca8-2b80a7f2871e.png#align=left&display=inline&height=237&margin=%5Bobject%20Object%5D&name=image.png&originHeight=654&originWidth=1142&size=233614&status=done&style=none&width=413)wait()原理<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/2109626/1613717383465-4cd1371b-5af8-444b-b5c8-eb5967970af0.png#align=left&display=inline&height=257&margin=%5Bobject%20Object%5D&name=image.png&originHeight=720&originWidth=1142&size=220051&status=done&style=none&width=407)notify()原理,当条件满足时调用 notify(),会通知等待队列(**互斥锁的等待队列**)中的线程,告诉它条件**曾经**满足过。<br />所以存在以下编程范式:
  9. ```java
  10. while(条件不满足) {
  11. wait();
  12. }

课后问题

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()区别

不同点:

  1. sleep是Thread的方法
  2. wait只能在同步方法方法/块内调用
  3. wait会释放锁资源,sleep不会
    1. wait()方法会释放对象的“锁标志”

相同点:

  1. 都会挂起当前线程,让渡cpu执行时间

07 | 安全性、活跃性以及性能问题

安全性问题

线程安全问题本质是因为存在共享变量且该数据会发生变化,通俗的讲就是多个线程会同时写同一个数据。
解决办法第一类就是不共享或者让数据不发生变化,比如线程本地存储、不变模式等,或者直面问题使用锁。

  1. public class Test {
  2. private long count = 0;
  3. synchronized long get(){
  4. return count
  5. }
  6. synchronized void set(long v){
  7. count = v;
  8. }
  9. void add10K() {
  10. int idx = 0;
  11. while(idx++ < 10000) {
  12. set(get()+1) //add10k()方法线程不安全,虽然get、set加了锁,但是在这个方法内部同时get到旧值再做加法,存在竞态条件
  13. }
  14. }
  15. }

所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序

活跃性问题

所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。活锁是多个线程类似死锁的情况下,同时释放掉自己已经获取的资源,然后同时获取另外一种资源,又形成依赖循环,导致都不能执行下去。可以设置一个随机等待时间解决。
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。解决方法1.保证资源充足 2.尽量避免单个线程长时间占用锁 3.公平分配。 其中3最靠谱,并发编程中具体实现即是公平锁。

性能问题

“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

  1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  2. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  3. 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

课后问题

Java 语言提供的 Vector 是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?

  1. void addIfNotExist(Vector v, Object o){
  2. if(!v.contains(o)) {
  3. v.add(o);
  4. }
  5. }

vector是线程安全,指的是它方法单独执行的时候没有并发正确性问题,并不代表把它的操作组合在一起问木有,而这个程序显然有老师讲的竞态条件问题。

08 | 管程:并发编程的万能钥匙

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。

MESA 模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。

互斥

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。假如我们要实现一个线程安全的阻塞队列,一个最直观的想法就是:将线程不安全的队列封装起来,对外提供线程安全的操作方法,例如入队操作和出队操作。

同步

image.png

  1. 最开始线程在入口等待队列中竞争锁资源
  2. 竞争到的线程发现条件变量不满足,加入到相应的条件变量等待队列中(wait)
  3. 接收到条件变量满足(notify)后,重新到入口队列,进行锁资源竞争

    Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。