目录
一、wait和notify、notifyAll
由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知,但在实际开发过程中,我们希望合理地协调多个线程之间的执行先后顺序。这时候我们会用到几个方法:
方法 | 作用 |
wait()/wait(long timeOut)/wait(long timeOut,int nanos) | 让当前线程进入等待状态 |
notify() | 通知持有该对象锁的所有线程中的的随意一个线程被唤醒 |
notifyAll() | 通知持有该对象锁的所有线程被同时唤醒 |
wait\notify\notifyAll操作都是属于Object类提供的方法,而所有对象都继承了Object类,即所有的对象都具有该方法。
1、wait方法
wait方法在执行的时候要做几件事情,即释放当前的锁,让线程进入阻塞,最后当线程被唤醒的时候重新获取到锁。
wait要搭配synchronized来使用,脱离synchronized使用wait会直接抛出异常:
public class Demo1 {
public static void main(String[] args) throws InterruptedException{
Object lock = new Object();
lock.wait();
}
}
这是个什么东西?
我们之前提过对象头,对象头里其实是有一个重量级锁的指针,而重量级锁的指针指向的就是monitor监视器对象,这个monitor是什么呢?Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制:
public class Object {
...
private transient int shadow$_monitor_;
public final native void notify();
public final native void notifyAll();
public final native void wait() throws InterruptedException;
public final void wait(long millis) throws InterruptedException {
wait(millis, 0);
}
public final native void wait(long millis, int nanos) throws InterruptedException;
...
}
monitor的机制可以用下面的图片来概括:
我们来分析一下,Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。
当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。
再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set队列中被唤醒的线程和entry-set队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。
当然,一个线程在等待队列中被唤醒后并不一定直接获取monitor,而是加入内卷大军一起竞争,获取到monitor后,它会去读取它自己保存的PC计数器中的地址,从它调用wait方法的地方开始执行。
这样的话,一切关于这个问题的解释就通畅了。我们看这个图,wait其实就是进入等待队列,在这之前,你必须到中间这个房子里面去,也就是获取monitor,所以同步代码块保证了这点,一切都串联起来了。
wait结束等待的条件有几种,第一种就是其他线程调用该对象的notify方法告诉该对象别等啦;第二种就是wait等待时间超时;第三种就是其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException异常。
我们观察一下wait方法的使用:
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待中");
object.wait();
System.out.println("等待结束");
}
}
}
如果没有什么东西刺激一下这个wait,就会一直等待。
2、notify方法
notify方法是唤醒等待的线程:
- 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
- 如果有多个线程等待,则有线程调度器随机挑选出一个呈wait状态的线程。(并没有"先来后到")
- 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
我们查看以下代码:
public class Demo1 {
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
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 t2 = new Thread(new NotifyTask(locker));
t1.start();
Thread.sleep(1000);
t2.start();
}
}
代码的逻辑很简单,t1wait等待t2的通知,有兴趣的可以自己去试一下。
3、notifyAll()方法
notify方法只是唤醒某一个等待线程,而使用notifyAll方法可以一次唤醒所有的等待线程。
我们在上面的代码基础上做一点修改:
public class Demo1 {
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class NotifyTask implements Runnable {
private Object locker;
public NotifyTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
System.out.println("notifyAll 开始");
locker.notifyAll();
System.out.println("notifyAll 结束");
}
}
}
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();
}
}
我们可以看到,notifyAll能同时唤醒3个wait中的线程。注意,虽然是同时唤醒3个线程,但是这3个线程需要竞争锁。所以并不是同时执行,而仍然是有先有后的执行。而notifyAll后锁还在t2上,所以我们会看到notifyAll上下打印语句连着的。
notify只唤醒等待队列中的一个线程。其他线程还是乖乖等着:
而notifyAll一下全都唤醒,需要这些线程重新竞争锁:
4、wait与sleep对比
共同点
- 他们都是在多线程的环境下,都可以在程序的调用处阻塞指定的毫秒数,并返回。
- wait()和sleep()都可以通过interrupt()方法打断线程的暂停状态,从而使线程立刻抛出InterruptedException。
- 如果线程A希望立即结束线程B,则可以对线程B对应的Thread实例调用interrupt方法。如果此刻线程B正在wait/sleep/join,则线程B会立刻抛出InterruptedException,在catch() {} 中直接return即可安全地结束线程。
- 需要注意的是,InterruptedException是线程自己从内部抛出的,并不是interrupt()方法抛出的。对某一线程调用interrupt()时,如果该线程正在执行普通的代码,那么该线程根本就不会抛出InterruptedException。但是,一旦该线程进入到wait()、sleep()、join()后,就会立刻抛出InterruptedException。
不同点
- 每个对象都有一个锁来控制同步访问。Synchronized关键字可以和对象的锁交互,来实现线程的同步。sleep方法没有释放锁,而wait方法有释放锁,使得其他线程可以使用同步控制块或者方法。
- wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用。
- sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常。
- sleep是线程类(Thread)的方法,导致此线程暂停执行指定时间,将执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用sleep不会释放对象锁。
- wait是Object类的方法,对此对象调用wait方法导致本线程放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象发出notify方法(或notifyAll())后本线程才进入对象锁定池准备获得对象锁进入运行状态。
5、线程饿死
使用notify和wait可以有效解决线程饿死问题。饥饿是指系统不能保证某个进程的等待时间上界,从而使该进程长时间等待,当等待时间给进程推进和响应带来明显影响时,称发生了进程饥饿。当饥饿到一定程度的进程所赋予的任务即使完成也不再具有实际意义时称该进程被饿死。
当我们在多个线程中加入锁之后,由于这些线程是抢占式并发执行的,这些线程就会去竞争这把锁,当某一个线程竞争到锁之后,如果由于缺乏某些条件导致CPU没有执行该线程,然后该线程释放锁之后还会继续去参与竞争。如果极端情况下一直都是该线程抢到锁,其他线程一直处于阻塞状态,就会线程饿死。
当第一次抢到锁的线程发现条件不成熟导致CPU无法执行该线程时,可以通过wait()方法释放锁然后进入阻塞队列,等待通知,等待期间不会再去参与竞争,也就不会去抢夺CPU资源,这样就不会出现“线程饿死”现象了。处于等待状态的线程直到其他线程中调用notify()方法告知其条件成熟之后才会继续参与锁的竞争。
二、单例模式
单例模式是设计模式之一,那什么是设计模式呢?
设计模式好比象棋中的"棋谱"。红方当头炮,黑方马来跳。针对红方的一些走法,黑方应招的时候有一些固定的套路。按照套路来走局势就不会吃亏。
软件开发中也有很多常见的"问题场景"。针对这些问题场景,大佬们总结出了一些固定的套路。按照这个套路来实现代码,也不会吃亏。
其中,单例模式能保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例。
而单例模式的实现,分为“饿汉”与“懒汉”两种。
1、饿汉模式
类加载的同时创建实例:
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
2、懒汉模式
类加载的时候不创建实例,第一次使用的时候才创建实例:
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
但是上面的懒汉模式的实现是线程不安全的,线程安全问题发生在首次创建实例时。如果在多个线程中同时调用getInstance方法,就可能导致创建出多个实例。饿汉模式只是读取,不会有啥问题。但是懒汉模式既有读取又有修改如果有多个线程同时修改同一个变量,此时就会产生线程安全问题。同一时间,好几个线程同时进行if判断,发现可以创建,就都创建,这时候就不止一个对象了,违背了单例的要求。
我们可以加上synchronized改善这里的线程安全问题:
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
当然我们可以做出进一步改动,使用双重if判定,降低锁竞争的频率;同时给instance加上volatile:
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
如何理解双重if判定和volatile呢?
加锁、解锁是一件开销比较高的事情。而懒汉模式的线程不安全只是发生在首次创建实例的时候。因此后续使用的时候,不必再进行加锁了。
外层的if就是判定下看当前是否已经把instance实例创建出来了。同时为了避免"内存可见性"导致读取的instance出现偏差,于是补充上volatile。
当多线程首次调用getInstance,大家可能都发现instance为null,于是又继续往下执行来竞争锁,其中竞争成功的线程,再完成创建实例的操作。当这个实例创建完了之后,其他竞争到锁的线程就被里层if挡住了,也就不会继续创建其他实例。
我们通过吊图再来理解下:
1)有三个线程,开始执行getInstance,通过外层的if(instance == null)知道了实例还没有创建的消息。于是开始竞争同一把锁。
2)其中线程1率先获取到锁,此时线程1通过里层的if(instance == null)进一步确认实例是否已经创建。如果没创建,就把这个实例创建出来。
3)当线程1释放锁之后,线程2和线程3也拿到锁,也通过里层的if(instance == null)来确认实例是否已经创建,发现实例已经创建出来了,就不再创建了。
4)后续的线程,不必加锁,直接就通过外层if(instance == null)就知道实例已经创建了,从而不再尝试获取锁了。降低了开销。