单例模式详解

1. 什么是单例模式

定义

单例模式是指在内存中有且只创建一次对象的设计模式,当在程序中可以被多次使用,且每次都是同一个对象其作用相同。

作用

防止频繁地创建对象使内存飙升

让所有需要调用的地方都共享这一单例对象

2. 单例模式的类型

  • 懒汉式:在真正需要使用对象时才去创建该单例类对象
  • 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用

懒汉式

在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象,否则先执行实例化操作。

代码

public class LazyMan{
	private LazyMan(){
        
    }
    private static LazyMan lazyMan;
    
    public static LazyMan getInstance(){
        if(lazyMan == null){
            lazyMan = new LazyMan();
        }
        return lazyMan;
    }
}

饿汉式

饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即在编码时就已经指明要创建的对象,不需要等到被调用时再去创建

代码

public class HungryMan{
    private HungryMan(){
        
    }
    private final static HungryMan hungryMan = new HungryMan();
 	public static HungryMan getInstance(){
        return hungryMan;
    }   
}

注意上面的代码在第5行已经实例化好了一个HungryMan对象在内存中,不会有多个HungryMan对象实例存在

类在加载时会在堆内存中创建一个HungryMan对象,当类被卸载时,HungryMan对象也随之消亡了。

3. 讨论

懒汉式如何保证只创建一个对象?

如果两个线程同时判断singleton为空,那么它们都会去实例化一个Singleton对象,这就变成双例了。所以,我们要解决的是线程安全问题。

1. 线程安全

synchronized 或 Lock

代码

public static LazyMan getInstance(){
	synchronized(LazyMan.class){
        if(lazyMan == null){
            lazyMan = new LazyMan();
        }
    }
    return lazyMan;
}
// 或
public synchronized static LazyMan getInstance(){
    if(lazyMan == null){
        lazyMan = new LazyMan();
    }
    return lazyMan;
}

问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。

优化性能,目标是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

Double Check(双重校验) + Lock(加锁)

public static LazyMan getInstance(){
    // 线程A和线程B同时看到lazyMan = null,如果不为null,则直接返回lazyMan
    if(LazyMan == null){
        // 线程A或线程B获得该锁进行初始化
        synchronized(LazyMan.class){ 
            // 其中一个线程进入该分支,另外一个线程则不会进入该分支
            if(lazyMan == null){
                lazyMan = new LazyMan();
            }
        }
    }
    return lazyMan;
}

解决了并发安全+性能低效问题

2. 指令重排

通过JVM的学习,我们知道

使用volatile防止指令重排

创建一个对象,在JVM中会经过三步:

(1)为singleton分配内存空间

(2)初始化singleton对象

(3)将singleton指向分配好的内存空间

指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能

使用volatile关键字可以防止指令重排序,其原理较为复杂,这篇博客不打算展开,可以这样理解:使用volatile关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了

public class LazyMan{
    private volatile static LazyMan lazyMan;
    private LazyMan(){
        
    }
    public static LazyMan getInstance(){
        if(lazyMan == null){
            synchronized(LazyMan.class){
                if(lazyMan == null){
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
}

破坏懒汉式单例与饿汉式单例

无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。

反射

public class TestSingle {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
        // 通过反射获取属性doif
        Field doif = LazyMan.class.getDeclaredField("doif");
        //可获取属性
        doif.setAccessible(true);
        
        // 获取类的显式构造器
        Constructor<LazyMan> constructor = LazyMan.class.getDeclaredConstructor(null);
        // 可访问私有构造器
        constructor.setAccessible(true);

        LazyMan lazyMan1 = constructor.newInstance();
        // 设置属性 doif 为 false
        doif.set(lazyMan1,false);
        LazyMan lazyMan2 = constructor.newInstance();

        System.out.println(lazyMan1);
        System.out.println(lazyMan2);
    }
}
// 懒汉式单例
public class LazyMan {
    private static boolean doif = false;
    private LazyMan(){
        synchronized (LazyMan.class){
            if (doif == false){
                doif = true;
            }else {
                throw new RuntimeException("不要试图使用反射破坏异常");
            }
        }
    }
    private volatile static LazyMan lazyMan;

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

通过测试可知即使添加属性也可以通过反射破坏懒汉式单例

即利用反射,强制访问类的私有构造器,去创建另一个对象

序列化与反序列化

// 序列化与反序列化
public static void testIO() throws IOException, ClassNotFoundException{
    // 创建输出流
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("LazyMan.file"));
    // 将单例对象写到文件中
    oos.writeObject(LazyMan.getInstance());
    // 从文件中读取单例对象
    File file = new File("LazyMan.file");
    ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
    LazyMan newInstance = (LazyMan) ois.readObject();
    // 判断是否是同一个对象
    System.out.println(newInstance == LazyMan.getInstance()); // false
}

两个对象地址不相等的原因是:readObject() 方法读入对象时,它必定会返回一个新的对象实例,必然指向新的内存地址。

解决

我们已经掌握了懒汉式与饿汉式的常见写法了,在《大话设计模式》中的单例模式章节也止步于此。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法,话不多说,学完下面,真的不虚面试官考你了。

在 JDK 1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举

public Enum Singleton{
    INSTANCE;
    public Singleton Singleton(){
 		return INSTANCE;
    }
}
// 枚举类型单例模式
public static void testEnum() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
    Singleton t1 = Singleton.INSTANCE;
    // java.lang.NoSuchMethodException
    Constructor<Singleton> d = Singleton.class.getDeclaredConstructor(String.class,int.class);
    d.setAccessible(true);
    // Cannot reflectively create enum objects
    Singleton t2 = d.newInstance();
    System.out.println("t1和t2的地址是否相同:"+ t1);
    System.out.println("t1和t2的地址是否相同:"+ t2);

}	

面试小亮点

尝试利用反射破坏,发现出现java.lang.NoSuchMethodException异常,没有空参构造方法

枚举类型的最终反编译源码:发现空参构造方法带String s,int i

详细代码
// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3)
// Source File Name: EnumSingle.java
package com.kuang.single;
public final class EnumSingle extends Enum
{
    public static EnumSingle[] values()
    {
        return (EnumSingle[])$VALUES.clone();
    }
    public static EnumSingle valueOf(String name)
    {
        return (EnumSingle)Enum.valueOf(com/kuang/single/EnumSingle, name);
    }
    private EnumSingle(String s, int i)
    {
        super(s, i);
    }
    public EnumSingle getInstance()
    {
        return INSTANCE;
    }
    public static final EnumSingle INSTANCE;
    private static final EnumSingle $VALUES[];
    static
    {
        INSTANCE = new EnumSingle("INSTANCE", 0);
        $VALUES = (new EnumSingle[] {
            INSTANCE
        });
    }

需要思考:使用枚举实现单例模式的优势在哪里?

我们从最直观的地方入手,第一眼看到这几行代码,就会感觉到“少”,没错,就是少,虽然这优势有些牵强,但写的代码越少,越不容易出错。

优势1:代码对比饿汉式与懒汉式来说,更加地简洁

其次,既然是实现单例模式,那这种写法必定满足单例模式的要求,而且使用枚举实现时,没有做任何额外的处理。

优势2:它不需要做任何额外的操作去保证对象单一性与线程安全性

我写了一段测试代码放在下面,这一段代码可以证明程序启动时仅会创建一个 Singleton 对象,且是线程安全的。

我们可以简单地理解枚举实现单例的过程:在程序启动时,会调用Singleton的空参构造器,实例化好一个Singleton对象赋给INSTANCE,之后再也不会实例化

优势3:使用枚举可以防止调用者使用反射序列化与反序列化机制强制生成多个单例对象,破坏单例模式。

防破坏的原理如下:

(1)防反射

枚举类默认继承了 Enum 类,在利用反射调用 newInstance() 时,会判断该类是否是一个枚举类,如果是,则抛出异常。

(2)防止反序列化创建多个枚举对象

在读入Singleton对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的类型和变量名输出到文件中,在读入文件反序列化成对象时,利用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。

所以,在序列化和反序列化的过程中,只是写出和读入了枚举类型和名字,没有任何关于对象的操作。

小总结:

(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象

(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象

(3)枚举类不需要关注线程安全、破坏单例和性能问题,因为其创建对象的时机与饿汉式单例有异曲同工之妙

总结

单例模式常见的写法有两种:懒汉式、饿汉式

(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题

(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。

(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;

(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题

(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加volatile关键字防止指令重排序

(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例

posted @ 2022-03-03 23:36  鬼面笑脸  阅读(159)  评论(0编辑  收藏  举报