轻松理解设计模式:1.单例模式(创建型)

300 阅读11分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

前言

设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。也就是说,它是解决特定问题的一系列套路,是前辈们的代码设计经验的总结,具有一定的普遍性,可以反复使用。其目的是为了提高代码的可重用性、代码的可读性和代码的可靠性。

经过汇总的23种设计模式它是总结了面向对象设计当中最有价值的经验。对之前来讲可能是对其中部分设计模式还是相对来说熟悉的但仔细琢磨还是会有些疑问,正好在目前相对来说有更多的业余时间,可以来一次重新学习设计模式!

设计模式的一篇单例模式,内容包含2点一是模式的定义与目的,二是Java具体实现与疑问

定义

对于单例模式大家应该还算比较熟悉,可能很多时候需要手写啊啥的。

先还是体会下最初的定义

单例模式最初的定义出现于《设计模式》(艾迪生维斯理, 1994):“保证一个类仅有一个实例,并提供一个访问它的全局访问点。”

我们可以从中得到两个信息:

  1. 保证一个类仅仅只能有一个实例

    也就是不能被其他外界实例化。那么构造方法得私有private,且对象此实例属于类也就是static成员存在

  2. 提供这个实例的全局访问点

    提供public static方法给外界获取此成员

那么为什么要设计这样的单例类呢?

  1. 比如网站点登录会有一个登录窗口,把这个登录窗口当成一个对象。那么每次点击登录都要创建这样一个登录窗口吗? 用完之后再销毁掉么?同样还有访问数据库,需要建立连接使用完毕销毁连接,但每次就是使用的这样一个一模一样的东西。

  2. 再比如全局的计数器,如果一个计数器,每次获取的是不一样的对象,那就没作用了。

两点:1.减少开销、2.共享资源

饿汉式

综上定义所说实现单例模式的类有一下几点:

  1. 构造方法私有
  2. 作为静态成员存在
  3. 提供公开方法获取

那么很容易写出以下代码,就是熟悉的饿汉的实现

public class Single{
    private static final Single single = new Single();
    private Single(){}
    public static Single getSingle(){
        return single;
    }
}

懒汉式

对于单例的饿汉与懒汉大家也应该都清楚,一个是这个类加载的时候实例就创建了,另一个是等去调用getSingle()才去new,就像下面这样

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        if(single == null){
            single = new Single();
        }
        return single;
    }
}

好处就是避免了一开始的空间占用。问题就在于创建对象需要两步,一步是判断第二步是创建。因此存在线程安全问题导致多次创建违背了单例,如下:

image-20210905182509558

image-20210905181938236

可以看的到在前几个结果已经出现,新实例的情况.所以这个获取的这个操作是存在线程安全问题的。

那解决也很简单比如:

public class Single{
    private static Single single;
    private Single(){}
    public synchronized static Single getSingle(){
        if(single == null){
            single = new Single();
        }
        return single;
    }
}

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        synchronized(Single.class){
            if(single == null){
                single = new Single();
            }
        }
        return single;
    }
}

也就是加上synchronized保证代码块的同步. 但其实也就是第一次被外界获取是需要创建唯一的实例的,之后就都是返回已经存在的唯一实例,为了第一次的安全性锁住了全部包括之后每次获取都要走synchronized.是否是同步范围过大了?是不是没有这个必要?

DCL实现

了解多线程的都会熟悉双重检测锁(Double Check Lock)的写法,在这里一样 DCL的单例实现

public class Single{
    private static Single single;
    private Single(){}
    public static Single getSingle(){
        if(single == null){
            synchronized(Single.class){
                if(single == null){
                    single = new Single();
                }
            }
        }
        return single;
    }
}

其实就是在synchronized外面再判断一次,这样就保证实例创建完之后只用走个判断就返回不用保证同步,而因为首次创建进去的一批线程会在后面经过同步块与判空来让唯一的一个线程去创建.

了解过单例实现的,都知道对于这个实例成员还需加上volatile修饰,知道对于new对象并不是原子的,而是有大概如下的三步:

  1. 开辟空间
  2. 初始化对象到空间
  3. 将空间地址进行引用

image-20210909182308932 当2与3步骤进行调换,也就是图上的字节码,21与24的地方。当完成字段赋值,判断则不为空,但如果21与24的指令交换了顺序,那么字段不为空时对象并没初始化。由于双重检测锁的实现第一个判断是开放的,也就是在一个线程在创建对象的过程中,另一个线程可以经过判断如果不为空直接返回.

private static volatile Single single;

在后面疑问章节,可以不使用volatile达到同样的目的么?

内部类实现

懒汉式实现在使用到实例成员时才创建,那么除了像上面一样获取的时候进行判空来进行延迟创建,其实对于Java来说还有另一种方式就是内部类.

public class Single{
    private Single(){}
    private static class CreateSingle{
        private static final Single SINGLE = new Single()
    } 
    public static Single getSingle(){
        return CreateSingle.SINGLE;
    }
}

这个是利用了Java内部类的特性:

  1. 外部类可以访问内部的私有成员
  2. 类被加载时不会加载其中的内部类,只有内部类被访问使用时才加载

那么这样就做到了延迟加载也就是懒汉式的单例,并且没有多步骤操作,是自然的线程安全.

枚举

既然上面已经有较好的单例实现,为啥还要说枚举?

它是Java特性天然的单例模式

public enum EnumSingle {
    SINGLE;
}

大家都知道枚举类型,是把实例确定了的类。如果使用枚举类型就限制了对应的值。因为对于枚举的实例是在枚举类里列出来确定好了,并且构造器也是私有的。

下面是枚举类反编译的删减版(其实就是去掉了一些方法,只留下构造器与与成员实例)

public final class EnumSingle extends Enum{
    public static final EnumSingle SINGLE = new EnumSingle("SINGLE", 0);
    private EnumSingle(String s, int i){
        super(s, i);
    }
}

疑问

在开篇之前,是觉得这东西是很熟悉的。最终梳理完了感觉还是有很多疑问的,下面列出来疑问点以及我的想法:

  1. 为什么说单例的最佳实现是无状态的?(很多地方看到这句)

什么是无状态? 就是说类不具有成员属性信息,或者说没有可修改的成员属性。那么无状态的单例也就是完全的并发安全的,没有可供修改的内容,获取实例只是使用其中的方法,这样一般用来做工具类。第二种就是状态的单例,共享资源。比如全局计数器,那就要去保证线程安全提供安全的操作方法。

  1. 我们知道Java里面有发射机制,这些单例实现会被破坏么?

如果使用反射的话是确实可以去破坏,通过反射可以获取构造器来创建新对象,比如像下面:

// 1.获取无参构造器
Constructor<Single> constructor = Single.class.getDeclaredConstructor();
// 2.关闭私有访问
constructor.setAccessible(true);
// 3.调用两次构造器获取对象
Single single1 = constructor.newInstance();
Single single2 = constructor.newInstance();
// 4.测试
System.out.println(single1);
System.out.println(single2);

反射能去随意的使用单例类的构造器,因此确实可以破坏。但我们可以去点开newInstance方法

image-20210907212626845

可以很惊喜的发现,如果是枚举类型就不允许通过反射来执行构造器,会抛出异常

throw new IllegalArgumentException("Cannot reflectively create enum objects");
  1. 枚举这种方式它是属于饿汉式还是懒汉式?

我们可以通过枚举实际的一个class代码,枚举的成员实际都是static final的、一开始就创建的。是属于饿汉式的。

public static final EnumSingle SINGLE = new EnumSingle("SINGLE", 0);

但在查找资料时发现有的博客上写的是枚举是懒汉,理由是一句实际运行时会延迟加载。如果按照最终的执行的class反编译的代码它一定就是饿汉。抛开执行代码看看内存分析是否一致 测试结果没问题和预期一样,类加载就会创建实例。枚举是饿汉。

  1. 经过序列化之后还是同一个实例么?

其实我们大概都知道,进行序列化与反序列化它是一个深拷贝的过程,是产生另外一个属性内容都相同的新对象,所以如果一个单例类它可以被序列化,那么确实可以打破单例得到新对象。因此对于任何对象按照下面结果都是false

// 进行序列化
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("tempFile"));
out.writeObject(Single.getSingle());
File file = new File("tempFile");
// 还原
ObjectInputStream input =  new ObjectInputStream(new FileInputStream(file));
Object newInstance = input.readObject();
//判断是否是同一个对象
System.out.println(newInstance == Single.getSingle());

但经过测试发现枚举是与众不同的

image-20210908223340198

Java规范字规定,每个枚举类型及其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。在序列化的时候Java仅仅是将枚举对象的name属性输到结果中,反序列化的时候则是通过java.lang.Enum的valueOf()方法来根据名字查找枚举对象。也就是说,序列化的时候只将DATASOURCE这个名称输出,反序列化的时候再通过这个名称,查找对应的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同 —— 《Java编程思想》

抱着一探究竟的想法看了跟着看了readObject如下图,就想上面描述的一样,通过枚举的name属性获取到枚举类里存在的实例。 image-20210908225725870 image-20210908225810282

  1. DCL实现可以不使用volatile么?

理论上来讲是可以的,毕竟加这个volatile也是出于理论上,目前没有找到测试方法。

那么不加volatitle怎么保证不会拿到为初始化好的对象呢?很简单虽然对于JVM确实会可能进行指令的顺序调整提高效率,前提是调换顺序不影响结果才可能被重排序。

为了让其不被重排序除了volatile修饰成员之外。是不是可以让创建对象之后被依赖,通过依赖关系让JVM不对其进行重排序。将最终创建完整的对象再直接赋值给单例字段single

下面是我的猜想,让try-catch限制创建对象的指令完成之后再进行下面赋值操作。也就是原子操作给single。保证single字段不为空时,一定是个完整的对象。

try{
  Single temp = new Single();
}catch(Exception e){

}
single = temp;

当然这是猜想,关于指令重排序怎么测试出来,怎么追踪到JVM里面去看最终操作。目前没有找到合适的方法。

总结

首先就是这种单例设计的思想,是比较好理解的全局唯一的对象。第二就是我们对这种思想落实到具体编码实现需要考虑的点:1.需要懒汉还是饿汉式?2.是否需要保证是否线程安全?3.是否有必要防反射序列化等等。对于优缺点而言就是有得必有舍得问题,比如它不能被继承不好扩展等。这些缺点就是为了实现单例得思想带来的因此必须是不能的。所以也不算缺点而是说这个使用场景是不是满足了使用单例的需要。