并发理论基础

并发编程的核心:可以总结为三个核心问题:分工、同步、互斥。

分工:指的是如何高效的拆解任务并分配给线程

同步:指的是线程之间如何协作

互斥:保证同一时刻只允许一个线程访问共享资源

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

1.核心矛盾

a、核心矛盾:CPU、内存、IO设备的速度差异

b、为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

i. CPU 增加了缓存,以均衡与内存的速度差异;

ii. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;

iii. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

2.可见性问题

a、可见性:CPU单核时代一个线程对共享变量的修改,另外一个线程能够立刻看到

b、可见性问题原因:CPU多核,每颗CPU都有自己的缓存导致的可见性问题

3.原子性问题

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

b、原因:一条高级语言语句执行往往需要多条CPU指令;但是操作系统做线程切换可能发生在任何一条CPU 指令执行完;这就违背了高级语言层面保证操作的原子性

4.有序性问题

a、有序性:指的是程序按照代码的先后顺序执行

b、原因:编译器为了优化性能,有时候会改变程序中语句的先后顺序,可能会导致bug

5.原子性和有序性的区别

a、volatile有三个重要的特性:可见性,有序性,线程不安全性。

b、可见性:线程在处理变量的时候,不会从自己的内存中获取,而是才能够java堆里面获取其他线程也会改变的量,这个量也可以称为最后值,永远是最新的,

c、有序性:是保证他不会重新排序,java会对有延迟的代码进行重新排序,在不影响结果的情况下,效率块的代码会放到前面执行,但volatile会保证代码不会重排序。

d、线程不安全性:他能保证可见性和有序性,但是不能保证原子性,因为java里的运算是非原子的。

2、Java内存模型:解决可见性和有序性问题

1.什么是Java内存模型

a、Java 内存模型:规范了 JVM 如何提供按需禁用缓存和编译优化的方法

b、按需禁用缓存和编译优化的方法:包括 volatile、synchronized 和 final 三个关键字,以及六项Happens-Before 规则

2.使用volatile 的困惑

a、volatile关键字的作用: 告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入 (禁用缓存以及编译优化)

b、1.5 以前的版本变量 x可能被 CPU 缓存而导致可见性问题

c、Java 内存模型在 1.5 版本对 volatile 语义进行了增强。利用一项 Happens-Before规则

d、volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚 于)锁操作。频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能

e、只有 volatile 字段的写操作会强制刷新缓存。理想情况下对volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作

f、volatile 字段的每次访问均需要直接从内存中读写。

3. Happens-Before 规则

a、Happens-Before的含义:前面一个操作的结果对后续操作是可见的 (happens-before 关系是用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见

b、Happens-Before 是 Java 内存模型中保证多线程操作可见性的机制

c、程序的顺序性规则:指在一个线程中,按照程序顺序,前面的操作 Happens-Before于后续的任意操作(程序前面对某个变量的修改一定是对后续操作可见的)

d、volatile 变量规则: volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。

e、传递性: 如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。

x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;

写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容

“x=42” Happens-Before 读变量“v=true”

线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的

f、管程中锁的规则: 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。 (管程是一种通用的同步原语,在Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现;管程中的锁在 Java 里是隐式实现的)

例:线程A执行前自动加锁,执行完写操作自动释放锁;线程B开始执行能够看到线程A的写操作结果

g、线程 start() 规则: 主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作(主线程A启动B线程后,子线程B可以看到启动之前主线程A对共享变量的所有修改)

h、线程 join() 规则: 主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作 (子线程B所有对共享变量的操作在主线程A调用B.join()之后可见)

4. final关键字

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

b、在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。

c、即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前

5. Java 内存模型的底层实现

a、Java 内存模型是通过内存屏障(memory barrier)来禁止重排序和保证可见性的

b、即时编译器会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障

c、Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它 所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作

d、即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令

e、以 volatile 为例:

i.对该 volatile变量的写操作之后,编译器会插入一个写屏障

ii.对该变量的读操作之前,编译器会插入一个读屏障

f、内存屏障能够在类似变量读、写操作之后,保证其他线程对 volatile 变量的修改对当前线 程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强 迫刷出处理器缓存的方式,让其他线程能够拿到最新数值

3、互斥锁:解决原子性问题

1.原子性问题到底该如何解决

a、原子性问题的原因: 线程切换

b、解决方法1:禁用线程切换。 操作系统做线程切换是依赖 CPU 中断的,禁止 CPU 发生中断就能够禁止线程切换

c、单核CPU场景:同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行。

d、多核CPU场景:同一时刻,有可能有两个线程同时在执行,两个线程执行在不同的CPU上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行

e、最终解决方法:保证对共享变量的修改是互斥的(即同一时刻只有一个线程执行),无论是单核 CPU 还是多核 CPU,就都能保证原子性

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

2.改进后的锁模型

a、临界区:需要互斥执行的代码

b、加锁过程:

i.要保护资源 R 就得为它创建一把锁 LR

ii.针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作

iii.注意锁 LR 和受保护资源之间的关联关系

3. Java 语言提供的锁技术:synchronized

a、synchronized 关键字:锁的一种实现。既可以修饰方法,又可以修饰代码块。

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

b、加锁操作:Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()

c、锁定对象:

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

ii.当修饰非静态方法的时候,锁定的是当前实例对象 this。

iii.当修饰代码块的时候,锁定的是 synchronized(obj) 中的obj

4. 用 synchronized 解决 count+=1 问题

a、SafeCalc 这个类有两个方法:一个是get() 方法,用来获得 value 的值;另一个是addOne() 方法,用来给 value 加 1

b、保证可见性:需要给get() 方法和 addOne() 方法加锁,才可以保证可见性

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

c、管程(synchronized): synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码

d、管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁;指的是前一个线程的解锁操作对后一个线程的加锁操作可见;综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的

5. 锁和受保护资源的关系

a、受保护资源和锁之间的关联关系是 N:1 的关系

b、把 value 改成静态变量,把 addOne() 方法改成静态方法:

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

c、改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class

d、由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,因为addOne()操作对 get() 操作有依赖性,这就导致并发问题了

e、分别给两个代码块加锁,能解决可见性和原子性问题吗:

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

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

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

a、第一种方法:给没有没有关联关系的多个资源分配不同的锁来解决并发问题

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

b、第二种方法:用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字 synchronized 就可以了

c、第二种方法缺点:性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的

d、第一种方法优点:用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

7.保护有关联关系的多个资源

a、银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作:

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

b、synchronized 关键字修饰一下 transfer() 方法:

8.使用锁的正确姿势

a、解决方法:锁能覆盖所有受保护资源

b、this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁呢?

c、第一种解决方法:

i.可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入

ii.把 Account 默认构造函数变为 private,同时增加一个带 Object lock 参数的构造函数,创建 Account 对象时,传入相同的 lock,这样所有的 Account 对象都会共享这个lock 了

class Account {
  private Object lock;
  private int balance;
  private Account();
  // 创建 Account 时传入同一个 lock 对象
  public Account(Object lock) {
     this.lock = lock;
  } //构造函数
  // 转账
  void transfer(Account target, int amt){
     // 此处检查所有对象共享的锁
     synchronized(lock) { //创建Account的时候,会传入相同的lock;对lock对象加锁,所有的Account都会共享这个lock
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
         }
       }
    }
}  

d、第二种解决方法:用Account.class 作为共享的锁。Account.class 是所有 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;
           }
      }
   }
}

4、死锁

1.如何实现并行转账

a、使用共享锁(一把锁保护有关联关系的多个资源)的问题:当多个账户之间实现转账时,只能所有账户之间的转账操作都是串行的,性能太差

b、解决方法(使用细粒度锁):使用两把锁,转出账本一把,转入账本另一把

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

c、this.balance 和 target.balance的转账操作之间没有依赖关系,所以使用两把锁不会导致并发问题。

2.细粒度锁的代价:死锁

a、细粒度锁: 用不同的锁对受保护资源进行精细化管理,能够提升性能

b、使用细粒度锁的代价:可能会导致死锁。

c、死锁的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

T1:A转账给B

T2:B转账给A

3.如何预防死锁

a、解决死锁的方法:重启应用;规避死锁

b、死锁发生的条件:

i.互斥,共享资源 X 和 Y 只能被一个线程占用;

ii.占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;

iii.不可抢占,其他线程不能强行抢占线程 T1 占有的资源;

iv.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待

c、规避死锁的方法:只要我们破坏其中一个,就可以成功避免死锁的发生

d、破坏占用且等待条件: 可以一次性申请所有资源

i. Java 里面的Allocator 类来管理"同时申请"这个临界区

ii. Allocator的重要功能:同时申请资源 apply() 和同时释放资源 free()

iii.账户 Account 类里面持有一个Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

e、破坏不可抢占条件: 能够主动释放它占有的资源(synchronized 做不到,synchronized申请不到资源,线程直接进入阻塞状态)

f、破坏循环等待条件: 需要对资源进行排序,然后按序申请资源(不同的并发线程采用相同的加锁顺序)

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

1.需要等待-通知机制的原因

a、在破坏占用且等待条件的时候,如果转出账本和转入账本不满足同时在文件架上这个条件,就用死循环的方式来循环等待,核心代码如下:

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

b、apply() 操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的

c、如果 apply()操作耗时长,或者并发冲突量大的时候, 循环等待方案太消耗 CPU了

d、一个完整的等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。

2.就医流程类比等待-通知机制

a、患者挂号,到就诊门口分诊,等待叫号—线程获取互斥锁

b、患者被叫号—线程获取互斥锁

c、大夫让患者去做检查—线程要求的条件没有满足

d、患者去做检查—线程进入等待状态

e、大夫叫下一个患者—线程释放持有的互斥锁

f、患者做完检查—线程要求的条件已经满足

g、患者拿着检测报告重新分诊—线程重新获取互斥锁

3. 用 synchronized 实现等待 - 通知机制

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

b、工作过程:

i.左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室)

ii.当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)

iii.当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求

iv.调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列

v.线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了

vi.当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过

vii.因为notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)

viii.被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)

c、这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列

d、wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,如果 synchronized 锁定的是 this,那么对应的一定是this.wait()、this.notify()、this.notifyAll();

e、notify() 是会随机地通知等待队列中的一个线程(某些线程可能永远不会被通知到 ),而 notifyAll() 会通知等待队列中的所有线程;尽量使用 notifyAll()

4.用等到-通知机制解决实际问题

a、需要考虑以下四个要素:

i.互斥锁:上一篇文章我们提到 Allocator 需要是单例的,所以我们可以用 this 作为互斥锁。

ii.线程要求的条件:转出账户和转入账户都没有被分配过

iii.何时等待:线程要求的条件不满足就等待

iv.何时通知:当有线程释放账户时就通知

b、

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

6、安全性、活跃性及性能问题

1.安全性问题

a、线程安全的概念:正确性,程序按照我们期望的执行

b、如何保证线程安全:理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题

c、存在原子性、可见性和有序性问题的场景:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据

d、数据竞争(Data Race):多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug

e、竞态条件:程序的执行结果依赖线程执行的顺序(在并发场景中,程序的执行依赖于某个状态变量)

f、保护线程安全的方法:互斥的技术方案,CPU提供了相关的互斥指令,操作系统、编程语言也会提供相关的API,逻辑上统一归为锁

2.活跃性问题

a、概念:某个操作无法执行下去

b、种类:死锁、活锁、饥饿

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

d、活锁:

i.概念:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况。比如两个线程一直相互谦让

ii.解决方法:谦让时,尝试等待一个随机的时间就可以了

e、饥饿:

i.概念:线程因无法访问所需资源而无法执行下去的情况;线程优先级“不均”,CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

ii.解决方法:

1)保证资源充足

2)公平地分配资源:在并发编程里,主要是使用公平锁(公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源)

3)避免持有锁的线程长时间执行

3.性能问题

a、问题:

i.锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程提升性的优势

ii.互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间

b、串行对性能的影响:阿姆达尔(Amdahl)定律

n: CPU核数

p:并行百分比

c、如何避免锁带来的性能问题:(Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能)

i.解决方案1:使用无锁的算法和数据结构

相关技术:线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁、Java 并发包里面的原子类也是一种无锁的数据结构、Disruptor 则是一个无锁的内存队列

ii.解决方案2:减少锁持有的时间

相关技术:使用细粒度的锁,一个典型的例子就是 Java 并发包里的ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍)、使用读写锁,也就是读是无锁的,只有写的时候才会互斥

d、性能衡量指标:

i.吞吐量:单位时间内能处理的请求数量

ii.延迟:从发出请求到收到响应的时间

iii.并发量:能同时处理的请求数量(随着并发量的增加、延迟也会增加)

7、管程:并发编程的万能钥匙

1.什么是管程

a、概念:指的是管理共享变量以及对共享变量的操作过程,让他们支持并发;Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的

b、管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程(信号量:编程原语,能解决所有并发问题)

c、组成部分:synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分

2. MESA模型

a、管程模型:Hasen 模型、Hoare 模型、MESA 模型(Java管程的实现参考的也是MESA模型)

b、并发编程两大核心问题:

i.互斥:同一时刻只允许一个线程访问共享资源

解决思路:将共享变量及其对共享变量的操作统一封装起来;管程模型和面向对象高度契合的,互斥锁背后的模型就是MESA

ii.同步:线程之间如何通信、协作

解决思路:管程模型里,共享变量和对共享变量的操作是被封装起来的;只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待;每个条件变量都对应有一个等待队列,条件变量和等待队列的作用就是解决线程同步的问题

实现过程:

i.线程 T1 执行出队操作,执行出队操作的条件变量A为队列不为空,不满足条件A,就去条件变量对应的等待队列里等(通过调用 A.wait() 来实现);线程T1进入条件变量等待队列后,允许其他线程进入管程

ii.线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。(调用 A.notify() 来通知 A 等待队列中的线程T1)当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面

iii.notifyAll() 这个方法,它可以通知等待队列中的所有线程

iv.await() 和前面提到的 wait() 语义是一样的;signal() 和前面提到的 notify() 语义是一样的

3. wait() 的正确姿势

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

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

b、Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别:当条件满足后,如何通知相关线程(当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行)

i. Hasen 模型:要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行

ii. Hoare 模型:T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒T2,也能保证同一时刻只有一个线程执行

iii. MESA 管程:T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面(notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量)

4. notify() 何时可以使用

a、尽量使用 notifyAll()

b、使用notify()的场景:

i.所有等待线程拥有相同的等待条件;(重点是 while 里面的等待条件是完全相同的)

ii.所有等待线程被唤醒后,执行相同的操作;

iii.只需要唤醒一个线程

5.小结

a、Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简

b、MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量

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

d、并发编程里两大核心问题——互斥和同步,都可以由管程来解决

8、Java线程

1.通用的线程生命周期(五态模型)

a、初始状态:线程已经被创建(编程语言层面的创建,操作系统层面还没创建),但是还不允许分配 CPU 执行

b、可运行状态:线程可以分配 CPU 执行

c、休眠状态:运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件 (例如条件变量),那么线程的状态就会转换到休眠状态

d、运行状态:有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态

e、终止状态:线程执行完或者出现异常就会进入终止状态

2. java线程的生命周期

a、六种状态

i. NEW(初始化状态)

ii. RUNNABLE(可运行 / 运行状态)

iii. BLOCKED(阻塞状态)

iv. WAITING(无时限等待)

v. TIMED_WAITING(有时限等待)

vi. TERMINATED(终止状态)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值