wait / notify、单例模式
目录
1、wait 和 notify
1.1、wait 方法
1.2、wait 和 sleep 的区别
1.3、notify 方法
1.3、总结
1.4、练习题
2、单例模式
2.1、饿汉模式
2.2、懒汉模式
1、wait 和 notify
1. wait / notify:等待 / 通知,用于协调线程之间的执行顺序
wait() / wait(long timeout):让当前线程进入等待状态
notify() / notifyAll():唤醒在当前对象上等待的线程
2. wait 和 join 的区别:wait 和 join 都是等。join 是等另一个线程彻底执行完,才继续走;wait 是等到另一个线程执行 notify,才继续走(不需要另一个线程执行完)
3. 线程饥饿:线程饥饿是指线程因长期无法获取所需资源(如CPU时间、锁、1/O资源等)而无法正常执行的现象。
例如:
当多个线程竞争一把锁的时候,获取到锁的线程释放了,其他哪个线程拿到锁?
虽然由于调度是随机的,哪个线程拿到锁是不确定的,但当前这个线程释放锁的瞬间,是就绪状态,此时其他线程还在锁上阻塞等待,是阻塞状态。
因此这个线程有很大的概率能够再次拿到这个锁,所以就会出现一个线程反复的获取到锁,其他线程长期无法获取锁,这个现象就是 线程饥饿 / 线程饿死
对这种情况,就可以使用 wait / notify 来解决。当拿到锁的线程,发现要执行的任务时机还不成熟,就使用 wait 阻塞等待,让其他线程有更多的机会获取这个锁资源
1.1、wait 方法
示例:
运行结果报错:
分析:
1. wait 和 notify 都是 Object 的方法,也就是说 Java 中的任意对象都能使用 wait 和 notify
2. 使用 wait 方法需要抛出 InterruptedException 异常。Java 标准库中,每个产生阻塞的方法,都会抛出这个异常,意味着随时可能会被 Interrupt 方法给唤醒
异常原因:
3. IllegalMonitorStateException:非法的锁状态,Monitor:监视器(synchronized 锁)
4. object 调用 wait 方法,会释放 object 对象对应的锁。能够释放锁的前提是,object 对象应该处于加锁状态,但上述代码的 object 对象没有被加锁,所以出现了这个异常
简单修改代码:
分析:
1. 代码执行到 wait,就会先释放锁,并且阻塞等待。等到其他线程完成了必要的工作,调用notify 唤醒这个 wait 线程,wait 就会解除阻塞,重新获取到锁,继续往后执行
2. 要求 synchronized 的锁对象必须和调用 wait 的对象是同一个
wait 和 join 类似,也提供了 “死等” 版本和 “超时时间” 版本:wait(long timeout)
10s 后还没有 notify,就不等了
1.2、wait 和 sleep 的区别
相同点:唯一的相同点 就是都可以让线程放弃执行一段时间
wait 和 sleep 最主要的区别,在于针对锁的操作:
1)wait 是 Object 的方法,sleep是 Thread 的静态方法
2)wait 必须要搭配 synchronized 使用,sleep 没有限制
3)如果都是在 synchronized 内部使用,wait会释放锁,sleep不会释放锁
wait 可以使用 notify 提前唤醒,sleep 也可以使用Interrupt提前唤醒。不同的是,Interrupt 看起来是唤醒 sleep,其实本身的作用是通知线程终止
学习阶段,会用很多 sleep,真正开发很少用 sleep,因为使用 sleep 很浪费时间
1.3、notify 方法
示例1:
public class Demo24 {public static void main(String[] args) {Object locker = new Object();Thread t1 = new Thread(() -> {try {System.out.println("wait 之前");synchronized (locker) {locker.wait();}System.out.println("wait 之后");} catch (InterruptedException e) {throw new RuntimeException();}});Thread t2 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("输入任意内容, 通知唤醒 t1");scanner.next();synchronized (locker) {locker.notify();}});t1.start();t2.start();}
}
分析:
1. scanner.next():next 方法就是一个带有阻塞的操作,等待用户在控制台输入,是等待lO产生的阻塞
2. wait 操作必须要搭配锁使用,因为 wait 要释放当前的锁。而 notify 操作,原则上不涉及到加锁解锁操作,但在 Java 中,强制要求 notify 搭配 synchronized
3. 线程、锁,都是操作系统本身支持的特性。wait 和 notify 在操作系统中,也有原生的对应的 api (C语言)。操作系统原生 api 中,wait 必须搭配锁使用,notify 则不需要
4. wait 和 notify 必须针对同一个对象才能生效,这个相对象是这两个线程沟通的桥梁;如果是两个不同对象,则没有任何相互的影响和作用
5. 务必要确保,先 wait,后 notify,才有作用,如果是先 notify,后 wait,wait 将无法被唤醒。此时 notify 的这个线程,也不会有副作用(notify 一个没有在 wait 的对象,不会报错)
示例2:
public class Demo25 {public static void main(String[] args) {Object locker = new Object();Thread t1 = new Thread(() -> {try {System.out.println("t1 wait 之前");synchronized (locker) {locker.wait();}System.out.println("t1 wait 之后");} catch (InterruptedException e) {throw new RuntimeException();}});Thread t2 = new Thread(() -> {try {System.out.println("t2 wait 之前");synchronized (locker) {locker.wait();}System.out.println("t2 wait 之后");} catch (InterruptedException e) {throw new RuntimeException();}});Thread t3 = new Thread(() -> {Scanner scanner = new Scanner(System.in);System.out.println("输入任意内容, 唤醒第一个线程");scanner.next();synchronized (locker) {locker.notify();}System.out.println("输入任意内容, 唤醒第二个线程");scanner.next();synchronized (locker) {locker.notify();}});t1.start();t2.start();t3.start();}
}
分析:
1. 如果有多个线程在同一个对象上 wait,进行 notify 时,随机唤醒其中一个线程;一次 notify 只能唤醒一个 wait
2. 可以使用 notifyAll 一次唤醒所有的 wait 线程
3. 虽然同时唤醒了t1和t2,但由于 wait 唤醒之后,要重新加锁,其中某个线程先加上锁,另一个线程因为加锁失败,再次阻塞等待。等到先走的线程解锁了,后走的线程才能加上锁,继续执行。看似全部唤醒了,实际只有一个线程能继续执行,其他依然要阻塞等待,所以notifyAll 很少被使用
1.3、总结
wait 做的事情:
1)使当前执行代码的线程阻塞等待(把线程放到等待队列中)
2)释放当前的锁
3)满足一定条件时被唤醒,重新尝试获取这个锁
wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常
wait 结束等待的条件:
1)其他线程调用该对象的 notify 方法
2)wait 等待时间超时(wait 方法提供一个带有 timeout 参数的版本,可以指定等待时间)
3)其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常
notify 唤醒等待的线程:
1)notify() 也要在同步方法或同步块(synchronized)中调用,该方法是用来唤醒在当前对象上等待的线程,对其发出 notify 通知,并使它们重新获取该对象的对象锁。
2)如果有多个线程等待,则有线程调度器随机挑选出一个呈wait状态的线程。(并没有"先来后到")
3)在 notify() 后,当前线程不会马上释放该对象锁,只有当 当前线程执行完 notify() 所在的同步代码块后,才会释放锁。若当前线程未完成同步代码块内的操作,其他线程无法获取该锁。
4)唤醒的线程数量与 notify() 调用次数相关,调用一次notify() 唤醒一个线程;调用 notifyAll() 唤醒所有等待线程。
1.4、练习题
有三个线程,分别只能打印 A,B 和 C,要求按顺序打印 ABC,打印10次
public class Demo26 {public static void main(String[] args) throws InterruptedException {Object locker1 = new Object();Object locker2 = new Object();Object locker3 = new Object();Thread t1 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (locker1) {locker1.wait();}System.out.print("A");synchronized (locker2) {locker2.notify();}}} catch (InterruptedException e) {throw new RuntimeException();}});Thread t2 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (locker2) {locker2.wait();}System.out.print("B");synchronized (locker3) {locker3.notify();}}} catch (InterruptedException e) {throw new RuntimeException();}});Thread t3 = new Thread(() -> {try {for (int i = 0; i < 10; i++) {synchronized (locker3) {locker3.wait();}System.out.println("C");synchronized (locker1) {locker1.notify();}}} catch (InterruptedException e) {throw new RuntimeException();}});t1.start();t2.start();t3.start();// 主线程中, 先通知一次 locker1, 让上述逻辑从 t1 开始执行Thread.sleep(1000); // 确保上述三个线程都执行到 wait, 再进行 notifysynchronized (locker1) {locker1.notify();}}
}
2、单例模式
单例模式是设计模式中一种非常典型的模式,也是比较简单的模式,还是校招中最常考的设计模式之一
设计模式:是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。
单例模式强制要求,某个类在某个程序中,只有唯一一个实例(不允许创建多个实例,不允许 new多次)
这一点在很多场景上都需要,比如 JDBC 中的 DataSource 实例就只需要一个(DataSource 是描述了数据库信息的类,创建多个实例没有意义)
在编程中,实例这个术语往往有多种含义,不单单指对象
比如:
一台服务器,也能称为是一个实例
一个服务器中运行的一个应用程序,也能称为是一个实例
2.1、饿汉模式
在类加载时创建实例(饿:尽早创建实例)
// 通过饿汉模式构造单例模式
class Singleton {private static Singleton instance = new Singleton();public static Singleton getInstance() {return instance;}private Singleton() {}
}public class Demo27 {public static void main(String[] args) {Singleton t1 = Singleton.getInstance();Singleton t2 = Singleton.getInstance();System.out.println(t1 == t2); // true// Singleton t3 = new Singleton(); // 编译错误}
}
分析:
1. 静态成员 instance 的初始化,是在类加载的阶段触发的(类加载往往就是在程序一启动就会触发)
2. 构造方法私有化是单例模式中的 “点睛之笔”,在类外进行 new 操作,都会编译失败
2.2、懒汉模式
类加载的时候不创建实例,第一次使用的时候才创建实例(延迟创建,甚至可能不创建了)
1. 单线程版
// 通过懒汉模式构造单例模式
class SingletonLazy {private static SingletonLazy instance = null;public static SingletonLazy getInstance() {if (instance == null) {instance = new SingletonLazy();}return instance;}private SingletonLazy() {}
}public class Demo28 {public static void main(String[] args) {SingletonLazy s1 = SingletonLazy.getInstance();SingletonLazy s2 = SingletonLazy.getInstance();System.out.println(s1 == s2); // true// SingletonLazy s3 = new SingletonLazy(); // 编译错误}
}
分析:
1. 懒汉模式下,创建实例的时机,是在第一次使用的时候,而不是在程序启动的时候
2. 上述 懒汉 / 饿汉 模式是存在缺陷的,比如可以通过反射的方式,来创建该类的示例。反射本身就属于 “非常规” 编程手段,在日常开发中不推荐使用反射
面试题:上述两个代码(饿汉,懒汉),是否是线程安全的? 如果不是,该怎么办?
等价与:这两个版本的 getlnstance 在多线程环境下调用,是否会出bug?
答:饿汉模式的实现是线程安全的,因为饿汉模式的 getlnstance 方法中,只有 ruturn 操作,是读操作,读操作不会出现线程安全问题。
懒汉模式的实现是线程不安全的,对其进行改进:
1. 在正确的位置加锁
2. 双重 if,确保执行效率
3. 加上 volatile,避免内存可见性和指令重排序
2. 多线程版
上面的懒汉模式的实现是线程不安全的
线程安全问题发生在首次创建实例时,如果在多个线程中同时调用 getlnstance 方法,就可能导致创建出多个实例。
虽然后创建的实例会覆盖上一个创建的实例,但这个对象,new 的过程中,可能要把100G的数据从硬盘加载到内存,本来程序启动时间是10分钟,由于上述的bug,加载两份,导致最终的时间远远超过 10分钟
class SingletonLazy {private static SingletonLazy instance = null;private static Object locker = new Object();public static static SingletonLazy getInstance() {synchronized (locker) {if (instance == null) {instance = new SingletonLazy();}}return instance;}// 或者对类对象 SingletonLazy.class 加锁public synchronized static SingletonLazy getInstance2() {if (instance == null) {instance = new SingletonLazy();}return instance;}private SingletonLazy() {}
}
引入加锁之后,后执行的线程就会在加锁的位置阻塞,直到前一个线程解锁。当后一个线程进入条件的时候,前一个线程已经修改完了,此时 Instance 不为 null,就不会进行后续的 new 操作,改善了线程安全问题
加锁之后又有了新的问题:
1. 当把实例创建好之后,后续再调用 getlnstance 都是直接执行 return。如果只进行 if 判定 + return,相当于只读操作了,读操作不涉及到线程安全问题。每次调用上述的方法,都会触发一次加锁操作,虽然不涉及线程安全问题了,但多线程情况下,这里的加锁会相互阻塞,影响程序的执行效率
2. instance 变量可能存在内存可见性问题,为了稳妥起见,可以给 lnstance 直接加一个 volatile,从根本上杜绝内存可见性问题
3. new 对象时,可能会触发指令重排序问题,给 lnstance 加 volatile 也能解决重排序问题
完成这个操作需要三个步骤:
1. 申请内存空间
2. 在空间上构造对象(初始化)
3. 内存空间的地址,赋值给引用变量
正常来说,这三个步骤是按照 1 2 3 的顺序来执行的,但是在指令重排序下,可能会变成 1 3 2 这样的顺序。
单线程环境下,123还是132其实无所谓,但在多线程环境下,132 可能会出现bug
^
例如:
t1 线程在 new 操作时,按132顺序先执行了13,然后调度到 t2 线程上执行。经过 3 后,Instance 已经不为 null 了,此时 t2 线程第一个 if 的判断为否,直接返回一个 “未初始化” 的对象来进行操作了,这显然是错误的
出现这样的问题不仅仅是指令重排序引起的,也和双重 if 有关,如果没有双重 if ,t2 线程会被阻塞,等待 t1 释放锁后才能继续执行,就不会出现上述情况了。
懒汉模式-多线程版(改进)
1. 在正确的位置加锁
2. 双重 if,确保执行效率
3. 加上 volatile,避免内存可见性和指令重排序
多线程中,两次判定可能结果不同,可能存在其他线程把 if 中的 Instance 变量给修改了
1. 第一个 if 判定是否需要加锁
2. 第二个 if 判定是否需要 new 对象