<并发编程>学习笔记------(一) 并发相关理论

前面

并发编程可以总结为三个核心问题:

  • 分工指的是如何高效地拆解任务并分配给线程
  • 同步指的是线程之间如何协作
  • 互斥则是保证同一时刻只允许一个线程访问共享资源

并发相关理论

可见性、原子性和有序性

核心矛盾
CPU、内存、I/O 设备的速度差异
cpu >>> 内存 >>> I/O 设备

CPU 增加了缓存,以均衡与内存的速度差异
操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

缓存导致的可见性问题

可见性: 一个线程对共享变量的修改,另外一个线程能够立刻看到

<并发编程>学习笔记------(一) 并发相关理论_第1张图片
多核 CPU 的缓存与内存关系图

多核时代,每颗 CPU 都有自己的缓存
当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存
这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了

线程切换带来的原子性问题

<并发编程>学习笔记------(一) 并发相关理论_第2张图片
线程切换示意图

多进程: 单核的 CPU可以同时执行多个任务
时间片: 某个进程执行的一小段时间, 之后进行任务切换
一个时间片内, 某个进程IO可以先休眠, 让出CPU使用权, 读入内存后再由OS唤醒该进程, 可以同时提高CPU和IO的使用率
一个进程创建的所有线程共享一个内存空间的,不需要切换内存映射地址

count += 1, 三条CPU指令:
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完

<并发编程>学习笔记------(一) 并发相关理论_第3张图片

假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1

原子性: 一个或者多个操作在 CPU 执行的过程中不被中断的特性

编译优化带来的有序性问题
应该这样写
class Single{  
    private static volatile Single s = null;   //禁止重排序
    private Single(){}  

    public static Single getInstance(){
        if(null==s){
            synchronized(Single.class){
                if(null==s)  
                    s = new Single();  
            }
        }
        return s;  
    }  
}
双重检查创建单例对象
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 还是存在问题 – 重排序 – volatile!!!

new 操作:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

优化后的执行顺序:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

产生问题: 空指针异常
假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常
<并发编程>学习笔记------(一) 并发相关理论_第4张图片
双重检查创建单例的异常执行路径

思考

在 32 位的机器上对 long 型变量进行加减操作存在并发隐患
long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题

java内存模型, 按需禁用缓存以及编译优化

通过volatile, synchronized, final关键字和happens-before规则

volatile

禁用 CPU 缓存
volatile int x = 0:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入

Happens-Before 规则

Happens-Before 规则: 前面一个操作的结果对后续操作是可见的, 约束了编译器的优化行为

  1. 程序的顺序性规则
    前面的操作 Happens-Before 于后续的任意操作
  2. volatile 变量规则
    对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作
  3. 传递性
    如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
  4. 管程synchronized中锁的规则
    对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
    前一个线程的解锁操作对后一个线程的加锁操作可见
    管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现
    管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的
synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁
  1. 线程 start() 规则
    主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现)
    当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。
    当然所谓的“看到”,指的是对共享变量的操作
    eg: 如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作
  2. 线程 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 变量的值会变化

final int x;
// 错误的构造函数
// 在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}
思考

有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?

1.声明共享变量abc,并使用volatile关键字修饰abc
2.声明共享变量abc,在synchronized关键字对abc的赋值代码块加锁,由于Happen-before管程锁的规则,可以使得后续的线程可以看到abc的值。
3.A线程启动后,使用A.JOIN()方法来完成运行,后续线程再启动,则一定可以看到abc==3

总结
  1. 为什么定义Java内存模型?现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。
  2. 三个基本原则:原子性、可见性、有序性。
  3. Java内存模型涉及的几个关键词:锁、volatile字段、final修饰符与对象的安全发布。其中:第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。
  4. Happens-Before的7个规则:
    (1).程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
    (2).管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
    (3).volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
    (4).线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
    (5).线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
    (6).线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
    (7).对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  5. Happens-Before的1个特性:传递性。
  6. Java内存模型底层怎么实现的?主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。

互斥锁, 解决原子性问题

原子性问题: 线程切换
操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换

eg: 32 位 CPU 上执行 long 型变量的写操作
明明已经把变量成功写入内存,重新读出来却不是自己写入的
<并发编程>学习笔记------(一) 并发相关理论_第5张图片

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现诡异 Bug : 明明已经把变量成功写入内存,重新读出来却不是自己写入的

同一时刻只有一个线程执行
保证对共享变量的修改是互斥

简易锁模型

<并发编程>学习笔记------(一) 并发相关理论_第6张图片
临界区: 一段需要互斥执行的代码

改进后的锁模型

<并发编程>学习笔记------(一) 并发相关理论_第7张图片

受保护的资源 R
要保护资源 R 就得为它创建一把锁 LR
针对这把锁 LR,在进出临界区时添上加锁操作和解锁操作
锁 LR 和受保护资源之间,保护自家的资源!!

Java 语言提供的锁技术:synchronized 管程
class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  
  // 修饰代码块
  Object obj = new Object()void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)

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

synchronized 修饰静态方法相当于:

class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}

修饰非静态方法,相当于:

class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}
用 synchronized 解决 count+=1 问题
class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

addOne方法:
  1. 原子性
  addOne() 方法,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,
  所以一定能保证原子操作
  2. 可见性
  2.1 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码
  2.2 管程(synchronized)中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
  	  前一个线程的解锁操作对后一个线程的加锁操作可见
  2.3 综合 Happens-Before 的传递性原则
      前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的

问题: 执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的
get() 方法并没有加锁操作,所以可见性没法保证

问题的解决: get() 方法也 synchronized 一下

class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

<并发编程>学习笔记------(一) 并发相关理论_第8张图片
锁模型示意图

保护临界区 get() 和 addOne() 的示意图

锁和受保护资源的关系

合理的关系应该是: 受保护资源和锁之间的关联关系是 N:1 的关系
使用一把保护多个资源

class SafeCalc {
  static long value = 0L;
  synchronized long get() { --- 非静态方法
    return value;
  }
  synchronized static void addOne() { --- 静态方法
    value += 1;
  }
}
conflict: 一个静态方法, 一个非静态方法, 两个锁保护一个资源
受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class
由于临界区 get()addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系
临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了

<并发编程>学习笔记------(一) 并发相关理论_第9张图片

两把锁保护一个资源的示意图

思考

下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?


class SafeCalc {
  long value = 0L;
  long get() {
    synchronized (new Object()) {
      return value;
    }
  }
  void addOne() {
    synchronized (new Object()) {
      value += 1;
    }
  }
}

加锁本质就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。

经过JVM逃逸分析的优化后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的

sync锁的对象monitor指针指向一个ObjectMonitor对象,所有线程加入他的entrylist里面,去cas抢锁,更改state加1拿锁,执行完代码,释放锁state减1,和aqs机制差不多,只是所有线程不阻塞,cas抢锁,没有队列,属于非公平锁。
wait的时候,线程进waitset休眠,等待notify唤醒

两把不同的锁,不能保护临界资源。而且这种new出来只在一个地方使用的对象,其它线程不能对它解锁,这个锁会被编译器优化掉。和没有syncronized代码块效果是相同的

互斥锁, 用一把锁保护多个资源

受保护资源和锁之间合理的关联关系应该是 N:1 的关系

可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源

保护没有关联关系的多个资源 – 用多把锁
不同的资源用不同的锁保护
细粒度锁: 用不同的锁对受保护资源进行精细化管理,能够提升性能
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;
    }
  }
}
保护有关联关系的多个资源 – 不能用自己的锁锁别人的资源!
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,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。

<并发编程>学习笔记------(一) 并发相关理论_第10张图片
用锁 this 保护 this.balance 和 target.balance 的示意图

假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元

线程 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。

<并发编程>学习笔记------(一) 并发相关理论_第11张图片
并发转账示意图

使用锁的正确姿势 – 使用共享的类锁 (非对象锁)

同一把锁来保护多个资源: 锁能覆盖所有受保护资源

上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁?


class Account {
  private Object lock;
  private int balance;
  private Account();
  
  // 创建Account时传入同一个lock对象 -- key point
  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;
      }
    }
  } 
}

<并发编程>学习笔记------(一) 并发相关理论_第12张图片
使用共享的锁 Account.class 来保护不同对象的临界区

总结

对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系

  • 如果资源之间没有关系,很好处理,每个资源一把锁就可以了
  • 如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源
  • 除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁

“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见

例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见

思考
能否账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁
class Account {
  // 锁:保护账户余额
  private final Object balLock = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock = new Object();
  // 账户密码
  private String password;
}

不能用可变对象做锁
用this.balance 和this.password 都不行。
在同一个账户多线程访问时候,A线程取款进行this.balance-=amt的时候, 此时this.balance对应的值已经发生变换,线程B再次取款时拿到的balance对应的值并不是A线程中的,也就是说不能把可变的对象当成一把锁

死锁了咋办

解决转账串行问题 – 性能太差 – 使用细粒度锁 (对象锁而非类锁)

问题的产生:
用 Account.class 作为互斥锁,来解决银行业务里面的转账问题
虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差
转账操作串行化 性能太差

真实世界的解决方案: 每个账户对应一个账本

  1. 同时拿走转出账本和转入账本
  2. 只能拿到其中一个账本, 需要等待拿到另一个账本
  3. 都没有账本, 等待账本归还

<并发编程>学习笔记------(一) 并发相关理论_第13张图片
两个转账操作并行示意图

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 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多
使用细粒度锁可以提高并行度,是性能优化的一个重要手段。

使用细粒度锁带来的死锁问题

<并发编程>学习笔记------(一) 并发相关理论_第14张图片

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;
        }
      }
    }
  } 
}

线程a拿到账本a,
线程b拿到账本b
死锁: 一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

资源分配图
资源分配图是个有向图,它可以描述资源和线程的状态
资源用方形节点表示,线程用圆形节点表示;
资源中的点指向线程的边表示线程已经获得该资源,线程指向资源的边则表示线程请求资源,但尚未得到
<并发编程>学习笔记------(一) 并发相关理论_第15张图片

预防死锁的方法, 规避死锁

发生死锁的四个条件:

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

避免死锁的三个方法: 互斥条件不可破坏

  • 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
1. 破坏占用且等待条件 – 一次性申请所有资源(资源管理员)

资源管理员, 只允许管理员对资源进行操作
对于同时申请多个资源, 如果有的资源被占用, 并不会申请成功
只有申请的资源没有被占用才会申请成功

<并发编程>学习笔记------(一) 并发相关理论_第16张图片
通过账本管理员拿账本

// 同时申请资源 apply() 和同时释放资源 free(), 用来管理这个临界区
// 当账户 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.util.concurrent 这个包下面提供的 Lock, 留待后续

3. 破坏循环等待条件 – 需要对资源进行排序,然后按序申请资源
class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请
    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) 有没有性能优势呢?

  • synchronized(Account.class) 锁了Account类相关的所有操作。相当于文中说的包场了,只要与Account有关联,通通需要等待当前线程操作完成。
  • while死循环的方式只锁定了当前操作的两个相关的对象。两种影响到的范围不同。

用"等待-通知"机制优化循环等待 – wait(), notify(), notifyAll()

循环等待问题

破坏占用且等待条件
如果资源被占用, 用死循环的方式来循环等待

// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
  • 如果 apply() 操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的,因为这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。
  • 但是如果 apply() 操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,因为在这种场景下,可能要循环上万次才能获取到锁,太消耗 CPU 了

更好的方案:

  • 如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;
  • 当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题
等待-通知机制

就医流程

  1. 患者先去挂号,然后到就诊门口分诊,等待叫号;
  2. 当叫到自己的号时,患者就可以找大夫就诊了;
  3. 就诊过程中,大夫可能会让患者去做检查,同时叫下一位患者;
  4. 当患者做完检查后,拿检测报告重新分诊,等待叫号;
  5. 当大夫再次叫到自己的号时,患者再去找大夫就诊。

保证同一时刻大夫只为一个患者服务,而且还能够保证大夫和患者的效率

  1. 患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,类似线程已经获取到锁了。
  2. 大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的条件没有满足。
  3. 患者去做检查,类似于线程进入等待状态;然后大夫叫下一个患者,这个步骤我们在前面的等待 - 通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁
  4. 患者做完检查,类似于线程要求的条件已经满足;患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁,这个步骤我们在前面的等待 - 通知机制中也忽视了

一个完整的等待 - 通知机制:

  • 线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;
  • 当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用 synchronized 实现等待 - 通知机制

Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现

<并发编程>学习笔记------(一) 并发相关理论_第17张图片
wait() 操作工作原理图

  • 上面这个图中,左边的等待队列
    左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列

  • 上面这个图中, 右边的等待队列
    在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。
    上面这个图,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。(注意, 和前一个等待队列不同, 这个是互斥锁的等待队列)
    线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

<并发编程>学习笔记------(一) 并发相关理论_第18张图片
当线程要求的条件满足时, 调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过

为什么说是曾经满足过呢?因为 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

一次性申请所有资源: 一个更好地资源分配器的实现

解决一次性申请转出账户和转入账户的问题

考虑:

  • 互斥锁:前面提到 Allocator 需要是单例的,所以我们可以用 this 作为互斥锁
  • 线程要求的条件:转出账户和转入账户都没有被分配过。
  • 何时等待:线程要求的条件不满足就等待。
  • 何时通知:当有线程释放账户时就通知。

另外注意:
利用这种范式可以解决上面提到的条件曾经满足过这个问题. 范式,意味着是经典做法
因为当 wait() 返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了,所以要重新检验条件是否满足。

	while(条件不满足) {
		wait();
	}

	对比 循环等待:

	// 一次性申请转出账户和转入账户,直到成功
	while(!actr.apply(this, target))
// 使用this作为互斥锁, 保证allocator单例
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();
  }
}

对比一下之前的实现:

// 同时申请资源 apply() 和同时释放资源 free(), 用来管理这个临界区
// 当账户 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)
    }
  } 
}
尽量使用notifyAll()

notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程

从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到

  • 随机的好处在于:释放了资源之后,后续就有其他线程继续使用;
  • 其劣势也在于:随机,无法指定需要的或者说是优先级高的线程在资源释放之后能够快速响应,最坏的情况也可能会存在永远获取不到资源的情况。
总结

等待 - 通知机制是一种非常普遍的线程间协作的方式。
可以代替工作中使用的轮询方式

思考

wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

wait与sleep区别在于:

  1. wait会释放所有锁而sleep不会释放锁资源.
  2. wait只能在同步方法和同步块中使用,而sleep任何地方都可以.
  3. wait无需捕捉异常,而sleep需要. – 实现上都需要捕获异常

两者相同点:都会释放CPU执行时间,等待再次调度!

wait()方法与sleep()方法的不同之处在于,

  • wait()方法会释放对象的“锁标志”。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池。
  • sleep()方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。但是sleep()方法不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。
public class MyLock {

	// 测试转账的main方法
	public static void main(String[] args) throws InterruptedException {
	    Account src = new Account(10000);
	    Account target = new Account(10000);
	    CountDownLatch countDownLatch = new CountDownLatch(9999);
	    for (int i = 0; i < 9999; i++) {
	        new Thread(()->{
	            src.transactionToTarget(1,target);
	        	countDownLatch.countDown();
	        }).start();
	    }
	    countDownLatch.await();
	    System.out.println("src="+src.getBanalce() );
	    System.out.println("target="+target.getBanalce() );
	}
	
	static class Account{ //账户类
	
	    private Integer banalce;
	    
	    public Account(Integer banalce) {
	        this.banalce = banalce;
	    }
	    public Integer getBanalce() {
	        return banalce;
	    }
	    public void setBanalce(Integer banalce) {
	        this.banalce = banalce;
	    }

		//转账方法
	    public void transactionToTarget(Integer money, Account target){
	        Allocator.getInstance().apply(this,target);
	        this.banalce -= money;
	        target.setBanalce(target.getBanalce()+money);
	        Allocator.getInstance().release(this,target);
	    }
	}
	
	//单例锁类
	static class Allocator { 
		
	    private List<Account> locks = new ArrayList<>();
	
		// 单例
	    private Allocator(){
	    }
	    public static Allocator getInstance(){
	        return AllocatorSingle.install;
	    }
	    static class AllocatorSingle{
	        public static Allocator install = new Allocator();
	    }
	    
	    public synchronized void apply(Account src,Account tag){
	        while (locks.contains(src)||locks.contains(tag)) {
	            try {
	                this.wait();
	            } catch (InterruptedException e) {
	            }
	        }
	        locks.add(src);
	        locks.add(tag);
	    }
	    
	    public synchronized void release(Account src,Account tag){
	        locks.remove(src);
	        locks.remove(tag);
	        this.notifyAll();
	    }
	}
}

安全性, 活跃性以及性能问题

安全性 – 数据竞争, 竞态条件

原子性问题、可见性问题和有序性

其实只需要考虑一种情况:

存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据

如果不共享数据或者数据状态不发生变化,就能保证线程的安全性:

  • 线程本地存储(Thread Local Storage,TLS)
  • 不变模式

对于必须共享数据:
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)

add10K()方法, 当多个线程调用时候就会发生数据竞争
public class Test {
  private long count = 0;
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
}
// 使用 synchronized 修饰 get() 和 set() 方法
// 所有访问共享变量 value 的地方,我们都增加了互斥锁,此时是不存在数据竞争的
但是add10K() 方法并不是线程安全的 -- 依然不安全
// 假设 count=0,当两个线程同时执行 get() 方法时,get() 方法会返回相同的值 0,
get 执行是一前一后,但是这两都在 set 执行前,所以 get 到的值都一样
两个线程执行 get()+1 操作,结果都是 1,之后两个线程再将结果 1 写入了内存。你本来期望的是 2,而结果却是 1public 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)      
    }
  }
}

竞态条件,指的是程序的执行结果依赖线程执行的顺序
例如上面的例子,如果两个线程完全同时执行,那么结果是 1;如果两个线程是前后执行,那么结果就是 2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大 Bug。

竞态条件, 在并发场景中,程序的执行依赖于某个状态变量

if (状态变量 满足 执行条件) {
  执行操作
}
  • 当某个线程发现状态变量满足执行条件后,开始执行操作
  • 可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了

当然很多场景下,这个条件不是显式的,例如前面 addOne 的例子中,set(get()+1) 这个复合操作,其实就隐式依赖 get() 的结果

面对数据竞争和竞态条件问题, 可以用互斥这个技术方案,而实现互斥的方案有很多,
CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。

活跃性问题 – 活锁, 饥饿

所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿

活锁
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。

类比现实世界例子:
路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
这种情况,基本上谦让几次就解决了,因为人会交流啊。
可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”

解决“活锁”的方案: 
路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;
同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。
由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。
“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它

所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况

  • 在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;
  • 持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案

  • 一是保证资源充足,
  • 二是公平地分配资源,
  • 三就是避免持有锁的线程长时间执行

这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些

那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

性能问题 – 减少串行

“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。

所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是 5%,我们用多核多线程相比单核单线程能提速多少呢?

阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
<并发编程>学习笔记------(一) 并发相关理论_第19张图片
公式里的 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,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
度量性能方面的度量指标
  • 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好
  • 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好
  • 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
思考

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

void addIfNotExist(Vector v, Object o){
  if(!v.contains(o)) {
    v.add(o);
  }
}

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

Vector实现线程安全是通过给主要的写方法加了synchronized,类似contains这样的读方法并没有synchronized,该题的问题就出在不是线程安全的contains方法,两个线程如果同时执行到if(!v.contains(o)) 是可以都通过的,这时就会执行两次add方法,重复添加。也就是竞态条件

class Vector {
    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
}

管程, 并发编程的万能钥匙

什么是管程?

Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程
但是管程更容易使用,所以 Java 选择了管程

管程,对应的英文是 Monitor,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是直译。
操作系统领域一般都翻译成“管程”,这个是意译。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。
翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?

MESA 模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。

在并发编程领域,有两大核心问题:

  • 一个是互斥,即同一时刻只允许一个线程访问共享资源;
  • 另一个是同步,即线程之间如何通信、协作。

这两大问题,都可以使用管程解决。

1. 管程解决互斥问题的思路

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

<并发编程>学习笔记------(一) 并发相关理论_第20张图片
管程模型的代码化语义

如图,管程 X 将共享变量 queue 这个线程不安全的队列和相关的操作入队操作 enq()、出队操作 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程

管程模型和面向对象高度契合的

2. 管程解决线程间的同步问题

<并发编程>学习笔记------(一) 并发相关理论_第21张图片
MESA 管程模型示意图

图中最外层的框就代表封装的意思 - 共享变量和对共享变量的操作是被封装起来的
框的上面只有一个入口,并且在入口旁边还有一个入口等待队列 - 当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待

每个条件变量都对应有一个等待队列, 即条件变量等待队列, 用来解决线程同步问题.

注意阻塞队列和等待队列是不同的:
用管程来实现线程安全的 --> 阻塞队列
管程内部的 --> 等待队列

  1. 假设有个线程 T1 执行阻塞队列的出队操作,执行出队操作,需要注意有个前提条件,就是阻塞队列不能是空的(空队列只能出 Null 值,是不允许的),阻塞队列不空这个前提条件对应的就是管程里的条件变量。
  2. 如果线程 T1 进入管程后恰好发现阻塞队列是空的,就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。
  3. 这个过程类似于大夫发现你要去验个血,于是给你开了个验血的单子,你呢就去验血的队伍里排队。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。这和你去验血的时候,医生可以给其他患者诊治,道理都是一样的。
  4. 再假设之后另外一个线程 T2 执行阻塞队列的入队操作,入队操作执行成功之后,“阻塞队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。
  5. 当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。这个过程类似你验血完,回来找大夫,需要重新分诊
wait()、notify()、notifyAll() – 线程调用条件.wait(), 条件.notify()

线程 T1 发现“阻塞队列不空”这个条件不满足,需要进到对应的等待队列里等待, 通过调用 wait()实现
如果我们用对象 A 代表“阻塞队列不空”这个条件,那么线程 T1 需要调用 A.wait()

同理当“阻塞队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个等待队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。

// 用管程实现一个线程安全的阻塞队列
// 注意: 这个阻塞队列和管程内部的等待队列没关系
// 入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口

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()
        notFull.await();
      }  
      // 省略入队操作...
      // 入队后,通知可出队
      // 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
      notEmpty.signal(); ---- 满足使用signal的三个条件, 相同的等待条件, 唤醒后执行相同的操作, 只需要唤醒一个线程
    }finally {
      lock.unlock();
    }
  }
  
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        // 对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();。
        notEmpty.await();
      }
      // 省略出队操作...
      // 出队后,通知可入队
      // 如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

注意: await() 和前面我们提到的 wait() 语义是一样的;signal() 和前面我们提到的 notify() 语义是一样的
wait()的正确姿势 – 范式: 条件不满足, 条件.wait()

MESA 管程特有的编程范式: 在一个 while 循环里面调用 wait()

while(条件不满足) {
  wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别就是当条件满足后,如何通知相关线程。
管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?

  1. Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
  2. Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
  3. MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量
  • hasen 是执行完,再去唤醒另外一个线程。能够保证线程的执行。
  • hoare,是中断当前线程,唤醒另外一个线程,执行玩再去唤醒,也能够保证完成。
  • mesa是进入等待队列,不一定有机会能够执行。

当线程被唤醒后,是从wait命令后开始执行的(不是从头开始执行该方法,示意图容易让人产生歧义),而执行时间点往往跟唤醒时间点不一致,所以条件变量此时不一定满足了,所以通过while循环可以再验证
而if条件却做不到,它只能从wait命令后开始执行,所以要用while ------- point

notify() 何时可以使用

除非经过深思熟虑,否则尽量使用 notifyAll()

使用notify()需要满足以下三个条件:

  1. 所有等待线程拥有相同的等待条件
  2. 所有等待线程被唤醒后,执行相同的操作
  3. 只需要唤醒一个线程

比如上面阻塞队列的例子中,对于“阻塞队列不满”这个条件变量,其等待线程都是在等待“阻塞队列不满”这个条件,反映在代码里就是下面这 3 行代码。对所有等待线程来说,都是执行这 3 行代码,重点是 while 里面的等待条件是完全相同的。

入队 --> 阻塞队列已满 --> 线程去 等待队列不满条件 的等待队列里等待
while (阻塞队列已满){
  // 等待队列不满 -- 这里的条件变量: '阻塞'队列不满
  notFull.await();
}


------------- 参考代码
  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        // 对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以这里用了notFull.await()
        notFull.await();
      }  
      // 省略入队操作...
      // 入队后,通知可出队
      // 如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }

所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行:

// 省略入队操作...
// 入队后,通知可出队
notEmpty.signal();

同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。

总结

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

<并发编程>学习笔记------(一) 并发相关理论_第22张图片

  • Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量
  • 而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。

并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。

1.管程是一种概念,任何语言都可以通用。
2.在java中,每个加锁的对象都绑定着一个管程(监视器)
3.线程访问加锁对象,就是去拥有一个监视器的过程。如一个病人去门诊室看医生,医生是共享资源,门锁锁定医生,病人去看医生,就是访问医生这个共享资源,门诊室其实是监视器(管程)。
4.所有线程访问共享资源,都需要先拥有监视器。就像所有病人看病都需要先拥有进入门诊室的资格。
5.监视器至少有两个等待队列。一个是进入监视器的等待队列一个是条件变量对应的等待队列。后者可以有多个。就像一个病人进入门诊室诊断后,需要去验血,那么它需要去抽血室排队等待。另外一个病人心脏不舒服,需要去拍胸片,去拍摄室等待。
6.监视器要求的条件满足后,位于条件变量下等待的线程需要重新在门诊室门外排队,等待进入监视器。就像抽血的那位,抽完后,拿到了化验单,然后,重新回到门诊室等待,然后进入看病,然后退出,医生通知下一位进入。

总结起来就是,管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。

参考:
北大-操作系统-管程课程
https://www.coursera.org/lecture/os-pku/mesaguan-cheng-Fya0t

思考

wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是没有参数的,而在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?

wait() 不加超时参数,相当于得一直等着别人叫你去门口排队,
加了超时参数,相当于等一段时间,再没人叫的话,我就受不了自己去门口排队了,这样就诊的机会会大一点

就诊机会不一定大,但是能避免没人叫的时候傻等

Java线程(上) – Java线程的生命周期

<并发编程>学习笔记------(一) 并发相关理论_第23张图片
通用线程状态转换图——五态模型

  1. 初始状态,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  2. 可运行状态,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  3. 当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态
  4. 运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  5. 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

这五种状态在不同编程语言里会有简化合并。
例如,C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了;
Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
除了简化合并,这五种状态也有可能被细化,比如,Java 语言里就细化了休眠状态(这个下面会详细讲解)。

Java中线程的生命周期

Java 语言中线程共有六种状态,分别是:

  • RUNNABLE(可运行 / 运行状态)
  • BLOCKED(阻塞状态)
  • WAITING(无时限等待)
  • TIMED_WAITING(有时限等待)
  • TERMINATED(终止状态)

在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权

<并发编程>学习笔记------(一) 并发相关理论_第24张图片
Java 中的线程状态转换图

BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因

1. RUNNABLE 与 BLOCKED 的状态转换 – synchronized

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。

2. RUNNABLE 与 WAITING 的状态转换 – Object.wait(), Thread.join()

总体来说,有三种场景会触发这种转换。

  1. 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法
  2. 调用无参数的 Thread.join() 方法
    其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  3. 调用 LockSupport.park() 方法
    其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
3. RUNNABLE 与 TIMED_WAITING 的状态转换 – Thread.sleep.(t), Object.wait(t), Thread.join(t)

有五种场景会触发这种转换:

  1. 调用带超时参数的 Thread.sleep(long millis) 方法;
  2. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  3. 调用带超时参数的 Thread.join(long millis) 方法;
  4. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  5. 调用带超时参数的 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 状态, Thread.interrupt()

线程执行完 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 异常

上面我们提到RUNNABLE 状态转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 Object.wait()、Thread.join()、Thread.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 所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。例如,一个死锁的程序,导出的线程栈明确告诉发生了死锁,并且将死锁线程的调用栈信息清晰地显示出来了(如下图)。导出线程栈,分析线程状态是诊断并发问题的一个重要工具。

<并发编程>学习笔记------(一) 并发相关理论_第25张图片

发生死锁的线程栈

思考

下面代码的本意是当前线程被中断之后,退出while(true),你觉得这段代码是否正确呢?

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    e.printStackTrace();
  }
}

可能出现无限循环,线程在sleep期间被打断了,抛出一个InterruptedException异常,try catch捕捉此异常,应该重置一下中断标示,因为抛出异常后,中断标示会自动清除掉

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    Thread.currentThread().interrupt();
    e.printStackTrace();
  }
}

Java线程(中):创建多少线程才是合适的?

度量性能的指标:

  1. 延迟, 发出请求到收到响应这个过程的时间
  2. 吞吐量, 单位时间内能处理请求的数量

降低延迟,提高吞吐量:

  1. 优化算法
  2. 在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 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%
<并发编程>学习笔记------(一) 并发相关理论_第26张图片
单线程执行示意图

当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。
<并发编程>学习笔记------(一) 并发相关理论_第27张图片
两个线程执行示意图

此时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%

<并发编程>学习笔记------(一) 并发相关理论_第28张图片
多核执行多线程示意图

创建多少线程合适?

程序一般都是 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%。
<并发编程>学习笔记------(一) 并发相关理论_第29张图片

对于 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 耗时)]**

总结

原则: 将硬件的性能发挥到极致

对于 I/O 密集型计算场景,I/O 耗时和 CPU 耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计

需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系

思考

有些同学对于最佳线程数的设置积累了一些经验值,认为对于 I/O 密集型应用,最佳线程数应该为:2 * CPU 的核数 + 1,你觉得这个经验值合理吗?

个人觉得公式话性能问题有些不妥,定性的io密集或者cpu密集很难在定量的维度上反应出性能瓶颈,而且公式上忽略了线程数增加带来的cpu消耗,性能优化还是要定量比较好,这样不会盲目,比如io已经成为了瓶颈,增加线程或许带来不了性能提升,这个时候是不是可以考虑用cpu换取带宽,压缩数据,或者逻辑上少发送一些。最后一个问题,我的答案是大部分应用环境是合理的,老师也说了是积累了一些调优经验后给出的方案,没有特殊需求,初始值我会选大家都在用伪标准

本来就是分CPU密集型和IO密集型的,尤其是IO密集型更是需要进行测试和分析而得到结果,差别很大,比如IO/CPU的比率很大,比如10倍,2核,较佳配置:2*(1+10)=22个线程,而2*CPU核数+1 = 5

Java线程(下):为什么局部变量是线程安全的?

引子

// 返回斐波那契数列
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 项赋值,这里看上去感觉是存在数据竞争的

当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,
对此还有一个专业的术语,叫做数据竞争(Data Race)

局部变量不存在数据竞争

每个方法的调用对应着一次栈帧的创建,栈帧中包含了局部变量表

方法是如何被执行的
int a = 7int[] b = fibonacci(a);
int[] c = b;

当调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。

找到调用方法的下一条语句的地址:
通过 CPU 的堆栈寄存器。CPU 支持一种栈结构,栈就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈
<并发编程>学习笔记------(一) 并发相关理论_第30张图片

例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
<并发编程>学习笔记------(一) 并发相关理论_第31张图片
调用栈结构

利用栈结构来支持方法调用: java虚拟机, CPU 里内置了栈寄存器

局部变量存哪里?

局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。
–> 局部变量就是放到了调用栈里

<并发编程>学习笔记------(一) 并发相关理论_第32张图片
保护局部变量的调用栈结构图

new 出来的对象是在堆里,局部变量是在栈里
局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里

调用栈与线程

两个线程可以同时用不同的参数调用相同的方法, 每个线程都有自己独立的调用栈
每个线程都有自己独立的调用栈,线程之间互相不干扰. 当两个线程同时调用一个方法时,是对应两个调用栈
<并发编程>学习笔记------(一) 并发相关理论_第33张图片
线程与调用栈的关系图

Java 方法里面的局部变量是否存在并发问题?一点问题都没有。
因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。
再次重申一遍:没有共享,就没有伤害。

线程封闭

仅在单线程内访问数据
由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的

采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题

思考

递归调用太深,可能导致栈溢出。

栈溢出原因:
因为每调用一个方法就会在栈上创建一个栈帧,方法调用结束后就会弹出该栈帧,而栈的大小不是无限的,所以递归调用次数过多的话就会导致栈溢出。而递归调用的特点是每递归一次,就要创建一个新的栈帧,而且还要保留之前的环境(栈帧),直到遇到结束条件。所以递归调用一定要明确好结束条件,不要出现死循环,而且要避免栈太深。
解决方法:

  1. 简单粗暴,不要使用递归,使用循环替代。缺点:代码逻辑不够清晰;
  2. 限制递归次数;
  3. 使用尾递归,尾递归是指在方法返回时只调用自己本身,且不能包含表达式。编译器或解释器会把尾递归做优化,使递归方法不论调用多少次,都只占用一个栈帧,所以不会出现栈溢出。然鹅,Java没有尾递归优化。

如何用面向对象思想写好并发程序?

在 Java 语言里,面向对象思想能够让并发编程变得更简单

  1. 封装共享变量
  2. 识别共享变量间的约束条件
  3. 制定并发访问策略
1. 封装共享变量

封装: 将属性和实现细节封装在对象内部, 外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性

将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略

计数器程序共享变量只有一个,就是 value,我们把它作为 Counter 类的属性,并且将两个公共方法 get() 和 addOne() 声明为同步方法,这样 Counter 类就成为一个线程安全的类了


public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}

对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰

2. 识别共享变量间的约束条件

这些约束条件,决定了并发访问策略

库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限

原子类是线程安全的,所以这两个成员变量的 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 语句的时候,就应该立刻意识到可能存在竞态条件

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);
  }
  // 省略其他业务代码
}

假设库存的下限和上限分别是 (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) 了,
显然此时的结果是不符合库存下限要小于库存上限这个约束条件的

在没有识别出库存下限要小于库存上限这个约束条件之前,我们制定的并发访问策略是利用原子类,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。

所以说,在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。

3. 制定并发访问策略
  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

写出“健壮”的并发程序的宏观原则:

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足日常的需要,建议熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的
总结

对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面

思考

类 SafeWM 不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件

  1. setUpper() 跟 setLower() 都加上 “synchronized” 关键字。不要太在意性能,老师都说了,避免过早优化。
  2. 如果性能有问题,可以把 lower 跟 upper 两个变量封装到一个类中,例如
public class Boundary {
    private final lower;
    private final upper;
    
    public Boundary(long lower, long upper) {
        if(lower >= upper) {
            // throw exception
        }
        this.lower = lower;
        this.upper = upper;
    }
}

移除 SafeVM 的 setUpper() 跟 setLower() 方法,并增入 setBoundary(Boundary boundary) 方法。

并发相关理论疑难杂症

– 01节
起源是一个硬件的核心矛盾:CPU 与内存、I/O 的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的 Bug 之源
1、CPU 增加了缓存,以均衡与内存的速度差异;
2、操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3、编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

Java 内存模型,以应对可见性和有序性问题; – 02节
原子性问题, 用互斥锁才是关键 – 0304节

虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,死锁的产生原因以及解决方案 – 05节
线程间协作的问题,介绍线程间的协作机制:等待 - 通知 – 06节

站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺 – 07节

管程,是 Java 并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的 – 08节

线程相关的知识 – 091011节

面向对象思想写好并发程序 – 12节

<并发编程>学习笔记------(一) 并发相关理论_第34张图片
并发编程理论基础模块思维导图

用锁的最佳实践

– 03节思考

下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?

class SafeCalc {
  long value = 0L;
  long get() {
    synchronized (new Object()) {
      return value;
    }
  }
  void addOne() {
    synchronized (new Object()) {
      value += 1;
    }
  }
}
每次调用方法 get()addOne() 都创建了不同的锁,相当于无锁

一个合理的受保护资源与锁之间的关联关系应该是 N:1, 只有共享一把锁才能起到互斥的作用

JVM 开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在

– 04节思考

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;
    }
  } 
}

一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁
如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。

锁,应是私有的、不可变的、不可重用的

《Java 安全编码标准》
// 普通对象锁
private final Object lock = new Object();
// 静态对象锁
private static final Object lock = new Object(); 
锁的性能要看场景

– 05节思考
比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好

这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许 A->B、C->D 这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。

竞态条件需要格外关注
contains()add() 方法虽然都是线程安全的,但是组合在一起却不是线程安全的
void addIfNotExist(Vector v, Object o){
  if(!v.contains(o)) {
    v.add(o);
  }
}

Vector实现线程安全是通过给主要的写方法加了synchronized,类似contains这样的读方法并没有synchronized,该题的问题就出在不是线程安全的contains方法,两个线程如果同时执行到if(!v.contains(o)) 是可以都通过的,这时就会执行两次add方法,重复添加。也就是竞态条件。

解决方法:
将共享变量 v 封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对 Vector v 变量的滥用,从而导致并发问题


class SafeVector{
  private Vector v; 
  // 所有公共方法增加同步控制
  synchronized void addIfNotExist(Object o){
    if(!v.contains(o)) {
      v.add(o);
    }
  }
}
方法调用是先计算参数

set(get()+1);这条语句是进入 set() 方法之后才执行 get() 方法,其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体

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);
InterruptedException 异常处理需小心

调用 Java 对象的 wait() 方法或者线程的 sleep() 方法时,需要捕获并处理 InterruptedException 异常

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    e.printStackTrace();
  }
}
希望:
通过 isInterrupted() 检查线程是否被中断, 如果中断了就退出 while 循环
当其他线程通过调用th.interrupt().来中断 th 线程时,会设置 th 线程的中断标志位,
从而使th.isInterrupted()返回 true,这样就能退出 while 循环

这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在 sleep(100) 上,当其他线程通过调用th.interrupt().来中断 th 线程时,大概率地会触发 InterruptedException 异常,在触发 InterruptedException 异常的同时,JVM 会同时把线程的中断标志位清除,所以这个时候th.isInterrupted()返回的是 false。

正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:

try {
  Thread.sleep(100);
}catch(InterruptedException e){
  // 重新设置中断标志位
  th.interrupt();
}
理论值 or 经验值

最佳线程 =2 * CPU 的核数 + 1

从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说,却是一个很好到初始值。

最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O 耗时 / CPU 耗时”往往都大于 1,所以基本上都是在这个初始值的基础上增加
增加的过程中,应关注线程数是如何影响吞吐量和延迟的。

  • 一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加
  • 但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。

实际工作中,不同的 I/O 模型对最佳线程数的影响非常大,例如大名鼎鼎的 Nginx 用的是非阻塞 I/O,采用的是多进程单线程结构,Nginx 本来是一个 I/O 密集型系统,但是最佳进程数设置的却是 CPU 的核数,完全参考的是 CPU 密集型的算法。所以,理论我们还是要活学活用。

你可能感兴趣的:(并发编程,并发编程)