0
点赞
收藏
分享

微信扫一扫

【Java设计模式 二】创建型模式之单例模式

快乐小码农 2022-03-13 阅读 73

由于4年前为了准备设计模式面试,简单研究过单例模式,创建型模式的第一篇就来研究研究单例模式,回顾和熟练一下,由于学习的都是设计模式,所有系列文章都遵循如下的目录:

  • 模式档案:包含模式的定义、模式的特点、解决什么问题、优缺点、使用场景
  • 模式示例:包含模式的实现方式代码举例
  • 模式实践:如果工作中或开源项目用到了该模式,就将使用过程贴到这里,并且客观讨论使用的是否恰当

接下来所有设计模式的介绍都暂且遵循此基本行文逻辑吗,对于模式实践有则增加,没有就不需要了。

模式档案

模式定义:单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象,通过单例模式可以保证整个系统中该类只有一个实例

模式特点:单例模式有以下几个特点

  • 单例类只能有一个实例
  • 单例类必须自己创建自己的唯一实例
  • 单例类必须给所有其他对象提供这一实例

总而言之就是:保证一个类仅有一个实例,并提供一个访问它的全局访问点

解决什么问题:对于系统中的某些类来说,只有一个实例很重要,当系统中需要该实例全局保证唯一以避免对该类实例频繁地创建与销毁造成大量性能开销时、当系统中需要该实例全局保证唯一以避免产生二义性时。例如一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。

优缺点优点显而易见:内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例。缺点:由于构造函数是私有的,单例类不能被继承;一个类应该只关心内部逻辑,而不关心外面怎么样来实例化,当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用new,可能会给其他开发人员造成困扰,特别是看不到源码的时候

使用场景:创建对象时耗时过多或耗费资源过多,但又经常用到的对象;工具类对象;频繁访问数据库或文件的对象;该对象持有的数据是唯一的无二义性的。以上几种,当系统中对该类的实例使用较为通用化、全局化、频繁化、唯一化时,设计成单例是个比较好的选择

模式示例

单例模式的实现方式有很多种,也是大多数面试八股爱考的,这里我们简单盘一盘几种最能用到的吧,毕竟不是为了八股文而学习设计模式的。总体而言分为懒汉式和饿汉式,懒汉式就是延迟加载,在第一次调用方法时实例化自己,饿汉式就是类加载时就实例化自己。

1 双检锁单例实现【懒汉式】

双检锁/双重校验锁(DCL,即 double-checked locking),这种方式采用双锁机制,安全且在多线程情况下能保持高性能,是一种懒汉式单例最佳的解决方案。

package com.example.designpattern.singleton;

/**
 * The type Singleton.
 */
public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

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


这段代码有三个要素需要注意:

  • 第一重instance == null的作用,为了防止每个进入该方法的线程都进入同步块,当实例创建好后无需再执行同步代码,避免内存损耗。

  • 第二重instance == null的作用,当两个线程同时到达方法内部,即同时调用 getInstance() 方法,此时由于instance == null 两个线程都可以通过第一重instance == null检查,进入第一重 if 语句后,由于存在锁机制,所以会有一个线程进入 lock 语句并进入第二重instance == null,而另外的一个线程则会在 lock 语句的外面等待。而当第一个线程执行完 instance = new Singleton()语句后,便会退出锁定区域,此时,第二个线程便可以进入 lock 语句块,此时,如果没有第二重instance == null第二个线程还是可以调用 instance = new Singleton()语句,这样第二个线程也会创建一个 Singleton 实例,这样也还是违背了单例模式的初衷

  • volatile关键字的作用,为什么需要使用volatile呢?因为instance = new Singleton();并非一个原子操作, 首先应该注意的是使用内置锁加锁的是Singleton.class(在此处,由于instance未初始化,所以使用对象锁会报空指针异常),并不是instance ,也就是说没有在instance 实现同步,那么在这种情况下,当有两个线程同时进行到synchronized代码块时,只有一个线程可以进入,然后初始化了instance ,但是这仅仅只能保证的是两个线程在访问上的独占性,也就是说两个线程在此一定是一先一后进行访问,但是不能保证的是instance的内存可见性,原因很简单,因为同步的对象并不是instance, 而是Singleton.classinstance = new Singleton();可以拆解为三个JVM指令:

memory = allocate();      //1 分配对象的内存空间 
ctorInstance(memory);     //2 初始化对象 
instance = memory;      //3 设置instance指向刚分配的内存地址 

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();      //1 分配对象的内存空间 
instance =memory;      //3 instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);    //2 初始化对象

可以看到指令重排之后,instance 指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致返回了一个没有被初始化的对象而出错出错。

2 类加载实例化【饿汉式】

它基于 classloader 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用 getInstance 方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 显然没有达到 lazy loading 的效果。

package com.example.designpattern.singleton;

/**
 * The type Singleton.
 */
public class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

3 静态内部类【懒汉式静态类】

这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

package com.example.designpattern.singleton;

/**
 * The type Singleton.
 */
public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    private Singleton() {
    }

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

4 枚举单例【终极式】

以上三种单例的实现方式在正常场景下是绝对单例的,但在某些场景下也不是绝对安全的,例如:反射和序列化

  • 反射可以获得类的私有构造方法,这样就会导致可以创建多个实例,破坏单例模式的对象唯一性
  • 通过序列化和反序列化,得到的对象是一个新的对象,破坏了单例模式对象的唯一性。

枚举单例不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化

public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}

使用时直接调用方法即可:

package com.example.designpattern.singleton;

/**
 * The type Singleton.
 */
public enum Singleton {
    INSTANCE;

    public void whateverMethod() {
        System.out.println("我是单例执行的方法");
    }

    public static void main(String[] args) {
        Singleton.INSTANCE.whateverMethod();
    }
}

针对单例的唯一性、线程安全、反射和序列化安全做如下说明:

  • 枚举实例是static final 的,保证只被实例化一次
  • 枚举是一个饿汉式加载,因此也就是线程安全的
  • 对于反射来说,枚举的反编译是一个抽象类,就不能通过反射来创建实例了
  • 对于序列化和反序列化,因为每一个枚举类型和枚举变量在JVM中都是唯一的,即Java在序列化和反序列化枚举时做了特殊的规定,枚举的writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法是被编译器禁用的,因此也不存在实现序列化接口后调用readObject会破坏单例的问题。

所以说枚举类才是实实在在的单例王者啊!对于以上的几种单例实现方式,一般情况下建议使用类加载实例化方式双检锁方式。静态域只有在要明确实现 lazy loading 效果时,才会使用 静态内部类方式。如果涉及到反序列化创建对象时,非常推荐使用枚举方式

模式实践

说到单例模式最广泛的应用,我想莫过于Spring对对象的管理了吧,这里就不详细展开了,至于为什么Spring的对象默认设置为单例,可以参考我这篇BLog【Spring学习笔记 一】Sping基本概念及理论基础,更详细的内容可能需要更深入原理的探索吧

总结一下

这篇Blog对历史学习的单例进行了一个整合性学习,加深了单例的概念内容探究。重新回顾了双边锁的懒汉式单例、类加载的饿汉式单例,以及静态内部类的静态域懒汉式单例,以及常规单例的反射和序列化破坏,最终祭出大杀器:枚举单例,也可以当做是永恒的单例吧。

举报

相关推荐

0 条评论