系列文章目录
JAVAEE初阶第二节——多线程基础(中)
多线程基础(中)
- 多线程带来的的风险-线程安全 (重点)
- synchronized 关键字
- volatile 关键字
- wait 和 notify
文章目录
- 系列文章目录
- JAVAEE初阶第二节——多线程基础(中)
- 多线程基础(中)
- 一.多线程带来的的风险-线程安全 (重点)
- 1.线程安全的概念
- 2.线程不安全的体现
- 3.线程不安全的原因
- 3.1 原子性(直接原因)
- 3.2 线程的随机调度(根本原因)
- 3.3 修改共享数据
- 3.4 内存可见性
- 3.4.1 内存可见性的解决方法(volatile)
- 二.synchronized 关键字
- 1.分析如何解决线程安全问题
- 2.synchronized的使用
- 3.synchronized 的特性
- 3.1 互斥
- 3.2 刷新内存
- 3.3 可重入
- 4.死锁问题
- 4.1产生死锁的四个必要条件
- 4.2 如何解决死锁问题
- 5.Java 标准库中的线程安全类
- 三.volatile 关键字
- 1.volatile 能保证内存可见性
- 2.volatile 不保证原子性
- 3.synchronized 也能保证内存可见性
- 四.wait 和 notify
- 1.wait()方法
- 2.notify()方法
- 2.1 使用wait和notify解决线程执行顺序问题
- 3.notifyAll()方法
- 4.wait 和 sleep 的对比
一.多线程带来的的风险-线程安全 (重点)
1.线程安全的概念
引入多线程,目的是为了能够实现"并发编程".实现"并发编程”,也不仅仅只能依靠多线程~~
解决方案是很多的,相比之下,多线程,属于一种比较原始,也比较朴素的方案。(问题和注意事项比较多)
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
某个代码,无论是在单个线程下执行,还是多个线程下执行,都不会产生bug.这个情况就称为"线程安全".
如果这个代码,单线程下运行正确,但是多线程下,就可能会产生bug.这个情况就称为"线程不安全"或者"存在线程安全问题”.
2.线程不安全的体现
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
count++;
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
count++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count = " + count);
}
按理说,一个线程自增6w次,两个线程,一共自增12W次.最终结果应该是12w
多次运行程序,发现结果并没有如设想的一样是12W,因为上述这个代码就是属于存在线程安全问题的代码.
3.线程不安全的原因
3.1 原子性(直接原因)
即修改数据的操作不是原子的
上面代码中的count++其实是由三个CPU指令构成的(CPU需要读指令,解析指令,执行指令)。
下面是简化后的count++在CPU得执行过程
(1)Load: 从内存中读取数据到CPU的寄存器中。
(2)Add操作 : 把寄存器中的值+1
(3)Save :把寄存器的值写回到内存中。
因此如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
这点也和线程的随机调度引起得"抢占式执行"密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
3.2 线程的随机调度(根本原因)
如果是一个线程执行上述的非原子操作,当然没问题.但如果是两个线程,并发德执行上述操作,此时就会存在变数(线程之间调度的顺序是不确定的!)
这就会导致线程的执行顺序出现很多种情况:
错误的执行顺序导致的结果:
由于这俩线程是并行执行,还是并发执行也不知道,但是即使是并发执行,在一个CPU核心上,两个线程有各自的上下文(各自一套寄存器的值,不会相互影响)。这里为了方便观察,先假设它们是并行执行的。
如果按照上面这种错误的执行顺序执行指令,就会发现两个线程都自增后count的值却只是一次自增的结果.而且这里其实有无数种执行顺序错误的情况!所以完全有可能出现,在thread2执行一次++的时候,thread1执行两次++;在thread2执行一次++的时候,thread1执行三次++这些情况。(多线程代码难编写的体现)
正确的执行过程:
从上面的两个不同的执行过程中就能看出,想要解决这个问题最关键的在于,要确保第一个线程Save了之后,第二个线程再Load,这个时候第二个线程Ioad到的才是第一个线程自增后的结果。否则的话,第二个线程Ioad到的就是第一个线程自增前的结果了。两次自增,实际就只增加了1了!
再回到之前的代码上,假设所有的6w次自增,都是两个线程触发了和刚才一样的错误执行过程,这个时候得到的结果就是刚好6w。假设所有6w次自增,都是两个线程触发了没问题的方式,这个时候得到的结果就刚好是12w
实际上,多少次是有问题的调度,多少次是没有问题的调度是无法得知的。所以结果大概率就是6w-12w之间的数值!
3.3 修改共享数据
之前两个线程自增12w次的代码中,由于修改的是同一个值count,所以才会出现线程安全问题.假设:
(1)一个线程修改一个变量。
(2)多个线程读取同一个变量。
(3)多个线程修改不同的变量。
这些都不会引起线程安全问题
3.4 内存可见性
如果一个线程写,一个线程读,这个时候是否会有线程安全问题呢?
也是可能存在的.
private static int flag = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(()->{
while(flag == 0){
}
System.out.println("thread1线程结束!");
});
Thread thread2 = new Thread(()->{
System.out.println("请输入flag->");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
thread1.start();
thread2.start();
}
按照正常思路理解,上面代码通过thread2线程输入的整数只要输入的不为0,就可以使t1线程结束.(无论t1先启动还是t2先启动,等待用户输入的过程中,t1必然都是已经循环很多次了)
运行程序,输入一个非0的值后,thread1并没有结束并打印日志,这就是“内存可见性”引起的BUG。
- 分析出现上面BUG的原因
thread1中while循环的核心指令有2条
1.Load读取内存中flag的值到CPU寄存器里
2.拿着寄存器的值和0进行比较(条件跳转指令)
在这个执行过程中:
(1)Load执行的结果每一次都是一样的(因为输入要等几秒,而在这几秒时间内已经执行了很多次循环)
(2)Load操作的开销远远超过了条件跳转(访问寄存器的操作速度远远比访问内存的速度快)
在这种频繁执行Load和条件跳转的情况下。(Load的开销大并且在这几秒内没有变化)。此时JVM就会认为Load操作没有存在的必要,于是JVM就会做出代码优化,将Load操作优化掉。(只有前几次循环执行Load,后面发现Load的值都一样,就不再重复读内存,直接使用寄存器中之前Load的值,从而大幅度提高循环的执行速度)
因此当thread2修改了内存,但是thread1没有读取到这个内存的变化,这种情况就叫“内存可见性”问题
内存可见性,高度依赖编译器的优化的具体实现编译器啥时候触发优化,啥时候不触发优化,是不能预测的!
上述代码如果稍微改动一点,就可能截然不同了。
其他地方与之前代码一样
Thread thread1 = new Thread(()->{
while(flag == 0){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("thread1线程结束!");
});
不加sleep,一秒钟就会循环上百亿次,Load的整体开销就非常大,就非常有必要进行优化了。
加上sleep,加了sleep,一秒钟循环1000次,Load整体开销就没那么大了。优化就没那么必要了。
3.4.1 内存可见性的解决方法(volatile)
JAVA提供了volatile(强制读取内存)就可以使JVM的优化强制关闭,这样就可以确保每次循环条件都会重新从内存中读取数据了。(开销是大了,效率也低了,但是数据的准确性(逻辑的正确性)提高了)
其他地方与之前一样
private volatile static int flag = 0;
关于volatile的其他特性和用法下面再详细介绍。
二.synchronized 关键字
1.分析如何解决线程安全问题
- 针对线程的随机调度,因为"抢占式执行"系统内部实现的,一般情况下不能调整.
- 针对多个线程修改同一个变量,这里就需要看代码的执行逻辑了,有的时候可以调整,有的时候就不能调整.
- 针对修改操作非原子性,可以通过synchronized 关键字来给这个操作的多个指令加锁,让他们成为一个"整体".(原子性)
2.synchronized的使用
使用synchronized加锁解决自增12w次的线程安全问题
修饰代码块,明确指定锁哪个对象.
public static void main(String[] args) throws InterruptedException {
//随便创建一个对象都可以
Object locker = new Object();
Thread thread1 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
synchronized (locker){
count++;
}
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
synchronized (locker){
count++;
}
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.print("count = " + count);
}
线程在进入synchronized后的大括号就会加锁(lock) , 出了大括号后就会解锁(unlock)
thread1 先执行lock此时由于该锁还没有被占用,使用可以成功加锁.(在执行指令的过程中仍然会被调度出CPU)
thread2后执行执行lock的时候,因为当前的锁已经被thread1占用了,所以thread2就会阻塞等待(锁竞争),直到thread1线程解锁(unlock)后,thread2才能获取到锁.
这样就能确保thread2的Load能够在thread1的Save执行完了后再执行,结果自然就正确了。
加锁后确实会影响到多线程的执行效率,但是即使如此也比一个线程串行执行要更快
- 注意事项:
- 如果一个线程加锁一个线程不加锁,就不会出现锁竞争了,自然无法解决线程安全问题
- 如果两个线程,针对不同的对象加锁,也会存在线程安全问题
- 加强对加锁的理解
class test{
public int count = 0;
public void add(){
synchronized(this){
count++;
}
}
}
public class ThreadSafe {
public static void main(String[] args) throws InterruptedException {
test t = new test();
Thread thread1 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
t.add();
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
t.add();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.print("count = " + t.count);
}
}
上面的代码也能进行加锁操作,加锁操作的关键就在于多个线程对同一个对象加锁会存在锁竞争.
thread1调用add时次数add中的this指向的是t对象,而thread2调用add时次数add中的this指向的也是t对象。两个线程指向的是同一个对象,就会存在锁竞争
另一种写法:(其他地方不变)
public void add(){
synchronized(ThreadSafe.class){
count++;
}
}
通过ThreadSafe.class就可以获取到ThreadSafe的类对象了。(在一个JAVA进程中,一个类的类对象都是只有一个的)
因此,第一个线程中拿到的类对象和第二个线程中拿到的类对象是同一个对象因此锁竞争仍然存在,还是可以保障线程安全的!
- 直接修饰普通方法
class test{
public int count = 0;
public synchronized void add(){
count++;
}
}
- 修饰静态方法
class test{
public int count = 0;
public synchronized static void add(){
count++;
}
}
但是最常见的加锁方式还是在每个线程中单独地对需要加锁的地方加锁。
3.synchronized 的特性
3.1 互斥
synchronized* 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。(通过这个特性可以解决多个线程实现自增到12W的线程安全问题)
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
Object locker = new Object();
Thread thread1 = new Thread(()-> {
for (int i = 0; i < 60000; i++) {
synchronized (locker){
count++;
}
}
});
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
- 理解 “阻塞等待”.
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
注意:
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这
也就是操作系统线程调度的一部分工作.
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B
和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能
获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。
3.2 刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性. 具体代码后面学到 volatile学习.
3.3 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
public static void main(String[] args) {
Object locker = new Object();
Thread thread1 = new Thread(()->{
synchronized (locker){
System.out.println("thread1:s1上锁");
synchronized (locker){
System.out.println("thread1:s2上锁");
}
System.out.println("thread1:s2解锁");
}
System.out.println("thread1:s1解锁");
});
thread1.start();
}
上面代码从这个代码,直观看起来,好像是有锁冲突的(thread1中里面的synchronized想要拿到锁要先“阻塞”等外面解锁,但是外面又要等加锁的内容执行完才能解锁)但是实际上是可以运行成功的。
这是因为当前由于是同一个线程,此时锁对象,就知道了第二次加锁的线程,就是持有锁的线程。第二次操作,就可以直接放行通过不会出现阻塞。这个特性就是“可重入”
“可重入”的实现逻辑:
public static void main(String[] args) {
Object locker = new Object();
Thread thread1 = new Thread(()->{
synchronized (locker){
System.out.println("thread1:s1上锁");
synchronized (locker){
System.out.println("thread1:s2上锁");
}
System.out.println("thread1:s2解锁");
}
System.out.println("thread1:s1解锁");
});
Thread thread2 = new Thread(()->{
synchronized (locker){
System.out.println("thread2 拿到锁");
}
});
thread1.start();
thread2.start();
}
对于可重入锁来说,内部会持有两个信息。
1.当前这个锁是被哪个线程持有的。
2.加锁次数的计数器。
过程分析:
(1)上面代码中,thread1第一次加锁的时候是真正的加锁,同时计数器+1(初始为0)。说明当前这个对象被该线程加锁一次,同时记录线程是谁。
(2)第二次加锁的时候发现加锁线程和持有锁的是同一个线程,所以也能加锁成功(但因为是同一个线程,所以就只是计数器+1(计数器的值变为2),“加锁”操作不重复执行)
(3)当执行到第二次加锁的 }(右大括号) 时,计数器 -1(计数器变为1),同时判断当前计数器的值是否为0(为0才能真正解锁)。所以此时thread2线程仍然处于阻塞状态,不能拿到锁,也就打印不了信息。
(4)当执行到第一次加锁的 }(右大括号) 时,计数器 -1(计数器变为0),此时才是真正的解锁,因此thread1打印解锁的信息后,thread2拿到锁并打印拿到锁的信息。
4.死锁问题
- 一个线程一把锁
就像刚才介绍synchronized的可重入性设想的一样,如果锁是不可重入锁 thread1中里面的synchronized想要拿到锁要先“阻塞”等外面解锁,但是外面又要等加锁的内容执行完才能解锁。这就会出现死锁的情况。
- 两个线程两把锁
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread thread1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);//sleep一下,是给thread2时间,让thread2也能拿到locker2
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("thread1拿到两把锁");
}
}
});
Thread thread2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);//sleep一下,是给thread1时间,让thread2也能拿到locker1
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("thread1拿到两把锁");
}
}
});
thread1.start();
thread2.start();
}
上面的代码,线程1拿到locker1,线程2拿到locker2,接下来线程1试图拿到locker2,线程2也尝试拿到线程1。这种情况下,就出现了死锁。(如果此处约定加锁顺序,先对A加锁,后对B加锁。此时,死锁仍然可以解决!)
程序卡死:
使用jconsole观察现在的状态
- N个线程M把锁
只要指定合理的加锁顺序就能解决:
针对五把锁,都进行编号,约定每个线程获取锁的时候,一定要先获取编号小的锁,后获取编号大的锁。
4.1产生死锁的四个必要条件
- 互斥使用。获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想要获取就要阻塞等待
- 不可抢占。一个线程拿到锁后,其他线程想要拿到这把锁,只能等第一个线程主动解锁,不能让别的线程强行把锁抢走
- 请求保持。一个线程拿到锁1之后,在持有锁1的情况下尝试获取锁2.
- 循环等待(环路等待)。
上面条件必须全部具备,才能产生死锁问题
4.2 如何解决死锁问题
- 从互斥使用上来说,这是锁的基本特性,不太好改变。
- 从不可抢占上来说,这也是锁的基本特性,不太好改变。
- 从请求保持上来说,这需要看实际的代码结构能不能修改。
- 从循环等待上来说,这是从代码结构上来说最容易改变的,只要指定一定的规则,就可以有效避免循环等待。
解决死锁其实有很多解决方案
(1)引入一个新的锁。
(2)去掉一个线程
(3)引入计数器,限制最多同时有几个线程加锁。
虽然上面的方法并不复杂,但是因为普适性不高,只能用来解决一些特殊的死锁
一般建议引入加锁顺序的规则来解决死锁问题
5.Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
上面的这些类都是因为没有加锁操作所以可能会有线程安全问题(具体看代码怎么实现)
但是还有一些是线程安全的. 使用了一些锁机制来控制
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
上面这些方法虽然有加锁措施但也不是100%不会有线程安全问题(具体看代码怎么实现)
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
- String
三.volatile 关键字
1.volatile 能保证内存可见性
volatile 修饰的变量, 能够保证 “内存可见性”(还能禁止指令重排序)
代码在写入 volatile 修饰的变量的时候,
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
前面讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度 非常快, 但是可能出现数据不一致的情况.
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了
代码示例可以看之前介绍内存可见性引起的线程不安全
2.volatile 不保证原子性
这个是最初的演示线程安全的代码.
(1)给count++去掉 synchronized
(2)给 count 加上 volatile 关键字
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for (int i = 0; i < 60000; i++) {
count++;
}
});
Thread thread2 = new Thread(()->{
for (int i = 0; i < 60000; i++) {
count++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.print("count = " + count);
}
此时可以看到, 最终 count 的值仍然无法保证是 12W
3.synchronized 也能保证内存可见性
synchronized 既能保证原子性, 也能保证内存可见性.
对上面内存可见性的代码进行调整:
去掉 flag 的 volatile
给 thread1 的循环内部加上 synchronized, 并借助 locker 对象加锁.
private static int flag = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(()->{
synchronized (locker){
while(true){
synchronized (locker){
if(flag == 0) break;
}
}
}
System.out.println("thread1线程结束!");
});
Thread thread2 = new Thread(()->{
System.out.println("请输入flag->");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
thread1.start();
thread2.start();
}
四.wait 和 notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候希望合理的协调多个线程之间的执行先后顺序
一种需要调节线程执行先后顺序的情况:
public static void main(String[] args) {
Object locker = new Object();
Thread thread1 = new Thread(()->{
synchronized(locker){
System.out.println("thread1 开始");
}
System.out.println("thread1 结束");
});
Thread thread2 = new Thread(()->{
synchronized(locker){
System.out.println("thread2 开始");
}
System.out.println("thread2 结束");
});
thread2.start();
thread1.start();
}
要想让上面代码在不改变start顺序的情况协调thread1和thread2的顺序,让thread1先执行。
完成这个协调工作, 就可以使用wait和notify了。
wait() / wait(long timeout): 让当前线程进入等待状态.
notify() / notifyAll(): 唤醒在当前对象上等待的线程.
注意: wait, notify, notifyAll 都是 Object 类的方法.
1.wait()方法
wait 做的事情:
- 释放当前的锁
- 使当前执行代码的线程进行等待. (把线程放到等待队列中(阻塞等待))
- 满足一定条件时被唤醒, 重新尝试获取这个锁. (当其他线程调用notify的时候,wait解除阻塞并重新获取到锁)
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
(1)wait()- 其他线程调用该对象的 notify 方法.(死等)
(2)wait(long timeout) - wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
(3)其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.(wait和sleep, join都是一类的都可能会被interrupt提前唤醒)
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待中");
object.wait();
System.out.println("等待结束");
}
}
这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。
2.notify()方法
notify 方法是唤醒等待的线程.
方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的。其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
2.1 使用wait和notify解决线程执行顺序问题
public static void main(String[] args) {
Object locker = new Object();
Thread thread1 = new Thread(()->{
synchronized(locker){
System.out.println("thread1 开始");
locker.notify();
}
System.out.println("thread1 结束");
});
Thread thread2 = new Thread(()->{
synchronized(locker){
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread2 开始");
}
System.out.println("thread2 结束");
});
thread2.start();
thread1.start();
}
上面代码的执行过程:
(1)thread2执行起来后,就会立刻拿到锁,然后进入wait方法,释放锁并阻塞等待thread1释放锁。
(2)thread1执行起来后,虽然thread2拿到过锁但是因为thread2目前处于wait状态,锁是释放了的,thread1就能拿到锁打印“thread1 开始 ”,然后执行notify操作,将thread2唤醒。但是由于thread1还没有释放锁,所以thread2从WAITING状态恢复后,尝试获取锁并进入阻塞状态(锁竞争引起的).
(3)thread1打印"thread1 “后,thread1结束并释放锁。
(4)thread2拿到锁并从BLOCKED状态恢复并打印"thread2 开始”,在打印完"thread2 结束"后线程结束。
注意事项:
(1)随便使用一个对象都可以进行wait,但是如果直接这样使用就会抛出异常.因为wait一旦被调用就要尝试释放锁,如果当前调用wait的不是锁对象,就不能不能完成释放锁,这时就会直接抛出异常了。
调用wait的对象,必须和synchronized中的锁对象是一致的!因此,wait解锁必然是解的object的锁,后续wait被唤醒之后,重新获取锁,当然还是获取到object的锁。
(2)wait必须放到synchronized里面使用。(因为要释放锁,前提是先加上锁)
(3)调用notify的对象也必须是和调用wait一样的锁对象。
(4)如果有其他线程也尝试获取锁,从wait等待被唤醒的锁,也是要参与到锁竞争当中的。
(5)notify其实可以不放到synchronized里,不需要先加锁的(但是Java中特别约定要把notify放到synchronized里)
3.notifyAll()方法
notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.
范例:使用notifyAll()方法唤醒所有等待线程
//创建 3 个 WaitTask 实例
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
while (true) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
// 1 个 NotifyTask 实例.
static class NotifyTask implements Runnable {
private Object locker;
public NotifyTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t3 = new Thread(new WaitTask(locker));
Thread t4 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
t3.start();
t4.start();
Thread.sleep(1000);
t2.start();
}
此时可以看到, 调用 notify 只能唤醒一个线程.
- 修改 NotifyTask 中的 run 方法, 把 notify 替换成 notifyAll
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notifyAll();
System.out.println("notify 结束");
}
}
此时可以看到, 调用 notifyAll 能同时唤醒 3 个wait 中的线程
注意: 虽然是同时唤醒 3 个线程, 但是这 3 个线程需要竞争锁. 所以并不是同时执行, 而仍然是有先有后的执行
4.wait 和 sleep 的对比
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间
- wait 需要搭配 synchronized 使用. sleep 不需要.
- wait 是 Object 的方法 sleep 是 Thread 的静态方法.
- wait提供了一个带有超时时间的版本(大多数情况下,wait都是在超时时间之内就被唤醒了),sleep也是能指定时间。都是时间到,就继续执行,解除阻塞了。
- wait和sleep都可以被提前唤醒(虽然时间没到,但是也能提前唤醒)
- wait通过notify唤醒
- sleep通过interrupt唤醒
- 使用wait,最主要的目标,一定是不知道要等多少时间的前提下使用的。所谓的超时时间,其实是"兜底的"。而使用slep,一定是知道要等多少时间的前提下使用的。虽然能提前唤醒,但是通过异常唤醒,这个操作不应该作为"正常的操作"(通过异常唤醒,说明程序应该是出现一些特殊的情况了)