zl程序教程

您现在的位置是:首页 >  其他

当前栏目

详解设计模式@单例的进化之路

设计模式 详解 单例 进化
2023-06-13 09:15:38 时间

概念

单例模式(Singleton Pattern)是设计模式中一个重要的模式之一,是确保一个类在任何情况下都绝对只有一个实例。单例模式一般会屏蔽构造器,单例对象提供一个全局访问点,属于创建型模式

根据初始化时间的不同,可以将单例模式分为两类:

  • 饿汉式单例
  • 懒汉式单例

当然,除了上面的两个分类之外,处于对性能、安全等方面的考量,单例模式还演化出了各种实现版本,每一种版本的演进,都是单例的一次进化与升级,下面就来看看单例模式的进化之路上都经历了哪些挑战与对抗。

饿汉式单例

饿汉式单例,特指在 类加载的时候就立即初始化并创建单例对象的一种单例模式写法。由于是在线程还没有出现之前就被实例化了,所以这种模式下的单例是线程绝对安全的,不存在访问安全的问题。

根据具体的实现方式划分,饿汉式单例可以通过 私有化构造器以及 使用静态代码块的方式具体实现。

  • 私有化构造器写法:HungrySingleton.java
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 9:32
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 饿汉式单例-构造器私有化写法
 */
public class HungrySingleton {
    private static final HungrySingleton hungrSingleton  = new HungrySingleton();

    private HungrySingleton() {};

    public static HungrySingleton getInstance() {
        return hungrSingleton;
    }
}

上面的代码中,我们将构造器进行了私有化之后,无法再通过new来创建对象,这种实现下,只能通过提供的getInstance()方法来获得单例对象。

  • 静态代码块写法:HungryStaticSingleton.java
package 设计模式.单例模式.饿汉式单例;

/**
 * @author: 八尺妖剑
 * @date: 2022/4/23 8:36
 * @description: 饿汉式单例静态块写法
 * @blog:www.waer.ltd
 */
@SuppressWarnings({"all"})
public class HungryStaticSingleton {
    private static final HungryStaticSingleton hungryStaticSingleton;


    static {
        hungryStaticSingleton = new HungryStaticSingleton();
    }

    private HungryStaticSingleton(){

    }

    public static HungryStaticSingleton getInstance(){
        return hungryStaticSingleton;
    }
}
  • 测试类
package ltd.waer.javabaseforio.PatternDesign;

@SuppressWarnings("all")
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 9:42
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 饿汉式单例测试类
 */
public class HungrySingletonTest {
    public static void main(String[] args) {
        //私有构造器写法
        HungrySingleton hungrySingleton1 = HungrySingleton.getInstance();
        HungrySingleton hungrySingleton2 = HungrySingleton.getInstance();
        System.out.println(hungrySingleton1 == hungrySingleton2);

        //静态块初始化写法
        HungryStaticSingleton singleton3 = HungryStaticSingleton.getInstance();
        HungryStaticSingleton singleton4 = HungryStaticSingleton.getInstance();
        System.out.println(singleton3==singleton4);
    }
}

测试结果:true。说明两种方式实现的单例都是有效的,因为不论我们调用多少次 getInstance 方法最后返回的就是同一个对象

优缺点:

创建的对象没有添加任何锁,执行效率高。

由于是在类加载的时候就初始化了,所以不管我们使用与否,它都将占有一定的内存空间,这种情况下,通过项目中存在了大量的单例,那么所占用的内存量就很可观了,着实浪费。

懒汉式单例

那么针对上述饿汉式单例存在的空间占用问题,有没有合适的替换或者解决方案呢?那么有请懒汉出场。 见名知意, 懒汉式单例饿汉式单例的理念刚好相反。它不会在 类加载的时候就初始化,而是等到用到了才会初始化,就这点来说,确实很 懒汉,不饿不吃饭(似乎有点道理??我不饿的时候也不想吃饭)。

到这里,单例模式就开始自己的进化之路了,下面列一下进化路线

进化主线:

  • 普通非线程安全单例
    • sync线程安全单例
      • 双重检查锁单例
        • 内部类单例
          • 枚举式单例

打野副本:

  • 内部类单例
    • 注册式单例
    • 单线程安全单例
      • 枚举式单例

1. 普通非线程安全单例

package ltd.waer.javabaseforio.PatternDesign.LazySingleton;
@SuppressWarnings("all")
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 9:47
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 版本一:非线程安全
 */
public class LazySingleton {
   private static LazySingleton lazySingleton = null;

   private LazySingleton() {

   };

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

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

上面是单例的最简单写法,也是最初的一种版本,在开始时将实例赋值为null,并没有进行初始化,而是在调用getInstance方法的时候才会初始化,虽然实现简单,但也存在线程安全问题,多线程环境下有一定几率会返回多个单例对象,这显然违背了单例的原则,进一步的解决办法就是下面这种实现。使用synchronizeed关键字保证线程安全。

2. sync线程安全单例

package ltd.waer.javabaseforio.PatternDesign.LazySingleton;
@SuppressWarnings("all")
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 9:51
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 线程安全的懒汉式单例-synchronized
 */
public class LazySyncSingleton {
    private static LazySyncSingleton lazySyncSingleton = null;

    private LazySyncSingleton() {};

    public synchronized LazySyncSingleton getInstance () {
        if (null == lazySyncSingleton) {
            lazySyncSingleton = new LazySyncSingleton();
        }
        return lazySyncSingleton;
    }
}

上面的实现也非常简单,在前面一种写法的基础山加了一个synchronized关键字即可,这样确实解决了线程安全的问题,但也引出了一个新的问题,假如单例对象的创建非常复杂耗时的情况下,一旦并发量上来了,CPU压力上升,那么可能会导致大批量线程出现阻塞的情况,从而导致程序的允许性能大幅下降,解决方法是**双重检查锁(double-checked locking)**单例写法,如下:

3. 双重检查锁单例

package ltd.waer.javabaseforio.PatternDesign.LazySingleton;
@SuppressWarnings("all")
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 9:59
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 解决写法2的问题,双重检查锁写法
 */
public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;

    private LazyDoubleCheckSingleton () {

    };

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

这种写法中,较于上面的写法做了两个地方的改变。

  • lazyDoubleCheckSingleton属性加上了volatile关键字,原因就是为了解决多线程下可见性问题,因为我们的getInstance方法在判断lazyDoubleCheckSingleton是否为null时并没有加锁,所以假如线程1初始化了对象,另外线程2是无法感知的,而加上了volatile之后便可以解决这个问题。
  • synchronized关键字移到了方法内部,尽可能缩小加锁的代码块,提升效率

迭代了这几个版本,到这里是否就已经完美了呢?其实不是,这种写法依旧存在问题,那就是指令重排问题。

上面new对象只有一行代码,然而这行代码在JVM底层却分成了3步:

  1. 分配内存来创建对象,即new操作。
  2. 创建一个对象lazyDoubleCheckSingleton此时lazyDoubleCheckSingleton==nul
  3. new出来的对象赋给lazyDoubleCheckSingleton

但实际运行的时候为了提升效率,这3步并不会按照实际顺序来运行。

假如线程t1进入同步代码块正在创建对象,而此时执行了后面2步,也即是此时lazyDoubleCheckSingleton依已经不为null了,但是对象却没有创建结束,这时候又来了一个线程t2进入getInstance方法,这时候if条件不再成立,线程t2会直接返回一个残缺不全的对象,自然会出现报错。

为了解决这个问题,下面引出了第四个单例版本,即

4. 内部类单例

package ltd.waer.javabaseforio.PatternDesign.LazySingleton;
import java.io.Serializable;
import java.lang.reflect.Constructor;
@SuppressWarnings("all")
/**
 * @author: 八尺妖剑
 * @date: 2023/1/31 10:48
 * @email: ilikexff@gmail.com
 * @blog: https://www.waer.ltd
 * @Description: 内部类懒汉式单例-解决指令重排问题
 */
public class LazyInnerClassSingleton  implements Serializable {
    private LazyInnerClassSingleton () {

    };

    public static final LazyInnerClassSingleton getInstance() {
        return InnerLazy.LAZY;
    }

    private static class InnerLazy {
        private static final LazyInnerClassSingleton LAZY =new LazyInnerClassSingleton();
    }
}

这种写法巧妙的利用了内部类会等到外部调用时才会被初始化的特性,用饿汉式单例的思想实现了懒汉式单例。

这种写法看起来已经是高效完美,但其实存在安全隐患,比如可以通过反射的方式破坏这种写法,测试代码如下:

public static void main(String[] args) throws Exception {
    Class<?> clazz = LazyInnerClassSingleton.class;
    Constructor constructor = clazz.getDeclaredConstructor();
    constructor.setAccessible(true);
    Object o1 = constructor.newInstance();
    Object o2 = LazyInnerClassSingleton.getInstance();
    System.out.println(o1 == o2); //false
}

可以看到,虽然构造方法被私有化了,但是我们仍然可以利用反射来破坏单例。为了防止反射破坏单例,我们将上面的写法再改造一下。

5. 改进版的内部类单例

public class LazyInnerClassSingleton {

    private LazyInnerClassSingleton(){
        //防止反射破坏单例
         if(null != InnerLazy.LAZY){
           throw new RuntimeException("不允许通过反射类构造单例对象");
         }
    }

    public static final LazyInnerClassSingleton getInstance(){
        return InnerLazy.LAZY;
    }

    private static class InnerLazy{
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
}

尽管如此,但假如我们的单例对象实现了 Serializable 接口,那么内部类的写法就还是能通过序列化来破坏

6. 实现了Serializable接口的内部类单例

package singleton.lazy;

import java.io.Serializable;

public class LazyInnerClassSingleton implements Serializable {

    private LazyInnerClassSingleton(){
        //防止反射破坏单例
         if(null != InnerLazy.LAZY){
           throw new RuntimeException("不允许通过反射类构造单例对象");
         }
    }

    public static final LazyInnerClassSingleton getInstance(){
        return InnerLazy.LAZY;
    }

    private static class InnerLazy {
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
}

由于实现了序列化的接口,所以内部类的写法依然可以通过序列化来进行破坏,比如使用下面这段测试代码。

package singleton.lazy;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class TestLazyInnerClassSingleton2 {
    public static void main(String[] args) {
        //序列化攻击内部类式单例
        LazyInnerClassSingleton s1 = null;
        LazyInnerClassSingleton s2 = LazyInnerClassSingleton.getInstance();

        FileOutputStream fos = null;

        try {
            fos = new FileOutputStream("LazyInnerClassSingleton.text");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(s2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("LazyInnerClassSingleton.text");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (LazyInnerClassSingleton)ois.readObject();
            ois.close();
            System.out.println(s1 == s2);//输出:false
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

上面示例中 s1 是通过我们自己提供的全局入口创建的对象,而 s2 是通过序列化的方式创建的对象,不相等说明这是两个对象,也就是说序列化破坏了单例模式。

解决办法就是在 LazyInnerClassSingleton 类中加一个 readResolve 方法,防止序列化破坏单例。

7. 再改进版的内部类单例

package singleton.lazy;

import java.io.Serializable;

public class LazyInnerClassSingleton implements Serializable {

    private LazyInnerClassSingleton(){
        //防止反射破坏单例
         if(null != InnerLazy.LAZY){
           throw new RuntimeException("不允许通过反射类构造单例对象");
         }
    }

    public static final LazyInnerClassSingleton getInstance(){
        return InnerLazy.LAZY;
    }

    private static class InnerLazy {
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
    
    //防止通过序列化破坏单例
    private Object readResolve(){
        return InnerLazy.LAZY;
    }
}

这次返回了 true,也就是序列化没有破坏单例了。原因是因为 JDK 源码中在序列化的时候会检验一个类中是否存在一个 readResolve 方法,如果存在,则会放弃通过序列化产生的对象,而返回原本的对象。

这种方式虽然保证了单例,但是在校验是否存在 readResolve 方法前还是会产生一个对象,只不过这个对象会在发现类中存在 readResolve 方法后丢掉,然后返回原本的单例对象。这种写法只是保证了结果的唯一,但是过程中依然会被实例化多次,假如创建对象的频率增大,就意味着内存分配的开销也随之增大。

上面介绍了这么多种写法,看起来每种写法似乎都存在问题,难道就没有一种最优雅、安全、高效的方法吗?这就是我们最后要介绍的枚举式单例,不过在介绍枚举式单例之前,我们先刷一下副本,看看其它写法。

8. 注册式单例

将每一个实例都保存起来,然后在需要使用的时候直接通过唯一的标识获取实例,这便是注册式单例。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ContainerSingleton {
    private ContainerSingleton(){

    }

    private static Map<String,Object> ioc = new ConcurrentHashMap<>();

    public static Object getBean(String className){
        synchronized (ioc){
            //如果容器中不存在当前对象
            if(!ioc.containsKey(className)){
                Object obj = null;
                try {
                    obj = Class.forName(className).newInstance();
                    //将className作为唯一标识存入容器
                    ioc.put(className,obj);
                }catch (Exception e) {
                    e.printStackTrace();
                }
                return obj;
            }
        }
        //如果容器中已经存在了单例对象,则直接返回
        return ioc.get(className);
    }
}

新建一个空对象 MyObject.java,用来测试单例。

package singleton.register;

public class MyObject {
}

新建一个测试类 TestContainerSingleton.java

package singleton.register;

public class TestContainerSingleton {
    public static void main(String[] args) {
        MyObject myObject1 = (MyObject) ContainerSingleton.getBean("singleton.register.MyObject");
        MyObject myObject2 = (MyObject) ContainerSingleton.getBean("singleton.register.MyObject");

        System.out.println(myObject1 == myObject2);//输出:true
    }
}

上面返回 true 是因为我们加了 synchronized 关键字,实际上 Spring 框架中用的就是容器式单例,默认是线程不安全的。

9. 单线程安全单例

基于ThreadLocal实现,该单例不能保证其创建的对象是全局唯一,但是能保证在单个线程中是唯一的,在单线程环境下线程天生安全。

import java.util.concurrent.ThreadFactory;
public class ThreadLocalSingleton {
    private ThreadLocalSingleton(){

    }

    private static final ThreadLocal<ThreadLocalSingleton> singleton = new ThreadLocal<ThreadLocalSingleton>(){
        @Override
        protected ThreadLocalSingleton initialValue() {
            return new ThreadLocalSingleton();
        }
    };
    
    public static ThreadLocalSingleton getInstance(){
        return singleton.get();
    }
}

测试类:

public class TestThreadLocalSingleton {
    public static void main(String[] args) {
        System.out.println(ThreadLocalSingleton.getInstance());//主线程输出
        System.out.println(ThreadLocalSingleton.getInstance());//主线程输出

        Thread t1 = new Thread(()->{
           ThreadLocalSingleton singleton = ThreadLocalSingleton.getInstance();
            System.out.println(Thread.currentThread().getName() + ":" + singleton);
        });
        t1.start();
    }
}

从上图可以看到,main 线程输出的和 t1 线程输出的并不是同一个对象,故而 ThreadLocal 式示例仅对单线程是安全的。

10. 枚举式单例

枚举式单例充分利用了枚举类的特性来创建单例对象,目前来说这是最优雅的一种写法。

照例我们新建一个空的对象 MyObject.java 来测试单例。

package singleton.meiju;

public class MyObject {
}
public class EnumSingleton {
    INSTANCE;
    private MyObject myObject;

    EnumSingleton(){
        this.myObject = new MyObject();
    }

    public Object getData() {
        return myObject;
    }

    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
}
  • 新建测试类 TestEnumSingleton.java 进行测试。
package singleton.meiju;

public class TestEnumSingleton {
    public static void main(String[] args) throws Exception{
        EnumSingleton enumSingleton = EnumSingleton.getInstance();
        System.out.println(enumSingleton.getData() == enumSingleton.getData());//输出:true
    }
}

输出结果为 true,枚举式单例写法能有效的防止通过反射以及序列化手段的破坏,确实为目前最佳的单例实践之选。

小结

尽管实现单例模式的具体思想和方法多种多样,也各有千秋和不足,但在实际的使用中,并不是最优的就是最合适的,在使用单例模式时,应该结合具体的项目需求以及场景来选择合适的实现方式。比如小项目追求线程安全又拥有足够空间的情况下使用饿汉式单例又何尝不可?

致谢&引用: