【本人秃顶程序员】最简单的设计模式——单例模式的演进和推荐写法(Java 版)

1,086 阅读7分钟

←←←←←←←←←←←← 快!点关注

前言

如下是之前总结的 C++ 版的;软件开发常用设计模式—单例模式总结(c++版),对比发现 Java 实现的单例模式和 C++ 的在线程安全上还是有些区别的。

概念不多说,没意思,我自己总结就是:

有这样一个类,该类在生命周期内有且只能有一个实例,该类必须自己创建自己的这个唯一实例,该类必须给所有其他对象提供这一实例(提供全局访问点),这样的类就叫单例类。

简单的说就是满足三个条件:

1、生命周期内有且只能有一个实例

2、自己提供这个独一无二的实例

3、该实例必须是能全局访问的

需要的考虑的细节

进一步,单例类,最好能实现懒加载,随用随生成,而不是初始化的时候就生成,提高启动速度和优化内存。

还有应该考虑并发环境下的场景,多线程的单例模式实现有什么难点,回答这个问题,必须先知道Java的内存模型

考虑黑客会做反序列化的攻击

考虑黑客会做反射的攻击,因为反射可以访问私有方法

单线程环境下懒加载的单例

如果程序确认没有多线程的使用场景,完全可以简单一些写。

public class NoThreadSafeLazySingleton {
    private static NoThreadSafeLazySingleton lazySingleton = null;

    private NoThreadSafeLazySingleton() {
    }

    public static NoThreadSafeLazySingleton getLazySingleton() {
        if (lazySingleton == null) {
            lazySingleton = new NoThreadSafeLazySingleton();
        }

        return lazySingleton;
    }
}

很简单,但是只适用于单线程环境

线程安全的懒加载单例

原理也很简单,没什么可说的,如下示例代码:

public class ThreadSafeLazySingleton {
    private static volatile ThreadSafeLazySingleton lazySingleton = null;

    private ThreadSafeLazySingleton() {
    }

    public static ThreadSafeLazySingleton getLazySingleton() {
        if (lazySingleton == null) {
            synchronized (ThreadSafeLazySingleton.class) {
                if (lazySingleton == null) {
                    lazySingleton = new ThreadSafeLazySingleton();
                }
            }
        }

        return lazySingleton;
    }
}

主要是注意 volatile 关键字的使用,否则这种所谓双重检查的线程安全的单例是有 bug 的。

静态内部类方案

在某些情况中,JVM 隐含了同步操作,这些情况下就不用自己再来进行同步控制了。这些情况包括:

  • 由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时
  • 访问final字段时
  • 在创建线程之前创建对象时
  • 线程可以看见它将要处理的对象时

在静态内部类里去创建本类(外部类)的对象,这样只要不使用这个静态内部类,那就不创建对象实例,从而同时实现延迟加载和线程安全。

public class Person {
    private String name;
    private Integer age;

    private Person() {
    }

    private Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    // 在静态内部类里去创建本类(外部类)的对象
    public static Person getInstance() {
        return Holder.instatnce;
    }

    // 静态内部类相当于外部类 Person 的 static 域,它的对象与外部类对象间不存在依赖关系,因此可直接创建。
    // 因为静态内部类相当于其外部类 Person 的静态成员,所以在第一次被使用的时候才被会装载,且只装载一次。
    private static class Holder {
        // 内部类的对象实例 instatnce ,是绑定在外部 Person 对象实例中的
        // 静态内部类中可以定义静态方法,在静态方法中只能够引用外部类中的静态成员方法或者成员变量,比如 new Person
        // 使用静态初始化器来实现线程安全的单例类,它由 JVM 来保证线程安全性。
        private static final Person instatnce = new Person("John", 31);
    }
}

静态内部类相当于外部类 Person 的 static 域(静态成员),它的对象与外部类对象间不存在依赖关系,因此可直接创建。

既然,静态内部类相当于其外部类 Person 的静态成员,所以在第一次被使用的时候才被会装载,且只装载一次,实现了懒加载和单例。

而且,使用静态初始化器来实现单例类,是线程安全的,因为由 JVM 来保证线程安全性

客户端调用

    Person person = Person.getInstance();

该方案实现了,线程安全的单例 + 懒加载的单例,但是并不能防反序列化攻击,需要额外的加以约束。

反序列化攻击单例类

其实这个 case 没必要说太多,知道就行,因为哪里就这么巧,一个能序列化的类(实现了Serializable/Externalizable接口的类),就恰恰是单例的呢?

看下面例子,把 Person 类改造为能序列化的类,然后用反序列攻击单例

public class SerializationTest {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Person person = Person.getInstance();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("person"));
        objectOutputStream.writeObject(person);

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("person"));
        Person person1 = (Person) objectInputStream.readObject();

        System.out.println(person == person1); // false
    }
}

比较两个 person 实例地址,是 false,说明生成了两个对象,违背了单例类的初衷,那么为了能在序列化过程仍能保持单例的特性,可以在Person类中添加一个readResolve()方法,在该方法中直接返回Person的单例对象

    public Object readResolve() {
        return Holder.instatnce;
    }

原理是当从 I/O 流中读取对象时,ObjectInputStream 类里有 readResolve() 方法,该方法会被自动调用,期间经过种种逻辑,最后会调用到可序列化类里的 readResolve()方法,这样可以用 readResolve() 中返回的单例对象直接替换在反序列化过程中创建的对象,实现单例特性。

也就是说,无论如何,反序列化都会额外创建对象,只不过使用 readResolve() 方法可以替换之。

反射攻击单例类

直接看例子,做法很简单,通过 Java 的反射机制,看看能不能拿到单例类的私有构造器,并且改变构造器的访问属性

public class ReflectTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException {
        Person person = Person.getInstance();

        Class clazz = Class.forName("com.dashuai.D13Singleton.Person");
        Constructor constructor = clazz.getDeclaredConstructor();
//        constructor.setAccessible(true);
        Person person1 = (Person) constructor.newInstance();

        System.out.println(person == person1); // false
    }
}

运行抛出了异常:

但是,如果把注释的行打开,就不会出错,且打印 false。

网上有一些解决方案,比如在构造器里加判断,如果二次调用就抛出异常,其实也没从根本上解决问题。

解决所有问题的方案——枚举实现单例类

目前公认的最佳方案,代码极少,线程安全,防止反射和序列化攻击

public enum EnumSingleton {
    ENUM_SINGLETON;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
////////////////////////调用
EnumSingleton.ENUM_SINGLETON.setName("dashuai");
System.out.println(EnumSingleton.ENUM_SINGLETON.getName());

所有的变量都是单例的。至于为什么,可以通过反编译工具查看枚举的源码。可以安装 idea 的 jad 插件,会发现就是按照单例模式设计的。

享元模式和单例模式的异同

享元模式是对象级别的, 也就是说在多个使用到这个对象的地方都只需要使用这一个对象即可满足要求。

单例模式是类级别的, 就是说这个类必须只能实例化出来一个对象。

可以这么说, 单例是享元的一种特例, 设计模式不用拘泥于具体代码, 代码实现可能有n多种方式, 而单例可以看做是享元的实现方式中的一种, 他比享元模式更加严格的控制了对象的唯一性

使用单例的场景和条件是什么?

1、单例类只能有一个实例。

2、单例类必须自己创建自己的唯一实例。

3、单例类必须给所有其他对象提供这一实例。

欢迎大家加入粉丝群:963944895,群内免费分享Spring框架、Mybatis框架SpringBoot框架、SpringMVC框架、SpringCloud微服务、Dubbo框架、Redis缓存、RabbitMq消息、JVM调优、Tomcat容器、MySQL数据库教学视频及架构学习思维导图