把书读薄 | 《设计模式之美》设计模式与范式(创建型-单例模式)

1,270 阅读14分钟

0x0、引言

之前做组内分享写过一篇 《重学设计模式 | 单例模式(Singleton Pattern)》,部分参考了《设计模式之美》,故直接搬运,且对此进行一些内容补充,对应 设计模式与范式:创建型(41-43),单例模式是日常开发中是用得最多的模式~

二手知识加工难免有所纰漏,感兴趣有时间的可自行查阅原文,谢谢。

0x1、定义

0x2、单例写法的演进

① 饿汉式(没有懒加载,线程安全,常用)

public class Singleton () {
    private static Singleton instance = new Singleton();
    private Singleton(){ }
    public static Singleton getInstance() {
        return instance;
    }
}
  • 优点:类装载(ClassLoader)时就完成实例化,避免线程同步问题,没加锁,执行效率高;
  • 缺点:没有懒加载,即使没用到这个实例还是会加载;

② 懒汉式(懒加载,线程不安全,不推荐使用)

就是在饿汉式的基础上加了一个判空,调用getInstance()方法才初始化实例:

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

虽然实现了懒加载,却存在线程安全问题,比如两个线程,都刚好走到判空,实例为空初始化,结果可能导致实例化了两个Singleton对象,破坏了单例,一种升级版的解决方式是加锁。

③ 升级版懒汉式(线程安全,但效率低,不推荐使用)

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

给getInstance()函数加锁,保证了线程安全,但也导致了函数的并发度很低,相当于串行操作,频繁调用此函数,会频繁地加锁、释放锁、效率太低。

而且,其实只需要在new的时候考虑线程同步就行了,所以改进后的DCL单例来了~

④ 懒汉式双重校验锁(DCL,线程安全,推荐使用)

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

代码看似完美,但还存在最后一个问题:指令重排序

JVM在保证最终结果正确的情况下,可以不按照编码的顺序执行语句,尽可能地提高程序的性能。

创建一个对象,在JVM中会经过这三步:

  • 1、为instance分配内存空间;
  • 2、初始化instance对象;
  • 3、将instance指向分配好的内存空间;

在这三步中,第2、3步有可能发生指令重排现象,导致对象的创建顺序变成了:1-3-2,多个线程在获取对象时,有可能获取到为初始化的instance对象对象,引起NPE异常。示例流程图如下所示:

而使用volatile关键字修饰变量,可以防止指令重排序(原理是内存屏障),使得指令执行顺序与程序指明顺序一致。

修改后的代码如下:

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

上面这个防止指令重排序又称**有序性,接着说说可见性**,即 每一时刻线程读取该变量的值都是内存中最新的值

也可以这样理解:

volatile修饰的变量,在线程对其进行写入操作时,不会把值缓存到工作内存中,而是直接将修改后的值重新刷回主内存。而当处理器监控(嗅探)到其他线程中该变量在主内存中的内存地址发生变化时,会让这些线程重新到主内存中拷贝这个变量的最新值到工作内存中,而不是继续使用工作内存中的旧缓存。

未加volatile的简单代码示例如下:

public class JavaTest {
    public static void main(String[] args) {
        Test test = new Test();
        test.start();
        while (true){
            if (test.isFlag()) {
                System.out.println("flag为true");
                break;
            }
        }
    }
}

class Test extends Thread {
    private boolean flag = false;
    public boolean isFlag() {
        return flag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag = " + flag);
    }
}

运行后,程序只输出了一个flag = true,然后就死循环卡住了,不会输出:flag为true!原因是:

我们在子线程中修改了flag的值,但是主线程并不知道这个更改,使用的依旧是之前的旧值,所以会一直死循环。

而只要我们为flag添加volatile修饰,程序就能正常结束了:

除此之外为if(test.ifFlag())加上synchronized锁也可以解决可见性问题~

线程在进入synchronized代码块前后,会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存称为副本,执行代码,将修改后的副本的值刷回主内存中,最后线程释放锁。

最后一点,volatile无法保证原子性(一次操作,要么完全成功,要么完全失败),比如下面的代码示例:

public class VolatileTest {
    public static volatile int count = 0;

    public static void increase() {
        count++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        for(int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for(int j = 0; j < 1000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        // 等待所有累加线程结束,此处>2的原因是idea执行用户代码时会创建一个监控线程Monitor
        // 可以调用 Thread.currentThread().getThreadGroup().list() 查看一番~
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(count);
    }
}

创建了20个线程,每个线程对变量count进行1000此自增,并发结果正常应该是20000,但实际运行过程中结果很多时候都不够20000,原因是count++这个自增操作不是院子操作。解决方法也很简单,要么加锁,要么使用原子类,如:AtomicInteger。

总结下就是:

volatile是JVM提供的一种最轻量级的同步机制,可看做轻量版的synchronized,但不保证原子性,如果是对共享变量进行多个线程的赋值而没有其他操作,那么可以用volatile来代替synchronized。

⑤ 静态内部类(懒加载,线程安全,非常推荐)

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

与饿汉式类似,两者都是通过类加载机制来保证初始化instance时只有一个线程,从而避免线程安全问题。

不同之处是Singleton类被加载时,不会立即初始化,只有调用getInstance()函数时,才会装载SingletonHolder类,从而实例化instance,间接实现了懒加载。

0x3、单例的其他安全问题

上述的单例写法都是围绕着 线程安全问题 进行的,即限制了new创建对象,而Java中除了这种创建对象的方式外,还有三种 克隆、反射和序列化,下面演示下如何通过这三种方式破坏单例。

① 克隆破坏单例

clone()是Object自带函数,每个对象都有,直接调用下clone函数,就能创建一个新对象了,那不就把单例破坏了吗?

答:想太多,被克隆类要实现 Cloneable 接口,然后重写clone()函数,才能完成对象克隆,而一般我们的单例是不会实现这个接口的,所以不存在此问题。

② 反射破坏单例

以静态内部类实现的单例为例,我们通过下述代码构建了两个对象,以此破坏单例:

public class ReflectTest {
    public static void main(String[] args) {
        try {
            Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
            constructor.setAccessible(true);    // 禁用访问安全检查
            Singleton s1 = constructor.newInstance();
            Singleton s2 = constructor.newInstance();
            System.out.println(s1.equals(s2)); // 输出结果:false
        } catch (NoSuchMethodException | IllegalAccessException |
                InstantiationException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

一个最简单的解决方式就是添加一个标志位,当二次调用构造函数时抛出异常,示例如下:

public class Singleton {
    private static boolean flag = true;

    private Singleton() {
        if (flag) {
            flag = !flag;
        } else {
            throw new RuntimeException("有不法之徒想创建第二个实例");
        }
    }

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

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

此时再运行反射代码:

Tips:当然先通过反射修改你的flag,在反射调构造方法依旧是可以破坏的~

③ 序列化破坏单例

同样以静态内部类实现的单例为例,先序列化到文件,然后在反序列化恢复为Java对象:

public class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton1 = Singleton.getInstance();
        Singleton singleton2 = null;
        // 序列化
        try {
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
            oos.writeObject(singleton1);
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 反序列化
        try {
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_file"));
            try {
                singleton2 = (Singleton) ois.readObject();
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(singleton1 == singleton2);   // 输出:false
    }
}

输出false,单例再次被破坏,接着我们来看下这个新对象是怎么创建出来的,从 readObject 跟到 readOrdinaryObject,定位到下述代码:

  • isInstantiable():一个serializable/externalizable的类是否可以在运行时被实例化;
  • desc.newInstance():通过反射的方式调用无参构造函数创建一个新对象;

这就是反序列化破坏单例的原理,接着说下怎么规避,在创建新对象的代码处往下走一些:

  • desc.hasReadResolveMethod():判断类是否实现了readResolve()函数;
  • desc.invokeReadResolve(obj):有的反射调用此函数,如果在此函数中返回实例就可以了;

修改后的单例类代码:

import java.io.Serializable;

public class Singleton implements Serializable {

    private Singleton() { }

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

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

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

此时再运行反序列单例时的代码,会输出:true,即同一个对象。

0x4、枚举单例(安全简单,没有懒加载,最佳实践)

上面讲解了除线程安全问题外,三种破坏单例的方式及解决方式,其实用枚举实现单例就能规避这些问题。一个简单的枚举单例代码示例如下:

public enum SingletonEnum {
    INSTANCE;
    private final AtomicLong id = new AtomicLong(0);

    public long getId() {
        return id.incrementAndGet();
    }
}

// 调用
SingletonEnum.INSTANCE.getId()

得益于jdk的enum语法糖,这么简单的代码就能预防这四种问题,接下来一一看下原理。

① 如何保证线程安全

直接在idea上打开生成的SingletonEnum.class文件:

好吧,没看到有用的信息,再用JDK自带反编译工具javap编译下:

可以看到继承自 Enum类,但是代码不够全,再用jad工具反编译下:

反编译后的代码如下:

import java.util.concurrent.atomic.AtomicLong;

public final class SingletonEnum extends Enum {
    public static SingletonEnum[] values() {
        return (SingletonEnum[]) $VALUES.clone();
    }

    public static SingletonEnum valueOf(String name) {
        return (SingletonEnum) Enum.valueOf(SingletonEnum, name);
    }

    private SingletonEnum(String s, int i) {
        super(s, i);
    }

    public long getId() {
        return id.incrementAndGet();
    }

    public static final SingletonEnum INSTANCE;
    private final AtomicLong id = new AtomicLong(0L);
    private static final SingletonEnum $VALUES[];

    static {
        INSTANCE = new SingletonEnum("INSTANCE", 0);
        $VALUES = (new SingletonEnum[]{
                INSTANCE
        });
    }
}

可以看到**INSTANCE的初始化发生在static静态代码块**中,即在类加载阶段执行,保证了线程安全,但跟饿汉式一样,没有懒加载。

② 如何保证克隆安全

而防克隆则是要来到父类**Enum类**中,直接实现了clone()函数:

调用此函数直接返回 CloneNotSupportedException 异常。

③ 如何保证反射安全

将反射部分代码中的Singleton改成SingletonEnum,接着运行下,抛出下述异常

在获取构造函数时抛出的异常,没有此构造方法,呕吼,看回jad反编译的代码:

这里使用的不是无参构造方法,而是有两个参数,改下反射代码,往getDeclaredConstructor()传入这个两个参数:

再次运行,还是报异常:

定位到 Constructor类newInstance()

反射通过newInstance()创建对象时,会检查该类是否**ENUM**修饰,是则抛出异常,反射失败。

④ 如何保证序列化安全

Java规范中规定:每一个枚举类型及其定义的枚举变量在JVM中都是唯一的

因此在枚举类型的序列化和反序列化上,Java做了特殊的规定:

  • 序列化时 → 仅仅将枚举对象的name属性输出到结果中;
  • 反序列化时 → 通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。

定位到Enum类的valueOf()方法:

调用enumType(Class对象的引用)的enumConstantDirectory获取一个Map集合,集合中存放的键值对:

枚举name : 枚举示例变量

根据name即可拿到枚举实例,所以枚举单例序列化并不会重新创建新实例!

0x5、Kotlin中的单例

说完Java的单例,顺带提提Kotlin中的单例,使用一行代码即可创建安全单例:

object KotlinSingleton

就是这么简短,依次点击 ToolsKotlinShow Kotlin BytecodeDecompile 反编译下:

呕吼,static静态代码块,饿汉式变种,线程安全,Android项目不考虑其他三个问题的话,可以大胆放心使用。

0x6、单例存在哪些问题?

  • 对OOP特性支持不友好 (将某个类设计成单例类,意味着放弃继承和多态两个特性);
  • 会隐藏类之间的依赖关系 (单例不需要显式创建、依赖参数传递,在函数中直接调用);
  • 对代码的扩展性不友好 (某一天,需要在代码中创建两个或多个实例,代码需要较大改动);
  • 对代码的可测试性不友好 (单例类依赖较重外部资源,mock方式无法替换,持有成员变量相当于全局变量);
  • 不支持有参数的构造函数 (解决思路:创建实例后再调init()函数传递参数、参数放到getInstance()方法中、将参数放到另一个全局变量中,里面的值可以静态常量定义或从配置文件中加载得到)

0x7、单例的替代方案

为了保证全局唯一,除了使用单例外,还可以用 静态方法 来实现,不过实际上它并不能解决上面提到的问题,而且没有懒加载。

只能另辟蹊径,用其他方式来保证类对象的全局唯一性:如工厂模式、IOC容器等(后面会讲),还可以通过程序员自己来保证(写代码时保证不要创建两个类对象)。

另外,如果单例类没有后续的扩展需求,且不依赖外部系统,设计单例类就没太大问题,对于一些全局的类,到处new,类之间传来传去,不如直接做成单例类,使用起来简洁方便。

0x8、如何实现一个多例

单例 指的是:一个类只能创建一个对象,对应的 多例 则是:

一个类可以创建多个对象,但个数是有限的,比如只能创建5个对象。

实现方式也比较简单,通过一个Map来存储对象类型及对象间的对应关系,来控制对象的个数。示例如下:

import java.util.HashMap;
import java.util.Map;
import java.util.Random;

public class BikeServer {
    private final long bikeNo;    // 共享单车编号
    private final String address; // 共享单车地址

    private static final int BIKE_COUNT = 5;    // 单车数量
    private static final Map<Long, BikeServer> bikeInstances = new HashMap<>(); // 单车实例集合

    // 私有化构造方法
    private BikeServer(long bikeNo, String address) {
        this.bikeNo = bikeNo;
        this.address = address;
    }

    // 静态代码块中初始化实例
    static {
        bikeInstances.put(1L, new BikeServer(1L, "罗湖区"));
        bikeInstances.put(2L, new BikeServer(2L, "南山区"));
        bikeInstances.put(3L, new BikeServer(3L, "福田区"));
        bikeInstances.put(4L, new BikeServer(4L, "宝安区"));
        bikeInstances.put(5L, new BikeServer(5L, "龙华区"));
    }

    // 根据编号获取单车实例
    public static BikeServer getInstance(long bikeNo) {
        return bikeInstances.get(bikeNo);
    }

    // 随机获取单车实例
    public static BikeServer getRandomInstance() {
        Random r = new Random();
        return bikeInstances.get(r.nextInt(BIKE_COUNT) + 1L);
    }

    @Override
    public String toString() {
        return "BikeServer{" +
                "bikeNo=" + bikeNo +
                ", address='" + address + '\'' +
                '}';
    }
}

调用下:

public class BikeTest {
    public static void main(String[] args) {
        System.out.println(BikeServer.getInstance(2L).toString());
        System.out.println(BikeServer.getRandomInstance().toString());
    }
}

// 输出结果:
BikeServer{bikeNo=2, address='南山区'}
BikeServer{bikeNo=5, address='龙华区'}

0x9、如何实现集群环境下的单例

上面介绍的单例、多例都是进程内唯一、进程间唯一,即不适用于多进程(集群),如果想实现集群环境下的单例:

要把单例对象序列化存储到外部共享存储区(如文件),进程用到单例对象时,先将它此从共享存储区将它读取内存,并反序列化为对象,然后再使用,使用完还需要把它序列化存储回外部共享存储区。

为了保证任何时刻进程中都只有一份对象存在,进程获取到对象后,需要对对象加锁,避免其他进程再获取,使用完后,还需要显式地将对象从内存中删除,并缩放对单例对象的加锁。

0xa、未解疑惑:饿汉式真的没有懒加载吗?

之前在群里看到有人发了这篇文章 《到底是用"静态类"还是单例》,其中说到:饿汉式本身就是延迟加载的,并附解释:

而在别的地方我又看到了:虚拟机规范严格规定了有且只有以下5种情况立即对类进行初始化,其中一条:

遇到new, getstatic, putstatic, invokestatic这4条字节码指令时,如果类没有进行过初始化,就需要先触发其初始化。

而初始化阶段是执行类构造器<clinit>() 方法的过程 → 由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生。所以 private static Singleton instance = new Singleton() 也会放入其中,所以还是在类加载的时候就完成了实例化。

欢迎有知道真相的的大佬评论区告知解惑,感谢~