卧槽,小小的单例模式竟然有这么多种写法?

来源:juejin.cn/post/6903802160665460743

单例模式无论是在现实世界中还是在程序员的代码世界里都是十分常见的,同时也是面试中比较常见的热身问题,不仅仅是因为单例模式在业务逻辑代码设计中比较重要,而且从单例模式可以引申出并发、锁机制以及一些其他的一系列问题,今天我们就一起讨论一下设计模式中的单例模式。

设计模式

设计模式最早出现在建筑行业,上世纪九十年代开始在软件开发行业出现。设计模式可以说是几十年来奋斗在一线的前辈们总结出的一套软件开发的经验法则,是代码设计经验的总结。它的目标就是提高代码的可重用性,提升代码质量与健壮性。

有的资料还会说提升代码的可读性,不可否认,设计模式在有些场景下确实可以提升代码的可读性,但是笔者认为大多数场景下为了尽量降低类与类之间的耦合,提升代码的可复用性,是在一定程度上牺牲了代码的可读性的。

我们知道软件开发设计中有一些比较重要的面向对象的设计原则,主要有下面几个:

  • 单一职责原则:一个类应该有且仅有一个引起它变化的原因,通俗一点说就是一个类应该只负责好做一件事情,这也同样适用于方法,每个方法尽量职责明确,不要在一个方法中实现过多的功能。

  • 开闭原则:对扩展开放,对修改封闭。这样可以提高系统的可维护性和可复用性。当应用的需求变更的时候,对原有的模块进行扩展以适应新的需求。

  • 里式替换原则:这个原则是表明继承必须确保超类所拥有的性质在子类中仍然成立,简单点说就是父类有的子类也得提供,父类没有的子类可以扩展,子类可以扩展父类的功能,但是不要改变父类的功能。

  • 依赖倒置原则:高层不应该依赖于底层,抽象不应该依赖于具体,具体应该依赖于抽象,也就是应该面向接口编程,而不要面向具体编程,这可以降低类之间的耦合。

  • 接口隔离原则,接口应该小而精,不应该大而全,客户端不应该为使用接口中的某一些功能而需要实现接口中的所有方法。

  • 迪米特法则:最少知道原则,指对于软件世界中的两个软件实体来说,如果没有通信的必要,那么就不需要直接的相互调用。从依赖对象的角度来看,就是只依赖必要依赖的对象,从被依赖对象角度来说,只暴露应该暴露的方法。

  • 合成复用原则:组合大于继承,优先使用组合而不是继承扩展类的功能。只有当确实需要继承结构时才选择继承。

上面的这些原则是软件开发领域这几十年来总结出来的方法论,而设计模式又是在这些方法论的指导下总结出来的最佳实践。

1995年,GoF四人合作在著名的《设计模式:可复用面向对象软件的基础》中收录了23种设计模式,这23种设计模式大体上可以分为三类:

  • 创建型设计模式:这一类主要用于描述如何创建对象,以及将对象的创建与使用分离。

  • 结构型设计模式:这一类主要是将类或者对象按照某一种布局结构组成一个更大的结构

  • 行为型设计模式:这一类主要是描述的类或对象之间的相互协作关系。

而我们本文要讨论的单例设计模式就属于创建型设计模式中的一种。

单例设计模式

单例设计模式是指从JVM进程创建到被销毁,在整个JVM进程中单例类只有一个实例,并且该类能够自行创建这个实例的一种模式。

实现单例的前提

要实现一个单例设计模式,首先需要注意以下3点:

  1. 构造函数私有。这个是最基本的了,否则外部可以通过构造函数随意的创建对象,那就不能称为单例了。

  2. 内部持有一个私有的静态单例模式的实例。

  3. 提供一个公共的静态方法用于获取单例对象。

单例模式的几种实现方式

1、饿汉式单例

实现

class Singleton1
{
    private static Singleton1 instance=new Singleton1();
    private Singleton1() {};
    public static Singleton1 getInstance()
    {
        return instance;
    }
}

优缺点

这种实现方式的优点实现简单,可以看到仅需几行代码就可以实现,该方式可以在多线程下很好的工作,保证线程安全。

缺点就是单例类一加载对象就被创建,没有延迟初始化,这在创建实例比较耗费资源时可能造成浪费资源启动变慢。

思考

饿汉式单例设计模式是如何保证线程安全的呢?

这个问题就需要归功于java类加载机制了,在文章的末尾给出了这个问题解释。

2、饿汉式变种

实现

class Singleton2
{
    private static Singleton2 instance=null;
    static
    {
        instance=new Singleton2();
    }
    private Singleton2(){};
    public static Singleton2 getInstance()
    {
        return instance;
    }
}

这种方式与上面的饿汉式单例设计模式几乎没有什么区别,同样可以保证线程安全。

3、懒汉式单例设计

class Singleton3
{
    private static Singleton3 instance=null;
    private Singleton3(){};
    public Singleton3 getInstance()
    {
        //1
        if(instance==null)
        {
            //2
            instance=new Singleton3();
        }
        return instance;
    }
}

优缺点

这种方式实现起来也比较简单,代码量比较好,而且这种方式使用延迟初始化,可以避免资源的浪费。

但是这种方式只能在单线程下工作的很好,在多个线程并发执行时并不能保证单例。

思考

这种方式为什么在多个线程并发执行的时候不能保证单例呢?

来看下面这张图:

当线程A执行getInstance方法是判断instance为null,进入到if中,刚准备创建对象,这是cpu被线程B抢去了,这是线程B执行getInstance方法判断instance也为null。然后线程A重新抢到了cpu,由于前面已经判断过,所以将会创建单例对象,而同样等到线程B拿到cpu也会创建线程对象。这样就创建出了两个对象。

4、懒汉式单例(线程安全1)

实现

class Singleton4
{
    private static Singleton4 instance=null;
    private Singleton4(){};
    public synchronized Singleton4 getInstance()
    {
        if(instance==null)
        {
            instance=new Singleton4();
        }
        return instance;
    }
}

优缺点

这种方式在解决了上面所说的线程安全的问题,并没有引入编码上太大的复杂度,使用synchronized保证方法的同步。

缺点就是这种方式在并发度比较高的情况下性能并不好,即便后续单例已经被创建了,每次获取单例对象都要经历加锁解锁。

补充

随着java对synchronized的不断优化,锁升级等是的synchronized的性能损耗没有那么严重了,但是这种方式仍然是简洁但不优美。

5、懒汉式单例(双重校验锁)

实现

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

优缺点

这种方式针对第四种写法进行了改进,因为当单例对象被创建后就不需要再对其进行加锁同步,只需要保证单例对象的创建过程的线程安全性即可,这种优化方式属于细化锁的粒度,只在该加锁的地方加锁。

缺点就是代码量上去了,需要synchronized和volatile保证,理解上有一定难度。

思考

1、为什么需要两次if判断,判断为null之后进入同步代码块直接创建对象不就可以,一次可不可以?下面再来看张图:

因为当多个线程一次执行到synchronized代码块时已经执行完成了if检查,那么当线程获取到锁的时候不会再检查,即使其他线程已经创建了对象。所以当线程拿到锁之后需要再次检查一下在阻塞期间其他线程是否已经完成单例对象的初始化。

2、为什么需要volatile修饰单例对象,不用可不可以。

这里就要对这段代码分析一下

if(instance==null)
{
 instance=new Singleton5();
}

jvm创建对象大体可以分为以下三步:

  1. 首先根据类的全限定名查找类的符合因为,判断该类是否已经被加载、验证、准备、解析过如果没有,执行上述步骤,然后为其创建一个对应的Class对象。

  2. 在确保了类被加载之后,在堆内存中开辟一块内存,执行类的初始化。

  3. 在栈中创建一个引用,指向堆内存中开辟的这一块内存。

正常情况下是这样的,然后我们说只要有正常情况就会有不正常情况,不正常情况就是,jvm为了实际执行时会根据性能、指令流等将指令进行重排序执行。经过重排序后那么3就有可能在2的前面,也就是有可能出现1->3->2的情况,这样在多线程的情况下,线程一块栈中已经有该类实例的引用,即认为对象已经创建完成,那么在执行上述if判断的时候就会返回false从而直接返回对象实例,但是该实例其实还并为完全初始化,这样会为外界提前暴露一个尚未完全初始化的单例对象实例,这种情况是比较危险的。

那么用volatile就管用了吗?是的volatile的一个作用就是可以防止指令的重排序,volatile通过插入内存屏障来防止后续的指令重排序到前面,从而可以确保jvm实际执行的时候就是按照1->2->3的顺序执行的,这样就没问题了。

6、枚举

实现

enum Singleton6
{
    INSTANCE;
    public void doSomething()
    {
        System.out.println("doSomething....");
    }
    //该方法可不需要,直接通过Singleton6.INSTANCE也可。
    public static Singleton6 getInstance()
    {
        return INSTANCE;
    }
}

优缺点

枚举实现单例的方式是在《Effective java》中推荐的一种实现方式,是因为该方式实现起来十分的简单,而且可以保证多线程下的线程安全性。同时,对于一些可能破坏序列化的手段,这个我们后面会讨论,该方式也可以防止。

缺点:要说非要找缺点就是尽管这种实现方式看起来比较完美,但是实际应用的并不是很多,就是讨论的比较热烈,但是应用的不太广泛。

(搜索公众号Java知音,回复“2021”,送你一份Java面试题宝典)

思考

1、枚举是如何实现线程安全的?

我们对上面的代码反编译后看看枚举背后到底有什么秘密。

从这里我们看出,我们的INSTANCE被加上了static final修饰,这样当类被加载的时候,JVM就保证了其线程安全性。

2、枚举是如何防止被破坏的呢?

首选剧透一下,破坏单例模式有两种方法,分别是序列化和反射。那么先看枚举是如何防止序列化破坏单例模式的。

我们看一看普通的序列化需要有哪些操作?

public static void main(String[] args) throws IOException, ClassNotFoundException
{
    FileOutputStream fout = new FileOutputStream("Singleton.obj");
    ObjectOutputStream out=new ObjectOutputStream(fout);
    Singleton1 instance = Singleton1.getInstance();
    out.writeObject(instance);
    FileInputStream fin = new FileInputStream("Singleton.obj");
    ObjectInputStream in = new ObjectInputStream(fin);
    Singleton1 singleton1 = (Singleton1) in.readObject();
    System.out.println(instance==singleton1);
}

输出结果为:

既然是这样,我们就顺藤摸瓜,看看ObjectInputStream的readObject()方法和ObjectOutputStream的writeObject()方法有什么?

public final void writeObject(Object obj) throws IOException 
{
    if (enableOverride) 
    {
        writeObjectOverride(obj);
        return;
    }
    try 
    {
        writeObject0(obj, false);//这里是关键
    }
    catch (IOException ex)
    {
        if (depth == 0) 
        {
            writeFatalException(ex);
        }
        throw ex;
    }
}

writeObject里面调用了 writeObject0(obj, false);那我们点进去看看这个方法

    private void writeObject0(Object obj, boolean unshared)
        throws IOException
    {
        boolean oldMode = bout.setBlockDataMode(false);
        depth++;
        try 
        {
            //... ...
            //省略了其他的代码。。
            // remaining cases
            if (obj instanceof String)
            {
                writeString((String) obj, unshared);
            }
            else if (cl.isArray()) 
            {
                writeArray(obj, desc, unshared);
            }
            else if (obj instanceof Enum)
            {
                writeEnum((Enum<?>) obj, desc, unshared);//这里是关键
            } 
            else if (obj instanceof Serializable)
            {
                writeOrdinaryObject(obj, desc, unshared);
            }
            else 
            {
                if (extendedDebugInfo)
                {
                    throw new NotSerializableException(
                        cl.getName() + "\n" + debugInfoStack.toString());
                } 
                else 
                {
                    throw new NotSerializableException(cl.getName());
                }
            }
        } 
        finally 
        {
            depth--;
            bout.setBlockDataMode(oldMode);
        }
    }

这里我们省略很多的其他的代码,只留下了关键的部分,最主要的是在第21行,可以看到java的序列化机制为枚举类型单独实现了一个方法 writeEnum((Enum<?>) obj, desc, unshared);

private void writeEnum(Enum<?> en,
                       ObjectStreamClass desc,
                       boolean unshared)
    throws IOException
{
    bout.writeByte(TC_ENUM);//这个是一个标志
    ObjectStreamClass sdesc = desc.getSuperDesc();
    writeClassDesc((sdesc.forClass() == Enum.class) ? desc : sdesc, false);
    handles.assign(unshared ? null : en);
    writeString(en.name(), false);
}

从这里可以看出,序列化在序列枚举的时候没有序列化任何的字段,他只是将枚举类的标志(126)及其描述以及枚举类的名称序列化。同样对应的也有readEnum方法了

private Enum<?> readEnum(boolean unshared) throws IOException 
{
    if (bin.readByte() != TC_ENUM)
    {
        throw new InternalError();
    }

    //读出类描述信息+++++++++++++++++++++++++++++++++++++++++++
    ObjectStreamClass desc = readClassDesc(false);
    if (!desc.isEnum()) 
    {
        throw new InvalidClassException("non-enum class: " + desc);
    }

    int enumHandle = handles.assign(unshared ? unsharedMarker : null);
    ClassNotFoundException resolveEx = desc.getResolveException();
    if (resolveEx != null) 
    {
        handles.markException(enumHandle, resolveEx);
    }
 //读出枚举名称+++++++++++++++++++++++++++++++++++++++++++
    String name = readString(false);
    Enum<?> result = null;
    Class<?> cl = desc.forClass();
    if (cl != null) 
    {
        try 
        {
            //根据名称找出枚举类+++++++++++++++++++++++++++++++++++++++++++
            @SuppressWarnings("unchecked")
            Enum<?> en = Enum.valueOf((Class)cl, name);
            result = en;
        } catch (IllegalArgumentException ex) 
        {
            throw (IOException) new InvalidObjectException(
                "enum constant " + name + " does not exist in " +
                cl).initCause(ex);
        }
        if (!unshared) 
        {
            handles.setObject(enumHandle, result);
        }
    }

    handles.finish(enumHandle);
    passHandle = enumHandle;
    return result;
}

这里主要看代码标+号的地方,可以看到在反序列化时枚举是通过其序列化的名称进行的。因此这是java的机制保证了枚举的序列化安全问题。

3、枚举是防止反射的?

首先枚举类型并没有提供无参的构造方法,在其继承的父类Enum中有一个如下构造方法:

protected Enum(String name, int ordinal) 
{
    this.name = name;
    this.ordinal = ordinal;
}

那么有同学可能会说,通过反射调用该构造方法不是一样可以创建实例嘛?这个。。。我们又明确知道枚举可以防止反射创建对象。那我们思考一下反射创建对象的话最终要调用newInstance()方法,那么看看这个方法做了什么?

public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
 //省略若干代码。。。
    
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
 
    //省略若然代码。。。
    return inst;
}

可以看到在newInstance方法中对枚举进行单独的检查,如果执意要反射创建,那就只能收到一个IllegalArgumentException异常大礼包了。枚举的相关问题我们就讨论到这里了。

7、静态内部类

实现

class Singleton7
{
    private static class SingletonHolder
    {
        private static final Singleton7 instance=new Singleton7();
    }
    private Singleton7(){};
    public static Singleton7 getInstance()
    {
        return SingletonHolder.instance;
    }
}

优缺点

这种方式是一种实现单例模式比较优雅的方式,他巧妙的利用了类加载器的线程安全特性保证了单例对象初始化的线程安全性。Static变量的创建是如何保证线程安全的,同时静态内部类只有在第一次被使用时才会被加载,所以可以进行延迟初始化。而且编码相对来说比较简洁。

缺点嘛,这个我还不知道有啥缺点,各位同学如果知道可以在评论区告诉我,不过如果非要说的话,那就是需要额外的努力防止序列化和反射破坏单例模式。

8、CAS无锁方式单例

实现

class Singleton8
{
    private static final AtomicReference<Singleton8> cas=new AtomicReference<>();
    private Singleton8(){};
    public static Singleton8 getInstance()
    {
        for(;;)
        {
            Singleton8 instance = cas.get();
            if(instance==null)
            {
                boolean b = cas.compareAndSet(null, new Singleton8());//++++++++++++++++++++++++++++
                if(b)
                {
                    break;
                }
            }
            else
            {
                break;
            }
        }
        return cas.get();
    }
}

优缺点

这种方式同时Atomic包中的AtomicReference使用cas来进行对象的创建,可以避免使用阻塞式锁,在某些场景下可以提高性能,提高并发度。

这种方式缺点也不少,首先如果多个线程同时执行到+号这行代码,那么只有一个线程能够cas成功,但是多个线程可能都创建了new Singleton8()对象,只是没有替换成功,所以可能会造成内存浪费。同时如果cas不成功,那么会白白的浪费cpu资源,降低系统吞吐量。

9、容器单例

实现

class Singleton9
{
    private static ConcurrentHashMap<String,Singleton9> map=new ConcurrentHashMap<>();
    private Singleton9(){};
    public Singleton9 getInstance()
    {
        if(!map.contains("singleton"))
        {
            map.putIfAbsent("singleton",new Singleton9());
           
        }
        return map.get("singleton");
    }
}

优缺点

这种方式使用容器类存放单例对象,同时ConcurrentHashMap可以保证线程安全,如果在不需要线程安全的场景下可以使用HashMap。(搜索公众号Java知音,回复“2021”,送你一份Java面试题宝典)

但是这种缺点也很明显,为了创建一个单例对象,必须额外维护一个容器对象。这种的适合在有大量单例对象需要统一管理的情况。

存在的问题

前面也已经提到过了,上述单例设计模式除了枚举,大有有共同的两个问题,那就是单例模式容易被破坏。具体有两种方式可以破坏单例模式:

  1. 序列化破坏单例模式

  2. 反射破坏单例模式。

防止反射与序列化

首先我们来看看对于除了枚举实现的单例模式,其他的类型应该如何防止反射创建对象。反射之所以可以破坏序列化是因为反射访问私有构造方法,从而调用私有构造方法的newInstance()方法创建对象。这时我们可以在其类内部添加一个成员变量flag标志。以防止第二次调用构造方法。

private Singleton1()
{
    if(flag)
    {
        throw new RuntimeException("不可以通过反射调用哦!");
    }
    flag=true;
}

针对防止序列化机制,《Effective java》告诉我们只要实现readResolve方法即可,就像这样

public Singleton1 readResolve()
{
    return instance;
}

这是为什么呢?我们还是从ObjectInputStream的readObject这里来看依次查看readObject->readObject0->checkResolve(readOrdinaryObject(unshared))->readOrdinaryObject在这个方法中找到了原因:

如果对象实现了readResolve方法,那么就调用对象的readResolve方法。

Spring中的单例

上述我们实现的单例设计模式是在JVM的角度上来考虑的,从JVM进程的创建到销毁,我们的单例类只保持一个实例对象。而Spring的单例是在容器的角度上来考虑的,从容器的创建到销毁,Spring保证容器中只保持一个单例类的实例对象。

Spring的容器底层对于单例模式的实现是通过上述第9种,也就是容器单例来实现的,这很好理解,因为Spring中需要管理大量的单例对象,需要将这些对象集中统一管理,所以容器实现是一种比较好的方式。同时Spring使用HashMap作为底层盛放单例对象的容器,并根据对象(在Spring中叫bean)的id来获取对象,也就是说spring的单例不是线程安全的,并且只要bean的id不一致,spring就认为是不一样的对象。

总结

说了这么多种单例设计模式,那么我们平时用那种呢?笔者认为饿汉式单例设计模式及其变种、静态内部类模式,枚举方式都是不错的选择。鉴于枚举方式目前讨论的比较热烈,但是应用的不太广泛,总得来说,选择哪一种还是应该根据具体的场景选择最为合适的实现方式。

Static变量的创建是如何保证线程安全的

static变量创建的线程安全性是由jvm替我们保证实现的,这就需要从jvm的类加载过程开始说起,我们知道java中类是由类加载器从某一个地方(硬盘,网络等等)加载到jvm内存中。而类从加载到内存直到对象被创建的过程一般分为加载、验证、准确、解析、初始化。其中在准备阶段,会调用类构造器方法对于静态变量,以及静态域执行初始化操作。在此过程中,我们看看ClassLoader,也就是类加载器做了什么。

类加载器通过其loadClass方法,将指定位置的class字节码文件加载到内存,并创建相应的Class对象,作为方法区该类原数据的入口。那我们看看loadClass方法做了什么?

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) 
        {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
   //...省略了一系列类加载的代码。
            return c;
        }
    }

可以看到loadClass使用synchronized保证加载过程的线程安全性,而static变量的初始化正好就在classloader加载类的这个过程之中,于是JVM可以保证static变量及static域的初始化是线程安全的。

推荐好文

强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

分享一套基于SpringBoot和Vue的企业级中后台开源项目,代码很规范!

能挣钱的,开源 SpringBoot 商城系统,功能超全,超漂亮

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值