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 的执行。

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

相关推荐
爱上语文7 分钟前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people10 分钟前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政6 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
拾光师7 小时前
spring获取当前request
java·后端·spring
Java小白笔记9 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
龙哥·三年风水9 小时前
活动系统开发之采用设计模式与非设计模式的区别-后台功能总结
设计模式·php·tinkphp6
一头老羊10 小时前
前端常用的设计模式
设计模式
JOJO___10 小时前
Spring IoC 配置类 总结
java·后端·spring·java-ee
严文文-Chris11 小时前
【设计模式-组合】
设计模式
白总Server11 小时前
MySQL在大数据场景应用
大数据·开发语言·数据库·后端·mysql·golang·php