Java 单例模式的二三事

文章详细介绍了Java中的单例模式,包括饿汉模式、懒汉模式(双重锁机制)和静态内部类方式,强调了volatile关键字在多线程环境中的作用,防止指令重排序导致的问题。同时,文章还讨论了如何通过反射和序列化打破单例,并提出了相应的反制措施,如在构造器中添加异常判断和实现readResolve方法。
摘要由CSDN通过智能技术生成

        单例模式是日常开发中比较常见,使用比较多的设计模式之一,它保证了该类在运行中只有一个实例对象。根据其特性可以用在线程池、上传/下载管理、数据库管理等地方。

        对于有一定开发经验的小伙伴们来说写个线程安全的单例并不是难事,无非是构造私有,提供获取实例的静态方法并保证不同模式下的线程安全,本文对基础不过多赘述,咱们主要讨论一些单例中的细节。

一、单例模式

        讨论细节还是得把咱们常用的单例代码给搬出来

        1.1、饿汉模式:

        饿汉模式的特点是提前创建,直接使用。

public class HungerSingleton {
    private static HungerSingleton INSTANCE = new HungerSingleton();

    private HungerSingleton() {
    }

    public static HungerSingleton getInstance() {
        return INSTANCE;
    }
}

        饿汉模式比较简单,在类初始化的时候就在内存中创建好了实例对象,通过类加载机制保证了线程安全,有个问题是无论有没有使用到该类,其实例对象会一直占用着内存空间。

        1.2、双重锁机制下的懒汉模式

        懒汉模式的特点是随用随创建,当遇到多线程时需要自己来实现线程安全问题。

public class LazySingleton {
    private volatile static LazySingleton INSTANCE;

    private LazySingleton() {
    }

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

        双重锁机制是在获取实例对象的方法中先进行第一次实例判空,主要是为了如果创建过就直接返回没有必要再次加锁;如果为空则进行加锁再判空,最后创建并返回单例对象。当然还有比较重要的一步是需要在变量前加上volatile关键词修饰,防止指令重排序。

        关于volatile:

        相信很多小伙伴用此模式写单例类的时候会忘记加volatile修饰,或者知道加volatile但是不太清楚加这个关键词的具体作用,在这里我们浅说一下

 volatile是一种轻量级同步机制,有保证可见性、禁止指令重排等特性,咱们在这主要讲一下禁止指令重排。

        我们知道JVM是无法直接运行.java文件的,它需要通过Java编译器编译成.class文件后才能在JVM中运行,在编辑器中我们看.class文件好像跟我们写的代码没啥太大区别,但是在JVM中会把.class翻译成一条条的指令。

        我们在编辑器中可以通过javap -c反编译.class可以看到以下输出:

        学过汇编的小伙伴们可能会感到一股“亲切”,其中每一行就是一条指令,其中比较重要的有new开辟空间、invokespecial初始化、putstatic变量赋值等操作。

        但是编辑器或者CPU为了优化程序的执行性能可能会对指令进行重新排序,比如下图展示:

        可以看出单线程下将赋值和初始化的指令顺序替换,对与第4步的使用并没有影响。

        但是多线程的话就会出现如下情况:

         当线程1执行完赋值操作但是没有初始化的时候,线程2进入判断会发现该成员已经被赋值不为null,进而直接返回使用发现没有初始化,程序的执行就会出现异常。而volatile就是防止此事件发生,让指令禁止排序,顺序执行。

        回归正题可以看出此模式能尽可能的节约空间,但是存在锁也就代表着时间上的消耗。

        那么问题来了有没有既可以懒加载又能不使用锁提高效率的单例模式呢,答案是有的,通过静态内部类的方式创建单例对象

        1.3、静态内部类方式

public class InnerClassSingleton {
    private InnerClassSingleton() {
    }

    public static InnerClassSingleton getInstance() {
        return Singleton.singleton;
    }

    private static class Singleton {
        private static InnerClassSingleton singleton = new InnerClassSingleton();
    }
}

        首先在静态内部类和饿汉模式一样先初始化静态成员变量,通过类加载机制保证了线程安全;由于是内部类所以外部类初始化的时候不会立即加载内部类,内部类不加载那内部的变量自然也不会占用内存,从而保证了延迟加载,这样高效且懒加载的单例方式遍实现了。

        1.4、其他方式

        创建单例还可以通过枚举的方式,不过局限性比较大,这里就不再细说了。

二、单例反制

        上述的单例模式真的能保证实例对象唯一吗?这个当时不是。

        2.1、反射创建

        我们知道通过反射是可以创建新的实例的,如下代码:

try {
    InnerClassSingleton instance = InnerClassSingleton.getInstance();
    Constructor<InnerClassSingleton> constructor = InnerClassSingleton.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    InnerClassSingleton singleton = constructor.newInstance();

    System.out.print(instance == singleton);
} catch (Exception e) {
    e.printStackTrace();
}

        打印结果会发现两个对象并不是一个

        解决方法:

        类可以多次创建,但是静态对象在内存只能有一个引用,并且反射创建也是要走构造器的,那么我们可以在构造器里加入以下判断:

private InnerClassSingleton() {
    if (Singleton.singleton != null) {
        throw new RuntimeException("单例不能有多个实例");
    }
}

        在构造器中判断静态变量是否被赋值,只要检测被赋值代表已经初始化过,那直接可以抛出异常提醒。

        测试结果:

        局限性:相信细心的小伙伴就会发现,这种方式只能用于懒汉模式下的单例创建,确实这样,因为饿汉模式在开始就把单例初始化了,所以无法再进行判断。

        2.2、序列化创建

        序列化在日常开发中也是比较常见,但是用在单例上面那问题就会出来了

        将单例进行序列化和反序列化:

//1、实现Serializable接口
public class InnerClassSingleton implements Serializable {
    /**
     * 防止类更新修改后反序列化失败,保证序列化文件id的统一
     */
    static final long serialVersionUID = 42L;
    //....
}

InnerClassSingleton instance = InnerClassSingleton.getInstance();

//2、序列化生成本地数据文件
try {
    FileOutputStream fosSingleton = new FileOutputStream("singletonInfo");
    ObjectOutputStream oos = new ObjectOutputStream(fosSingleton);
    oos.writeObject(instance);
    oos.close();
} catch (Exception e) {
    e.printStackTrace();
}

        输出的序列化文件:

         通过文件进行反序列化操作:

//3、反序列化
try {
    FileInputStream fisSingleton = new FileInputStream("singletonInfo");
    ObjectInputStream ois = new ObjectInputStream(fisSingleton);
    InnerClassSingleton o = (InnerClassSingleton) ois.readObject();
    ois.close();
    //对比
    System.out.print(instance == o);
} catch (Exception e) {
    e.printStackTrace();
}

        结果测试:

         可以看出通过序列化的方式也能产生不同的对象,并且反序列化创建有独有的机制对象没有走构造器,所以在构造器中判断没有生效。

        解决方法:

        其实如何预防这种情况在序列化注释中已经给出了解决方案:

Classes that need to designate a replacement when an instance of it is read from the stream should implement this special method with the exact signature.

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

如果从流中读取的对象需要替换成指定的类的话需要复写readResolve方法,其中修饰符随意,返回Object。

        根据描述我在单例中添加一下代码:

public class InnerClassSingleton implements Serializable {
    /**
     * 防止修改后反序列化失败
     */
    static final long serialVersionUID = 42L;
    //......
    
    /**
    * 复写readResolve,并返回单例对象
    */
    public Object readResolve() throws ObjectStreamException {
        return Singleton.singleton;
    }
}

        原理探究:

        那么为什么复写该方法就会保证实例统一呢,这里我们需要看一下ObjectInputStream的readObject方法内部具体实现原理。

        首先方法内调用到readObject(Class)方法

public final Object readObject()
    throws IOException, ClassNotFoundException {
    return readObject(Object.class);
}

        然后根据返回的obj,调用到readObject0(Class , boolean)方法

private final Object readObject(Class<?> type)
    throws IOException, ClassNotFoundException
{
    //....
    try {
        Object obj = readObject0(type, false);
        //.....
        return obj;
    } finally {
        passHandle = outerHandle;
        if (closed && depth == 0) {
            clear();
        }
    }
}

        根据读取的类型调用到readOrdinaryObject(boolean)方法

private Object readObject0(Class<?> type, boolean unshared) throws IOException {
    boolean oldMode = bin.getBlockDataMode();
    //...
    byte tc;
    while ((tc = bin.peekByte()) == TC_RESET) {
        bin.readByte();
        handleReset();
    }
    //...
    try {
        switch (tc) {
            //...
            case TC_OBJECT:
                if (type == String.class) {
                    throw new ClassCastException("Cannot cast an object to java.lang.String");
                }
                //根据读取类型调用readOrdinaryObject方法。
                return checkResolve(readOrdinaryObject(unshared));
            //...
        }
    }
    //...
}

        在readOrdinaryObject可以看到读取了ReadResolve方法

private Object readOrdinaryObject(boolean unshared)
    throws IOException
{
    //......
    Object obj;
    try {
        //先通过反射创建出新的实例对象
        obj = desc.isInstantiable() ? desc.newInstance() : null;
    } catch (Exception ex) {
        throw new InvalidClassException(desc.forClass().getName(),
                                    "unable to create instance", ex);
    }
    //......
    if (obj != null &&
        handles.lookupException(passHandle) == null &&
        desc.hasReadResolveMethod())
    {
        //反射执行ReadResolve方法,并返回对象,如果没有复习则返回null
        Object rep = desc.invokeReadResolve(obj);
        if (unshared && rep.getClass().isArray()) {
            rep = cloneArray(rep);
        }
        //返回的对象与新创建的对象对比是否一样
        if (rep != obj) {
            // Filter the replacement object
            if (rep != null) {
                if (rep.getClass().isArray()) {
                    filterCheck(rep.getClass(), Array.getLength(rep));
                } else {
                    filterCheck(rep.getClass(), -1);
                }
            }
            //如果不一样则将ReadResolve返回的对象赋值给obj
            handles.setObject(passHandle, obj = rep);
        }
    }
    //最后返回
    return obj;
}

        可以看出在反序列化读取对象的时候,会检测ReadResolve方法的执行,并且根据我们复写方法的返回值来制定反序列化返回的具体对象,如果返回的单例对象就能保证反序列化后对象的统一。

三、总结

        综上可以看出在使用单例的方式比较多种,比较优雅的是通过静态内部类的方式创建,既能保证懒加载又能保证性能,同时为了防止其他方式的创建,可以加入一些必要的反制措施。

        单例的细节就说到这里,如有不足欢迎指出~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值