并发编程的核心:可以总结为三个核心问题:分工、同步、互斥。
分工:指的是如何高效的拆解任务并分配给线程
同步:指的是线程之间如何协作
互斥:保证同一时刻只允许一个线程访问共享资源
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(终止状态)