创建型模式之:
单例模式
- 工厂模式
- 构造者模式
- 克隆模式
单例模式是什么呢?就是整个线程中存在着一个对象,可以让你在任何地方调用到这个对象的方法。
创建型模式是什么意思呢? 主要是指明这个设计模式的主要用途是用来获得对象,比如
- 单例模式是用来获得唯一的对象;
- 工厂模式是用来创建不同的对象;
- 建造者模式用来创建定制化的对象;
- 克隆模式用来获得创建成本较大的对象, 这些都是创建型模式。
1.为什么要使用单例呢?
原理:
单例模式是一种创建型
设计模式,它确保类只有一个实例,并提供一个全局访问点以便于访问该实例。
这样的设计模式主要用于限制某个类的实例化次数为一个,通常用于管理共享资源,如数据库连接、线程池、日志对象
等。
- 单一实例: 该模式确保一个类只有一个实例。这个实例由类自身管理,并在需要时被全局访问。
- 全局访问点: 单例模式提供一个全局的访问点,允许代码的其他部分从任何地方访问这个单一实例。
- 延迟实例化: 有些实现中,单例模式的实例是在第一次访问时才创建,而不是在程序启动时就创建。
可以看出来单列模式的优点:避免重复创建对象(性能),全局访问(线程安全),延期加载(性能)等方面进行考虑
案例1:处理资源冲突
我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
writer.write(mesasge);
}
}
// Logger类的应用示例:
public class UserController {
private Logger logger = new Logger();
public void login(String username, String password) {
// ...省略业务逻辑代码...
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// ...省略业务逻辑代码...
logger.log("Created an order: " + order.toString());
}
}
/Users/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。
怎么解决这个问题呢?
最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log()函数。具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
synchronized(this) {
writer.write(mesasge);
}
}
}
这真的能解决多线程写入日志时互相覆盖的问题吗?
答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。
那我们这样改动:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
synchronized(Logger.class) { // 类级别的锁
writer.write(mesasge);
}
}
}
后续为了避免重复创建对象:
可以把log函数进一步优化:
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
public Logger() {
File file = new File("/Users/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
writer.write(mesasge);
}
public static Logger getInstance() {
return instance;
}
2. 单例的几种模式
2.1 饿汉式
饿汉式就是提前加载和创建好所有的资源和对象,已经分配好了堆和内存。此方法是线程安全的,不过很明显的问题就是会造成性能损耗,比如说在一个页面的有一个if语句使用到了,单例对象,但是只有满足if条件时才会使用,但是每次页面代码加载,都会创建好了单例对象,造成资源浪费
。
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();
}
}
if (name=="test") {
Logger.getInstance
}else if (name=="Mon") {
//TODO
}else if (name=="Wen") {
Logger.getInstance
}else if (name=="Sat") {
//TODO
}else if (name=="Sun") {
//TODO
}
具体要不要使用饿汉式要看使用的场景了。存在即合理嘛。
如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化 过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作, 会导致此请求的响应时间变长,甚至超时)。
采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问 题。
2.2 懒汉式
看名字就可以知道,懒汉式是需要的时候再加载,通过前面的饿汉式,这里已经可以预判懒汉式的场景和使用条件了。
如果初始化耗时短
,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这不
会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。
采用饿汉式实现方式,将不耗时的初始化操作,在程序运行的时候,再去初始化导致的性能问
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
不过懒汉式的缺点也很明显,我们给 getInstance() 这个方法加了锁 (synchronzed),导致这个函数的并发度很低。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低 等问题,会导致性能瓶颈。
2.3 双重检测
饿汉式不支持延迟加载,不支持高并发
。为了解决这个问题,就有进化版本的出来,也就是双重检测方式,相对于懒汉式,仅仅加了一个判断条件,就解决了问题。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
//加了一个判断
if (instance == null) {
synchronized (IdGenerator.class) {
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再 进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题
2.4双重检测优化
网上有人说,这种实现方式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻 辑),就被另一个线程使用了。
什么是指令重排呢?
指令重排序是计算机程序执行时的一种优化技术,旨在提高处理器的执行效率。这种优化可能导致原本顺序执行的代码在实际执行中发生了乱序。
在单线程环境下,指令重排序通常不会引起问题,因为最终的执行结果是一致的。然而,在多线程环境下,指令重排序可能导致线程之间的交互出现问题,从而引发一些难以察觉和调试的 bug。
为了解决这个问题,Java 提供了一些内置的同步机制,如 synchronized
关键字和 volatile
关键字,以确保多线程环境下的可见性、有序性和原子性。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
//唯一不同点
private static volatile IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
//加了一个判断
if (instance == null) {
synchronized (IdGenerator.class) {
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
那么为什么不使用synchronized
优化上面的指令重排问题呢?
双重检查锁定是为了在多线程环境下延迟实例化并提高性能。使用 synchronized
关键字的版本会在整个方法
上加锁,导致所有线程在访问 getInstance
方法时都需要排队,降低了并发性能。
使用 volatile
关键字修饰 instance
的方式是为了确保在多线程环境下的可见性。volatile
修饰的变量在读取和写入时都会进行内存屏障操作,这可以防止指令重排序,确保了在一个线程写入 instance
时,其他线程能够立即看到最新的值。这样就保证了在实例化对象时其他线程不会拿到一个未完全初始化的对象。
假如现在有面试官问你有没有更简单的方法实现单例,你会怎么回答?
文章中和我前一段时间都遇到了一样的问题,加分项的答案就是静态内部类
实现单例。
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种方式的关键在于,静态内部类 SingletonHolder
只有在 getInstance
方法第一次被调用时才会被加载,而加载类的过程是线程安全的。因此,通过静态内部类的方式实现单例模式既能保证懒加载,又能确保线程安全,而不需要使用 synchronized
或双重检查锁定。
让我们分解一下为什么这种方式是线程安全的
- 类加载过程的线程安全性: 类加载过程是线程安全的,即在多线程环境下,一个类只会被加载一次。当第一次调用
getInstance
方法时,SingletonHolder
类会被加载,而这个加载过程是由类加载器负责的,保证了线程安全。 - 静态变量初始化的线程安全性: 静态变量的初始化在类加载的过程中完成,由 JVM 确保线程安全。因此,当
SingletonHolder
类被加载时,其静态变量INSTANCE
会被初始化为一个Singleton
的实例。 - 懒加载: 使用静态内部类的方式实现了懒加载,只有在第一次调用
getInstance
方法时才会加载SingletonHolder
类,进而初始化INSTANCE
。
简而言之:利用了静态内部类的加载以及初始化时机替换了volatile或者synchronized
当然,枚举也是十分方便的且简洁的一种方法,不过日常工作中用到的较少。
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
使用枚举实现单例模式有以下优点:
- 线程安全: 枚举实例的创建是线程安全的,不需要额外的同步操作。
- 防止反射攻击: 枚举类型的实例只能在枚举类被加载时被创建,因此无法通过
反射在运行时动态
创建枚举类型的实例。 - 自动序列化: 枚举类型默认实现了
Serializable
接口,因此可以直接被序列化而无需担心序列化问题。 - 保证单例: 枚举类型保证了在一个 Java 虚拟机中,每个枚举值只有一个实例。
3.单例模式的缺点
- 全局状态: 单例模式引入了全局状态,这可能导致系统的不灵活和难以测试。由于单例的实例在整个应用程序中是共享的,一个模块对单例的状态的修改会影响其他模块。
- 耦合性增加: 单例模式会增加代码的耦合性,因为它引入了一个全局访问点,模块之间直接依赖于这个单例实例。
- 测试困难: 单例模式可能会导致测试困难。由于单例实例是全局的,测试一个模块可能会受到其他模块的影响,难以实现模块的隔离测试。
- 违反单一职责原则: 在某些情况下,单例对象可能承担过多的职责,违反了单一职责原则。这可能导致代码难以理解、维护和扩展。
- 隐藏依赖关系: 单例模式可能隐藏了模块之间的真实依赖关系。由于单例是通过全局访问点访问的,模块之间的依赖关系可能不明显。
- 线程安全复杂性: 在多线程环境下,实现线程安全的单例可能会引入额外的复杂性,例如使用双重检查锁定等机制。
- 可能导致性能问题: 单例模式可能在高并发情况下引起性能问题。竞争访问单例实例的情况可能导致性能瓶颈。
- 单例的生命周期: 单例的生命周期由应用程序的整个生命周期确定。特别是一些内存优化过程中,发现有大内存对象一直存在导致内存审核不过关。