枚举单例yyds之单例模式没有那么简单

枚举单例yyds——单例模式没有那么简单

  • 前言
  • 一、饿汉与懒汉——得不偿失的改变
      • 饿汉模式
      • 懒汉模式
  • 二、解决线程安全——加锁的艺术
      • 方法上加锁
      • 代码块上加锁
      • 代码块上加锁plus——双重判断
  • 三、优雅的解决方案——静态内部类
  • 四、飘浮在大厦上的两朵乌云——反射和序列化
      • 反射的破解——私有化构造方法的失效
      • 序列化与反序列化的破解——加载机制的决定
  • 五、枚举单例——大道至简
      • 反射攻击
      • 序列化攻击
  • 总结


前言

我相信大多程序员同胞们第一个接触的设计模式就是在大学课堂上讲到的单例模式,功能目的很清晰,即在面向对象技术中,保证某个对象在内存中只存在一个,由于其功能目过于直白,以至于我竟然一开始觉得单例模式本身很单纯,但其实单纯的不是它,而是我(小丑竟是我自己.jpg)
那接下来我将讲述一个很不一样的单例模式


一、饿汉与懒汉——得不偿失的改变

饿汉模式

首先第一种单例模式的设计,也是最经典最常见最先接触到的——饿汉模式

public class Singleton1 {
     

    private static final Singleton1 INSTANCE = new Singleton1();

    private Singleton1(){
     }

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

我们通过私有化构造方法和静态成员以及它的静态方法,由于JVM加载类的特点,我们让JVM帮我们保证了对象的单例即类加载进内存的一开始就实例化好了这个对象,并且不允许外界再次创造该对象,简单干脆,近乎完美,但有一点瑕疵,刚刚也提到了,就是在类加载进内存的时候对象就已经创建,此时我们并没有使用该对象,这样就显得有那么些许的浪费内存空间(本人觉得其实这个问题其实并不算大,尤其是相比较于我们后面做出改变而言)

懒汉模式

那么,既然问题出现,我们就尝试着解决,解决方法也好想,我们不在一开始就创建对象,而是在使用时再创建对象,之后保持单例,这就是懒加载的——懒汉模式

public class Singleton2 {
     

    private static Singleton2 INSTANCE;

    private Singleton2(){
     }

    public static Singleton2 getInstance(){
     
        if (INSTANCE == null){
     
            INSTANCE = new Singleton2();
        }
        return INSTANCE;
    }
}

懒汉模式中,我们在外界需要获取对象时进行判断,如果对象为空,就创建对象;如果不为空就直接返回,这样我们就轻松实现了懒加载
可惜,天不遂人愿,这样的改动将保证对象单例化的任务交予到了我们自己手上或者换句话说交到了一个if判断上,从而带来了一个很严重的问题——多线程时不能保证对象单例
我们编写main函数来模拟一个简单的并发,为了结果看起来更明显,我们再把Singleton2改动一点点

public class Singleton2 {
     

    private static Singleton2 INSTANCE;

    private Singleton2(){
     }

    public static Singleton2 getInstance(){
     
        if (INSTANCE == null){
     
        
         try {
     
                Thread.sleep(5);
            } catch (InterruptedException e) {
     
                e.printStackTrace();
            }
            
            INSTANCE = new Singleton2();
        }
        return INSTANCE;
    }
}

我们在获取对象时让它睡个5ms(后续测试的过程中都会让线程睡一会,为了代码看起来清晰一点这一段代码就不再展示

public static void main(String[] args) {
     
        for (int i = 0; i<20; i++){
     
            new Thread(()->{
     
                System.out.println(Singleton2.getInstance().hashCode());
            }).start();
        }
}

main函数里逻辑很简单,用一个循环不停的开线程,在这些线程中我们打印懒汉模式下获取的对象的hash值,结果如下(惨的一)
枚举单例yyds之单例模式没有那么简单_第1张图片
在这简简单单20个线程里,出现了五花八门的对象,显然保证不了对象的单例,为了对比,我们同样试验一下饿汉模式,结果如下
枚举单例yyds之单例模式没有那么简单_第2张图片
显然,饿汉模式并不存在线程问题。
具体原因也很好理解
枚举单例yyds之单例模式没有那么简单_第3张图片

这也是为什么说没有必要为了懒加载而进行修改,那么如果我就想懒加载并且保证线程安全呢?

二、解决线程安全——加锁的艺术

方法上加锁

既然出现了线程安全问题,那我就直接在获取对象的方法上加锁,这样就直接避免了上述提到的两个线程先后访问产生的问题

public class Singleton3 {
     

    private static Singleton3 INSTANCE;

    private Singleton3(){
     }

    public static synchronized Singleton3 getInstance(){
     
        if (INSTANCE == null){
     
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }
}

同样的测试一下,结果如下:
枚举单例yyds之单例模式没有那么简单_第4张图片
OK,一个问题解决,但同时又带来了另一个问题,效率低了。为什么?道理也很清楚,加锁在方法上后,即使对象早已创建好了,根本不可能再出现线程问题,而我现在只是单纯想拿一个对象仍然要等上一个线程放开才能访问,效率肯定会低,此时,我们想到最直接的解决方式就是,如果我们只针对最开始的会造成线程安全问题的那一小部分线程加锁,那么是不是效率就会提升起来了呢?来,换个加锁的位置

代码块上加锁

既然由于把锁加到方法上会“六亲不认”,那我就把锁放到中间那段引起问题的创建对象的那一段代码块上,后续在实例对象有值的时候就根本不会进入if内,直接返回不存在加锁影响效率的问题,如下

public class Singleton4 {
     

    private static Singleton4 INSTANCE;

    private Singleton4(){
     }

    public static Singleton4 getInstance(){
     
        if (INSTANCE == null){
     

            synchronized (Singleton4.class){
     
                INSTANCE = new Singleton4();
            }
        }
        return INSTANCE;
    }
}

看起来似乎解决了问题,实际呢?来,测试一下
枚举单例yyds之单例模式没有那么简单_第5张图片
这里很多人就奇怪了,我们不是加锁了吗?那我们再回头仔细看看

枚举单例yyds之单例模式没有那么简单_第6张图片
好了,一夜回到解放前,继续想,如果我仍然是要只对这一部分代码加锁,还要继续保证线程安全,怎么办?之前问题还是出在了最开始的几个线程都进入到了if里面从而都会实例化对象,那如何让他们即使进来了也只让第一个进来获得锁的线程创建对象呢?再加一层判断

代码块上加锁plus——双重判断

这样写,一个词描述beautiful,不过单纯是功能上的,外观上可一点都不

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

还是测试一下,结果如下:
枚举单例yyds之单例模式没有那么简单_第7张图片
成功,其实到这里我们已经达到了非常完美的地步,懒加载、线程安全、效率这三个问题都得到了解决,只不过代码有些臃肿,看起来不太优雅,那到底有没有一个优雅的且仍然能解决问题的方案呢?我告诉你,有

三、优雅的解决方案——静态内部类

先贴代码

public class Singleton6 {
     

    private Singleton6(){
     }

    private static class SingletonHolder{
     
        private static final Singleton6 INSTANCE = new Singleton6();
    }

    public static Singleton6 getInstance(){
     
        return SingletonHolder.INSTANCE;
    }
}

首先,分析代码,我们使用了一个静态内部类来保存对象,其他部分和饿汉几乎一摸一样 ,而我们把饿汉中的静态公有属性,变成这样由静态内部类持有的状态是怎么解决的懒加载问题呢?其实,我们是又一次的将问题交给了JVM帮我们完成,在类加载的时候,内部类并不会随着外部类的加载而加载进内存,而是当第一次在getInstance方法中被使用的时候才被加载进内存,从而实现了懒加载

四、飘浮在大厦上的两朵乌云——反射和序列化

文章写到这,我其实可以很负责任的告诉大家,以上我们提到的所有写法都有问题!!!
为什么?很简单,就是由于反射和序列化反序列化的存在,让我们根本保证不了对象的单例下面我们具体来谈谈。(以下我们以静态内部类Singleton06为例,其他实现同理)

反射的破解——私有化构造方法的失效

到这里其实谈的其实就深入java语言的一些特性了,为什么说反射可以破解,了解的人其实都知道,我们可以通过类的字节码动态的获取类的无参构造器,再通过setAccessible(true),让我们可以调用即使是private修饰的构造方法,测试如下

public static void main(String[] args) {
     
        try {
     
            //通过反射让私有化的构造器被我们访问并调用,并通过API实例化对象
            Constructor<Singleton7> constructor = Singleton7.class.getDeclaredConstructor();//获取构造器
            constructor.setAccessible(true);
            Singleton7 singleton7 = constructor.newInstance();//通过构造器新建对象
            Singleton7 singleton8 = constructor.newInstance();
            System.out.println(singleton7.hashCode());
            System.out.println(singleton8.hashCode());
        } catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
     
            e.printStackTrace();
        }
}

测试结果
枚举单例yyds之单例模式没有那么简单_第8张图片
结果很显然,我们通过反射能够突破构造方法的限制去new对象,解决方法也有,既然你突破构造方法,那么我就再构造方法上动手,当系统要在已经存在对象的情况下去再次调用构造方法时抛出异常

private Singleton6() throws Exception {
     
        if (SingletonHolder.INSTANCE != null){
     
            throw new Exception("不允许再创建对象了");
        }
}

同样的,使用上面的方法测试
枚举单例yyds之单例模式没有那么简单_第9张图片
果不其然,报错,达到预期效果

序列化与反序列化的破解——加载机制的决定

首先,我们需要明白什么是序列化和反序列化,简单点来说就是一个java对象和字节序列的转化过程,实际情况中,我们序列化和反序列化对象一般用于通过字节序列的方式持久化存储对象和在网络上传输对象,而在这个过程中就存在着一个机制,当我们把序列化的对象反序列化过程中,不加设置的情况下程序会在内存里新建对象而不是使用原来的对象
代码如下:

public class Singleton7 implements Serializable {
     

    private Singleton7(){
     }

    private static class SingletonHolder{
     
        private static final Singleton7 INSTANCE = new Singleton7();
    }

    public static Singleton7 getInstance(){
     
        return SingletonHolder.INSTANCE;
    }

    public static void main(String[] args) {
     
        //可以通过序列化和反序列化破坏单例模式,因为在反序列的过程中程序会在内存中重新建立一个对象
        //当存在这个方法时反序列化就会直接调用这个方法返回对象
        Singleton7 singleton7 = Singleton7.getInstance();
        byte[] serialize = SerializationUtils.serialize(singleton7);//序列化
        Object deserialize = SerializationUtils.deserialize(serialize);//反序列化
        System.out.println(singleton7 == deserialize);
    }
}

这里提一点,一般我们在序列化和反序列化的时候,会让需要序列化的类实现Serializable接口,这是一个标志接口,没有实际内容(后面会具体讲原因)
然后测试运行
在这里插入图片描述
果然,序列化前后对象并不是同一个。
同样的,还是有解决方法,利用机制我们需要实现一个readResolve方法将单例对象返回,因为反序列化时会默认先回调这个方法,如果没有实现那么程序才会新建一个对象,那么我们就可以在类中加入这个方法

private Object readResolve() throws ObjectStreamException {
     
        return getInstance();
}

测试:
在这里插入图片描述
解决,那么到现在,我们得出的结论很让人蛋疼,如果想要实现完美的单例,我们需要加入太多的东西来保证其正确性和可用性,不过,我们要坚信办法总会有的,这里就给出一个终极方案,也是这篇帖子真正的主角——枚举单例

五、枚举单例——大道至简

枚举单例的做法是Joshua Bloch在《Effective Java》中提出的,书中这么说道,“单元素的枚举类型经常成为实现Singleton的最佳方法,虽然这种方法还没有广泛采用”
我们先直接贴代码

public enum Singleton8 {
     
    INSTANCE;
    public void doSomething(){
     
    	System.out.println("I will do something");
    }
}

在调用的时候,就更加简单

public static void main(String[] args) {
     
        Singleton8.INSTANCE.doSomething();
}

上面两段代码真正的诠释了什么是优雅,但是枚举单例就能防止反射和序列化的破坏了吗?我们来试试

反射攻击

首先是反射,还是使用之前的代码测试

 public static void main(String[] args) {
     
        try {
     
            Constructor<Singleton8> constructor = Singleton8.class.getDeclaredConstructor();
            constructor.setAccessible(true);
            Singleton8 singleton1 = constructor.newInstance();
            Singleton8 singleton2 = constructor.newInstance();
            System.out.println(singleton1.hashCode());
            System.out.println(singleton2.hashCode());
        } catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
     
            e.printStackTrace();
        }
    }

然后报了一个没找到方法的报错
在这里插入图片描述
为啥报错?回想起来我们发现枚举看起来貌似没有构造方法,是这样的吗?这时候就要谈到一个枚举的性质了,在Java里枚举都会隐式的继承Enum抽象类,我们进到Enum源码里面看看,发现Enum类中没有无参构造,只有一个有两个参数的构造函数

/**
     * Sole constructor.  Programmers cannot invoke this constructor.
     * It is for use by code emitted by the compiler in response to
     * enum type declarations.
     *
     * @param name - The name of this enum constant, which is the identifier
     *               used to declare it.
     * @param ordinal - The ordinal of this enumeration constant (its position
     *         in the enum declaration, where the initial constant is assigned
     *         an ordinal of zero).
     */
    protected Enum(String name, int ordinal) {
     
        this.name = name;
        this.ordinal = ordinal;
    }

那么,我们再改一下测试方法,获取这个含有两个参数的构造方法,如下

public static void main(String[] args) {
     
        try {
     
            Constructor<Singleton8> constructor = Singleton8.class.getDeclaredConstructor(String.class, int.class);
            constructor.setAccessible(true);
            Singleton8 singleton1 = constructor.newInstance();
            Singleton8 singleton2 = constructor.newInstance();
            System.out.println(singleton1.hashCode());
            System.out.println(singleton2.hashCode());
        } catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
     
            e.printStackTrace();
        }
    }

运行,依然报错
在这里插入图片描述
这次就直接说明了不允许通过反射来创建枚举对象了,说明jdk内部就帮我们保证了这一点,其实也可以点进去看看,我们发现了这样一段代码

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

这也印证了之前的猜想

序列化攻击

同样的,我们还是直接使用之前的代码

public static void main(String[] args) {
     
        byte[] serialize = SerializationUtils.serialize(Singleton8.INSTANCE);
        Object deserialize = SerializationUtils.deserialize(serialize);
        System.out.println(Singleton8.INSTANCE == deserialize);
}

运行,结果返回true,证明在序列化前后是同一个对象
为什么?这是因为在Java规范中规定了,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,故在枚举类型的序列化和反序列化上,Java做了和平常类不太一样的处理过程

首先,我们来看看一般的类java的序列化反序列化处理机制,这里想要了解具体过程的话就需要打断点debug进去看看,具体过程太复杂,就展示一下重点过程即如何从外部再次加载进内存

在这里插入图片描述
首先,加载进内存是通过deserialize方法,我们发现将传入的byte数组转成了InputStream,调用了另一个方法,找到这个方法
枚举单例yyds之单例模式没有那么简单_第10张图片
这里我们发现,这个方法在检查了inputstream不为空后利用它构造了一个ObjectInputStream,再通过ObjectInputStream调用了一个readObject方法
同样的进入这个方法,我们会发现在这个方法的执行路线上有这样一段代码
枚举单例yyds之单例模式没有那么简单_第11张图片枚举单例yyds之单例模式没有那么简单_第12张图片

由于太长,我就只截取了一部分,发现这里其实是根据对象的类型不同分别调用了不同的方法,在switch语句里,我们发现了最终处理过程交到了readOrdinaryObject方法
点进去看我们发现了以下的代码
枚举单例yyds之单例模式没有那么简单_第13张图片
这之中的desc是一个readClassDesc方法返回的ObjectStreamClass对象,根据方法名我们其实很容易猜出这是一个记录了类的相关信息的一个对象,我们也有理由继续猜测在序列化的时候肯定也会通过被序列化的对象构造这个流去输出信息(至于这个猜测对不对,那就自己去验证吧,嘿嘿嘿)

回到正题,这里的isInstantiable方法其实实在判断读入的这个类有没有实现Serializable接口,如果实现了就能反序列化成功,并调用newInstance方法新建一个类(这也是为什么我们在序列化操作的时候必须要实现Serializable这个什么都没写的接口),这样就相当于在内存中又新建了一个类

那么对于枚举来说呢?为什么枚举能够保证序列化和反序列化时在JVM中都是唯一的呢?依据之前的分析过程,我们在case中找到了这么一条单独处理enum的
枚举单例yyds之单例模式没有那么简单_第14张图片
这里调用了一个readEnum方法,其中大致步骤可以总结如下:

  • 通过类型描述符获得枚举的类型(枚举编译时会通过反射构造一个同名类继承自Enum)这里就是Singelton.class
  • 取得枚举单例中枚举的名字INSTANCE
  • 最后通过调用Enum的valueOf方式构造了一个枚举

枚举单例yyds之单例模式没有那么简单_第15张图片
这里提一点,在之前序列化时,即调用serialize时,同样也有一个writeEnum的方法,在序列化的时候Java就是将枚举对象的name和类型输出,自然在反序列化的时候就是通过valueOf() 方法和输出的两个参数直接查找枚举对象,这样就保证了枚举类型的单例化。

简单来说,枚举在序列化和反序列化的时候都有一套属于自己的处理逻辑,分别是位于ObjectInputStream和ObjectOutputStream的readEnum和writeEnum方法,而这两个方法能保证枚举的特点,以上就是所有分析过程,有感兴趣的小伙伴可以进行更深入的研究

总结

一句话,枚举单例yyds!!!

你可能感兴趣的:(设计模式,jdk源码,java,设计模式,反射,jdk)