你推荐使用单例模式吗?

如果大家觉得文章有错误内容,欢迎留言或者私信讨论~

单例的各种实现

  如果说四级的第一个词汇是abandon,那么学习设计模式的第一位永远是单例模式,作为最简单的设计模式之一,通常只要将构造设置为私有,顺带提供一个获取对象的 getInstance() 方法将对象传递出去即可。接下来让我们先来看看几种单例的实现方式,顺带思考每种方案各自的优劣。

饿汉与懒汉

  假设我们现在在一个单体项目中,业务上需要我们写一个 id 构建器:

// 饿汉
public class Hungry_IdGenerator {
	private static Hungry_IdGenerator instance = new Hungry_IdGenerator();
	private Hungry_IdGenerator() {
	}
	
	public static Hungry_IdGenerator getInstance() {
		return instance;
	}
}

// 懒汉
public class Lazy_IdGenerator {
	private static Lazy_IdGenerator instance = new Lazy_IdGenerator();
	private Lazy_IdGenerator() {
	}

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

		return instance;
	}
}

  看完饿汉与懒汉的编码后,我们来思考第一个问题:饿汉与懒汉哪一个在大部分的生产环境中会比较好? 博主个人的观点更推崇饿汉模式,因为他足够简单并且不会有线程安全问题(现在这版本的懒汉是存在线程安全问题)。一般而言当我们用到单例模式的时候都是处理一些配置类、或者像代码里提到的 id 生成器这种只需要存在一个的工具类,这些类通常都是需要较长时间的处理才能够生成,如果不在项目启动的时候就生成,而是在接口访问的时候才去加载,那么往往第一个访问该接口的用户就会觉得很痛苦,对我们软件的印象也会降低。
  看完两者的优劣,那么下一个问题是:懒汉如何确保自身的线程安全呢, 最简单的方案就是给方法 getInstance() 加上 synchronized 锁,但这样就无法支持高并发,并发度被降到了 1,变成了串行,频繁的使用必然不行。
  还有一个答案就是双重检测,这里提一嘴双重检测是因为网上有传言说,不加 volatileinstance 因为指令重排的缘故,可能会导致 IdGenerator 对象被 new 出来并且赋值给 instance 之后还没来得及初始化(执行构造器中的逻辑)就被另一个线程使用了,这个其实不用太担心,如果你使用的是高版本的 JDK 的话,官方已经帮你搞定这个问题了。 接下来看一下双重检测是如何实现的:

public class IdGenerator {
	private volatile static Two_IdGenerator instance;
	private Two_IdGenerator() {
    }
	
	public Two_IdGenerator getInstance() {
        if (instance == null) {
            synchronized (Two_IdGenerator.class) {
                if (instance == null) {
                    instance = new Two_IdGenerator();
                }
            }
        }

        return instance;
    }
}

静态与枚举

  剩下还有一种算是博主比较喜欢使用的方案——静态单例类,静态单例类的好处就是外部类被加载的时候 内部类不会被加载 只有当 getinstance 的时候内部静态类才会加载,静态类只有一份 将线程安全问题托付给了 JVM 处理。 而另一种枚举,基本没怎么用过,所以就展示一下实现方式:

// 静态
public class IdGenerator {
	 private Static_IdGenerator() {}
	   private static class Inner_IdGenerator {
        private static final IdGenerator instance = new IdGenerator();
    }
    public static IdGenerator getInstance() {
        return Inner_IdGenerator.instance;
    }
}

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

是否推荐使用单例模式

  回到标题的问题,做软件开发的都明白——软件工程没有“银弹”,大部分的工具都是存在优劣面的。对于单例设计模式,博主个人不推崇,但如果迫不得已也是可以应用的,毕竟有时候工期紧张,单例模式又比较契合,有什么理由拒绝。
  说说我不推崇的几个理由:

  • 单例模式违背了面向接口开发的原则,广义上也违背了 OOP 原则,这样带来的最显著的问题,比如说上面 id 构造器,我们在业务越来越庞大之后,订单需要有订单的 id 生成器,用户需要有用户的 id 生成器,这时候我们就需要全局的去修改有用到原始 id 构造器的地方,这样代码改动就很大,维护性、拓展性不高
  • 单例类偶尔也会造成可读性降低,例如我们通常会把目标类使用到的类都作为属性,写在最上面一目了然,但是你一旦用到了单例类,那么有时候新人去了解业务代码的时候,需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了那些单例类。
  • 单例类对单元测试也不近友好,单元测试我们很熟悉,这是以后重构代码的基石。假设一个单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
  • 最后一个原因是单例类不支持有参数的构造函数,也许你说可以写个init()方法,但就是处理起来麻烦。

  综上所述,这就是博主为什么不推崇单例模式原因。

更进一步

  那么了解单例模式的写法与一些缺点,我们来更进一步拓展以下,一起思考下面的问题:

  • 如何理解单例模式种的唯一性?
  • 如何实现线程唯一的单例?
  • 如何实现集群环境下的单例?

1. 如何理解单例模型种的唯一性

  单例模式的定义就是一个类只允许创建唯一一个对象,那么这个类就是单例类那么这个单例类的对象的唯一性范围在哪里?是进程还是线程?
  要知道我们的代码通过编译、链接、组织在一起,构成一个操作系统可执行的文件,最常见的就是 windows 平台下的 .exe 文件,可执行文件就是代码被翻译成了操作系统能够理解的指令。当我们运行这个可执行文件的时候,操作系统就会启动一个进程,将执行文件从磁盘上加载到自己的进程地址空间(内存-用于存储代码和数据),比如当我们执行User user = new User()的时候,就在自己的地址空间种创建了一个 user 临时变量和一个 User 对象。
  进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程,比如fock(),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中去。
  所以单例类在老进程种只能存在一个,在新进程中也只能存在一个,而且,这两个对象不是同一个。这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。

2. 如何实现线程间唯一?

  这个其实就很简单,我们可以在单例类内部维护一个 HashMap, 通过线程的 id 作为 key,然后将单例对象 new 出来存入作为value维护,这样我们就可以做到,不同的线程对应不同的对象。实际上,Java 语言本身也提供了 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();
	}
}

3. 如何实现集群环境下的单例

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值