聊一聊最难的设计模式 - 单例模式

很多人上来肯定一脸懵逼,因为在你的印象中,单例模式实现起来还是很简单的。不要着急,慢慢往下看,你就知道为什么我说它最难了。

1. 基本概念

  • 单例模式是一种常用的创建型设计模式。单例模式保证类仅有一个实例,并提供一个全局访问点。

2. 适用场景

  • 想确保任何情况下都绝对只有一个实例。

  • 典型的场景有:windows 的任务管理器、windows 的回收站、线程池的设计等。

3. 单例模式的优缺点

优点

  • 内存中只有一个实例,减少了内存开销。
  • 可以避免对资源的多重占用。
  • 设置全局访问点,严格控制访问。

缺点

  • 没有接口,扩展困难。

4. 常见的实现模式

  • 懒汉式
  • 饿汉式

5. 先搞一个懒汉式的玩一玩

public class LazySingleton {
    // 1. 私有对象
    private static LazySingleton lazySingleton = null;

    // 2. 构造方法私有化
    private LazySingleton() {}

    // 3. 设置全局访问点
    public static LazySingleton getInstance() {
        if (lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

接下来,我们单线程测试

public class MainTest {
    public static void main(String[] args) {
        LazySingleton instance = LazySingleton.getInstance();
        LazySingleton instance2 = LazySingleton.getInstance();
        System.out.println(instance == instance2);
    }
}

img

  • 测试代码及结果如上,一切看着毫无违和感。

那自然而然,我们考虑一下多线程如何呢。

  • 我们来创建一个线程类
public class MyThread implements Runnable {
    @Override
    public void run() {
        LazySingleton instance = LazySingleton.getInstance();
        System.out.println(Thread.currentThread().getName() + " " + instance);
    }
}
  • 然后修改我们的测试代码
public class MainTest {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyThread());
        Thread t2 = new Thread(new MyThread());
        t1.start();
        t2.start();
        System.out.println("program end.");
    }
}
  • 我们通过 IDEA 自带的断点测试来测试多线程下的问题,我们在 LazySingleton 如下位置打上断点。(设置断点的 Suspend 为 Thread)

img

  • 我们通过 debug 方式启动测试代码,然后通过 IDEA 的工具窗口切换线程进行查看。(具体的 IDEA 调试多线程代码的方法可以通过各种途径学习,当然,也可以找我,我教你。虽然我也是略知皮毛。)

img

  • 此时会看到有 Thread-0 和 Thread-1 两个线程,此时两个线程都判断了 lazySingleton 为空,此时两个线程都会创建对象。

  • 将代码执行完,此时可以看到控制台打印的消息。

img

  • 很明显地,两个线程拿到的是不同的对象。也就说明了,我们如上的懒汉式代码不是线程安全的,在多线程下可能会创建出多个对象。

那接下来,我们就应该想办法处理这种情况了。

  • 通过在全局访问点添加 synchronized 关键字处理
// 3. 设置全局访问点
public synchronized static LazySingleton getInstance() {
    if (lazySingleton == null) {
        lazySingleton = new LazySingleton();
    }
    return lazySingleton;
}

如上问题是处理了,但是出现了新的问题,该方法访问时会加锁,导致访问效率降低,但是只要是判断和创建对象的时候加锁即可,大概率情况下,该对象已经创建出来,并发访问也是没有什么问题的。为了实现这个目的,我们又提出了“Double Check 双重检查方案”

  • 废话不多说,上代码。
public class LazyDoubleCheckSingleton {
    private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;

    private LazyDoubleCheckSingleton() {}

    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                if (lazyDoubleCheckSingleton == null) {
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}
  • 此代码便实现了在大概率情况下,lazyDoubleCheckSingleton 已经不为空,也就不需要获取到锁,可以实现多线程并发访问。

但是如上代码还是有一些问题的,因为问题很难复现,也就不做演示。问题是由大名鼎鼎的“指令重排序”引起的。

  • 来大概说明一下原理,可能不是很准确,但是主要以理解这个问题为目的。

  • 其实创建对象(new LazyDoubleCheckSingleton())这个操作在底层我们可以看作三个步骤:

    • memory = allocate(); // 1:分配对象的内存空间
    • ctorInstance(memory); // 2:初始化对象
    • lazyDoubleCheckSingleton = memory; // 3:设置 lazyDoubleCheckSingleton 指向刚分配的内存地址
  • 针对这个问题,Java 语言规范中是有要求的,就是必须遵守 intra-thread semantics (线程内语义),保证重排序不会改变单线程内的程序执行结果。

  • 但是在上述例子中,2、3步骤可能会出现重排序,也就是可能出现,先指向内存地址,再初始化对象,此时,lazyDoubleCheckSingleton 不为空,但是对象还未初始化完成。问题也就出现了。并且此时重排序操作并不会违反 intra-thread semantics,因为在单线程的运行下,此类重排序是不会影响最终结果的。

上一个图来说明一下指令重排序引起的问题吧

img

  • 此时便会发生:线程0中对象未初始化完成,线程1就访问了对象。

那问题来了,也就该处理了。

  • 针对以上问题,我们处理思路其实有两种:
    • 不允许步骤 2、3 进行重排序。
    • 允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。

不允许步骤 2、3 进行重排序

  • 只需要对象添加 volatile 关键字即可。
private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
  • 具体其中的原理,会在其他内容中进行分析,不是此次的重点。

允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。

基于静态内部类的解决方案

img

public class StaticInnerClassSingleton {
    // InnerClass 对象锁
    private static class InnerClass {
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

    private StaticInnerClassSingleton() {}

    public static StaticInnerClassSingleton getInstance() {
        return InnerClass.staticInnerClassSingleton;
    }
}

到此为止,咱们的懒汉式先告一段落啊。。。丧心病狂呀,有木有。。。

6. 那咱们就再来玩玩饿汉式

public class HungrySingleton {
    private final static HungrySingleton hungrySingleton;

    static {
        hungrySingleton = new HungrySingleton();
    }

    private HungrySingleton() {}

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
}
  • 这个东西在多线程下就好点了,因为饿汉式是在类初始化的时候便把对象创建好了,所以也不需要判断对象是不是空,当然,在多线程下也就没那么多需要我们考虑的了。

7. 然后,然后,咱们再来看看序列化和反序列化的情况下,单例模式有没有什么问题呢。

  • 因序列化问题与懒汉式还是饿汉式实现无关,以下便以饿汉式代码为例展示。

饿汉式

  • 首先,我们的单例类实现序列化
public class HungrySingleton implements Serializable {
    // ...
}
  • 然后我们来写一个测试代码
public class MainTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        HungrySingleton instance = HungrySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.txt"));
        oos.writeObject(instance);

        File file = new File("test.txt");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newInstance = (HungrySingleton) ois.readObject();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 我们来看一下执行结果

img

  • 哈哈哈哈,瞬间窒息了,有木有。。。

针对上面问题,咱们来看一看源码,找一找原因啊。

  • 如下是跟踪源码的过程,我只做简单截图,有兴趣可自行研究(哈哈哈,或者你可以找我呀,我们一起研究)。

img

img

img

img

看到这儿,我感觉你应该也就知道了,desc.isInstantiable()方法返回了true,所以通过反射new了一个新的对象,导致读出的对象与写入的对象不是同一个对象。

那你一定想问我,那怎么处理呢,别着急啊,接着往下看。

img

img

  • 这个变量的初始化,可以直接通过查找看到。

img

这不就清楚了嘛,有readResolve()方法的时候,直接通过调用该方法返回了单例对象,那我们处理起来也就简单了,为我们的单例类添加一个方法即可。

private Object readResolve() {
    return hungrySingleton;
}
  • 然后重新直接测试代码,会出现如下结果。

img

8. 序列化和序列化的问题说完了,咱们再来看看反射的问题吧,毕竟反射我们用的还是很多的,通过反射去创建一个对象也是常用的操作。

该问题针对两种方式是不一样的,我们先来看看饿汉式的表现。

  • 我们来写个测试代码
public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;
        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 看看运行结果

img

有一种五雷轰顶的感觉了没,别着急,别着急,咱们慢慢搞啊,虽然花点时间,但是能搞到很多东西的。

既然问题出来了,那怎么处理呢?其实处理也简单,因为反射是讲私有构造方法权限进行了开放,那我们在私有构造中添加判断即可。

private HungrySingleton() {
    if (hungrySingleton != null) {
        throw new RuntimeException("单例构造器禁止反射调用!");
    }
}
  • 再来运行我们的测试代码,可以看到会抛出以下异常。

img

接下来我们分析分析懒汉式

  • 与饿汉式添加同样的操作,也是避免不了反射的。
  • 假如先使用getInstance()方法获取对象,然后使用反射创建对象,是可以抛出异常的。
  • 但是当先使用反射创建对象,再通过getInstance()方法获取对象时,便可以获取到两个不同的对象,还是避免不了对单例模式的破坏。

最终的结论,懒汉式是无法防止反射攻击的。

9. 然后估计你就快晕了,你肯定想问,难道以后做一个单例都要考虑这么多问题嘛,也太墨迹了点吧。那咱们接下来就看看用枚举来实现单例的方法吧。

  • 该方法为Effective Java书中推荐的用法。
  • 该方法完美解决了序列化及反射对单例模式的破坏。

上代码

public enum EnumInstance {
    INSTANCE;
    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }

    public static EnumInstance getInstance() {
        return INSTANCE;
    }
}

上面既然说了,完美解决了序列化及反射对单例模式的破坏,那咱们接下来就看看是如何解决的。

解决序列化对单例模式的破坏

  • 我们还是来看ObjectInputStream.readObject()方法

img

img

img

img

  • 可以看出是使用名称通过反射去获取到Enum,并没有创建新的对象,所以获取到的是同一个对象。

解决反射对单例模式的破坏

  • 来写一个测试代码
public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = EnumInstance.class;
        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        EnumInstance instance = EnumInstance.getInstance();
        EnumInstance newInstance = (EnumInstance) constructor.newInstance();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}
  • 结果

img

  • 来看一下 java.lang.Enum 类,我们可以看到只有一个构造方法,且需要两个参数。

img

  • 那我们就来传入两个参数试一下。

img

  • 最终的结果

img

  • 我们来看一下原因啊,请看 constructor.newInstance() 方法

img

  • 发现其对 Enum 类型进行了处理,不允许通过反射创建 Enum 对象。

  • 至此我们也就明白了,为什么 Enum 单例可以完美防止序列化及反射对单例模式的破坏了。

OK 了,我们再来搞两个相关的东西

10. 我们来聊聊容器单例

  • 为了方便,使用 HashMap 来实现一个容器单例

直接走代码

public class ContainerSingleton {
    private static Map<String, Object> singletonMap = new HashMap<>();

    private ContainerSingleton() {}

    public static void putInstance(String key, Object instance) {
        if (key != null && !"".equals(key) && instance != null) {
            if (!singletonMap.containsKey(key)) {
                singletonMap.put(key, instance);
            }
        }
    }

    public static Object getInstance(String key) {
        return singletonMap.get(key);
    }
}

针对上述代码的说明

  • 因其key 相同,所以最终获取到的是同一个对象。

  • 但是上述代码是线程不安全的。在多线程情况下,如果两个线程同时判断 if 条件成立,此时 t1 线程 put,t1 线程 get;然后 t2 线程 put ,t2 线程 get 时,t1 线程与 t2 线程获取到的对象是不同的。

  • 如果此时容器单例不使用 HashMap,而使用 HashTable 是可以实现线程安全的,但是从性能考虑,假如 get 请求多的情况下,HashTable 效率会非常低下。

11. 最后一个,我们来看看 ThreadLocal 线程单例怎么实现

定义一个线程单例类

public class ThreadLocalInstance {
    private static final ThreadLocal<ThreadLocalInstance> threadLocalInstanceThreadLocal =
            new ThreadLocal<ThreadLocalInstance>() {
                @Override
                protected ThreadLocalInstance initialValue() {
                    return new ThreadLocalInstance();
                }
            };

    private ThreadLocalInstance() {}

    public static ThreadLocalInstance getInstance() {
        return threadLocalInstanceThreadLocal.get();
    }
}

实现一个线程类做测试

public class MyThread implements Runnable {
    @Override
    public void run() {
        ThreadLocalInstance instance = ThreadLocalInstance.getInstance();
        System.out.println(Thread.currentThread().getName() + " " + instance);
    }
}

写一个测试代码来测试一下

public class MainTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
        Thread t1 = new Thread(new MyThread());
        Thread t2 = new Thread(new MyThread());
        t1.start();
        t2.start();
        System.out.println("program end.");
    }
}

结果

img

我们今天的讨论到现在就结束了。今天主要讨论了入如下内容。

  • 基本的单例模式的实现:懒汉式和饿汉式。
  • 针对多线程下的单例模式线程安全的讨论。
  • 序列化和反序列化对单例模式的破坏。
  • 反射对单例模式的破坏。
  • Enum 枚举单例。
  • 单例容器。
  • ThreadLocal 线程单例。

朋友们,一起加油吧!!!

posted @ 2019-09-12 10:44  紫穹  阅读(452)  评论(0编辑  收藏  举报