设计模式系列-------单例设计

单例模式(Singleton Pattern)是最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。我会通过不同的版本,演示单例代码。

内容总结:

Table of Contents

为什么要使用单例

1、 处理资源访问冲突

2、表示全局唯一类

3、避免资源重复创建和销毁

如何实现一个单例

饿汉式:

懒汉式:

双重检测:

静态内部类版

枚举单例模式(最佳代码)

使用CAS实现单例

使用ThreadLocal 实现单例

单例模式对比

饿汉式,静态内部类和枚举是怎么保证线程安全的

如何破坏单例和避免单例被破坏

单例存在什么问题 

有何替代的解决方案


为什么要使用单例

1、 处理资源访问冲突

避免对资源的多重占用,如果存在多个对象对资源进行操作,无法保证运行结果能按照我们要求的执行。

案例一:

假设日志类,向一个文件中写日志,如果存在多个对象向同一个文件中写日志,可能会出现日志覆盖的现象。

2、表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份。

案例一:

配置信息类。在系统中,我们只有一个配置文件,配置文件被加载到内存后,以对象的形式存在,应该也只有一个对象。

案例二:

例如要实现一个全局的id生成器,如果有两个对象,就会存在生成重复id的问题。

3、避免资源重复创建和销毁

如果有些资源创建或者销毁需要消耗大量的资源,那么使用单例可以减少内存的开销,提升性能。

如何实现一个单例

简单来说,要实现一个单例模式,要考虑以下几点:

  1. 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;

  2. 考虑对象创建时的线程安全问题;

  3. 考虑是否支持延迟加载;

  4. 考虑 getInstance() 性能是否高(是否加锁)。

实现方式

饿汉式:

饿汉式指在类加载时,就创建对应的实例,实现简单。由于一加载类就完成单例的创建,因此在获取实例的时候不会有线程安全问题。

有人觉得这种方式不支持延迟加载,占用内存,或者是系统初始化时间长,提前初始化是一种浪费资源的表现。但是我觉得这个观点不准确,如果占用资源太多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),这样我们就能提前发现问题,而不是在运行一段时间以后,系统突然因为初始化这个对象导致占用资源过多,导致系统崩溃。而且如果初始化时间长,那么我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能。例如用户在调用时,做这个初始化动作,会导致响应时间变长,甚至超时。

public class SingletonObject {

    private static final SingletonObject singletonObject = new SingletonObject();

    private SingletonObject() {}

    public static SingletonObject getInstance() {
        return singletonObject;
    }
}

懒汉式:

懒汉式指在加载类的时候并不创建实例,而是在获取实例的时候才去加载实例。相对于饿汉式的优势是支持延迟加载,缺点是性能低,因为加了synchronized,因此并发度为1。

public class SingletonObject {

    private static SingletonObject singletonObject = null;

    private SingletonObject() {}

    public static synchronized SingletonObject getInstance() {
        if (singletonObject == null) {
            singletonObject = new SingletonObject();
        }
        return singletonObject;
    }
}

双重检测:

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。

拓展(正确性有待探究):在很低版本java中可能会因为指令重排的原因,导致单例new以后(分配地址),还没来得及初始化就被其他线程使用。要解决这个问题,需要在单例前添加volatile关键字。但是在高版本中,Java已经在JDK内部解决了这问题,把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序,所有不用加volatile。这个特性貌似跟不同类型的编译器有关,如果我们使用一个基于直接访问对象的编译器(如HotSpot默认编译器),这个地方不加volatile关键字也不会出现问题。而如果我们使用一个基于句柄方式访问对象的编译器(如Symantec JIT),不加volatile关键字可能会导致重排序,返回一个未初始化完成的实例。

/**
 * 懒汉式,线程安全,
 * 使用双重检测机制初始化实例。
 * 当两个线程都要初始化实例时,只有一个能初始化。
 */
public class SingletonObject {

    /*
    volatile必须要加,因为singletonObject = new SingletonObject()这个指令实际上会被分解为
    1、分配对象内存
    2、调用构造器方法,执行初始化
    3、将对象引用赋值给变量。
    如果不加volatile,可能会被执行顺序可能为132,那么对象还没有初始化,但是singletonObject 就已经不为空了。(但貌似不同编译器不加volatile也不会指令重排)
    */
    private volatile static SingletonObject singletonObject = null;

    private SingletonObject() {}

    public static SingletonObject getInstance() {
        //因为volatile修饰的静态变量访问比较慢,如果不用局部变量则getInstance需要多次访问singletonObject变量,使用局部变量可以有一定的性能提升。
        SingletonObject temp = singletonObject ;
        if (temp == null) {
            synchronized (temp .class) {
                if (temp == null) {
                    temp = new SingletonObject();
                    singletonObject =temp;
                }
            }
        }
        return singletonObject;
    }
}

静态内部类版

静态内部类它有点类似饿汉式,但又能做到了延迟加载。只有在调用getInstance()时,LazyHolder才会被加载,这个时候才会初始化singletonObject 。 singletonObject 的唯一性、创建过程的线程安全性,都由 JVM 来保证。

/**
 * 静态内部类版,线程安全,
 * 使用静态内部类
 */
public class SingletonObject {
    
    private static class LazyHolder{
        private static final SingletonObject singletonObject = new SingletonObject();
    }
    
    private SingletonObject() {}
    
    public static SingletonObject getInstance() {
        return LazyHolder.singletonObject;
    }
}

枚举单例模式(最佳代码)

这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。 还可以有效防止序列化和反序列化造成多个实例和利用反射创建多个实例的情况。

public enum SingletonObject {

    SINGLETON_OBJECT,
    
    //然后写你要的方法或者成员变量,在其他类中,可以通过SingletonObject.SINGLETON_OBJECT来调用方法或者获取成员变量
   
}

使用CAS实现单例

这种方式类似于双重判断锁生产单例,都是先判断单例是否为null,如果为null,则通过线程安全的方式生成单例对象。

跟用同步锁实现相比,这种方式的优点是CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

缺点则是在于如果忙等待一直执行不成功(一直在死循环中),会对CPU造成较大的执行开销。另外,代码中,如果N个线程同时执行到 singleton = new Singleton();的时候,会有大量对象被创建,可能导致内存溢出。:

/**
* 这种方式来自 公众号 程序员面试现场《面试官真是搞笑!让实现线程安全的单例,又不让使用synchronized!》
*/
public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();

    private Singleton() {}

    public static Singleton getInstance() {
        for (;;) {
            Singleton singleton = INSTANCE.get();
            if (null != singleton) {
                return singleton;
            }

            singleton = new Singleton();
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}

使用ThreadLocal 实现单例

这种做法是每个线程都拥有自己的单例,在线程范围内保证每次获取的单例是唯一的,但是不同线程获取的单例是不同的。这种写法可以当成炫技,实用性不高。

/**
* 这种方式来自 公众号 程序员面试现场《面试官真是搞笑!让实现线程安全的单例,又不让使用synchronized!》
*/
public class Singleton {
     private static final ThreadLocal<Singleton> singleton =
     new ThreadLocal<Singleton>() {
         @Override
         protected Singleton initialValue() {
            return new Singleton();
         }
     };
     public static Singleton getInstance() {
        return singleton.get();
     }
     
     private Singleton() {}
}

 

单例模式对比

饿汉式:不支持延迟加载,但是线程安全

懒汉式:支持延迟加载,但是如果要保证线程安全,那么性能就很差

双重检测:支持延迟加载,并且线程安全。但是要在单例前添加volatile关键字,不然可能会出现因为指令重排导致的bug

静态内部类:既支持延迟加载,也支持高并发,实现起来也比双重检测简单。

枚举:保证了实例创建的线程安全性和实例的唯一性。 还可以有效防止序列化和反序列化造成多个实例和利用反射创建多个实例的情况。(推荐做法)

CAS:不需要传统的锁保证线程安全,没有线程切换和阻塞的额外消耗吗,但是如果一直在等待,会消耗CPU的资源,同时如果很多线程同时创建对象,可能会内存溢出。

ThreadLocal :通过ThreadLocal 维护一个线程范围的单例,提供一种思路,但是实用性不高。

 

饿汉式,静态内部类和枚举是怎么保证线程安全的

饿汉式,静态内部类

通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。所以, 除非被重写,这个方法默认在整个装载过程中都是线程安全的。所以在类加载过程中对象的创建也是线程安全的。

枚举

枚举其实底层是依赖Enum类实现的,这个类的成员变量都是static类型的,并且在静态代码块中实例化的,和饿汉有点像, 所以他天然是线程安全的。

如何破坏单例和避免单例被破坏

反射

方法一:在构造函数中添加判断是否创建新的对象的逻辑

反射是一种常用的破坏单例的方式,通过反射,我们可以获取到单例对象的构造方法(原来构造方法上的private字段失效了),创建新的对象,简单实现如下:

    /**
    * 通过反射破坏单例
    */
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();
        try {
            Class<Singleton> singleClass = (Class<Singleton>) Class.forName("czz.study.basic.Singleton");

            Constructor<Singleton> constructor = singleClass.getDeclaredConstructor(null);

            constructor.setAccessible(true);

            Singleton singletonByReflect = constructor.newInstance();

            System.out.println("singleton : " + singleton);
            System.out.println("singletonByReflect : " + singletonByReflect);
            System.out.println("singleton == singletonByReflect : " + (singleton == singletonByReflect));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

----------------------运行结果----------
singleton : czz.study.basic.Singleton@448139f0
singletonByReflect : czz.study.basic.Singleton@7cca494b
singleton == singletonByReflect : false

要想避免这种情况,我们只需要在构造器中添加判断单例是否存在的代码。

    private Singleton() {
        if (singletonObject != null) {
            throw new RuntimeException("创建失败,单例已存在");
        }
    }
------------------运行结果----------------

java.lang.reflect.InvocationTargetException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
	at czz.study.Application.main(Application.java:26)
Caused by: java.lang.RuntimeException: 创建失败,单例已存在
	at czz.study.basic.Singleton.<init>(Singleton.java:21)
	... 5 more

方法二:使用枚举

使用枚举之所以可以避免反射创建新的对象,原因有以下几点:

①枚举Enum是个抽象类,其实一旦一个类声明为枚举,实际上就是继承了Enum,所以会有(String.class,int.class)的构造器。

②由于我们没有定义无参构造函数,当我们使用反射Singleton.class.getDeclaredConstructors()去获取枚举的构造函数,只能获取到父类的构造函数。

③当我们使用父类的构造函数.newInstance()初始化对象时,从Constructor类的newInstance方法源码中,我们会发现,反射在通过newInstance创建对象时,会检查该类是否ENUM修饰,如果是则抛出异常,反射失败。

    @CallerSensitive
    public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, null, modifiers);
            }
        }
        //就是(clazz.getModifiers() & Modifier.ENUM) != 0这个判断
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
    }

 

序列化和反序列化

序列化和反序列化是另一种破坏单例的方式,因为在对象反序列化的过程中,序列化会通过反射调用无参数的构造方法(跟我们调用的反射构造器不是同一个)创建一个新的对象,所以,通过反序列化也能破坏单例。

    /**
    * 通过序列化和反序列化破坏单例
    */ 
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();

        //Write Obj to file
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(new FileOutputStream("tempFile"));
            oos.writeObject(singleton);
            //Read Obj from file
            File file = new File("tempFile");

            ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
            Singleton singletonBySerialize = (Singleton) ois.readObject();

            System.out.println("singleton : " + singleton);
            System.out.println("singletonBySerialize : " + singletonBySerialize);
            System.out.println("singleton == singletonBySerialize : " + (singleton == singletonBySerialize));

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
--------------运行结果--------------------
singleton : czz.study.basic.Singleton@4dd8dc3
singletonBySerialize : czz.study.basic.Singleton@1a86f2f1
singleton == singletonBySerialize : false

 从运行结果发现,发序列化后的对象跟之前的对象不再是同一个对象,要避免也很简单,修改反序列化策略(在单例中添加readResolve())方法,因为反序列化过程中,在反序列化执行过程中会执行到ObjectInputStream#readOrdinaryObject方法,这个方法会判断对象是否包含readResolve方法,如果包含的话会直接调用这个方法获得对象实例

/**
* 添加反序列化策略
*/
private Object readResolve() {
    return getSingleton();
}

----------------------运行结果---------------------------
singleton : czz.study.basic.Singleton@4dd8dc3
singletonBySerialize : czz.study.basic.Singleton@4dd8dc3
singleton == singletonBySerialize : true

你可能会问,为什么不通过前面说的在构造器中添加异常来避免,我试了一下,并不能避免。反序列化使用的反射构造器和我们代码中使用反射的构造器不是同一个,反序列化用到的构造器并不会调用到我们对象中的构造函数。

方法二:使用枚举

在枚举序列化的时候,Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。因此,保证了反序列化后的对象是同一对象(更加深入的原因以后再探究)。

 

单例存在什么问题 

  • 对OOP特性的支持不友好。OOP 的四大特性是封装、抽象、继承、多态。单例对抽象,继承,多态的支持都不友好。
    • 抽象:单例违背了基于接口而非实现的设计原则。如果我们以后要根据不同的业务实现的单例,就要修改所有用到单例的地方
    • 继承、多态:理论上来说,单例可以被继承,也可以实现多态,但是会让代码看起来很奇怪。
  • 会隐藏类之间的依赖关系

在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。 通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

  • 对代码的拓展性不友好

单例只能有一个对象实例,如果以后需要创建多个实例,那就要对代码有较大的改动。

  • 对代码的可测性不友好

如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。

除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

  • 不支持有参数的构造器

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小(指的是通过构造器传递参数)。

 

有何替代的解决方案

可以使用工厂模式或者IOC容器解决

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值