理论基础 - 图1

01 可见性、原子性和有序性问题(出现诡异问题的根源)

1.1 缓存导致的可见性问题?

思考方向:CPU 和 内存之间还存在 CPU 缓存,假如 CPU 的两个核上分别有两个线程同时读入内存中的共享变量值,分别进行计算,再刷入内存时会无法得到正确结果
image.png

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

思考方向:CPU中的单个核,在一个时间片内只有一个线程在执行,一些我们直觉上是原子操作的高级语言代码相对于CPU来说可能都不是原子操作,都有可能在执行时发生任务切换(一般指线程切换)。CPU 能保证的原子操作是 CPU 指令级别的:

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

image.png

1.3 编译优化带来的有序性问题

思考方向:编译器在编译时会进行指令重排

  1. public class Singleton {
  2. private volatile static Singleton instance;
  3. private Singleton() { }
  4. public static Singleton getInstance(){
  5. if (instance == null) {
  6. synchronized(Singleton.class) {
  7. if (instance == null)
  8. instance = new Singleton();
  9. }
  10. }
  11. return instance;
  12. }
  13. }

例如:上面的一段代码中的 new 操作,我们以为的 new 操作应该是:

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

但是实际上优化后的执行路径却是这样的:

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

如果没有volatile关键字那么可能会出现上面的指令重排,这样当线程1进入临界区时,已经将内存地址赋值给了 instance 变量,这样线程2执行临界区外的 if 语句时,就会直接返回 instance 变量了,但是此时对象并没初始化完成,线程2在使用时就可能触发空指针异常。

02 Java内存模型-volatile和happen-before规则(解决可见性和有序性)

通过 volatile 和七项 happen-before 规则+一个特性解决可见性和有序性。

2.1 volatile 关键字

volatile 可以禁止指令重排,例如 1.3 中若在 instance 变量再加一个 volatile 修饰,则可以保证 new 操作是按照我们预期的顺序执行的;
volatile 修饰的变量,还相当于告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。不过单独看这一项会有一定的困惑,还需结合 happen-before 规则,例如下面的代码在 jdk 1.5 时 x 可能是42也可能是0,但在1.5以上就是42,原因就是通过 happen-before 规则约束了编译器的优化:

  1. class VolatileExample {
  2. int x = 0;
  3. volatile boolean v = false;
  4. public void writer() {
  5. x = 42;
  6. v = true;
  7. }
  8. public void reader() {
  9. if (v == true) {
  10. // 这里x会是多少呢?
  11. }
  12. }
  13. }

2.2 七项 happen-before 原则+一个特性

  1. 传递性:这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
  2. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作 Happens-Before 于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  3. volatile 变量规则:对一个 volatile 变量的写操作 Happens-Before 于后续对这个 volatile 变量的读操作,这
    里的”后续”是指时间上的先后顺序。
  4. 管程锁定规则:这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。这里必须强调的是同一个锁,而”后续”是指时间上的先后顺序。
  5. 线程 start() 规则:主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
  6. 线程 join() 规则:主线程A等待子线程B完成(主线程A通过调用子线程B的 join() 方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
  7. 线程中断规则:对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  8. 对象终结规则:一个对象的初始化完成(构造函数执行结束) Happens-Before 于它的 finalize() 方法的开始。

    2.3 final 关键字

    在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,这就是“逸出”,线程通过 global.obj 读取x是有可能读到0的。因此我们一定要避免“逸出”

    1. final int x;
    2. // 错误的构造函数
    3. public FinalFieldExample() {
    4. this.x = 3;
    5. this.y = 4;
    6. // 此处就是讲this逸出,
    7. global.obj = this;
    8. }

    03-04 互斥锁(解决原子性问题)

    原子性的本质操作的中间状态对外不可以被修改

    3.1 synchronied 关键字

  9. 修饰静态方法:锁定的是当前类的Class对象;

  10. 修饰非静态方法:锁定的是当前实例对象this;
  11. 修饰代码块 :指定加锁对象,对给定对象/类加锁;

    3.2 锁和受保护资源的关系

    锁和受保护资源的一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。同时如果资源之间存在一定的关联关系,此时需要注意不要出现只对其中一个资源加锁的情况:
    1. class Account {
    2. private int balance;
    3. // 转账
    4. synchronized void transfer(
    5. Account target, int amt){
    6. if (this.balance > amt) {
    7. this.balance -= amt;
    8. target.balance += amt;
    9. }
    10. }
    11. }
    image.pngimage.png

    05:引发死锁的原因和解决办法

    当多个受保护资源存在一定的关联关系时,最简单的办法就是通过同一把锁把所有资源都锁住,但是这种粗粒度锁可能会导致原本并行的操作串行化效率很低。因此,一般会使用细粒度锁,每个受保护资源一个锁可以提高并行度,但细粒度锁也可能导致死锁(一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象)。例如如下代码:
    1. class Account {
    2. private int balance;
    3. // 转账
    4. void transfer(Account target, int amt){
    5. // 锁定转出账户
    6. synchronized(this){
    7. // 锁定转入账户
    8. synchronized(target){
    9. if (this.balance > amt) {
    10. this.balance -= amt;
    11. target.balance += amt;
    12. }
    13. }
    14. }
    15. }
    16. }

    5.1 预防死锁

    有个叫Coffman的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
  • 互斥:共享资源X和Y只能被一个线程占用;
  • 占有且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  • 不可抢占:其他线程不能强行抢占线程T1占有的资源;
  • 循环等待:线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。

因此,只要我们破坏其中一个,就可以成功避免死锁的发生:

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

    06:synchronized 等待-通知机制

    synchronized 配合 Object 的实例方法 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;
    image.pngimage.png
    notify() 和 notifyAll() 方法被调用后,会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。被通知的线程要想重新执行,仍然需要获取到互斥锁。为了解决“条件曾经满足过”这个问题,可以采用以下范式:
    while(条件不满足) {<br /> wait();<br />}
    notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。一般建议使用 notifyAll(),notify() 可能导致某些线程永远不会被通知到。

    07:安全性、活跃性以及性能(宏观角度重新审视并发编程相关概念和理论)

    7.1 安全性问题 - 数据竞争和竞态条件

    当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)。有些情况可以通过简单的加锁解决,但是下面的代码 set 和 get 都是线程安全的方法,组合在一起使用却出现了并发问题。

    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) // 出现了竞态条件
          }
      }
    }
    

    这种问题,有个官方的称呼竞态条件(Race Condition),指的是程序的执行结果依赖线程执行的顺序。 例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
    也可以按照下面这样来理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
    if (状态变量 满足 执行条件) {
    执行操作
    }

    7.2 活跃性问题 - 死锁、活锁和饥饿

  • 发生“死锁”后线程会互相等待,而且会一直等待下去,技术上表现形式是线程永久地“阻塞”了 ;

  • 有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”;
  • 所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况 ;

    7.3 性能问题

    所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
    理论基础 - 图8

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

    synchronized 关键字详解

  • 不可不说的Java“锁”事

  • 再谈 synchronized 锁升级

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞

8.1 什么是管程

Java 中的 synchronized 采用的是管程技术(Monitor)解决并发问题 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。 synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。

8.2 MESA 模型

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量A和条件变量B分别都有自己的等待队列。
image.png

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

8.3 wait()的正确姿势

但是有一点,需要再次提醒,对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的。
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再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

    09 | Java线程(上):Java线程的生命周期

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

  4. NEW(初始化状态)

  5. RUNNABLE(可运行/运行状态)
  6. BLOCKED(阻塞状态)
  7. WAITING(无时限等待)
  8. TIMED_WAITING(有时限等待)
  9. TERMINATED(终止状态)

理论基础 - 图10

9.1 RUNNABLE 与 BLOCKED 的状态转换

只有线程等待 synchronized 隐式锁会触发。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。而我们平时所谓的 Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。

9.2 RUNNABLE 与 WAITING 的状态转换

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

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

    9.3 RUNNABLE 与 TIMED_WAITING 的状态转换

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

  4. 调用带超时参数的 Thread.sleep(long millis) 方法;

  5. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  6. 调用带超时参数的 Thread.join(long millis) 方法;
  7. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  8. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。

9.4 从 NEW 到 RUNNABLE 状态

Java刚创建出来的Thread对象就是 NEW 状态,而创建Thread对象主要有两种方法:

  1. 继承Thread对象,重写run()方法。示例代码如下:

    // 自定义线程对象
    class MyThread extends Thread {
    public void run() {
     // 线程需要执行的代码
     ......
    }
    }
    // 创建线程对象
    MyThread myThread = new MyThread();
    
  2. 实现 Runnable 接口,重写run()方法,并将该实现类作为创建Thread对象的参数。示例代码如下:

    // 实现Runnable接口
    class Runner implements Runnable {
    @Override
    public void run() {
     // 线程需要执行的代码
     ......
    }
    }
    // 创建线程对象
    Thread thread = new Thread(new Runner());
    

    NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。只要调用线程对象的start()方法就可以从NEW状态转换到RUNNABLE状态了,示例代码如下:
    MyThread myThread = new MyThread();
    // 从NEW状态转换到RUNNABLE状态
    myThread.start();

    9.5 从 RUNNABLE 到 TERMINATED 状态

  3. 线程执行完 run() 方法或执行时异常抛出,会自动转换到 TERMINATED 状态;

  4. 调用 stop() 方法,已经标记为@Deprecated,不建议使用;
  5. 调用 interrupt() 方法;

    9.5.1 stop()和interrupt()方法的主要区别是什么呢?

    stop() 方法会直接杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。