[Java] Java并发编程实战笔记

Java并发编程实战

开篇词

并发编程的三个核心问题:分工、同步、互斥

所谓分工指的是如何高效地拆解任务并分配给线程,而同步指的是线程之间如何协作,互斥则是保证同一时刻只允许一个线程访问共享资源。Java SDK 并发包很大部分内容都是按照 这三个维度组织的,例如 Fork/Join 框架就是一种分工模式,CountDownLatch 就是一种 典型的同步方式,而可重入锁则是一种互斥手段。

image-20210203165120005

1. 分工

所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。

在并发编程领域,你就是项目经理,线程就是项目组成员。任务分解和分工对于项目成败非常关键,不过在并发领域里,分工更重要,它直接决定了并发程序的性能。学习这部分内容,最佳的方式就是和现实世界做对比。例如生产者 - 消费者模式,可以类比一下餐馆里的大厨和服务员,大厨就是生产者,负责做菜,做完放到出菜口,而服务员就是消费者,把做好的菜给你端过来。不过,我们经常会发现,出菜口有时候一下子出了好几个菜,服务员是可以把这一批菜同时端给你的。其实这就是生产者 - 消费者模式的一个优点,生产者一个一个地生产数据,而消费者可以批处理,这样就提高了性能。

2. 同步

分好工之后,就是具体执行了。在项目执行过程中,任务之间是有依赖的,一个任务结束后,依赖它的后续任务就可以开工了,后续工作怎么知道可以开工了呢?这个就是靠沟通协作了,这是一项很重要的工作。

在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。

在 Java 并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型,除了能解决线程协作问题,还能解决下面我们将要介绍的互斥问题。可以这么说,管程是解决并发问题的万能钥匙

3. 互斥

分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。不确定,则意味着可能正确,也可能错误,事先是不知道的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java 语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。

所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。

实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?可以分场景优化,Java SDK 里提供的 ReadWriteLock、Stamped Lock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。

除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。

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

并发程序背后的故事

CPU、内存、I/O设备三者的速度差异,计算机体系结构、操作系统、编译程序都作出了贡献,主要体现为:

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

源头一:缓存导致的可见性问题

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

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。

image-20210203223200712

public class Test {
    private static long count = 0;

    private static void add10K() {
        int idx = 0;
        while (idx++ < 100000000) {
            count += 1;
        }
    }

    public static long calc() {
        final Test test = new Test();
        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(() -> {
            test.add10K();
        });
        Thread th2 = new Thread(() -> {
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        try {
            th1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            th2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return count;
    }

    public static void main(String[] args) {
        System.out.println(Test.calc());
    }
}

最后得到的数基本上介于10000-20000之间。

源头二:线程切换带来的原子性问题

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

执行java语言中count += 1`,至少需要三条 CPU 指令。

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

image-20210203223346143

线程 A 和线程 B 按照上图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

源头三:编译优化带来的有序性问题

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

实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

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

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

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

image-20210203223628588

在32位的系统上,long类型为64位,因此简单的加减操作不能保证原子性

02 Java内存模型:看Java如何解决可见性和有序性问题

什么是Java的内存模型?

解决可见性、有序性最直接的方法就是将禁止缓存编译优化,但是这样的话会降低程序的性能,因此应该是按需禁用缓存和编译优化。

本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说这些方法包括volatilesynchronizedfinal三个关键字,以及六项Happens-Before规则,这也是本期的重点内容。

使用volatile的困惑

volatile其中c中也有,它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里 x 会是多少呢?
    }
  }
}

直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

Happens-Before规则

在现实世界里,如果 A 事件是导致 B 事件的起因,那么 A 事件一定是先于(Happens-Before)B 事件发生的,这个就是 Happens-Before 语义的现实理解。

前面一个操作的结果对后续操作是可见的。

Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。

1. 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

2. volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3. 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

image-20210203231526382

也就是说由于三条规则可以推导出,x = 42 对于读变量v=true是可见的,也就是说线程B能看到x = 42,这样也就说三条规则使得x这个变量顺带变成线程B可见(增强了volatile语义)。

4. 管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此处自动解锁

所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

6. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

final关键字

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。

// 以下代码来源于【参考 1】
final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲 this 逸出,
  global.obj = this;
}

“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。有可能还没访问到没有初始化好的this对象。

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

也可以简单的粗暴的直接禁止线程切换,也就是禁止线程切换;但是在多核场景下, 这个方法显然不可行。

简易锁模型

image-20210203234150736

改进后的锁模型

image-20210203234206701

避免锁自家门来保护他家资产的问题。

Java 语言提供的锁技术:synchronized

可以用来修饰方法,也可以用来修饰代码块。

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

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

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

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

用 synchronized 解决 count+=1 问题

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

就是下面图示这个样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。

image-20210203235040167

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

不能用一把锁来保护一个资源,那如何用一把锁来保护多个资源呢?

保护没有关联关系的多个资源

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;
  // 转账
  void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

如果仅仅是在transfer上加入synchronized关键字,锁的资源是this,但是这可以保护自己的资源this.balance,却保护不了别人的余额。

image-20210204001746692

使用锁的正确姿势

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。

Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单。

在前面的文章中,我们提到的原子性,主要是面向 CPU 指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。所以解决原子性问题,是要保证中间状态对外不可见

05 死锁怎么办?

细粒度锁来锁定多个资源,要注意死锁问题。

Coffman死锁总结,只有以下四种情况都发生才会出现死锁

  • 互斥
  • 占有且等待
  • 不可抢占
  • 循环等待

也就是说破坏其中一个,就可以成功避免死锁的发生。

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

1、破坏占用且等待条件

一次性申请所有资源

package chapter05;

import java.util.ArrayList;
import java.util.List;

/*
破坏占用且等待条件:一次性申请所有资源
 */

public class Allocator {
    //延迟加载的单例模式,在多线程上实际上也不一定就是一个单例
    private static Allocator instance = null;

    private Allocator() {
    }

    public static Allocator getInstance() {
        if(instance == null) {
            instance = new Allocator();
        }
        return instance;
    }

    private List<Object> als = new ArrayList<>();

    //锁的是this且只有一个(单例模式)
    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 Account1 {
    private Allocator actr = Allocator.getInstance();
    private int balance;

    void transfer(Account1 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关键字做不到,当申请不到时,线程进入阻塞状态。在sdk层面,用java.util.concurrent这个包下的lock是可以解决这个问题。

3、破坏循环等待条件

对资源进行排序,比如Account就可以增加id字段,按照从小到大的顺序申请

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

06 用“等待-通知机制”优化循环等待

// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
  ;

如果要循环上万次才能获取到锁,这样太耗CPU了。

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

医院中,排队等待叫号,大夫就诊,去做检查叫下一位患者,那检查报告重新分诊等待叫号,在此就诊。

用 synchronized 实现等待 - 通知机制

在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。

class Allocator {
  private List<Object> als;
  // 一次性申请所有资源
  synchronized void apply(
    Object from, Object to){
    // 经典写法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait(); //发现from或to中一个已经被占用了,就进入等待状态
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();//通知所有等待队列中的线程
  }
}

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

安全性

安全性,其本质上就是正确性,而正确性的含义就是程序按照我们期望的执行。

存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。那如果能够做到不共享数据或者数据状态不发生变化,不就能够保证线程的安全性了嘛。但是,现实生活中,必须共享会发生变化的数据,这样的应用场景还是很多的。

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

竞态条件,指的是程序的执行结果依赖线程执行的顺序。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大 Bug。

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

活跃性

包含死锁、活锁和饥饿

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,所谓活锁,类比于现实世界互相让道。Raft等待一个随机时间,知名的分布式一致性算法。

饥饿,指的是线程因无法访问所需资源而无法执行下去的情况。解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

都和操作系统很类似。

性能问题

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

Amdahl定律

image-20210204225229997

Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能

第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……

第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

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

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

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。

MESA 模型

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

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了。线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。

image-20210204233603948

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的,就去条件变量对应的等待队列里面等。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。

image-20210204234128870

wait() 的正确姿势

有一个编程范式,就是需要在一个 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 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

Java的管程模型

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

image-20210204234031749

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

posted @ 2021-02-04 00:25  herrhu  阅读(519)  评论(0)    收藏  举报