懒汉,饿汉单例优缺点分析,内部类单例,枚举单例,容器单例等实现及优缺点分析

单例模式作为非常重要的一种设计模式,其实现方式多种多样,本文介绍懒汉,饿汉单例,内部类单例,枚举单例以及它们可能存在的失效场景分析.

先说结论:单例模式需要注意的是线程安全,序列化和反序列化安全及防止反射攻击,懒加载模式在处理这几个问题时比较麻烦甚至无解(反射攻击),枚举单例则简洁与安全.

接下来对这些内容举例.依旧通过代码分析.

首先看看懒加载单例

public class LazySingleton implements Serializable {
    private static LazySingleton instance;

    private LazySingleton(){

    }

    public static LazySingleton getInstance(){
        //线程不安全
        if (instance==null){
            instance=new LazySingleton();
        }
        return instance;
    }
}

上述代码线程不安全,通过一个测试方法来看看现象,这里说个题外话,使用spring的@Test注解调用测试会抛出异常,原因在于JUnit调用方法是要求被调用的方法必须只有一个公共构造方法(Test class should have exactly one public constructor),然而单例的构造方法是私有的.

//测试线程安全
    public static void main(String[] args) {
        for (int i=0;i<10;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+"-----------"+ LazySingleton.getInstance());
                }
            }).start();
        }
    }

调试过程中可以看到不同的线程都能进入,并且获取单例

得到的打印结果也可以看得出,不同的线程获取到的单例对象不是同一个

那么如何解决这个问题,第一反应自然是加上锁,虽然是解决了线程安全问题,但同时也影响到了效率

public static LazySingleton getInstance(){
        //影响效率
        synchronized (LazySingleton.class){
            if (instance==null){
                instance=new LazySingleton();
            }
        }
        return instance;
    }

再进一步,可以使用双检锁,尽可能的在保证线程安全的情况下再提高效率,这是这种写法的优势,从目前的角度看似乎已经很完善了,其实这里面依旧存在问题,这些问题,下文会继续说明.

public static LazySingleton getInstance() {
        //双检锁
        if (instance == null) {
            synchronized (LazySingleton.class) {
                if (instance == null) {
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }

 

JDK 1.6之后 锁的代价已经不大,双检锁的意义在1.6之后不大


接下来看看饿汉模式的单例

public class HungerSingleton implements Serializable{

    //初始化的时候就创建对象,消耗内存空间
    private static HungerSingleton instance=new HungerSingleton();

    private HungerSingleton(){

    }

    public static HungerSingleton getInstance(){
        return instance;
    }

}

饿汉模式的好处在于不存在线程安全的问题,因为初始化的时候就将单例给创建出来了,这就导致了另一个问题,会消耗内存空间,无论这个单例需不需要被使用,统统都会被创建.如何让单例跳过线程安全又能延迟加载,这里就能再进一步,使用静态内部类来实现单例

public class StaticInnerClassSingleton implements Serializable{
    //静态内部类  可以延迟加载
    private static class InnerClass{
        private  static StaticInnerClassSingleton instance=new StaticInnerClassSingleton();
    }

    private StaticInnerClassSingleton(){

    }

    public static StaticInnerClassSingleton getInstance(){
        return InnerClass.instance;
    }

    public void doSome(){
        System.out.println(Thread.currentThread().getName()+"-----------"+ StaticInnerClassSingleton.getInstance()+"----------");
    }
}

通过一段代码测试一下

//测试线程安全
    public static void main(String[] args) {
        for (int i=0;i<10;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+"-----------"+ StaticInnerClassSingleton.getInstance());
                }
            }).start();
        }
    }

得到的结果是线程安全,所以线程获得的单例都是同一个,打印结果如下

似乎内部静态类来实现单例是完美的?先不要过早下结论.至于原因,下文会继续说.


接下来看看枚举类型的单例实现

public enum  EnumSingleton {
    INSTANCE {
        public void doSome() {
            System.out.println("----doSome----");
        }
    };

    public static EnumSingleton getInstance(){
        return EnumSingleton.INSTANCE;
    }

    public abstract void doSome();

}

用枚举实现单例非常简洁非常推荐使用,那么对不懒汉和饿汉,枚举类型单例有什么优势呢?这里就涉及到反序列化和反射攻击使得单例实现的问题了,接下来一一论述.

反序列化问题,懒汉和饿汉都一样,这里用加了双检锁的懒汉模式来举例,写一段代码测试一下

public static void main(String[] args) throws Exception {
        //反序列化使得单例失效

        LazySingleton lazySingleton=LazySingleton.getInstance();

        //序列化
        ObjectOutputStream out=new ObjectOutputStream(new FileOutputStream("lazySingleton"));
        out.writeObject(lazySingleton);

        ObjectInputStream in=new ObjectInputStream(new FileInputStream("lazySingleton"));

        //反序列化
        LazySingleton lazySingletonNew= (LazySingleton) in.readObject();

        System.out.println(lazySingleton);
        System.out.println(lazySingletonNew);
    }

打印结果如下:

通过反序列化,拿到了两个不同的"单例",这是不符单例原则的.原因在于LazySingleton lazySingletonNew= (LazySingleton) in.readObject();这行代码.

如何防止这种问题产生?这里先说结论,在单例中实现readResolve()方法,将其改造,代码如下

public class LazySingletonThree implements Serializable{
    private static LazySingletonThree instance;

    private LazySingletonThree(){
    }

    public static LazySingletonThree getInstance() {
        //双检锁
        if (instance == null) {
            synchronized (LazySingleton.class) {
                if (instance == null) {
                    instance = new LazySingletonThree();
                }
            }
        }
        return instance;
    }

    //防止反序列化生成单例
    public Object readResolve(){
        return instance;
    }
}

那么问题来了,为什么?

跟入代码,来看看原因所在.

首先进入ObjectInputStream 反序列化获取的object对象在readObject0这个方法中,继续跟入

进入到checkResolve方法,注意这里进入条件时TC_OBJECT

进入该方法后,通过反射获取对象,之后做了一个判断desc.hasReadResolveMethod(),如果返回为true则会走desc.invokeReadResolve(obj);这行代码

而后来分析这个desc.hasReadResolveMethod()里面到底做了什么,源码是这样的

接下来去找readResolveMethod,发现

这便是实现readResolve()方法可以防止反序列化造成单例失效的原因,有readResolve这个方法,则在序列化的时候会用该方法覆盖掉反序列化生成的对象.

接下来再说说反射攻击导致单例失效的问题,拿到对象的class,哪怕对象的构造方法是私有的,反射也有调用.这可能会导致单例失效,写一段代码具体表现,这里先用饿汉模式举例

public static void main(String[] args) throws Exception {
        //反射构建单例使得单例失效

        HungerSingleton hungerSingleton=HungerSingleton.getInstance();

        Class clazz= HungerSingleton.class;
        //构造器
        Constructor constructor=clazz.getDeclaredConstructor();
        //设置可访问
        constructor.setAccessible(true);
        //创建一个对象
        Object hungerSingletonNew=constructor.newInstance();

        System.out.println(hungerSingleton);
        System.out.println(hungerSingletonNew);
    }

运行结果如下:

这里又出现了两个"单例",由此可以看出饿汉模式虽然没有线程安全问题,通过实现readResolve()方法可以防止反序列化造成单例失效,但是无法防止反射攻击,该如何解决?这里讲饿汉模式改造一下

public class HungerSingletonTwo implements Serializable {

    //初始化的时候就创建对象,消耗内存空间
    private static HungerSingletonTwo instance=new HungerSingletonTwo();

    private HungerSingletonTwo(){
        if (instance!=null){
            throw new RuntimeException("不允许反射创建单例对象");
        }
    }

    public static HungerSingletonTwo getInstance(){
        return instance;
    }

}

在反射调用构造方法的时候先判断instance是否存在,如果存在,那么意味着单例已经创建了,抛出运行时异常即可.这样就解决了反射攻击的问题,再次测试,运行结果如下:

这是对于饿汉模式的解决办法,但这个办法却无法对懒汉模式单例起作用,那懒汉举例说明来看

public static void main(String[] args) throws Exception {
        //懒汉模式无法通过构造器抛异常的方式避免反射攻击
        //先反射,再调用懒汉单例
        Class clazz= LazySingletonTwo.class;
        Constructor constructor=clazz.getDeclaredConstructor();
        constructor.setAccessible(true);
        Object lazySingletonNew=constructor.newInstance();

        LazySingletonTwo lazySingletonTwo=LazySingletonTwo.getInstance();

        System.out.println(lazySingletonTwo);
        System.out.println(lazySingletonNew);
    }

运行结果如下:

即使在懒汉模式的构造方法中加入了判断

也无法保证该条件成立,因为调用反射的时候如果懒汉模式单例之前没有被调用,那么意味着该单例本身不存在,这样就会导致出现两个"单例",而这个问题,目前是没有解决办法的.所以懒汉单例并不推荐使用(当然具体情况需要结合自身相关业务逻辑).

那么回过头来看,枚举类型的单例是否能防止反序列化和反射攻击使得单例失效呢?答案是:可以.

看一个例子

public static void main(String[] args) throws Exception {
        EnumSingleton enumSingleton= EnumSingleton.getInstance();
        ObjectOutputStream out=new ObjectOutputStream(new FileOutputStream("enumSingleton"));
        out.writeObject(enumSingleton);

        ObjectInputStream in=new ObjectInputStream(new FileInputStream("enumSingleton"));
        //反序列化
        EnumSingleton enumSingletonNew= (EnumSingleton) in.readObject();
        System.out.println(enumSingleton);
        System.out.println(enumSingletonNew);
    }

运行结果如下:

这又是为什么?依旧进入反序列化的代码看看,和上文中不同的是,这一次进入的是TC_ENUM条件中的readEnum方法

而在该方法中,name这个值依旧是"INSTANCE",最后返回的也是这个对象.这是枚举的天然优势.

从上面看来,枚举天然优势,可以防止反序列化造成单例失效,至于反射,枚举对象无法通过反射来创建.所以通过枚举来实现单例是比较理想的方式,推荐使用.


说完这些,再看看容器创建单例的方式,这里举一个spring源码的例子,在spring的bean单例注册器中就是通过容器来实现单例的

这里的singletonObjects是一个ConcurrentHashMap类型(后续有机会再说说这种类型).

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值