【设计模式】好好聊一聊单例模式

? 对于系统中的某些类来说,只有一个实例很重要,例如,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。

如何保证一个类只有一个实例并且这个实例易于被访问呢?定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供个访问该实例的方法。这就是单例模式的模式动机。

单例模式是一种对象创建型模式,又名单件模式或单态模式。单例模式( Singleton Pattern)的定义如下:单例模式确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。

单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例

单例模式包含如下角色:

  • Singleton:单例

? 我们下面来看一下它的实现,因为单例模式是最重要的一种设计模式,这里要好好地说一下:

懒汉式写法:

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
        if(lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

关键就是将构造器私有,限制只能通过内部静态方法来获取一个实例。

但是这种写法,很明显不是线程安全的。如果多个线程在该类初始化之前,有大于一个线程调用了getinstance方法且lazySingleton == null 判断条件都是正确的时候,这个时候就会导致new出多个LazySingleton实例。可以这么改一下:

这种写法叫做DoubleCheck。针对类初始化之前多个线程进入 if(lazySingleton == null) 代码块中情况

这个时候加锁控制,再次判断 if(lazySingleton == null) ,如果条件成立则new出来一个实例,轮到其他的线程判断的时候自然就就为假了,问题大致解决。

public class LazyDoubleCheckSingleton {

    private static LazyDoubleCheckSingleton lazySingleton = null;

    private LazyDoubleCheckSingleton() {

    }

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

但是即使是这样,上面代码的改进有些问题还是无法解决的。

因为会有重排序问题。重排序是一种编译优化技术,属于《编译原理》的内容了,这里不详细探讨,但是要告诉你怎么回事。

正常来说,下面的这段代码

lazySingleton = new LazyDoubleCheckSingleton();

执行的时候是这样的:
1.分配内存给这个对象
2.初始化对象
3.设置LazyDoubleCheckSingleton指向刚分配的内存地址。

但是编译优化后,可能是这种样子
1.分配内存给这个对象
3.设置LazyDoubleCheckSingleton指向刚分配的内存地址。
2.初始化对象

2 步骤 和 3 步骤一反,就出问题了。(前提条件,编译器进行了编译优化)
比如说有两个线程,名字分别是线程1和线程2,线程1进入了 if(lazySingleton == null) 代码块,拿到了锁,进行了 new LazyDoubleCheckSingleton()的执行,在加载构造类的实例的时候,设置LazyDoubleCheckSingleton指向刚分配的内存地址,但是还没有初始化对象。线程2判断 if(lazySingleton == null) 为假,直接返回了lazySingleton,又进行了使用,使用的时候就会出问题了。

画两张图吧:

重排序的情况如下:
【设计模式】好好聊一聊单例模式
再看出问题的地方
【设计模式】好好聊一聊单例模式
当然这个很好改进,从禁用重排序方面下手,添加一个volatile。不熟悉线程安全可以参考这篇文章【Java并发编程】线程安全性详解

    private volatile static LazyDoubleCheckSingleton lazySingleton = null;

方法不止一种嘛,也可以利用对象初始化的“可见性”来解决,具体来说是利用静态内部类基于类初始化的延迟加载,名字很长,但是理解起来并不困难。(使用这种方法,不必担心上面编译优化带来的问题)

类初始化的延迟加载与JVM息息相关,我们演示的例子的只是被加载了而已,而没有链接和初始化。

我们看一下实现方案:
定义一个静态内部类,其静态字段实例化了一个单例。获取单例需要调用getInstance方法间接获取。

public class StaticInnerClassSingleton {

    private static class InnerClass{
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }

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

如果对内部类不熟悉,可以参考这篇文章【Java核心技术卷】深入理解Java的内部类

【设计模式】好好聊一聊单例模式
懒汉式的介绍就到这里吧,下面再看看另外一种单例模式的实现


饿汉式

演示一下基本的写法

public class HungrySingleton {

    // 类加载的时候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在静态块里进行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

饿汉式在类加载的时候就完成单例的实例化,如果用不到这个类会造成内存资源的浪费,因为单例实例引用不可变,所以是线程安全的

同样,上面的饿汉式写法也是存在问题的

我们依次看一下:

首先是序列化破坏单例模式

先保证饿汉式能够序列化,需要继承Serializable 接口。

import java.io.Serializable;

public class HungrySingleton implements Serializable {

    // 类加载的时候初始化
    private final static HungrySingleton hungrySingleton = new HungrySingleton();

    /*
    也可以在静态块里进行初始化
      private static HungrySingleton hungrySingleton;

     static {
        hungrySingleton = new HungrySingleton();
     }
     */
    private HungrySingleton() {

    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }

}

我们测试一下:

package com.example.demo.example.count.singleton;

import lombok.extern.slf4j.Slf4j;

import java.io.*;

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
       HungrySingleton hungrySingleton = HungrySingleton.getInstance();
       ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
       oos.writeObject(hungrySingleton);

       File file = new File("singleton");
       ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));

        HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject();

        log.info("结果 {}",hungrySingleton);
        log.info("结果 {}",newHungrySingleton);
        log.info("对比结果 {}",hungrySingleton == newHungrySingleton);
    }
}

结果:
【设计模式】好好聊一聊单例模式
结果发现对象不一样,原因就涉及到序列化的底层原因了,我们先看解决方式:

饿汉式代码中添加下面这段代码

private Object readResolve() {
        return hungrySingleton;
    }

重新运行,这个时候的结果:
【设计模式】好好聊一聊单例模式
原因出在readResolve方法上,下面去ObjectInputStream源码部分找找原因。(里面都涉及到底层实现,不要指望看懂)

在一个读取底层数据的方法上有一段描述
就是序列化的Object类中可能定义有一个readResolve方法。我们在二进制数据读取的方法中看到了是否判断
【设计模式】好好聊一聊单例模式
private Object readOrdinaryObject()方法中有这段代码,如果存在ReadResolve方法,就去调用。不存在,不调用。联想到我们在饿汉式添加的代码,大致能猜到怎么回事了吧。
【设计模式】好好聊一聊单例模式


另外一种情况就是反射攻击破坏单例

演示一下

package com.example.demo.example.count.singleton;

import lombok.extern.slf4j.Slf4j;

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

@Slf4j
public class Test {

    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class objectClass = HungrySingleton.class;

        Constructor constructor = objectClass.getDeclaredConstructor();
        constructor.setAccessible(true); // 强行打开构造器权限
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        log.info("结果{}",instance);
        log.info("结果{}",newInstance);
        log.info("比较结果{}",newInstance == instance);
    }
}

【设计模式】好好聊一聊单例模式
这里强行破开了private的构造方法的权限,使得能new出来一个单例实例,这不是我们想看到的。

解决方法是在构造方法中抛出异常

   private HungrySingleton() {
        if( hungrySingleton != null) {
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

这个时候再运行一下
【设计模式】好好聊一聊单例模式
其实对于懒汉式也是有反射破坏单例的问题的,也可以采用类似抛出异常的方法来解决。

饿汉式单例与懒汉式单例类比较

  • 饿汉式单例类在自己被加载时就将自己实例化。单从资源利用效率角度来讲,这个比懒汉式单例类稍差些。从速度和反应时间角度来讲,则比懒汉式单例类稍好些。
  • 懒汉式单例类在实例化时,必须处理好在多个线程同时首次引用此类时的访问限制问题,特别是当单例类作为资源控制器在实例化时必然涉及资源初始化,而资源初始化很有可能耗费大量时间,这意味着出现多线程同时首次引用此类的机率变得较大,需要通过同步化机制进行控制。

? 看一下单例模式的优缺点:
单例模式的优点

  • 提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它,并为设计及开发团队提供了共享的概念。
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。
  • 允许可变数目的实例。我们可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例

单例模式的缺点

  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产角色包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态的丢失。

? 在以下情况下可以使用单例模式:

  • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器,或者需要考虑资源消耗太大而只允许创建一个对象。
  • 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。
  • 在一个系统中要求一个类只有一个实例时才应当使用单例模式反过来,如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式。
上一篇:RHEL 5基础篇—Linux常用命令参考手册


下一篇:Apache Log4j 2.0介绍