Spring内功心法篇:设计模式-单例设计模式

1.单例设计模式

单例设计模式 (Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

​ 单例模式是创造型模式。单例模式在现实生活中应用也非常广泛,例如,公司CEO、部门经理等。jJ2EE标准中的ServletContext、ServletContextConfig等、Spring框架应用中的ApplicationContext、数据库的连接池等也都是单例形式

1.1为什么要使用单例

1.1.1表示全局唯一

如果有些数据在系统中应该且只能保存一份,那就应该设计为单例类。如:

  • 配置类:在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,应该被映射为一个唯一的【配置实例】,此时就可以使用单例,当然也可以不用。
  • 全局计数器:我们使用一个全局的计数器进行数据统计、生成全局递增ID等功能。若计数器不唯一,很有可能产生统计无效,ID重复等。
java 复制代码
public class GlobalCounter {
    private AtomicLong atomicLong = new AtomicLong(0);
    private static final GlobalCounter instance = new GlobalCounter();
    // 私有化无参构造器
    private GlobalCounter() {}
    public static GlobalCounter getInstance() {
        return instance;
    }
    public long getId() { 
        return atomicLong.incrementAndGet();
    }
}
// 查看当前的统计数量
long courrentNumber = GlobalCounter.getInstance().getId();

以上代码也可以实现全局ID生成器的代码。

1.1.2、处理资源访问冲突

如果让我们设计一个日志输出的功能,你不要跟我杠,即使市面存在很多的日志框架,我们也要自己设计。

如下,我们写了简单的小例子:

java 复制代码
public class Logger {
    private String basePath = "D://info.log";

    private FileWriter writer;
    public Logger() {
        File file = new File(basePath);
        try {
            writer = new FileWriter(file, true); //true表示追加写入
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String message) {
        try {
            writer.write(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }
}

当然,任何的设计都不是拍脑门,这是我们写的v1版本,他很可能会存在很多的bug,设计结束之后,我们可能是这样使用的:

java 复制代码
@RestController("user")
public class UserController {

    public Result login(){
        // 登录成功
        Logger logger = new Logger();
        logger.log("tom logged in successfully.");
        
        // ...
        return new Result();
    }
}

当然,其他千千万的代码,我们都是这样写的。这样就会产生如下的问题:**多个logger实例,在多个线程中,同时操作同一个文件,就可能产生相互覆盖的问题。*因为tomcat处理每一个请求都会使用一个新的线程(暂且不考虑多路复用)。此时日志文件就成了一个*共享资源,但凡是多线程访问共享资源,我们都要考虑并发修改产生的问题。

有的可能想到如下的解决方案,加锁呀,代码如下:

java 复制代码
public synchronized void log(String message) {
    try {
        writer.write(message);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

事实上这样加锁毫无卵用,方法级别的锁可以保证new出来的同一个实例多线程下可以同步执行log方法,然而你却new了很多:

其实,writer方法本身也是加了锁的,我们这样加锁就没有了意义:

java 复制代码
public void write(String str, int off, int len) throws IOException {
    synchronized (lock) {
        char cbuf[];
        if (len <= WRITE_BUFFER_SIZE) {
            if (writeBuffer == null) {
                writeBuffer = new char[WRITE_BUFFER_SIZE];
            }
            cbuf = writeBuffer;
        } else {    // Don't permanently allocate very large buffers.
            cbuf = new char[len];
        }
        str.getChars(off, (off + len), cbuf, 0);
        write(cbuf, 0, len);
    }
}

回到正题

2、如何实现一个单例

常见的单例设计模式,有如下五种写法,在编写单例代码的时候要注意以下几点:

​ 1、构造器需要私有化

​ 2、暴露一个公共的获取单例对象的接口

​ 3、是否支持懒加载(延迟加载)

​ 4、是否线程安全

2.1、饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。从名字中我们也可以看出这一点。具体的代码实现如下所示:

java 复制代码
public class EagerSingleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
    	return instance;  
    }  
}

事实上,恶汉式的写法在工作中反而应该被提倡,面试中不问,只是应为他简单。很多人觉得饿汉式不能支持懒加载,即使不使用也会浪费资源,一方面是内存资源,一方面会增加初始化的开销。

1、现代计算机不缺这一个对象的内存。

2、如果一个实例初始化的过程复杂那更加应该放在启动时处理,避免卡顿或者构造问题发生在运行时。满足 fail-fast 的设计原则。

2.2、懒汉式

有饿汉式,对应地,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载,具体的代码实现如下所示:

java 复制代码
public class LazySingleton {  
    private static Singleton instance;  
    private Singleton (){}  

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

以上的写法本质上是有问题,当面对大量并发请求时,其实是无法保证其单例的特点的,很有可能会有超过一个线程同时执行了new Singleton();

当然解决他的方案也很简单,加锁呗:

java 复制代码
public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  

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

以上的写法确实可以保证jvm中有且仅有一个单例实例存在,但是方法上加锁会极大的降低获取单例对象的并发度。同一时间只有一个线程可以获取单例对象,为了解决以上的方案则有了第三种写法。

2.3、双重检查锁

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式:

在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:

java 复制代码
public class DclSingleton {  
    // volatile如果不加可能会出现半初始化的对象
    // 现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序),为了兼容性我们加上
    private volatile static Singleton singleton;  
    private Singleton (){}  

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

2.4、静态内部类

我们再来看一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。

java 复制代码
public class InnerSingleton {

    /** 私有化构造器 */
    private Singleton() {
    }

    /** 对外提供公共的访问方法 */
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }

    /** 写一个静态内部类,里面实例化外部类 */
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

}

SingletonHolder 是一个静态内部类,当外部类 Singleton被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。insance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

2.5、枚举

最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:

这是一个最简单的实现,因为枚举类中,每一个枚举项本身就是一个单例的:

java 复制代码
public enum EnumSingleton {
    INSTANCE;
}

更通用的写法如下:

java 复制代码
public class EnumSingleton {
    private Singleton(){
    }   
    public static enum SingletonEnum {
        EnumSingleton;
        private EnumSingleton instance = null;
        private SingletonEnum(){
            instance = new Singleton();
        }
        public EnumSingleton getInstance(){
            return instance;
        }
    }
}

事实上我们还可以将单例项作为枚举的成员变量,我们的累加器可以这样编写:

java 复制代码
public enum GlobalCounter {
    INSTANCE;
    private AtomicLong atomicLong = new AtomicLong(0);

    public long getNumber() { 
        return atomicLong.incrementAndGet();
    }
}

这种写法是Head-first中推荐的写法,他除了可以和其他的方式一样实现单例,他还能有效的防止反射入侵。

2.6、反射入侵

事实上,我们想要阻止其他人构造实例 仅仅私有化构造器还是不够的,因为我们还可以使用反射获取私有构造器进行构造,当然使用枚举的方式是可以解决这个问题的,对于其他的书写方案,我们通过下边的方式解决:

java 复制代码
public class Singleton {
    private volatile static Singleton singleton;
    private Singleton (){
        if(singleton != null) 
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
    }

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

此时方法如下:

java 复制代码
@Test
public void testReflect() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    Class<DclSingleton> clazz = DclSingleton.class;
    Constructor<DclSingleton> constructor = clazz.getDeclaredConstructor();
    constructor.setAccessible(true);

    boolean flag = DclSingleton.getInstance() == constructor.newInstance();

    log.info("flag -> {}",flag);
}

结果如下:

2.7、序列化与反序列化安全

事实上,到目前为止,我们的单例依然是有漏洞的,看如下代码:

java 复制代码
@Test
public void testSerialize() throws IllegalAccessException, NoSuchMethodException, IOException, ClassNotFoundException {
    // 获取单例并序列化
    Singleton singleton = Singleton.getInstance();
    FileOutputStream fout = new FileOutputStream("D://singleton.txt");
    ObjectOutputStream out = new ObjectOutputStream(fout);
    out.writeObject(singleton);
    // 将实例反序列化出来
    FileInputStream fin = new FileInputStream("D://singleton.txt");
    ObjectInputStream in = new ObjectInputStream(fin);
    Object o = in.readObject();
    log.info("他们是同一个实例吗?{}",o == singleton);
}

我们发现,即使我们废了九牛二虎之力还是没能阻止他返回false,结果如下:

readResolve()方法可以用于替换从流中读取的对象,在进行反序列化时,会尝试执行readResolve方法,并将返回值作为反序列化的结果,而不会克隆一个新的实例,保证jvm中仅仅有一个实例存在:

java 复制代码
public class Singleton implements Serializable {
    
    // 省略其他的内容
    public static Singleton getInstance() {
        
    }
    
    // 需要加这么一个方法
    public Object readResolve(){
        return singleton;
    }
}

一切问题迎刃而解。

3.应用中的单例

3.1Mybatis中的单例

Mybaits中的org.apache.ibatis.io.VFS使用到了单例模式。VFS就是Virtual File System的意思,mybatis通过VFS来查找指定路径下的资源。查看VFS以及它的实现类,不难发现,VFS的角色就是对更"底层"的查找指定资源的方法的封装,将复杂的"底层"操作封装到易于使用的高层模块中,方便使用者使用,

3.2jdk的中的单例

jdk中有一个类的实现是一个标准单例模式->Runtime类,该类封装了运行时的环境。每个 Java 应用程序都有一个 Runtime 类实例,使应用程序能够与其运行的环境相连接。 一般不能实例化一个Runtime对象,应用程序也不能创建自己的 Runtime 类实例,但可以通过 getRuntime 方法获取当前Runtime运行时对象的引用。

4.单例中存在的问题和反思问题

尽管单例是一个很经典的设计模式,但在实际的开发中,我们也很少按照严格的定义去使用它,以上的知识大多是为了理解和面试而使用和学习,有些人甚至认为单例是一种反模式(anti-pattern),压根就不推荐使用。

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题,所以我们一般会使用spring的单例容器作为替代方案。那单例究竟存在哪些问题呢?

4.1、无法支持面向对象编程

我们知道,OOP 的三大特性是封装、继承、多态 。单例将构造私有化,直接导致的结果就是,他无法成为其他类的父类,这就相当于直接放弃了继承和多态的特性,也就相当于损失了可以应对未来需求变化的扩展性,以后一旦有扩展需求,比如写一个类似的具有绝大部分相同功能的单例,我们不得不新建一个十分【雷同】的单例。

4.2、极难的横向扩展

我们知道,单例类只能有一个对象实例。如果未来某一天,一个实例已经无法满足我们的需求,我们需要创建一个,或者更多个实例时,就必须对源代码进行修改,无法友好扩展。

有人一定会说"这不是沙雕"吗?明明一个实例无法满足,你却要设计成单例?事实上,这种场景是很常见的,因为我们要明白一个道理,永远不变的就是"永远在变"。人生的开始,你可能觉得自己有一辆车就够了,但是将来有一天你变成了亿万富翁,你可能就会想买一百辆。

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。

如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。

相关推荐
用户490558160812510 分钟前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白12 分钟前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈13 分钟前
VS Code 终端完全指南
后端
该用户已不存在38 分钟前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃1 小时前
内存监控对应解决方案
后端
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit2 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言
bobz9652 小时前
ThanosRuler
后端