单例模式
单例模式属于设计模式中的创建型。
什么是单例模式
一个类只允许创建一个对象,并提供该实例的全局访问点,这就是单例模式。
应用场景
1. 在资源访问冲突的场景中
在解决资源竞争的场景时,往往是通过加锁的方式来解决竞争。但在有些场景中。可以通过单例模式来解决。例如有个日志类,负责记录系统调用日志,那么这个类就会被频繁访问并将信息写入日志。如果不采用单例模式,那么就会频繁创建对象,耗费系统资源。并且因为不同的对象,锁的粒度也要上升到类级别。对性能的影响也大。
2. 全局唯一类
在业务上,如果某些数据在系统中只需要保存一份,那么就可以设计成单例模式。
例如配置信息类,当配置信息被加载到内存中,以对象的形式存在。或者递增ID生成器,需保持全局唯一。
3. 无状态类
无状态类都可以用单例模式,避免资源消耗。因为无状态类不保存数据,所以可以共用,使用单例模式,免去了频繁的创建。
如何实现一个单例
使用一个私有的构造函数、一个私有的静态变量,以及一个公有的静态函数来实现。
私有构造函数保证了外部不能通过new来创建实例对象,只能通过公有的静态函数返回唯一的私有静态变量。
1. 饿汉式
在类加载的阶段,就初始化创建类实例,保证了线程安全。但是直接实例化也丢失了延迟加载带来的节省资源的好处。
其实如果有的实例占用大量资源或初始化的耗时较长,如果采用延迟的方式,虽然一定上会节省资源。但是当加载的时候有可能会对业务产出影响。
而采用饿汉式在程序启动的时候完成这些耗时的初始化或者防止占用大量资源造成OOM的情况。
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton(){
}
public static Singleton getInstance(){
return singleton;
}
}
2. 懒汉式
懒汉式的优点就是支持延迟加载,但是在获取实例的时候,需要加锁,在类被频繁访问的时候,会导致性能瓶颈。
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static synchronized Singleton getInstance(){
if(null == singleton){
singleton = new Singleton();
}
return singleton;
}
}
3. 双重检验锁
双重校验机制既支持延迟加载又支持高并发的单例模式。双重校验先是判断实例是否实例化,如果没有实例化,才会对实例化语句加锁。
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
现在用的高版本java可以不用添加volatile关键字,因为在jdk内部将对象new操作和初始化操作设计为原子操作,自然就能防止指令重排。
只有很低版本的java需要通过给singleton成员变量添加volatile关键字来防止指令重排序。
原因:
singleton = new Singleton(); 这段代码其实是分为三步执行:
1.为 singleton 分配内存空间
2.初始化 singleton
3.将 singleton 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getInstance() 后发现 singleton 不为空,因此返回 singleton,但此时 singleton 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
4. 静态内部类
SingletonHolder 是一个静态内部类,当外部类 Singleton 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 singleton。singleton 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载
public class Singleton {
private Singleton() {
}
private static class SingletonHolder {
private static final Singleton singleton = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.singleton;
}
}
5.枚举
public enum Singleton {
INSTANCE;
private String objName;
public String getObjName() {
return objName;
}
public void setObjName(String objName) {
this.objName = objName;
}
public static void main(String[] args) {
// 单例测试
Singleton firstSingleton = Singleton.INSTANCE;
firstSingleton.setObjName("firstName");
System.out.println(firstSingleton.getObjName());
Singleton secondSingleton = Singleton.INSTANCE;
secondSingleton.setObjName("secondName");
System.out.println(firstSingleton.getObjName());
System.out.println(secondSingleton.getObjName());
// 反射获取实例测试
try {
Singleton[] enumConstants = Singleton.class.getEnumConstants();
for (Singleton enumConstant : enumConstants) {
System.out.println(enumConstant.getObjName());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
该实现可以防止反射攻击。在其它实现中,通过 setAccessible() 方法可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象,如果要防止这种攻击,需要在构造函数中添加防止多次实例化的代码。该实现是由 JVM 保证只会实例化一次,因此不会出现上述的反射攻击。
该实现在多次序列化和序列化之后,不会得到多个实例。而其它实现需要使用 transient 修饰所有字段,并且实现序列化和反序列化的方法。
如何理解单例模式的唯一性
单例类中对象的唯一性的作用范围是 进程唯一。
**进程唯一 :**进程内唯一,线程内唯一,进程间不唯一。
**线程唯一 :**线程内唯一,进程间不唯一。例如ThreadLocal。
**集群唯一 :**进程内唯一,线程内也唯一。
如何实现线程唯一的单例
通过一个HashMap来存储对象,Key为线程ID,value是对象
public class Singleton {
private static final ConcurrentHashMap<Long, IdGenerator> instances
= new ConcurrentHashMap<>();
private Singleton() {}
public static Singleton getInstance() {
Long currentThreadId = Thread.currentThread().getId();
instances.putIfAbsent(currentThreadId, new Singleton());
return instances.get(currentThreadId);
}
}
如何实现集群唯一
需要把单例对象序列化到外部共享存储区。进程在使用单例对象的时候,需要从外部共享存储区读取到内存,反序列化为对象,然后在使用,使用完以后存储返回外部共享存储区。为了保证任何时刻在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,需要显式地将对象从内存中删除,并且释放对对象的加锁。
如何实现一个多例模式
多例指一个类可以创建多个对象,但是有个数限制。也是通过一个map来存储。
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);
}
}
//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");
单例模式存在的问题
1. 单例模式对OOP特性的支持不友好
OOP的四大特性是封装、抽象、继承、多态。单例模式对其中的抽象、继承、多态支持都不友好,使用了单例模式也就意味着损失了可以应对未来需求变化的扩展性。
2. 单例会隐藏类之间的依赖关系
因为单例类不需要显示创建、不依赖参数传递,在函数中直接调用即可。如果代码复杂,这种调用关系就会很隐蔽。在阅读代码的时候就需要仔细查看调用了哪些单例类。使代码的可读性下降。
3. 单例模式对扩展性不友好
单例类只有一个类实例。如果后期需求变更需创建多个实例,代码就会面临大量改动。例如将数据库连接池设计为单例,某天因为某些sql执行很慢,造成连接资源占用,其他sql无法请求的场景。我们希望通过将慢sql和其他sql分开放在两个连接池中,避免慢sql影响到其他sql的执行。所以单例类在某些情况下会影响代码的扩展性、灵活性。
4. 单例模式对代码的可测试性不友好
如果单例类依赖比较重的外部资源,例如DB。在写单元测试的时候,希望通过mock方式替换掉,而单例类这种硬编码方式,导致无法实现mock替换。
5. 单例模式不支持有参数的构造函数
如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。