设计模式:单例模式,如何设计实现一个集群环境下的分布式单例模式

如何理解单例模式中的唯一性

首先,我们重新来看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式叫做单例设计模式,简称单例模式”。

定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的:

  • 我们编写的代码,通过编译、连接、组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时说的“可执行文件”。可执行文件实际上就是代码被翻译成操作系统可以理解的一组指令
  • 当我们使用命令行或者双击运行这个可执行文件的时候,操作系统就会启动一个进程,将这个可执行文件从磁盘加载到自己的进程地址空间(可以理解为操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条的执行可执行文件中包含的代码。比如,当进程读到代码中的 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象。
  • 进程之间是不共享地址空间的,如果我们在一个进程中创建另一个进程(fork()),操作系统就会给新进程分配新的地址空间,并将老的地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内存包括代码、数据(比如 user 临时变量、User 对象)。
  • 所以,单例类在老进程中存在而且只能存在一个对象,在新进程中也会存在而且只能存在一个对象。而且,这两个对象不是同一个对象。也就是说,单例类中对象的唯一性的作用范围是进程内的,在进程中是不唯一的。

如何实现线程间的唯一单例

我们先来看一下,什么是线程唯一的单例,以及“线程唯一”和“进程唯一”的区别。

  • “进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。
  • 举个例子:假设 IdGenerator 是一个线程唯一的单例类。在线程 A 内,我们可以创建一个单例对象a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。

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

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 = FileSharedObjectStorage(/*入参省略*/);
	private static DistributedLock lock = new DistributedLock();
	
	private IdGenerator() {}
	
	public synchronized static IdGenerator getInstance()
		if (instance == null) {
			lock.lock();
			instance = storage.load(IdGenerator.class);
		}
		return instance;
	}
	
	public synchroinzed void freeInstance() {
		storage.save(this, IdGeneator.class);
		instance = null; //释放对象
		lock.unlock();
	}
	
	public long getId() {
		return id.incrementAndGet();
	}
}
// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();

如何实现一个多例模式

跟单例模式概念相对应的还有一个多例模式。那如何实现一个多例模式呢?

单例是指,一个类只能承接一个对象。对应的,多例就是一个类可以创建多个对象,但对象的个数是有限的,比如只能创建3个对象。如果用代码来简单示例一下,就是下面:

public class BackendServer {
	private long serverNo;
	private String serverAddress;
	private static final int SERVER_COUNT = 3;
	private static final Map<Long, BackendServer> serverInstances = new HashMap<>
	
	static {
		serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
		serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
		serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
	}
	
	private BackendServer(long serverNo, String serverAddress) {
		this.serverNo = serverNo;
		this.serverAddress = serverAddress;
	}
	
	public BackendServer getInstance(long serverNo) {
		return serverInstances.get(serverNo);
	}
	
	public BackendServer getRandomInstance() {
		Random r = new Random();
		int no = r.nextInt(SERVER_COUNT)+1;
		return serverInstances.get(no);
	}
}

实际上,对于多例模式,还有一种理解方式:同一类型只能创建一个对象,不同类型可以创建多个对象。这里的“类型”怎么理解呢?

举个例子,如下。代码中,logger name就是刚刚说到的“类型”,同一个logger name获取到的对象实例是相同的,不同的logger name获取到的对象实例是不同的

public class Logger {
	private static final ConcurrentHashMap<String, Logger> instances = new ConcurrentHashMap<>();
	
	private Logger() {}
	
	public static Logger getInstance(String loggerName) {
		instances.putIfAbsent(loggerName, new Logger());
		return instances.get(loggerName);
	}
	
	public void log() {
	//...
	}
}
//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

这种多例模式的理解方式有些类似工厂模式。它跟工厂模式不同的是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象

实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。

总结

(1)如何理解单例模式的唯一性:

  • 单例类中对象的唯一性的作用范围是“进程唯一”的。
  • “进程唯一”指的是进程内唯一,进程间不唯一;“线程唯一”指的是线程内唯一,线程间可以不唯一。
  • 实际上,“进程唯一”就意味着线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。
  • “集群唯一”指的是进程内唯一、进程间也唯一

(2)如何实现线程唯一的单例?

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

(3)如何实现集群环境下的单例?

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

(4)如何实现一个多例模式?

  • “单例”指的是一个类只能创建一个对象。对应地,“多例”指的就是一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。
  • 多例的实现也比较简单,通过一个Map 来存储对象类型和对象之间的对应关系,来控制对象的个数。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值