前提回顾
在开始讲解单例设计模式之前,先来复习先前的知识。
上一章一共讲了以下内容:
1. 给了一个线程不安全的例子(两个线程各自增 5w 次,结果为一个小于 10w 的随机数)
2. 线程抢占式执行,执行到任何一行都可能跳出去执行其他线程的代码。
3. 多个线程同时修改一个变量
4. 修改操作不是原子的
5. 内存可见性
6. 指令重排序
对此的解决方式就是:加锁,也就是将其写在 synchronized 代码块内部。
某个线程中的某个对象调用了 synchronized 代码块 中的代码,就会照成其他线程的 阻塞。
wait 和 notify
已经写到 第五章了,我们从第二章开始就一直说线程调度是无序的,但是总有情况是想要我们写一个有序的代码,当然我们之前学过一个 方法: sleep() ,是的,这个方法可以让线程睡眠,想要一个有序的程序,那么就只能一直睡了,这就相当于写一个 单线程了,效率并没有那么快。
🌰(栗子)
对于上述情况,我们需要有个人通知他,老铁 ATM 没钱,等到 ATM 中有钱了再来通知他一次,让 老铁再去 抢占。
这里就换成 方法就是 wait 和 notify 。
当第一次发现 ATM 没钱时就 wait 来告诉他, 这里没钱了,让 滑稽 1 号休息一会,等到 notify 通知他的时候,滑稽 1 号就醒过来了,继续去 ATM 机取钱。
涉及到的方法:
wait() / wait(long timeout): 让当前线程进入等待状态.(带参的方法等会再说)
notify() / notifyAll(): 唤醒在当前对象上等待的线程. (notifyAll等会再说)
注意:
wait 和 notify 都是Object方法,只要你是类对象都可以调用 wait 和 notify
但是 内置类型(基本数据类型)不可以。
我们来写写看这个代码:
注意,任何可能照成线程阻塞的都需要抛 异常:
InterruptedException
我们来运行一下:
我们来翻译一下报的异常:非法的 监视(这里指的是synchroniezd 监视器锁) 状态异常。
这个异常主要是告诉我们 需要搭配 synchroniezd 来使用。
这里的 wait 主要做三件事:
1. 解锁
2. 阻塞等待
3. 当收到通知时,就唤醒,同时尝试重新获得锁。
注意加锁的对象要和 wait 的对象是同一个。
并且,notify 也要放在 synchroniezd 中使用。
来看看代码:
运行一下:
达到我们想要的效果了。
注意了 notify 必须要在 wait 之后 执行,才有效;如果反了,不会错,但是没有效果。
此时代码 不会被唤醒 ,不会产生其他异常。
上述代码中,虽然是 t1 先执行的,但是我们可以通过 方法来控制代码执行顺序,这就是 wait 和 notify 的功劳。
我们上面还提到了 wait 带参的方法。
wait 带参方法和 notifyAll 方法
这个参数就和 sleep 方法的参数一样,给定一个时间,在时间范围内 wait 被唤醒了就和 wait 方法一样,但是时间已经到了,wait 方法还是没有被唤醒,那么他就自己醒了。
但是无参的就是死等,等不到就一直等,倔强。
这里就相当于设了一个闹钟,时间到了被闹钟吵醒,时间没到自己就醒了。
这里就相当于设了一个闹钟,时间到了被闹钟吵醒,时间没到自己就醒了。
我们使用了wait 线程就进入到了 WAITING 状态;我们wait 的初心就是让线程进入阻塞状态。
上面还有一个 notifyAll 方法,看名字就知道这个是用于唤醒所有的线程的,这个用的比较少,我们了解一下即可。
wait 和 sleep 的比较
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间
当然为了面试的目的,我们还是总结下:
1. wait 需要搭配 synchronized 使用. sleep 不需要.
2. wait 是 Object 的方法 sleep 是 Thread 的静态方法
二者初心不同:
sleep 单纯的想让线程睡一会,wait 解决线程之间的顺序控制。
带参的 wait 和 sleep 都能被提前唤醒。
单例设计模式
首先介绍一下什么叫做设计模式:
设计模式就相当于 软件开发中的棋谱,通过前辈对一些常见的场景,总结出的代码编写套路。
单例的意思就是说只能创建一个对象,不允许创建多个对象。
我们从语法上如何做出单例模式的实现。
我们要写的话,可以写出 5 ~ 6 种方法;但是我们只讲两种(在校招种一般只考这两种):饿汉式、懒汉式。
饿汉模式(急迫)
懒汉模式(从容)
举个很简单的例子,从硬盘读取内容到显示器上,有两种方式,一种是要等一会,把所有的数据全都读到显示器上,还有一种是立即显示,一点一点读,前者是饿汉模式,后者就是懒汉模式,两者相比,当然是懒汉模式更高效了
饿汉模式
从上述例子看来,我们需要从一开始就 new 出一个对象,并且因为是单例,后面不允许再 new 出新对象。
那么我们可以在类的内部 将 构造器私有化,同时在内部实例化一个对象 ,指定为唯一的对象。
看代码:
class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }
这里的 instance 被 static 修饰,就是类的属性,在 JVM 中每个类对象只有唯一一份,那么这个属性也是唯一一份。
并且我们将构造方法 私有化,外部无法调用 这个构造方法,再提供一个 获取类对象方法。
只能通过 get 方法去获取 对象的引用。
如上图。
这样 单例饿汉模式就写好了。
我们每次都只是读取这个实例化对象 ,只读不会对照成线程安全问题。
懒汉模式
非必要,不创建;能不 new ,就不 new 。
我们来实现一个代码:
class SingletonLazy { private static SingletonLazy instance = null; private SingletonLazy() {} public static SingletonLazy getInstance() { if (instance == null) { instance = new SingletonLazy(); } return instance; } }
我们先将唯一对象 设为空,只当我们需要调用 获取到实例对象引用时才 给他 new 出新对象;否则一直为空。
我们在单线程下不必考虑线程安全问题,但是在多线程下必须要考虑到线程安全问题。
这里设计到了 " 写 " 的操作,那么必然涉及到了线程安全问题。
在 getInstance 方法中 ,从 if 到 return 这一整段不是原子的,可能会产生如下情况
看似只是多 new 了几次,如果我们 每个对象占有 100G 的内存呢,多 new 几次,后果不可估量!!!!
所以我们必须得对这段代码进行修改。
我们可以对其进行加锁操作。
为了保证这个方法是原子的,可以直接选择对这个方法加锁:
但是这样一来,会降低整个访问的速度,而且每次都要判断。那么有没有更好的方式来实现呢?
双重检查加锁
可以使用"双重检查加锁"的方式来实现,就可以既实现线程安全,又能够使性能不受到很大的影响。那么什么是"双重检查加锁"机制呢?
所谓双重检查加锁机制,指的是:并不是每次进入getInstance方法都需要同步,而是先不同步,进入方法过后,先检查实例是否存在,如果不存在才进入下面的同步块,这是第一重检查。进入同步块过后,再次检查实例是否存在,如果不存在,就在同步的情况下创建一个实例,这是第二重检查。这样一来,就只需要同步一次了,从而减少了多次在同步情况下进行判断所浪费的时间。
双重检查加锁机制的实现会使用一个关键字volatile,它的意思是:被volatile修饰的变量的值,将不会被本地线程缓存,所有对该变量的读写都是直接操作共享内存,从而确保多个线程能正确的处理该变量。
class SingletonLazy3 { private static volatile SingletonLazy3 instance = null; private SingletonLazy3() {} public static SingletonLazy3 getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new SingletonLazy3(); } } } return instance; } }
总结(保证懒汉式线程安全的方法):
1.加锁,保证if和new是原子的
2.双重if判定,防止不必要的加锁
3.加volatile关键字,禁止指令重排序,保证后面的线程拿到的是完整的对象
饿汉模式:是天然线程安全的,涉及到读操作
懒汉模式:不安全,需要操作把它边安全
单例设计模式就到这里,下一章继续多线程的其他案例。