目录
[1.设计模式 - 单例模式](#1.设计模式 - 单例模式)
[3.1 初始版-非线程安全](#3.1 初始版-非线程安全)
[3.2 synchronized 修饰](#3.2 synchronized 修饰)
[3.3 双重 if](#3.3 双重 if)
[3.4 volatile 修饰](#3.4 volatile 修饰)
经过几期的多线程学习,已经对多线程有了一定的了解。从这期开始,将会对多线程的使用做一个更深入的探讨。
1.设计模式 - 单例模式
设计模式 是一个抽象的概念,是指软件设计中针对常见问题的可重用解决方案。
大家都一定听说过高斯计算从 1 加到 100 的故事,有一天,高斯 的数学老师布置了一道从1加到100 的习题,想让学生们算一整节课,结果刚说完题目高斯就报出了答案。 原来,他发现数列 两头可以一 一配对**:1+100** ,2+99 ......每一对的和都是101,共有50对,所以总和是5050。后面这个问题就逐渐的演变为我们今天熟悉的**等差数列。**我们对等差数列并不陌生,但是如果前人发现这个规律,可能我们一时间也不会想起来这种规律,属于是站在巨人的肩膀上学习了。
设计模式就是这样的场景,在计算机中,一些大佬们会针对一些常用典型的场景,为了避免重复造轮子,就设计出了对应的典型的解决方案,这就是设计模式,相当于公式化,后人在使用时直接"套公式",就不会让这种问题的解决方案差到哪里去。单例模式就属于典型的设计模式之一。
本期主要讲的就是单例模式。单例模式是运行程序中的某个类只有一个实例,也就是只会 new 一次对象。比如一个学校只有一位正校长,一位书记,但是副校长可以有很多人,人体也只有一颗心脏。单例模式可以确保实例唯一、只会创建一次对象节省资源等。
单例模式最常见的有"饿汉模式"和"懒汉模式",下面将逐一介绍这两种模式。
2.饿汉模式
先从字面来理解,我们什么时候会形容一个人是"饿汉"?肯定是看到一个人在吃饭时狼吞虎咽,恨不得几口饭当一口饭吃的感觉,如果是这种情况,说明这个人可能非常"饥饿"。
在计算机单例模式中,如果一个类在加载时就创建了实例,就叫"饿汉模式"。这种模式因其"急切"初始化的特性而命名为"饿汉",因为只要程序一运行就创建了,都不知道会不会使用它。
怎么实现"饿汉模式"?首先要抓住"急切"的特性,既然是类在加载时就创了实例,那就说明不论是否需要这个类的实例,只要该类加载就会创建都要创建,并且在外部不能再对其实例化。先来看代码:
java
class Singleton{
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
private Singleton() {
}
}
public class Single1 {
public static void main(String[] args) {
}
}
代码解读 :主要看 class Singleton这个类
- 首先定义这个类的静态成员 instance ,静态变量初始化 在类初始化阶段完成,早于任何线程访问,类加载往往是程序一启动就触发,也就是说程序一启动,这个实例就被创建了。
- 提供 getInstance 方法 ,当线程想要使用这个实例时,可以通过这个方法获取获取实例 ,如果多线程一起调用,它们得到的对象是一样的,因为定义 instance 时就已经实例化,,不会出现竞争的情况,这里也说明了"饿汉模式"是天然线程安全的
- 构造方法 ,这是单例模式的点睛之笔,在以往学习任何一个类的构造方法时,查看源码都可以看见这些构造方法是被 public 修饰,而我们设计单例模式时,构造方法必须用 private 修饰,这就使得这个类将不再被实例化
测试:

测试1中,定义两个 Singleton 变量,用 == 判断它们是否相等,根据结果可以看到返回的是 true,说明它们得到的对象是相等的,当我们查看哈希值 时,哈希值是相等的,这就说明了这两个变量实际指向的是同一个对象。
测试2中,我们尝试实例化 Singleton 对象,但是编译失败,这就是 private 修饰构造方法的点睛之笔。
当然,这个代码实现实例化的对象是无参的,如果想要传参数,那么在一开始定义静态成员 instance 实例化的时候就可以传参数进去,这里不再演示。
如果还记得反射这一知识点,可能会有的小伙伴认为这个模式可以被反射攻击。确实会的,比如以以下是通过反射的方式尝试进行修改,导致结果是 false,并且哈希值也不一样:

怎么防御反射攻击?在构造方法里抛一个异常:
java
class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {
// 防止反射攻击
if (instance != null) {
throw new RuntimeException("单例模式禁止通过反射创建实例");
}
}
public static Singleton getInstance() {
return instance;
}
}
因为"饿汉模式"是类加载时就已经创建实例,而反射攻击时在这个阶段之后:
java
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton illegalInstance = constructor.newInstance(); // 这里会抛出异常

这里就相当于有一个时间差,即类加载初始化在反射调用之前,可以确保反射调用时 instance 就已经存在,并且 instance 被 final 修饰,可以确保实例引用时不会被反射修改。
这只是简单的防御措施,针对高强度攻击依然无法防御,这里不过多深究,本期主要是了解单例模式的设计。
3.懒汉模式
3.1 初始版-非线程安全
有了饿汉模式的基础,其实懒汉就不难理解了。"懒汉"则说明它非常懒,喜欢"摆烂",只要被催促的时候才会行动起来。
在计算机单例模式中,一个类只有在第一次请求时才会被创建,叫做懒汉模式。
我们先仿照"饿汉模式"的代码,把"懒汉模式"的代码整体框架写出来(这个一个初始版本):
java
class SingletonLazy {
private static SingletonLazy instance;
private SingletonLazy() {}
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
}
public class Single2 {
public static void main(String[] args) {
}
}
代码解读:
- 首先定义这个类的静态成员 instance ,因为是只有被需要的时候才会被创建实例 ,所以这里不用 final 修饰 ,同时也不进行初始化,默认为 null。
- 提供 getInstance 方法 ,当线程想要使用这个实例时,可以提供这个获取获取实例,由于定义的时候并没有进行初始化,默认为 null,所以在这里调用这个方法的时候,要先进行判断是否而空(可能多次调用,单例模式只有一个实例,不能多次创建),是空就实例化,后续再调用时就不会再进行实例化。
- 构造方法 ,这是单例模式的点睛之笔,在以往学习任何一个类的构造方法时,查看源码都可以看见这些构造方法是被 public 修饰,而我们设计单例模式时,构造方法必须用 private 修饰,这就使得这个类将不再被实例化
3.2 synchronized 修饰
与"饿汉模式"不同的是,"懒汉模式"的实例化是在 getInstance 方法里,这里有 new 和 == ,涉及到修改操作,是非原子的,而单例模式下实例只能有一份,所以这种方式存在线程安全问题。如何解决?
对于非原子的线程安全问题,可以进行加锁 synchronized ,synchronized 一是可以放在方法中直接修饰 getInstance ,二是可以放在 if 判断条件的外面 (如果把 synchronized 放在 if 判断里,是起不到作用的,因为涉及到的非原子问题主要是 == 导致)。这里以第二种方式实现(为什么不用第一种,后文会介绍)
java
class SingletonLazy {
private static SingletonLazy instance;
private static Object locker = new Object();//锁
private SingletonLazy() {}
public static SingletonLazy getInstance() {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
}
}
public class Single2 {
public static void main(String[] args) {
}
}
3.3 双重 if
我们知道加锁是为了让一个线程阻塞等待持有锁的线程先执行。但是,上面展示的代码,如果每次希望调用一次 getInstance 方法, 是不是意味着每次都要有加锁和释放锁的时间等待,对于计算来讲,这个时间是意味着很久的,在多线程下,这种加锁会造成相互堵塞,影响了程序的运行效率。
怎么解决这个问题?按需加锁 。按需加锁就是有需要的时候再加锁,涉及线程安全问题时再加锁,不涉及就不再加锁,而什么情况下涉及线程安全问题?就是在 instance 为 null 的时候,这个判断 == 以及在 new 对象的时候,会涉及到线程安全问题。当 instance 不为 null 的时候,是不是就意味着不需要加锁了?所以,这个时候我们 synchronized 的外层再用一个 if 条件判断 instance 是不是为 null,如果是,就加锁,如果不是,就不加锁,这就是双重 if。
java
class SingletonLazy {
private static SingletonLazy instance;
private static Object locker = new Object();//锁
private SingletonLazy() {}
public static SingletonLazy getInstance() {
if (instance == null) {
//第一次if
synchronized (locker) {
if (instance == null) {
//第二次if
instance = new SingletonLazy();
}
}
}
return instance;
}
}
这两个 if 的作用不同:第一个 if 是判断 instance 是否为空,如果为空才加锁,并创建实例,如果不为空,那么整个第一个 if 的代码块都不再执行,减少了加锁和释放锁的时间,第二个 if 也是判断 instance 是否为空,但这里的目的是创建实例,在锁内。
3.4 volatile 修饰
现在,上面的版本已经解决了一部分的线程安全问题。既然说一部分,那么就说明线程安全还没有达到预期(这里只考虑单例模式下"懒汉模式"的设计,不再考虑反射或者其它情况的攻击)。
我们说造成线程安全问题的原因主要有五条:
- 操作系统对线程的调度是随机的、抢占式执行的(根本原因)
- 多个线程同时修改同一个变量
- 修改变量的操作不是原子的
- 内存可见性问题
- 指令重排序问题
第1条是根本原因,我们无法改变,第2条在多线程情况下我们有时候就希望改变同一个变量,也无法改变,现在第3条已经被我们解决了,第4条和第5条用我们的角度看待解决方式是一样的。接下来分析一下会不会出现第4条和第5条的线程安全问题情况。
如果有两个线程,线程1在读取 instance 时,线程2会不会已经对它进行修改而线程1不知道呢?这肯定会存在的,因为线程1在读取时,可能判断 instance == null 条件是成立的,这会导致线程2的修改无法及时将修改的共享变量返回主内存,导致线程1再创建一个实例,这是会发生错误的,所以这里的"内存可见性问题"取决于编译器的优化,为了稳妥起见,我们可以用 volatile 修饰。
但不是说用 volatile 修饰 instance 后,就不考虑指令重排序的问题,因为这两个问题的解决方案是一致的。接下来分析一下指令重排序的情况:
我们直到指令重排序也是编译器优化的一种提现方式,会在保证逻辑不变的前提下,调整代码的执行顺序以达到提升性能的效果。但是在实例化对象的改成中,会涉及到三个步骤:1)申请内存空间;2)在这个内存空间上构造对象(初始化);3)将引用赋值给 instance (这个时候 instance 不再是 null)。但在编译器的优化下,如果发生指令重排序,就有可能把顺序调整成①③②,这在单线程条件下不需要担心,但在多线程情况下,如果线程发生顺序调整,就会出现bug,错误时间点如下:

所以,在这种情况下,就需要用 volatile 修饰 instance,而不是简单的"内存可见"问题,这里的 volatile 主要解决的问题就是"指令重排序"问题。
java
class SingletonLazy {
private volatile static SingletonLazy instance;
private static Object locker = new Object();//锁
private SingletonLazy() {}
public static SingletonLazy getInstance() {
if (instance == null) {
//第一次if
synchronized (locker) {
if (instance == null) {
//第二次if
instance = new SingletonLazy();
}
}
}
return instance;
}
}
这样的版本才是真正完成了"懒汉模式"的线程安全问题。关于测试,可以和"饿汉模式"类似,这里也不再进行演示。
4.小结
设计模式是指软件设计中针对常见问题的可重用解决方案。单例模式就属于典型的设计模式之一。
单例模式最常见的有"饿汉模式"和"懒汉模式"。
如果一个类在加载时就创建了实例,就叫"饿汉模式";一个类只有在第一次请求时才会被创建,叫做"懒汉模式"。
"懒汉模式"需要注意线程安全问题和双重 if 的含义。
"饿汉模式"和"懒汉模式"各有优点,比如"饿汉模式"最简单,在类加载时就创建,可以认为天然线程安全,但这种可能造成一定的资源浪费,而"懒汉模式:比较复杂,需要考虑多种情况下才能避免线程安全问题,虽然有双重 if ,但只有在被使用时才会被创建实例,可以认为比"饿汉模式"节省一定的资源。
单例模式实现的方式有很多,最常见的就是本期介绍的"饿汉模式"和懒汉"模式"。下期将介绍设计模式的第二种模式:阻塞队列。我们知道队列是一种先进先出的数据结构,那阻塞队列是什么?队列为什么会发生阻塞?欲知后事如何,且听下回分解!