创建型——单例模式

概述

这里我们通过几个问题,来简单了解下单例模式。

  1. 什么是单例模式?

    所谓单例模式,顾名思义就是:一个类只有一个实例,且这个实例是有该类自己创建的一种模式。

  2. 为什么要有单例模式?

    在实际应用中,很多对象其实我们只需要一个,比如:线程池、缓存、对话框、注册表对象、日志对象等等。往往这些类对象只能有一个实例,否则可能出现很多问题,例如:程序的行为异常、资源浪费或者不一致的结果等。

  3. 单例模式的类图啥样?

    因为单例模式下类只能有一个实例,因此需要通过 一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。

单例模式的特点可以总结为:

  • 单例类只有一个实例对象;

  • 该单例对象必须由单例类自行创建;

  • 单例类对外提供一个访问该单例的全局访问点。

  1. 单例模式有什么优缺点?

    单例模式的目的是:保证系统中只有一个对象实例,使用全局变量也可以达到同样的效果,那么单例模式有什么优势呢?

    • 单例模式 VS 全局变量

      • 使用全局变量的话,那么必须在程序一开始就创建好对象,万一程序运行过程中没有用到这个对象,那么就白创建了,造成资源浪费。
      • 全局变量管理不方便,容易存在命名冲突。
    • 单例模式的缺点

      当然,单例模式也不是没有缺点的:

      • 单例模式一般没有接口,扩展困难;
      • 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则(大家都这么说,我在实际应用中,还没体会到这点)。
  2. 单例模式使用过程中,有没有什么要注意的?

    • 使用时不能用反射模式创建单例,否则会实例化一个新的对象 ;
    • 使用懒单例模式时注意线程安全问题 ;
    • 饿单例模式和懒单例模式构造方法都是私有的,因而是不能被继承的。

实现方式

饿汉式

饿汉式单例模式,直接在类中定义一个初始化好的实例对象:

1
2
3
4
5
6
7
8
public class Singleton {
private static Singleton uniqueInstance = new Singleton();
private Singleton() {
}
public static Singleton getUniqueInstance() {
return uniqueInstance;
}
}

该模式的优点就是:线程安全,但是丢失了延迟实例化带来的节约资源的好处。

懒汉式

懒汉式的思想是,用到的时候我再创建实例对象,不用就不创建。懒汉式分为两种:线程安全和线程不安全。

线程不安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {

private static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//多个线程同时进入,会创建多个实例,因此是线程不安全的
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}

线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {

private static Singleton uniqueInstance;

private Singleton() {
}
//Synchronized修饰get方法,线程安全,但是影响并发性能
public static synchronized Singleton getUniqueInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}

双重校验锁

在线程安全的基础上,把synchronized移动到get方法里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

细心的小伙伴,可能会发现:

  1. 为什么get方法中有两次null判断?

    1
    2
    3
    4
    5
    if (uniqueInstance == null) {
    synchronized (Singleton.class) {
    uniqueInstance = new Singleton();
    }
    }

    如上面代码所示,如果只使用了一个 if 语句。在 uniqueInstance == null 的情况下,如果两个线程同时执行 if 语句,那么两个线程就会同时进入 if 语句块内。虽然在 if 语句块内有加锁操作,但是两个线程都会执行 uniqueInstance = new Singleton(); 这条语句,只是先后的问题,那么就会进行两次实例化,从而产生了两个实例。因此必须使用双重校验锁,也就是需要使用两个 if 语句。

  2. 单例实例引用为什么使用volatile修饰?

    这就要说到uniqueInstance = new Singleton()的执行过程了,其实分三步:分配内存空间、初始可能对象、将uniqueInstance指向内存地址。因为JVM具有指令重拍的特性,有可能第3步先于第2步执行,多线程环境中可能将一个未初始化的对象分配出去,导致线程出错。

    这里volatile的作用就是禁止指令重排序。

静态内部类(登记式)

当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance() 方法从而触发 SingletonHolder.INSTANCE 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例。

这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。 因此优缺点如下:

  • 优点: 资源利用率高,不执行getInstance()不被实例–>弥补了饿汉式的缺点,同时不需要像双重校验那样麻烦,且不像懒汉式那样效率低下 。

  • **缺点:**第一次加载时反应不够快;由于是静态内部类的形式去创建单例的,故外部无法传递参数进去 。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {

private Singleton() {
}

private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}

public static Singleton getUniqueInstance() {
return SingletonHolder.INSTANCE;
}
}

枚举实现

这是单例模式的最佳实践,它实现简单,并且在面对复杂的序列化或者反射攻击的时候,能够防止实例化多次。 但是和饿汉模式一样,在初始化时就需要创建单例对象。

1
2
3
public enum Singleton {
uniqueInstance;
}

下面是一个利用枚举实现单例的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 // 单例
public enum BeanContext {
Instance;
private BeanContext() {
System.out.println("init");
}
public void print() {
System.out.println("hello");
}
//测试
public static void main(String[] args) {
BeanContext b1 = BeanContext.Instance;
b1.print();
BeanContext b2 = BeanContext.Instance;
b2.print();
BeanContext b3 = BeanContext.Instance;
b3.print();
BeanContext b4 = BeanContext.Instance;
b4.print();
}
}

总结

上图摘自https://pdai.tech/md/dev-spec/pattern/2_singleton.html。

不同实现方式有着各自的优缺点和应用场景。在实际应用中可以参考以下建议:

  1. 不考虑资源浪费的情况下,最佳实践是使用枚举类型,因为不会出现反射攻击(这个后文会说)。
  2. 多个类加载器情况下,最好显示指定某个加载器加载单例类,否则可能导致系统中出现多个单例对象。
  3. 尽量使用饿汉模式,防止浪费资源。

单例模式的应用

常见应用场景

  1. 多线程的线程,以及各种连接池,比如数据库连接池、IO连接池等等;
  2. 资源共享的情况下,避免由于资源操作时导致的性能或损耗,避免并发操作冲突等。如应用程序的日志应用,共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加;应用的配置对象的读取,配置文件是共享的资源;Windows 是多进程多线程的,在操作一个文件的时候,就不可避免地出现多个进程或线程同时操作一个文件的现象,所以所有文件的处理必须通过唯一的实例(一个文件系统)来进行。
  3. 一些设备管理器常常设计为单例模式,比如一个电脑有两台打印机,只能有一个PrinterSpooler,在输出的时候不能两台打印机打印同一个文件。
  4. 网站的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来,否则难以同步。

单例模式下的反射机制

通过在get方法中new一个实例方式实现的单例模式,可能会被反射破坏。 下面的内容来自:https://www.cnblogs.com/ideal-20/p/13912766.html。

单例是如何破坏的

下面用双重锁定的懒汉式单例演示一下,这是我们原来的写法,new 两个实例出来,输出一下。

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
29
30
31
32
33
public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
}

// 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null;

// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}

public static void main(String[] args) {

Lazy1 lazy1 = getLazy1();
Lazy1 lazy2 = getLazy1();
System.out.println(lazy1);
System.out.println(lazy2);

}
}

运行后不难发现,结果是单例没有问题。

一个普通实例化,一个反射实例化

但是我们如果通过反射的方式进行实例化类,会有什么问题呢?

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) throws Exception {
Lazy1 lazy1 = getLazy1();
// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy2 = declaredConstructor.newInstance();
System.out.println(lazy1);
System.out.println(lazy2);
}

运行结果:

main 访问到了
main 访问到了
cn.ideal.single.Lazy1@1b6d3586
cn.ideal.single.Lazy1@4554617c

可以看到,单例被破坏了

解决办法:因为我们反射走的其无参构造,所以在无参构造中再次进行非null判断,加上原来的双重锁定,现在也就有三次判断了

1
2
3
4
5
6
7
8
// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if(lazy1 != null) {
throw new RuntimeException("反射破坏单例异常");
}
}
}

不过结果也没让人失望,这种测试下,第二次实例化会直接报异常。

两个都是反射实例化

如果两个都是反射实例化出来的,也就是说,根本就不去调用 getLazy1() 方法,那可怎么办?如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws Exception {

// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy1 = declaredConstructor.newInstance();
Lazy1 lazy2 = declaredConstructor.newInstance();

System.out.println(lazy1);
System.out.println(lazy2);
}

运行结果:

main 访问到了
main 访问到了
cn.ideal.single.Lazy1@1b6d3586
cn.ideal.single.Lazy1@4554617c

单例又被破坏了

解决方案:增加一个标识位,例如下文通过增加一个布尔类型的 ideal 标识,保证只会执行一次,更安全的做法,可以进行加密处理,保证其安全性

1
2
3
4
5
6
7
8
9
10
11
// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if (ideal == false){
ideal = true;
} else {
throw new RuntimeException("反射破坏单例异常");
}
}
System.out.println(Thread.currentThread().getName() + " 访问到了");
}

这样就没问题了吗,并不是,一旦别人通过一些手段得到了这个标识内容,那么他就可以通过修改这个标识继续破坏单例,代码如下(这个把代码贴全一点,前面都是节选关键的,都可以参考这个)

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class Lazy1 {

private static boolean ideal = false;

// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if (ideal == false){
ideal = true;
} else {
throw new RuntimeException("反射破坏单例异常");
}
}
System.out.println(Thread.currentThread().getName() + " 访问到了");
}

// 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null;

// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}

public static void main(String[] args) throws Exception {

Field ideal = Lazy1.class.getDeclaredField("ideal");
ideal.setAccessible(true);

// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy1 = declaredConstructor.newInstance();
ideal.set(lazy1,false);
Lazy1 lazy2 = declaredConstructor.newInstance();

System.out.println(lazy1);
System.out.println(lazy2);

}
}

运行结果:

main 访问到了
main 访问到了
cn.ideal.single.Lazy1@4554617c
cn.ideal.single.Lazy1@74a14482

实例化 lazy1 后,其执行了修改 ideal 这个布尔值为 false,从而绕过了判断,再次破坏了单例。

单例模式在JDK和Spring中的应用

  1. java.lang.Runtime类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class Runtime {
    private static java.lang.Runtime currentRuntime = new java.lang.Runtime();

    public static java.lang.Runtime getRuntime() {
    return currentRuntime;
    }

    private Runtime() {}
    ...
    }
  2. 在 Spring 中,bean 可以被定义为两种模式:prototype(多例)和 singleton(单例)。

参考资料

  1. 《Head First设计模式》
  2. https://pdai.tech/md/dev-spec/pattern/2_singleton.html
  3. http://c.biancheng.net/view/1338.html
  4. https://jackromer.github.io/2019/08/27/JAVA使用枚举实现单例模式/
  5. https://bbs.huaweicloud.com/blogs/239657
  6. https://www.cnblogs.com/ideal-20/p/13912766.html
  7. http://m.biancheng.net/view/8378.html
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2022 Yin Peng
  • 引擎: Hexo   |  主题:修改自 Ayer
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信