侧边栏壁纸
博主头像
qingtian博主等级

喜欢是一件细水流长的事,是永不疲惫的双向奔赴~!

  • 累计撰写 104 篇文章
  • 累计创建 48 个标签
  • 累计收到 1 条评论

再谈单例模式

qingtian
2022-03-28 / 0 评论 / 0 点赞 / 713 阅读 / 7,390 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-03-28,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

单例模式

单例模式:一个类只允许创建一个对象(或者实例),那这个类就是一个单例类

单例模式的作用

1. 处理资源访问冲突

看下面的代码,我们要实现一个logger的功能

public class Logger {  
    private FileWriter writer;  
  
 public Logger() {  
        File file = new File("/Users/guank/log.txt");  
 writer = new FileWriter(file,true);  
 }  
  
    public void log(String message) {  
        writer.write(message);  
 }  
}

Logger 的使用实例

public class OrderController {  
    private Logger logger = new Logger();  
  
 public void create(OrderVo orderVo) {  
        //...业务逻辑  
 logger.log("created an order : " + orderVo.toString());  
 }  
}
public class UserController {  
  
    private Logger logger = new Logger();  
  
 public void login(String username, String password) {  
        // ...业务逻辑  
 logger.log(username + "logined");  
 }  
}

我们在UserControllerOrderController中创建了两个Logger对象。在Web容器的多线程环境下,如果分别执行login()create()两个函数,并且分别对日志文件进行操作,那就有可能出现日志信息相互覆盖的现象。

这个现象在多线程环境下应该很容易理解

如何来解决这个问题?

在Java中我们可以通过加锁的方式:给log()方法加上互斥锁(可以通过synchronized关键字),在同一时刻只允许一个线程调用执行log()函数。

public class Logger {  
    private FileWriter writer;  
  
 public Logger() {  
        File file = new File("/Users/guank/log.txt");  
 writer = new FileWriter(file,true);  
 }  
  
    public void log(String message) {  
        synchronized (this) {  
            writer.write(message);  
 }  
    }  
}

但是这样并不能解决问题,对象级别的锁是指,一个对象在不同线程中同时调用log()函数,会被强制要求按顺序执行。但是,不同对象之间不共享统一把锁。 在不同线程下,通过不同对象调用log()函数,加上的锁不会起作用。

如何解决这个问题呢?很简单,我们只要把对象级别的锁换成类级别的锁就行了。

如下面的代码

public class Logger {  
    private FileWriter writer;  
  
 public Logger() {  
        File file = new File("/Users/guank/log.txt");  
 writer = new FileWriter(file,true);  
 }  
  
    public void log(String message) {  
        synchronized (Logger.class) {  
            writer.write(message);  
 }  
    }  
}

实际上解决资源竞争问题的办法还有很多:

  1. 分布式锁
  2. 并发队列(Java中的BlockingQueue),多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写到日志中
  3. Logger类设计成单例类,程序中只允许创建一个Logger对象,所有线程共享使用一个Logger对象。

按照这个思路,我们可以实现Logger单例类。具体代码如下:

public class Logger {  
    private FileWriter writer;  
    private static final Logger instance = new Logger();  
  
 public Logger() {  
        File file = new File("/Users/guank/log.txt");  
		writer = new FileWriter(file,true);  
 }  
  
    public static Logger getInstance() {  
        return instance;  
 }  
  
    public void log(String message) {  
        writer.write(message);  
 }  
}

2. 表示全局唯一类

在业务模型中,有的数据只应该在系统中保存一份。

比如一个,全局唯一的id生成器,如果程序中存在两个对象的话,就可能生成两个相同的id。

所以我们应该将Id生成器设计为单例类。

public class IdGenerator {  
     private AtomicLong id = new AtomicLong(0);  
	 private static final IdGenerator instance = new IdGenerator();  
	 private IdGenerator() {}  
      
    public static IdGenerator getInstance() {  
        return instance;  
 }  
      
    public long getId() {  
        return id.incrementAndGet();  
 }  
}

如何实现一个单例模式

  1. 饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化
了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例)

public class IdGenerator {  
     private AtomicLong id = new AtomicLong(0);  
	 private static final IdGenerator instance = new IdGenerator();  
	 private IdGenerator() {}  
      
    public static IdGenerator getInstance() {  
        return instance;  
 }  
      
    public long getId() {  
        return id.incrementAndGet();  
 }  
}

有人会认为饿汉式的加载方式不能支持延迟加载,如果实例占用资源多,加载所需内存资源大,加载的初始化时间长,提前初始化是一种浪费资源的行为。

但是我认为一个单例类如果加载初始化时间长,如果我们等到真正使用它的时候再去执行耗时长的初始化,那么这将影响到系统的性能,可以采用饿汉式的方式,将初始化的耗时操作提前到系统启动时完成比较好。

如果一个类的加载所需资源多,我觉得也可以使用饿汉式,基于fast-fail的快速失败原则,如果在程序启动时触发报错,我们可以及时处理,而不是在程序启动一段时间后出现崩溃。

  1. 懒汉式

懒汉式我直接演示双重检验锁的懒汉式单例模式

public class IdGenerator {  
    private AtomicLong id = new AtomicLong(0);  
    private static IdGenerator instance;  
    private IdGenerator() {}  
  
    public static IdGenerator getInstance() {  
        if (null == instance) {  
            synchronized (IdGenerator.class) {  
                if (null == instance) {  
                    instance = new IdGenerator();  
 }  
            }  
        }  
        return instance;  
 }  
  
    public long getId() {  
        return id.incrementAndGet();  
 }  
}

这边有一个点:有人说,因为指令重排序,可能会导致 IdGenerator 对象被new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。

为了解决这个问题,我们需要在instance成员变量加上volatile关键字,禁止指令重排序。实际上,只有很低版本的java会出现这个问题,高版本的Java将new操作和初始化操作都设置为原子性操作,可以很好的解决这个问题。

  1. 静态内部类

使用静态内部类比双重检验锁实现方式简单,并且也能做到延迟加载的效果。代码如下:

public class IdGenerator {  
    private AtomicLong id = new AtomicLong(0);  
    private IdGenerator() {}  
  

 private static class SingletonHolder {  
        private static final IdGenerator instance = new IdGenerator();  
 }  
      
    public static IdGenerator getInstance() {  
        return SingletonHolder.instance;  
 }  
      
    public long getId() {  
        return id.incrementAndGet();  
 }  
}

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

  1. 枚举

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

public enum IdGenerator {  
 
 INSTANCE;  
 private AtomicLong id = new AtomicLong(0);  
 public long getId() {  
        return id.incrementAndGet();  
 }  
}

前几种方法在实践中都经常被使用到,但是我们需要注意到一个问题:在序列化时对单例模式的破坏,实际上只有枚举式实现单例模式的时候序列化不会对单例模式出现破坏。

单例存在的问题

  1. 单例模式对抽象,继承,多态支持的不好。

IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的OOP 的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。

  1. 单例会隐藏类之间的依赖关系

单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

  1. 单例对代码的扩展性不友好

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

如果数据库连接池被设计成一个单例类,就无法适应这种业务变更。

实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。

  1. 单例不支持有参数的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。

  • 解决思路一:

    创建完实例之后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。

  • 解决思路二:

    将参数放到 getInstance() 方法中。
    但是如果我们执行两次getInstance()方法,第二次传递的参数并不会生效。

  • 解决思路三:

    将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。

    具体代码实现如下:

   public class Config {  
    public static final int PARAM_A = 123;  
	public static final int PARAM_B = 245;  
}

______________________________________________________________________

   public class Singleton {  
     private static Singleton instance = null;  
	 private final int paramA;  
	 private final int paramB;  
	  
	 private Singleton() {  
		this.paramA = Config.PARAM_A;  
		this.paramB = Config.PARAM_B;  
	 }  
	  
	public synchronized static Singleton getInstance() {  
		if (null == instance) {  
			instance = new Singleton();  
		}  
		return instance;  
	 }  
}

单例模式的替代解决方案

类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。

在集群环境下的分布式单例模式

单例模式是指在同一进程下的实例是唯一的,在不同进程下,进程之间是不共享地址空间的。单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。

如何实现线程唯一的单例

在代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。

public class IdGenerator {  

     //线程唯一的单例模式  
	 private AtomicLong id = new AtomicLong(0);  
	 private static final ConcurrentHashMap<Long, IdGenerator> instances  
			 = new ConcurrentHashMap<>();  
	 private IdGenerator() {}  
	      
	    public static IdGenerator getInstance() {  
	        Long currentThreadId = Thread.currentThread().getId();  
		 instances.putIfAbsent(currentThreadId,new IdGenerator());  
		 return instances.get(currentThreadId); 
	 }  
	  
	    public long getId() {  
	        return id.incrementAndGet();  
	 }  
}

如何实现一个集群唯一的单例模式

先来解释一下什么是集群唯一的单例。

“进程唯一”指的是进程内唯一、进程间不唯一。“线程唯一”指的是线程内唯一、线程间不唯一。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。

具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。

伪代码如下:

public class IdGenerator {  
     private AtomicLong id =new AtomicLong(0);  
	 private static IdGenerator instance;  
	 private static SharedObjectStorage storage = new SharedObjectStorage();  
	 private static DistributedLock lock = new DistributedLock();  
	  
	 private IdGenerator() {}  
  
	 public synchronized static IdGenerator getInstance() {  
		if (null == instance) {  
		 lock.lock();  
		 instance = storage.load(IdGenerator.class);  
		 }  
        return instance;  
	 }  
  
    public synchronized void freeInstance() {  
         storage.save(this, IdGenerator.class);  
		 //释放对象  
		 instance = null;  
		 //解锁  
		 lock.unlock();  
    }  
  
    public long getId() {  
        return id.incrementAndGet();  
    }  
  
    /**  
	 * 使用示例  
	 * @param args  
	 */  
	public static void main(String[] args) {  
	         IdGenerator instance = IdGenerator.getInstance();  
			 long id = instance.getId();  
			 instance.freeInstance();  
	}  
}

[[学习笔记/设计模式/单例模式]]

0

评论区