设计模式之单例模式

91 阅读7分钟

单例模式概念

单例模式表示一个类在任何情况下有且仅有一个实例,并且提供一个全局访问点

单例模式写法

  1. 饿汉式单例
  2. 懒汉式单例
  3. 注册时单例
  4. ThreadLocal单例

饿汉式单例

饿汉式单例意为在类加载的时候就初始化一个实例对象。 代码如下:

public class HungrySingleton {

	//也可通过静态代码进行初始化
    private static HungrySingleton instance = new HungrySingleton();

    private HungrySingleton(){}

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

饿汉式单例的优缺点:

  • 缺点:由于饿汉式单例是在类加载的时候对象就创建好了,不管你使没使用过,这样会造成内存资源的浪费
  • 优点:执行速度快,因为在一开始就创建好了,使用到的时候直接从内存中拿,不需要进行初始化操作。另外没有线程安全问题

懒汉式单例

懒汉式单例就是一开始不创建,当你需要这个对象的时候才进行创建 代码如下:

public class LazySingleton {

    private static LazySingleton instance;

    private LazySingleton(){}

    public static LazySingleton getInstance(){
        if(instance == null){   //代码1
            instance = new LazySingleton();  //代码2
        }
        return instance;
    }
}

懒汉式单例优缺点:

  • 缺点:线程不安全。
  • 优点:避免内存资源的浪费。其实在上述代码这种情况下这句话是不对的,因为在多线程并发获取实例对象的情况下,会创建多次实例对象,这样怎么会更加的占用内存资源

懒汉式单例线程不安全情况分析

首先分析下多线程获取实例会出现哪些情况:

获取结果一致

这种情况就是多个线程获取到的对象都是同一个,但是这样的结果又有两种情况。
第一个情况是例如A、B两个线程顺序访问,获取到的对象始终是最先拿到对象的线程所创建的。
第二个情况就是A、B两个线程同时进入代码1时发现都是null,此时都进入代码2准备执行,这个时候A线程执行完代码2但还未返回,这个时候B线程执行完代码2将之前A线程创建的对象给覆盖了,这个时候A线程返回的就是B线程创建的对象。从结果看获取到的都是一个对象,但是对象创建了两次

获取结果不一致

例如A、B两个线程执行到代码1时发现都是null,于是就准备执行代码2,此时A线程执行完代码2并返回实例,然后B线程执行完代码2并返回实例,这样两次结果就不一致

所以其实不管获取到的结果是否一致,都会存在线程安全的问题。因此要对上述代码进行加锁,控制其并发访问

线程安全的懒汉式模式

public class SynchronizedLazySingleton {

    private static SynchronizedLazySingleton instance;

    private SynchronizedLazySingleton(){}

    public synchronized static SynchronizedLazySingleton getInstance(){
        if(instance == null){
            instance = new SynchronizedLazySingleton();
        }
        return instance;
    }
}

这种写法虽然避免了线程安全问题,但是由于synchronized是作用在方法级别上的,锁的粒度比较大,影响性能,一旦有多个线程进行访问,则会造成多个线程处于阻塞状态

双重检查锁模式

public class DoubleCheckLockSingleton {

    private **volatile** static DoubleCheckLockSingleton instance;

    private DoubleCheckLockSingleton(){}

    public static DoubleCheckLockSingleton getInstance(){
        //判断对象是否为空,从而决定是否阻塞
        if(instance == null){  //代码1
            synchronized (DoubleCheckLockSingleton.class) {
                //判断对象是否为空,从而决定是否要进行创建对象,避免二次创建对象
                if(instance == null) { //代码2
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
    
    public String getName(){
        return "Mary";
    }
}

双重检查锁机制避免了锁的粒度过大的问题,将锁的粒度降到最低。但为什么需要两个条件判断呢?这是因为如果没有代码2的话,一旦两个线程同时进入到代码1处判断,发现都是null,然后进入抢占锁阶段,不管是谁先抢到锁,都会创建两次实例,这样又出现了问题,所以需要加上代码2。
那为什么要加volatile关键字呢?这是如果不加volatile关键字,就会出现DCL半对象问题,接下来说明下什么是DCL半对象问题,以及怎样解决?

DCL半对象问题

如果多个对象获取到DoubleCheckLockSingleton对象,并且调用getName()方法,有可能会出现空指针异常的问题,这是因为获取到的对象还是个未初始化的对象。
出现这样的结果是因为new操作不是一个原子性操作,会发生指令重排序的情况。new的操作会有以下几个指令

  1. JVM分配一块内存A
  2. 在内存A上初始化对象
  3. 将内存A的地址赋给变量instance 但由于JVM会发生指令重排序,有可能顺序是1、3、2,这样的话会导致其他线程得到的对象是一个未初始化的对象,在调用getName()方法时会出现空指针异常。而volatile的作用就是禁止指令重排序,并且保证线程可见。这样就能解决这个问题。

懒汉式模式静态内部类写法

public class LazyInnerSingleton implement Serializable {

    private LazyInnerSingleton(){}

    public static LazyInnerSingleton getInstance(){
        return InnerSingle.instance;
    }

    private static class InnerSingle{
        private static LazyInnerSingleton instance = new LazyInnerSingleton();
    }

}

这种写法是利用java语法的特性,静态内部类在启动时不会加载,而是在用到的时候才进行加载。因此如果没用到这个对象,那么这个对象就不会创建,只有调用getInstance()方法时才会加载InnerSingle这个类,进而实例化LazyInnerSingleton对象。

暴力反射破坏单例

虽然上述几个单例写法都声明了私有构造器,但是通过暴力反射能够破坏单例。

public class ReflectSingletonTest {

    public static void main(String[] args) {
        LazyInnerSingleton innerSingleton = LazyInnerSingleton.getInstance();
        System.out.println(innerSingleton);
        Class clazz = LazyInnerSingleton.class;
        try {
            Constructor c = clazz.getDeclaredConstructor(null);
            //暴力反射
            c.setAccessible(true);
            Object obj = c.newInstance();
            System.out.println(obj);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

执行结果如下: image.png 解决方式就是在私有构造器中加个判断即可,这样当第二次创建对象的时候就会直接抛出异常

private LazyInnerSingleton(){
    if(InnerSingle.instance != null){
        throw new RuntimeException("已有实例,禁止构建");
    }
}

序列化破坏反射

即使在私有构造器中加了一个判断,但通过序列化与反序列还是能够破坏单例,如下述实例:

public class SerializableSingletonTest {
    public static void main(String[] args) throws Exception {
        LazyInnerSingleton lazyInnerSingleton = LazyInnerSingleton.getInstance();
        FileOutputStream fos = new FileOutputStream("LazyInnerSingleton.txt");
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        oos.writeObject(lazyInnerSingleton);
        oos.flush();
        oos.close();

        FileInputStream fis = new FileInputStream("LazyInnerSingleton.txt");
        ObjectInputStream ois = new ObjectInputStream(fis);
        Object obj = ois.readObject();
        ois.close();
        System.out.println(lazyInnerSingleton);
        System.out.println(obj);
    }
}

执行结果:

image.png 解决方式是加上在类中加上readResolve()方法

private Object readResolve(){
    return InnerSingle.instance;
}

原因是因为readObject()方法会判断是否含有readResolve()方法,如有就使用这个方法返回的对象,如果没有就新建一个对象

注册式单例

注册式单例就是把实例登记到一个地方,使用唯一标识进行标记。主要有枚举式单例和容器式单例

枚举式单例

public enum EnumSingleton {

    INSTANCE;

    private String data;

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

    public String getData(){
        return this.data;
    }

    public static EnumSingleton getInstance(){
        return INSTANCE;
    }

}

枚举式单例能够确定上下文中只有一个,并且都无法通过暴力反射和序列化反射来破坏单例。 首先看下暴力反射会出现什么情况,具体代码就不写了,将上述暴力反射代码修改下就行。直接看结果:

截屏2022-04-29 上午11.26.09.png 可以看到,这里直接报错了,大概意思就是没有这个方法(无参构造器)。这一因为枚举类他的父类是Enum,这个类中没有无参构造器,可以看下Enum类的源码,发现他只有一个带有两个参数的构造器,如下

截屏2022-04-29 下午1.41.11.png 那我们在反射时加入参数调试下:

截屏2022-04-29 下午2.14.02.png 调试结果如下:

截屏2022-04-29 下午2.14.49.png 这个错误明确的告知我们不能利用反射来创建枚举对象。所以,利用枚举来创建单例是最合适的。

容器式单例

虽然枚举式单例看起来比较合适,但是还是那个问题,一旦项目中需要大批量的单例,就会很耗内存资源,因为在加载的时候就会进行初始化。接下来看下容器式单例。

public class ContainerSingleton {

    private ContainerSingleton(){}

    private static Map<String,Object> container = new HashMap<>();

    public static Object getInstance(String instanceName){
        if(!container.containsKey(instanceName)){
            try {
                Object obk = Class.forName(instanceName).newInstance();
                container.put(instanceName,obk);
                return obk;
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            } 
        }
        return container.get(instanceName)
    }

}

容器式单例适合创建大批量的单例,并且在需要的时候才会进行加载,还是比较适合的,但是这里同样有线程安全的问题,可以利用双重检查锁来解决。但是有个问题,就是这里如果使用ConcurrentHashMap有没有线程安全的问题呢?由于ConcurrentHashMap源码没怎么看过,这个问题暂时本人无法解决,之后找个时间看下其源码,然后在补充下,先在这里打个标识。

ThreadLocal单例

所谓ThreadLocal单例就是不能保证全局唯一,但能保证在单个线程中是唯一的,并且天然线程安全。他的写法是这样的。

public class ThreadLocalSingleton {

    private ThreadLocalSingleton(){}

    public static ThreadLocal<ThreadLocalSingleton> threadLocal = new ThreadLocal<ThreadLocalSingleton>(){
        @Override
        protected ThreadLocalSingleton initialValue() {
            return new ThreadLocalSingleton();
        }
    };

    public static ThreadLocalSingleton getInstance(){
        return threadLocal.get();
    }
}