封面来源:单例设计模式 (refactoringguru.cn),如有侵权,请联系删除。
1. 什么是单例模式
百度百科是这么说的:
单例模式,属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例(根据需要,也有可能一个线程中属于单例,如:仅线程上下文内使用同一个实例)。
菜鸟教程关于单例模式的介绍如下:
意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。
主要解决: 一个全局使用的类频繁地创建与销毁。
何时使用: 当您想控制实例数目,节省系统资源的时候。
如何解决: 判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
关键代码: 构造函数是私有的。
简单来说:单例模式保证一个类仅有一个实例,并提供一个访问它的全局访问点,其关键是构造函数私有的。
2. 饿汉式与懒汉式
2.1 饿汉式
饿汉式的特点:类加载的时候就实例化,并且创建单例对象。
饿汉式是最简单的单例模式,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public class Hungry { private Hungry() {
}
private final static Hungry HUNGRY = new Hungry();
public static Hungry getInstance(){ return HUNGRY; } }
|
但是饿汉式也有问题,最主要一点就是可能会浪费资源。比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class Hungry {
private final byte[] data1 = new byte[1024 * 1024]; private final byte[] data2 = new byte[1024 * 1024]; private final byte[] data3 = new byte[1024 * 1024]; private final byte[] data4 = new byte[1024 * 1024];
private Hungry() {
}
private final static Hungry HUNGRY = new Hungry();
public static Hungry getInstance() { return HUNGRY; } }
|
上述代码一运行,byte 数组就会被初始化,并放入内存中。但如果我一直没有使用 getInstance()
方法,一直不用 Hungry
类,这不就浪费内存了吗?
有没有一种方法让类加载时不进行实例化,只有用的时候才实例化呢?
2.2 懒汉式
懒汉式的特点:类加载时没有生成单例,只有当第一次调用 getlnstance()
方法时才去创建这个单例。
初始代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
public class LazyMan { private LazyMan() {
}
private static LazyMan lazyMan;
public static LazyMan getInstance() { if (lazyMan == null){ lazyMan = new LazyMan(); } return lazyMan; } }
|
但是这样的懒汉式有问题,在并发多线程环境下并不是单例的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class LazyMan { private LazyMan() { System.out.println(Thread.currentThread().getName() + " ok"); }
private static LazyMan lazyMan;
public static LazyMan getInstance() { if (lazyMan == null){ lazyMan = new LazyMan(); } return lazyMan; }
public static void main(String[] args) { for (int i = 0; i < 20; i++) { new Thread(() -> { LazyMan.getInstance(); }).start(); } } }
|
运行上述代码后,控制台可能会出现:
可以看到出现了两个实例,这显然就不是单例的了。
为了避免这种情况,我们可以对代码加锁,修改 getInstance()
方法:
1 2 3 4 5 6 7 8 9 10 11
| public static LazyMan getInstance() { if (lazyMan == null) { synchronized (LazyMan.class) { if (lazyMan == null){ lazyMan = new LazyMan(); } } } return lazyMan; }
|
这就是双重锁定模式(DCL,Double Check Lock)。
早期 JVM 中因为同步的开销巨大,为了降低实现单例模式中同步带来的开销,人们想出了很多技巧,DCL 便是其中一种。
但是 DCL 懒汉式还是有问题,因为:
1
| lazyMan = new LazyMan();
|
不是一个原子性操作。它会进行三个步骤:
1、分配对象内存空间
2、执行构造方法初始化对象
3、把对象指向这个空间,此时 lazyMan != null
由于指令重排,本来是按 123 顺序的执行可能会变成 132。假设这时有一个线程 A 运行了 lazyMan = new LazyMan();
,按照 132 的步骤进行执行,又恰好在第三步执行完,第二步还没执行时线程 B 来了,由于这时候 lazyMan != null
就会直接返回 lazyMan
。由于线程 A 没有执行第二步,lazyMan 对象还未初始化,内部的属性极有可能都是 null
,将增加出现空指针的概率。
同时需要注意:synchronized
虽然保证了线程的原子性(即 synchronized
块中的语句要么全部执行,要么一条也不执行),但单条语句编译后形成的指令并不是一个原子操作(即可能该条语句的部分指令未得到执行,就被切换到另一个线程了)。
因此,需要禁止指令重排,即使用 volatile
修饰的变量。对此,优化后的代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| public class LazyMan { private LazyMan() { System.out.println(Thread.currentThread().getName() + " ok"); }
private volatile static LazyMan lazyMan;
public static LazyMan getInstance() { if (lazyMan == null) { synchronized (LazyMan.class) { if (lazyMan == null){ lazyMan = new LazyMan(); } } } return lazyMan; }
public static void main(String[] args) { for (int i = 0; i < 20; i++) { new Thread(() -> { LazyMan.getInstance(); }).start(); } } }
|
两次判断 null
的作用
第一次判断 null
的作用:减少进入同步代码块的次数,提高效率。去掉这次判断并不会影响单例对象的创建,但每次获取对象都需要进入同步代码块中,这会降低代码执行效率。
第二次判断 null
的作用:控制对象实例是单例的。假设线程 A、B 同时执行到同步代码块前,线程 A 先抢到锁,完成对象的创建,然后线程 B 进入同步代码块。如果没有这次 null
值判断,线程 B 又会将一个新的实例赋值给成员变量,这就违背了单例的原则。
3. 使用静态嵌套类实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
public class Holder { private Holder() {
}
public static Holder getInstance() { return InnerClass.HOLDER; }
public static class InnerClass { private static final Holder HOLDER = new Holder(); } }
|
使用这种方式也可以保证创建的对象是懒汉单例的,而且还是线程安全的。
静态嵌套类的优点是:外部类加载时并不需要立即加载静态嵌套类,静态嵌套类不被加载则不去初始化 HOLDER
,故而不占内存。即当 Holder
第一次被加载时,并不需要去加载 InnerClass
,只有当 getInstance()
方法第一次被调用时,才会去初始化 HOLDER
,第一次调用 getInstance()
方法会导致虚拟机加载 InnerClass
类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。
至于是怎么保证线程安全的,可以参考这篇文章:深入理解单例模式:静态内部类单例原理
4. 强大的反射
反射那是一个相当流弊的东西,它可以无视 private
关键字,也就是说它会破坏封装性。说远了,使用反射就将破坏我们编写的单例模式。
修改懒汉式最后给出的示例代码,对 main()
方法进行修改,利用反射创建两个 LazyMan
对象:
1 2 3 4 5 6 7 8 9 10 11
| public static void main(String[] args) throws Exception { LazyMan instance = LazyMan.getInstance(); Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null); declaredConstructor.setAccessible(true);
LazyMan lazyMan = declaredConstructor.newInstance();
System.out.println(instance); System.out.println(lazyMan); System.out.println(instance == lazyMan); }
|
运行截图如下:
哦豁,完犊子。创建的两个对象不是同一个,单例模式失效!
道高一尺,魔高一丈,我们可以在构造方法中加锁并判断,判断成功就抛出异常。修改私有的构造方法:
1 2 3 4 5 6 7 8
| private LazyMan() { synchronized (LazyMan.class) { if (lazyMan != null) { throw new RuntimeException("不要试图使用反射破坏单例"); } } System.out.println(Thread.currentThread().getName() + " ok"); }
|
然后再运行:
芜湖,✈️
别得意,看我修改一下 main()
方法中的测试代码:
1 2 3 4 5 6 7 8 9 10 11 12
| public static void main(String[] args) throws Exception {
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null); declaredConstructor.setAccessible(true);
LazyMan instance = declaredConstructor.newInstance(); LazyMan lazyMan = declaredConstructor.newInstance();
System.out.println(instance); System.out.println(lazyMan); System.out.println(instance == lazyMan); }
|
再次运行:
玩完儿咯,还是创建出两个对象。
小事情,添加一个标志位进行判断即可,再次修改私有的构造方法:
1 2 3 4 5 6 7 8 9 10
| private LazyMan() { synchronized (LazyMan.class) { if (!flag) { flag = true; }else { throw new RuntimeException("不要试图使用反射破坏单例"); } } System.out.println(Thread.currentThread().getName() + " ok"); }
|
再次运行:
诶嘿,成功!💪
不就是引入标志位?照样用反射解决它!修改一下 main()
方法中的测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public static void main(String[] args) throws Exception {
Field flag = LazyMan.class.getDeclaredField("flag"); flag.setAccessible(true);
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null); declaredConstructor.setAccessible(true);
LazyMan instance = declaredConstructor.newInstance(); flag.set(instance, false);
LazyMan lazyMan = declaredConstructor.newInstance();
System.out.println(instance); System.out.println(lazyMan); System.out.println(instance == lazyMan); }
|
运行:
完了,这咋整啊?
点开 newInstance()
的源码,源码中有这样一段判断:
1 2
| if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");
|
这段判断就是说不能通过反射创建枚举对象,枚举对象自带单例模式!
5. 枚举
enum
的全称为 enumeration, 是 JDK 1.5 中引入的新特性。
在 Java 中,被 enum
关键字修饰的类型就是枚举类型。形式如下:
1
| enum Color { RED, GREEN, BLUE }
|
明白了基本使用,创建一个枚举测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
public enum EnumSingle { INSTANCE;
public EnumSingle getInstance() { return INSTANCE; } }
class Test { public static void main(String[] args) { EnumSingle instance = EnumSingle.INSTANCE; EnumSingle instance2 = EnumSingle.INSTANCE;
System.out.println(instance.hashCode()); System.out.println(instance2.hashCode()); System.out.println(instance == instance2); } }
|
运行结果:
哟嚯?好像还行。
用反射创建枚举试试?修改一下 Test
测试类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Test { public static void main(String[] args) throws Exception { EnumSingle instance = EnumSingle.INSTANCE; Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null); declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance.hashCode()); System.out.println(enumSingle.hashCode()); System.out.println(instance == enumSingle);
} }
|
运行后有:
出现了一个异常,表示 EnumSingle
类中没有空参构造函数。
但并不是我们在 newInstance()
源码中看到的异常:
1 2
| if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");
|
我们反编译看看:
可以看到,枚举确实是一个类,而且也有空参构造函数。这…
我们需要使用更加专业的反编译工具,比如 JAD。
JAD 下载链接:JAD下载镜像
进入链接后,找到对应的地方进行下载:
下载之后进行解压,找到 jad.exe 并将其放在需要进行反编译的目录:
在这个目录下打开 cmd,我们需要反编译 EnumSingle.class
,因此输入命令:
1
| jad -sjava EnumSingle.class
|
然后会在当前目录下生成反编译后的 Java 文件 EnumSingle.java
,打开它:
终于看到不再是无参构造函数了,而是有参构造函数,参数类型分别是 String
和 int
。
既然如此,我们修改 Test
测试类中的测试方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Test { public static void main(String[] args) throws Exception { EnumSingle instance = EnumSingle.INSTANCE; Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class); declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance.hashCode()); System.out.println(enumSingle.hashCode()); System.out.println(instance == enumSingle);
} }
|
再次运行代码:
所以应该使用枚举来实现真正的单例。