单例设计模式

本文详细介绍了单例设计模式的概念、应用场景、实现方式(包括饿汉式、懒汉式、双重检查锁、静态内部类和枚举类),以及其潜在问题,如缺乏面向对象编程特性、难以横向扩展。还讨论了如何破坏单例和单例在JDK及MyBatis中的应用。
摘要由CSDN通过智能技术生成

1. 单例设计模式

单例设计模式(Singleton Design Pattern), 一个类只允许创建 一个对象(或者实例),那这个类就是一个单例类,这种设计模式称为单例设计模式,简称单例模式。

1.1 为什么要使用单例

1.1.1 表示全局唯一

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

  • 配置类:在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,应该被映射为一个唯一的【配置实例】,此时就可以使用单例,当然也可以不用。
  • 全局计数器:我们使用一个全局的计数器进行数据统计、生成全局递增ID等功能。若计数器不唯一,很有可能产生统计无效,ID重复等。

实现全局ID生成器的代码 :

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();

1.1.2 处理资源访问冲突

我们简单的设计一个日志输出的功能

v1版本

public class Logger {
    private String basePath = "D://info.log";
    private FileWriter writer;
    // new Logger的时候初始化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版本

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

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

V2版本,对log加锁处理。 这样加锁毫无卵用,方法级别的锁可以保证new出来的同一个实例多线程下可以同步执行log方法,然而你却new了很多个Logger实例。

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

在这里插入图片描述

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

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);
    }
}

加锁是一定能解决共享资源冲突问题的,我们只要放大锁的范围从【this】到 【class】,这个问题也是能解决的,代码如下:

在这里插入图片描述

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

从以上的内容我们发现:

  • 如果使用单个实例输出日志,锁【this】即可。
  • 如果要保证JVM级别防止日志文件访问冲突,锁【class】即可。
  • 如果要保证集群服务级别的防止日志文件访问冲突,加分布式锁即可。

同一个Logger无法并行输出到一个文件中,那么针对这个日志文件创建多个 Logger实例也就失去了意义,如果工程要求我们所有的日志输出到同一个日志文件中,并不需要创建大量的Logger实例,这样的好处有:

  • 一方面节省内存空间。
  • 另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能 随便浪费)。

1.2 如何实现单例

在编写单例代码的时候要注意以下几点:

  • 构造器私有化
  • 暴露一个公共的获取单例对象的方法
  • 是否支持延迟加载
  • 是否线程安全

1.2.1 饿汉式

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

public class EagerSingleton implements Serializable {

    //    持有一个jvm全局唯一的实例
    private static final EagerSingleton instance = new EagerSingleton();

    //    避免别人随意的创建,需要私有化构造器
    private EagerSingleton() {
        // 防止反射入侵创建对象
        /*if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }*/
    }

    //    暴露一个方法,用来获取实例
    public static EagerSingleton getInstance() {
        return instance;
    }
    
    public static void main(String[] args) throws Exception {
        // 测试饿汉式单例
        System.out.println("测试饿汉式单例>>>" + (EagerSingleton.getInstance() == EagerSingleton.getInstance()));
    }
}    

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

  • 现代计算机不缺这一个对象的内存。
  • 如果一个实例初始化的过程复杂,那更应该放在启动时处理,避免卡顿或者构造问题发生在运行时

1.2.2 懒汉式

懒汉式相对于饿汉式的优势是支持延迟加载 。

public class LazySingleton {

    //    持有一个jvm全局唯一的实例
    private static LazySingleton instance;

    //    避免别人随意的创建,需要私有化构造器
    private LazySingleton() {
        // 防止反射入侵创建对象
        if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }
    }

    //    暴露一个方法,用来获取实例
    public static LazySingleton getInstance() {
        if (null == instance) {
            instance = new LazySingleton();
        }

        return instance;
    }
    
    public static void main(String[] args) {
        System.out.println("测试懒汉式单例instance>>>" + (LazySingleton.getInstance() == LazySingleton.getInstance()));
    }
}

当大量并发请求时,上面的写法是无法保证其单例的特性,很有可能会有超过一个线程同时执行了new Singleton(); 从而出现线程安全问题。当然可以加锁来解决, 虽然synchronized锁确实可以保证jvm中有且仅有一个单例实例存在,但是方法上加锁会极大的降低获取单例对象的并发度。

public class LazySingleton {
    //    持有一个jvm全局唯一的实例
    private static LazySingleton instance;

    //    避免别人随意的创建,需要私有化构造器
    private LazySingleton() {
        // 防止反射入侵创建对象
        if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }
    }

    //    暴露一个方法,用来获取实例  synchronized在并发场景下,会排队等待,性能一般
    public static synchronized LazySingleton getInstance() {
        if (null == instance) {
            instance = new LazySingleton();
        }

        return instance;
    }
    
     public static void main(String[] args) {
        System.out.println("测试懒汉式单例instance>>>" + (LazySingleton.getInstance() == LazySingleton.getInstance()));
}

1.2.3 双重检查锁

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。 既要支持延迟加载、又要支持高并发的单例实现方式,双重检查锁。

public class DoubleCheckLockSingleton {
    //    持有一个jvm全局唯一的实例
    private static volatile DoubleCheckLockSingleton instance;
    //    避免别人随意的创建,需要私有化构造器
    private DoubleCheckLockSingleton() {
        // 防止反射入侵创建对象
        if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }
    }

    /**
     * 暴露一个方法,用来获取实例
     * cpu底层是乱序执行的,volatile如果不加可能会出现半初始化的对象, volatile保证内存可见,保证有序性。
     * 现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)
     * <p>
     */
    public static DoubleCheckLockSingleton getInstance() {
        // 多个线程过来,一旦一个线程抢到锁并完成实例化。后面的线程就不会排队等待锁,直接返回单例对象
        if (null == instance) {
            synchronized (DoubleCheckLockSingleton.class) {
                if (null == instance) {
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }

        return instance;
    }
    
     public static void main(String[] args) {
        System.out.println("测试双重检查锁创建单例instance>>>" + (DoubleCheckLockSingleton.getInstance() == DoubleCheckLockSingleton.getInstance()));
    }
}

1.2.4 静态内部类

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

public class InnerSingleton {

    private InnerSingleton() {
    }

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

    /**
     * 定义静态内部类来持有单例对象。
     * 静态在第一次使用的时候加载且只加载一次。(在第一次调用getInstance()方法的时候才会加载去实例化单例对象)
     */
    private static class SingletonHolder  {
        private static final InnerSingleton instance = new InnerSingleton();
    }
    
    public static void main(String[] args) {
        System.out.println("测试静态内部类创建单例 instance>>>" + (InnerSingleton.getInstance() == InnerSingleton.getInstance()));
    }
}

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

1.2.5 枚举类

Java 枚举类本身的特性保证了实例创建的线程安全性和唯一性。具体的代码如下所示:

public enum EnumSingleton {
    // INSTANCE就是单例对象,相当于 public static final EnumSingleton = new EnumSingleton();
	INSTANCE}

更通用的写法如下:

public class EnumSingleton {

    private EnumSingleton() {
    }

    public enum Singleton {
        // SINGLETON实例化是会执行new Singleton()构造器方法,会实例化EnumSingleton单例对象
        SINGLETON;

        private EnumSingleton instance;

        Singleton() {
            instance = new EnumSingleton();
        }
		// 暴露一个方法,用来获取实例
        public EnumSingleton getInstance() {
            return instance;
        }
    }
    
    public static void main(String[] args) {
        // 测试枚举单例
        System.out.println("测试枚举类创建单例>>>" + (EnumSingleton.Singleton.SINGLETON.getInstance() == EnumSingleton.Singleton.SINGLETON.getInstance()));
    }
}

1.3 如何破坏单例

1.3.1 反射入侵

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

public class EagerSingleton implements Serializable {

    //    持有一个jvm全局唯一的实例
    private static final EagerSingleton instance = new EagerSingleton();

    //    避免别人随意的创建,需要私有化构造器
    private EagerSingleton() {
        // 防止反射入侵创建对象
        if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }
    }

    //    暴露一个方法,用来获取实例
    public static EagerSingleton getInstance() {
        return instance;
    }

    public static void main(String[] args) throws Exception {
        // 反射入侵
        Class<EagerSingleton> eagerSingletonClass = EagerSingleton.class;
        Constructor<EagerSingleton> declaredConstructor = eagerSingletonClass.getDeclaredConstructor();
        declaredConstructor.setAccessible(true);
        System.out.println("测试反射入侵单例>>>" + (EagerSingleton.getInstance() == declaredConstructor.newInstance()));

}

测试结果:

# 第一次
测试反射入侵单例>>>false
# 私有构造中加入校验后
Exception in thread "main" java.lang.reflect.InvocationTargetException
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
	at cn.itcast.designPatterns.singleton.EagerSingleton.main(EagerSingleton.java:56)
Caused by: java.lang.RuntimeException: 实例:【cn.itcast.designPatterns.singleton.EagerSingleton】已经存在,该实例只允许实例化一次
	at cn.itcast.designPatterns.singleton.EagerSingleton.<init>(EagerSingleton.java:23)
	... 5 more

1.3.2 序列化与反序列化

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

public static void main(String[] args) throws Exception {
        // 序列化和反序列化入侵 https://blog.csdn.net/leo187/article/details/104332138
        // 获取单例并序列化
        EagerSingleton singleton = EagerSingleton.getInstance();
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.txt"));
        out.writeObject(singleton);

        // 反序列化读取实例
        ObjectInputStream input = new ObjectInputStream(new FileInputStream("singleton.txt"));
        Object o = input.readObject();
        System.out.println("测试序列化和反序列化入侵, 是同一个实例吗?" + (singleton == o));
    }

测试结果始终返回false

测试序列化和反序列化入侵, 是同一个实例吗?false

readResolve()方法可以替换从流中读取的对象,在进行反序列化时会尝试执行readResolve方法,并将返回值作为反序列化的结果而不会克隆一个新的实例,保证jvm中仅仅有一个实例存在, 我们只需要重写readResolve()方法即可, 代码如下:

public class EagerSingleton implements Serializable {

    //    持有一个jvm全局唯一的实例
    private static final EagerSingleton instance = new EagerSingleton();

    //    避免别人随意的创建,需要私有化构造器
    private EagerSingleton() {
        // 防止反射入侵创建对象
        if (instance != null) {
            throw new RuntimeException("实例:【"
                    + this.getClass().getName() + "】已经存在,该实例只允许实例化一次");
        }
    }

    //    暴露一个方法,用来获取实例
    public static EagerSingleton getInstance() {
        return instance;
    }

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

再来看测试结果 , 序列化和反序列的对象是同一个.

测试序列化和反序列化入侵, 是同一个实例吗?true

1.4 源码应用

在JDK或其他通用框架中很少能看到标准的单例设计模式,这也意味着单例设计模式确实很经典,但严格的单例设计确实有它的问题和局限性,我们先看看在源码中的一些案例。

1.4.1 JDK中的单例应用

Runtime类封装了运行时的环境, 每个 Java 应用程序都有一个 Runtime 类实例,使应用程序能够与其运行的环境相连接。

一般不能实例化一个Runtime对象,应用程序也不能创建自己的 Runtime类实例,但可以通过 getRuntime 方法获取当前Runtime运行时对象的引用。

public class Runtime {
    private static Runtime currentRuntime = new Runtime();
 
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    /** Don't let anyone else instantiate this class */
    private Runtime() {}
    
    public Process exec(String command) throws IOException {
        return exec(command, null, null);
    }    
    //...
}    

Runtime测试用例

@Test
public void testRuntime() throws IOException {
    Runtime runtime = Runtime.getRuntime();
    Process exec = runtime.exec("ping 127.0.0.1");
    InputStream inputStream = exec.getInputStream();
    byte[] buffer = new byte[1024];
    int len;
    while ((len = inputStream.read(buffer)) > 0) {
        System.out.println(new String(buffer, 0, len, Charset.forName("GBK")));
    }

    long maxMemory = runtime.maxMemory();
    System.out.println("maxMemory>>>" + maxMemory);
}

1.4.2 Mybatis中的单例应用

Mybaits中的org.apache.ibatis.io.VFS使用到了单例模式。VFS就是Virtual FileSystem的意思,mybatis通过VFS来查找指定路径下的资源。VFS的角色就是对更“底层”的查找指定资源的方法的封装,将复杂的“底层”操作封装到易于使用的高层模块中,方便使用.

public class public abstract class VFS {
    // 使用了内部类
    private static class VFSHolder {
        static final VFS INSTANCE = createVFS();
        @SuppressWarnings("unchecked")
        static VFS createVFS() {
       	 	// ...省略创建过程
        	return vfs;
        }
    }
    
    public static VFS getInstance() {
    	return VFSHolder.INSTANCE;
    }
}

1.5 单例存在的问题

1.5.1 无法支持面向对象编程

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

1.5.2 难以横向扩展

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

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。

为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。

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

spring提供的单例容器,确保一个实例在容器级别的单例,并且可以在容器启动时完成初始化,他的优势如下:
1、所有的bean以单例形式存在于容器中,避免大量的对象被创建,造成jvm内存抖动严重,频繁gc。
2、程序启动时,初始化单例bean,满足fast-fail,将所有构建过程的异常暴露在启动时,而非运行时,更加安全。
3、缓存了所有单例bean,启动的过程相当于预热的过程,运行时不必进行对象创建,效率更高。
4、容器管理bean的生命周期,结合依赖注入使得解耦更加彻底、扩展性无敌。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值