单例模式 Singleton

单例模式 Singleton

单例模式(Singleton):保证一个类仅有一个实例,并提供一个访问它的全局访问点。

A singleton is a class that ensures that only one instance of itself is created and provides a global point of access to that instance.

顾名思义,单例即单一的实例,确切地讲就是指在某个系统中只能存在一个实例,如果一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫单例模式。

一般来说,为了保证某个类只能生成一个对象,需要做三件事:

⨳ 构造器私有化,防止用户使用 new 创建对象

⨳ 封装单例类的唯一实例(如私有成员变量 instance

⨳ 向外暴露一个静态的公共方法(如 getInstance),来获取对象

java 复制代码
public class Singleton {
    // 封装单例类的唯一实例
    private static Singleton instance;
    // 私有化构造方法
    private Singleton(){}
    public static Singleton getInstance(){
        // 获取单一实例
        return instance;
    }
}

因为单例模式类封装了它的唯一实例,这样它可以严格地控制客户怎样访问它以及何时访问它。

基本实现

实现单例模式的方式有很多,现在就分别介绍一下:

饿汉式 Hungry

饿汉式是相对于懒汉式说的:

⨳ 饿汉式是在类加载的时候,instance 实例就已经创建并初始化好了;

⨳ 懒汉式并不是在类加载的时候实例化 instance,而是调用 getInstance 方法时才进行实例化,即延迟加载(Lazy Loading)。

java 复制代码
// 饿汉式
public class HungrySingleton {

    private final static HungrySingleton instance;

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

因为在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。

饿汉式的优点是提前实例化对象,没有线程安全问题,按照 fail-fast 的设计原则,有问题也可以提前暴露;而且如果单例对象初始化耗时长,饿汉式可以将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

缺点就是,如果这个单例一直没人用,提前初始化实例是一种浪费资源的行为,不如懒汉式的延迟加载。

懒汉式 Lazy

懒汉式并不是在类加载的时候实例化 instance,而是调用 getInstance 方法时才进行实例化,即延迟加载(Lazy Loading)。

java 复制代码
public class LazySingleton {
    private static LazySingleton instance = null;
    private LazySingleton(){}
    public synchronized static LazySingleton getInstance(){
        if(instance == null){
            instance = new LazySingleton();
        }
        return instance;
    }
}

注意,懒汉式的单例对象是在 getInstance 方法内部创建的,这会导致线程安全问题,所以 getInstance 方法要使用 synchronized 关键字修饰。

如果getInstance没有用 synchronized修饰,如果在多线程下,一个线程进入了 if(instance == null) 判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。

给整个 getInstance 方法加锁,可以保证同一时刻只有一个线程进入方法内部,可以保证实例最多只创建一次,但也会导致每个线程想要通过 getInstance 方法实例时候,都要进行同步(串行操作),并发度很低。

其实 getInstance 方法只执行一次实例化代码就够了,后面的想获得该类实例,直接 return 就行了。

如果只在必要的地方加锁,即创建 instance 的时候加锁,instance 创建完成后,获取 instance 的时候不加锁,会大大提高并发性能。

这就是所谓的双重检测(Double Check)机制。

java 复制代码
public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton instance = null;
    private LazyDoubleCheckSingleton(){}
    public static LazyDoubleCheckSingleton getInstance(){
        if(instance == null){
            synchronized (LazyDoubleCheckSingleton.class){ // 此处为类级别的锁
                if(instance == null){
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

注意上述代码有两层 if 判断 instance 是不是等于 null

⨳ 第一层 if,如果 instance 已经创建初始化完成,则直接 return,并没有锁;如果 instance 没有创建完,则进入同步代码块。

⨳ 第二层 if,在同步代码块中,只会在 instancenull 的时候才会进来。

假如此时 instancenull ,并且同时有两个线程调用 getInstance 方法,它们将都可以通过第一层 if 的判断,然后由于锁机制,这两个线程则只有一个进入同步代码块,另一个在外排队等候,必须要其中的一个进入并出来后,另一个才能进入。

而此时如果没有了第二层的 if 的判断,则第一个线程创建了实例,而第二个线程还是可以继续再创建新的实例,这就是为什么需要两层判断。

还有一点, instance 成员变量被 volatile 关键字修饰了,volatile 的作用有两个:保证内存可见性禁止指令重排序

保证内存可见性 :Java 内存模型规定,对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作(通过禁用 CPU 缓存实现)。

事实上,Java 内存模型还规定,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,synchronized 关键字已经保证了对instance 变量操作的可见性,所以这里加 volatile 关键字关键字最主要的意义是禁止指令重排序

禁止指令重排序:指令重排序是指编译器或 CPU 为了优化程序的执行性能,而对指令进行重新排序的一种手段。

比如说通过 new Singleton() 创建对象,我们以为的步骤是 ① 分配内存给这个对象;② 初始化对象;③ 设置 instance 变量指向刚分配的内存地址。

如果发生指令重排,这个顺序可能是 ① ③ ②,会在对象初始化之前就将刚分配的内存地址赋值给 instance 变量。如果线程A在执行完第 ① ③ 步后,执行步骤 ② 前,线程B又调用 getInstance 方法,发现 instance 变量已经不为 null,从而线程B获取到的对象并不是初始化完的对象,导致线程B使用资格对象时,程序执行出错。

静态内部类 Static Inner Class

静态内部类是将单例对象的创建放到了静态内部类中,从而实现懒加载。

java 复制代码
public class StaticInnerClassSingleton {

    private StaticInnerClassSingleton(){}
    
    // 静态内部类
    private static class InnerClass{
        private static StaticInnerClassSingleton instance = new StaticInnerClassSingleton();
    }
    public static StaticInnerClassSingleton getInstance(){
        return InnerClass.instance;
    }

}

InnerClass 是一个静态内部类,当外部类 StaticInnerClassSingleton 被加载的时候,并不会创建 InnerClass 实例对象。只有当调用 getInstance 方法时,InnerClass 才会被加载,从而完成 StaticInnerClassSingleton 的实例化。

类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM 帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。

静态内部类实现单例,有点像饿汉式与懒汉式的结合体:

⨳ 对于静态内部类来说,单实例会随着内部类加载而完成实例化,是饿汉式;

⨳ 对于外部类来说,内部类的加载只会在调用 getInstance 方法时才会发生,为懒汉式。

枚举 Enume

枚举是 Java 中一种特殊的类,它可以定义固定数量的枚举实例,例如: 性别、季节等等。格式如下:

java 复制代码
public enum Gender {
    MALE, FEMALE; // 男,女
}

⨳ 枚举类的声明不再是使用 class 关键字,而是 enum 关键字;

使用 JAD 反编译后,enum 类为 public final class Gender extends Enum,也就是枚举类,本质上就是继承 Enum 类、被 final 修饰的 class 类。

翻看 java.lang.Enum,可以看到它有两个被枚举类继承的属性:

java 复制代码
protected Enum(String name, int ordinal) {
    this.name = name;
    this.ordinal = ordinal;
}

MALE, FEMALE; 为枚举实例(也就是 Gender 对象),一般写在第一行,用逗号分隔,分号结尾。

使用 JAD 反编译后,这些枚举实例和饿汉式一样,在静态代码块中创建:

java 复制代码
    public static final Gender MALE;
    public static final Gender FEMALE;
    
    static 
    {
        MALE = new Gender("MALE", 0);
        FEMALE = new Gender("FEMALE", 1);
        $VALUES = (new Gender[] {
            MALE, FEMALE
        });
    }

枚举实现单例如下,这种方式是《Effective Java》作者 Josh Bloch 提倡的方式:

java 复制代码
public enum EnumSingleton {
    INSTANCE; // 单例对象
    
    private String someFiled; // 属性
    public void doSomething(){}// 方法
}

单例破坏

单例破坏(Singleton Breakage):通常指尝试绕过单例模式的约束,创建多个实例。

反射

通过反射可以访问和修改类的私有构造方法,从而使得即使使用了单例模式,也可以创建多个实例,破坏了单例的唯一性。

HungrySingleton 为例:

java 复制代码
HungrySingleton singleton = HungrySingleton.getInstance();
HungrySingleton singleton1 = HungrySingleton.getInstance();

System.out.println(singleton==singleton1); // true

// 反射调用私有构造方法
Constructor constructor = HungrySingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
HungrySingleton singleton2 = (HungrySingleton) constructor.newInstance();

System.out.println(singleton==singleton2); // false

对于懒汉式和静态内部类都可以使用同样的方式破坏单例,怎么预防反射破坏单例呢?

给构造方法里加上防御代码即可:

java 复制代码
private HungrySingleton(){
    if(instance != null){
        throw new RuntimeException("单例构造器禁止反射调用");
    }
}

这样如果单例对象已经存在的情况下,再次使用反射调用构造器就会报错。

这里需要注意一点,对于懒汉式单例,如果在调用 getInstance 生成对象前就使用反射创建对象,会绕过防御。

序列化与反序列化

在序列化和反序列化过程中,单例对象的状态可能会丢失,也会导致在反序列化时创建新的实例,破坏了单例模式的限制。

⨳ 序列化就是把 Java 对象转为⼆进制流,⽅便存储和传输。

⨳ 反序列化就是把⼆进制流恢复成对象。

对于需要序列化的对象需要实现 Serializable 接口,这个接口只是⼀个标记,没有具体的作⽤,但是如果不实现这个接口,在有些序列化场景会报错。

序列化还涉及一个 serialVersionUID 的东西:

private static final long serialVersionUID = 1 L ;

ID 的数字其实不重要,只要序列化时候对象的 serialVersionUID 和反序列化时候对象的 serialVersionUID ⼀致的话就⾏,如果没有显⽰指定 serialVersionUID ,则编译器会根据类的相关信息⾃动⽣成⼀个。

HungrySingleton 为例,让其实现 Serializable 接口,就可以对其进行序列化操作了:

java 复制代码
HungrySingleton singleton = HungrySingleton.getInstance();
HungrySingleton singleton1 = HungrySingleton.getInstance();

System.out.println(singleton==singleton1); // true

// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
oos.writeObject(singleton);

// 反序列化
File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton singleton2 = (HungrySingleton) ois.readObject();

System.out.println(singleton==singleton2); // false

在分布式系统中,对象需要在网络中进行传输,而网络传输的数据必须是序列化的,以便能够在不同的节点之间传递。所以会频繁地涉及到序列化和反序列化传输对象的情况。

如果传输的对象是单例,那传输一次生成一个新的对象?不就破坏单例了嘛?

解决办法很简单,只要在单例中添加 readResolve() 方法即可:

java 复制代码
private Object readResolve(){
    return instance;
}

这是因为 ObjectInputStreamreadObject 方法会检查类中是否有 readResolve() 方法。如果有的话,反序列化过程会调用该方法来获取实际返回的对象,而不是通过反序列化构造的新对象。

如果是懒汉式或静态内部类单例就这样加 readResolve

java 复制代码
    // 防止序列化破坏单例
    protected Object readResolve() {
        return getInstance();
    }

再看枚举单例

在Java中,枚举类型的实例是在类加载时被创建的,并且在整个应用程序生命周期中只会有一个实例存在。当枚举类型的实例被序列化时,实际上是将枚举常量的名称写入到序列化输出流中,而不是将对象的状态写入。

在反序列化过程中,Java虚拟机会根据枚举常量的名称来查找对应的实例,而不是重新创建一个新的实例。这样就保证了在反序列化时获得的对象是相同的枚举实例,不会破坏枚举的单例性质。

ini 复制代码
// ObjectInputStream#readEnum 节选
private Enum<?> readEnum(boolean unshared) throws IOException {
    // ...
    Enum<?> result = null;
    Class<?> cl = desc.forClass();
    if (cl != null) {
        try {
            @SuppressWarnings("unchecked")
            Enum<?> en = Enum.valueOf((Class)cl, name);
            result = en;
        }  
        // ...
    }

    // ...
    return result;
}

对于反射破坏的话,如果反射来创建新的枚举实例会抛出异常:

java 复制代码
// Constructor#newInstance(Object ... initargs) 方法节选
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

由于枚举类的特性,无法通过反射或反序列化来破坏单例模式。因此,在使用枚举类实现单例时,不需要额外处理反射或反序列化的情况。

这也是为什么《Effective Java》作者 Josh Bloch 提倡使用枚举实现单例的原因。

源码鉴赏

JDK 之 Runtime

JDK 中的 Runtime 类是一个经典的懒汉单例模式实现,它提供了与运行时环境相关的操作和信息。代码节选如下:

java 复制代码
public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}
     
    // ...
 }

Spring 之 Singleton

Spring中的Bean默认情况下作用域是单例的,容器负责创建Bean的实例,并在需要时将其提供给其他组件。

前文工厂模式讲了用于生产 Bean 的 Bean工厂 BeanFactory ,对于单例 Bean 来说,Spring 会将其放到DefaultSingletonBeanRegistry 中的容器中,这样再次获取该单例就不用重复创建了:

java 复制代码
// `DefaultSingletonBeanRegistry` 节选
 
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

@Nullable
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
   Object singletonObject = this.singletonObjects.get(beanName);
   if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
      synchronized (this.singletonObjects) {
         singletonObject = this.earlySingletonObjects.get(beanName);
         if (singletonObject == null && allowEarlyReference) {
            ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
            if (singletonFactory != null) {
               singletonObject = singletonFactory.getObject();
               this.earlySingletonObjects.put(beanName, singletonObject);
               this.singletonFactories.remove(beanName);
            }
         }
      }
   }
   return singletonObject;
}

所以说,单例模式的实现方式有很多,不要局限于让类自身负责保存它的唯一实例,让其他容器保存对象实例也可以,只要调用同样的方法获取到对象都是一个,我都愿意称之为单例模式。

总结

那什么样的情况下需要系统中只存在单一的实例呢?

比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。

资源共享:从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类,比如配置信息类等,使用单例模式可以确保所有的组件都使用同一个实例。

节省资源:有些对象创建和销毁的成本较高,而且在系统中只需要一个实例。这样的情况下,使用单例模式可以节省系统资源。

单例模式的缺点也显而易见,单例模式隐藏了类与类之间的依赖关系,增加了代码的耦合性,而且如果过度使用单例模式,将所有对象都设计成单例可能会导致代码结构复杂化,增加维护成本。

总之,设计模式只是解决特定问题的解决方案,没有最好,只有更合适。

相关推荐
星叔41 分钟前
ARXML汽车可扩展标记性语言规范讲解
java·前端·汽车
2401_857600951 小时前
SpringBoot框架:共享汽车管理的创新工具
java·spring boot·汽车
代码小鑫1 小时前
A15基于Spring Boot的宠物爱心组织管理系统的设计与实现
java·开发语言·spring boot·后端·毕业设计·宠物
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ1 小时前
mapper.xml 使用大于号、小于号示例
xml·java·数据库
一直学习永不止步1 小时前
LeetCode题练习与总结:迷你语法分析器--385
java·数据结构·算法·leetcode·字符串··深度优先搜索
Tech Synapse2 小时前
Java将Boolean转为Json对象的方法
java·开发语言·json
小冉在学习2 小时前
day55 图论章节刷题Part07([53.寻宝]prim算法、kruskal算法)
java·算法·图论
伴野星辰2 小时前
网站视频过大,加载缓慢解决方法【分段加载视频】
java·服务器·音视频
liang89992 小时前
设计模式之模版方法模式
设计模式
让生命变得有价值2 小时前
k8s 启用 ValidatingAdmissionPolicy 特性
java·容器·kubernetes·kubelet