设计模式之单例模式

843 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1. 简介

什么是单例模式?

单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁的创建对象使得内存飙升,单例模式可以让程序在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。

2. 分类

单例模式可以分为两种类型:

  • 懒汉式:在真正需要使用对象的时候才去创建该单例类对象
  • 饿汉式:在类加载时就已经创建好该单例对象,等待被程序使用

注意:上面两种创建单例对象的方式的构造器都要私有化

2.1 创建单例对象

2.1.1 懒汉式创建单例对象

单线程情况下,懒汉式创建单例对象可以在使用时先判断一下对象是否非空(实例化),如果非空就说明该单例类对象已经实例化,直接返回已经实例化的单例对象;如果为空,就说明该单例对象还没有实例化,就先实例化再返回对象就好。

package single;

public class Lazy {
    private  static Lazy lazy;

    private Lazy(){

    }

    public static Lazy getInstance() {
        if(lazy == null) {
            lazy = new Lazy();
        }
        return lazy;
    }

}

上面的这种懒汉式创建单例对象这种方式在单线程情况下是正确的,但是在多线程情况下就会出现一些问题,我们之后会去解决。接下来我们来看看饿汉式创建单例对象的方式吧!

2.1.2 饿汉式创建单例对象

饿汉式创建单例对象的方式是在类加载时就创建,而不是等到使用时才创建。

package single;

public class Hungry {
    private static Hungry hungry = new Hungry();
    
	private Hungry() {}
    
    public static Hungry getInstance() {
        return hungry;
    }
}

2.1.3 优化懒汉式创建单例对象

我们先回顾一下上面的懒汉式创建单例对象方式

package single;

public class Lazy {
    private  static Lazy lazy;

    private Lazy(){

    }

    public static Lazy getInstance() {
        if(lazy == null) {
            lazy = new Lazy();
        }
        return lazy;
    }

}

这种创建方式在多线程情况下是有问题的,如果两个线程都进入了if(lazy == null)这个if判断句里面,那么会创建两个对象,也不符合单例模式的要求了,所以我们首先要解决的是线程安全问题。

最简单的方法是在方法上加锁,如下:

package single;

public class Lazy {
    private  static Lazy lazy;

    private Lazy(){

    }

    public synchronized static Lazy getInstance() {
        if(lazy == null) {
            lazy = new Lazy();
        }
        return lazy;
    }

}

这种处理方式可以很好地解决解决线程安全问题,但是这种方式并发性能极差

接下来我们尝试可不可以使用其他方式去优化性能,上面这种方式无论对象是否已经实例化都会加锁。我们可以尝试这样优化:当我们没有实例化对象的时候,我们可以加锁创建,但是当我们已经实例化对象,我们直接返回已经实例化的对象即可。如下:

package single;

public class Lazy {
    private  static Lazy lazy;

    private Lazy(){

    }

    public static Lazy getInstance() {
        if(lazy == null) {
            synchronized (Lazy.class){
                if(lazy == null){
                    lazy = new Lazy();
                }
            }
        }
        return lazy;
    }

}

上面的代码完全解决了线程安全和性能低效问题,原理很简单,就不赘述了,上面的方式需要两次判空和对类加锁,该懒汉式写法也被称为:Double Check (双重锁)+ Lock(加锁)

但是上面的懒汉式创建单例模式还不是最完美的懒汉式创建单例模式,为什么呢?我们来学习一下。

我们先介绍一下指令重排:

什么是指令重排?

指令重排是指:JVM在保证最终结果正确的情况下,可以不按成程序编码的顺序执行语句,尽可能提高程序的性能

在程序中,如果两个指令之间不存在依赖性,就会发生指令重排。例如:a = 1, y = 2 这两个语句之间不存在依赖,就会发生指令重排;

又如 a = 1, y = a + 1 这两个指令之间就存在依赖,因此就不会发生指令重排

在单线程下,发生指令重排对于程序的结果其实并没有影响;

但是在多线程情况下,发生指令重排是会影响程序的执行结果的,我们举个例子来说明一下:

package single;

public class Demo02 {
    private int a = 0;
    private boolean flag = false;

    public void method01(){
        a = 1;
        flag = true;
    }

    public void metgod02(){
        if(flag){
            a = a + 5;
            System.out.println(a);
        }
    }
}

当线程一在执行method01()方法时,里面的两条语句没有依赖关系,可以发生指令重排,但是如果这时线程二执行了method02()方法,线程二可能会输出5或者6或者什么都不会输出。

而volatile关键字加在变量前就可以避免出现指令重排(为什么呢?还没有学,先记住)

之后就产生了懒汉式创建单例模式的最终版:

package single;

public class Lazy {
    private volatile static Lazy lazy;

    private Lazy(){

    }

    public static Lazy getInstance() {
        if(lazy == null) {
            synchronized (Lazy.class){
                if(lazy == null){
                    lazy = new Lazy();
                }
            }
        }
        return lazy;
    }

}

3. 破坏饿汉式单例与懒汉式单例

无论是懒汉式创建单例对象还是饿汉式创建单例对象,我们都可以利用反射来破坏单例模式(产生多个对象),如下:

package single;


import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public class Demo01 {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        // 获取类的显式构造器
        Constructor<Lazy> constructor = Lazy.class.getDeclaredConstructor();
        // 可以访问类的私有构造器,暴力反射
        constructor.setAccessible(true);
        // 利用反射构造新对象
        Lazy lazy1 = constructor.newInstance();
        // 利用正常方式获取单例对象
        Lazy lazy2 = Lazy.getInstance();
        System.out.println(lazy1 == lazy2);
    }
}

在这里插入图片描述

我们可以看到反射可以成功的破坏掉懒汉式创建单例对象,同样,饿汉式单例对象也可以一样被同样的方式破坏掉。

4. 利用枚举类实现单例模式

package single;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public enum Single {
    SINGLE;
    Single(){

    }

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Single s1 = Single.SINGLE;
        Single s2 = Single.SINGLE;
        System.out.println(s1 == s2);
    }
}

在这里插入图片描述

接着我们尝试一下利用反射破解:

package single;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public enum Single {
    SINGLE;
    Single(){

    }

    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Constructor<Single> constructor = Single.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Single s3 = constructor.newInstance();
        Single s4 = Single.SINGLE;
        System.out.println(s3 == s4);
    }
}

在这里插入图片描述

当利用反射破解的时候会抛出异常。

我们思考一下使用枚举类实现单例模式的优点在哪里?

  1. 代码对比懒汉式和饿汉式来说,代码更加简洁

  2. 不需要做任何额外的操作去保证对象的单一性与线程的安全性

    我们可以这样理解枚举类创建单例对象的过程:在程序启动时,会调用Single的空参构造器,实例化好一个Single对象后赋给SINGLE,之后再也不会实例化。

  3. 使用枚举类可以防止调用者使用反射序列化与反序列化机制强制生成多个对象破坏单例模式。

5. 总结

  1. 单例模式常见的实现方式有两种:懒汉式饿汉式,但是最完美的方式是使用枚举类,不仅代码简洁,而且不存在线程安全问题,还可以防止被反射和序列化、反序列化破坏。
  2. 对于懒汉式而言:采取Double Check + Lock方式解决线程安全和并发效率问题。
  3. 对于饿汉式而言:在类加载时就已经创建,不存在线程安全和并发效率问题。
  4. 在开发中如果对内存要求不高就采用饿汉式写法,因为简单且不易出错;对内存要求高就采用懒汉式写法。
  5. 使用volatile关键字可以防止出现指令重排
  6. 最优雅的方式是使用枚举类实现单例模式,不仅代码简洁,而且不存在线程安全问题,还可以防止被反射和序列化、反序列化破坏