单例设计模式
一、主要解决的问题场景
避免⼀个全局使⽤
的类频繁的创建和消费,提升整体的代码性能,减少内存开支
二、主要实现方式
2.1 饿汉式
public class HungryMan {
/**
* 饿汉式,类加载的时候就实例化对象
*/
private static final HungryMan HUNGRY_MAN = new HungryMan();
/**
* 构造方法私有化,外部无法访问并通过空参构造创建新的对象
*/
private HungryMan() {
}
/**
* 对外只提供一个获取对象的方法,每次调用只返回同一个对象
*/
public static HungryMan getInstance() {
return HUNGRY_MAN;
}
}
//测试
@Test
public void testHungry() throws InterruptedException {
for (int i = 0; i < 2; i++) {
new Thread(() -> {
HungryMan instance = HungryMan.getInstance();
System.out.println(Thread.currentThread().getName() + "------" + System.identityHashCode(instance)); //我们使用System.identityHashCode获取对象的hash值,检查是否为同一个对象
}).start();
}
Thread.currentThread().join();
}
//我们可以多线程调用,返回的都是同一个对象
Thread-1------374756906
Thread-2------374756906
Thread-0------374756906
Thread-3------374756906
Thread-4------374756906
2.2 懒汉式
(1)非线程安全
public class LazyMan {
private static LazyMan lazyMan;
private LazyMan() {
}
public static LazyMan getInstance() {
if (null == lazyMan) {
//检查是否有多个线程重新创建对象,导致并发问题
System.out.println("线程" + Thread.currentThread().getName() + ", 重新创建了对象");
lazyMan = new LazyMan();
}
return lazyMan;
}
}
//我们多线程调用,会发现Thread-1、Thread-3、线程Thread-4、Thread-2都重新创建对象,且其hashcode不一致,返回的不是同一个对象
线程Thread-1, 重新创建了对象
线程Thread-3, 重新创建了对象
Thread-3------1362804950
线程Thread-4, 重新创建了对象
Thread-4------1017499014
线程Thread-2, 重新创建了对象
Thread-2------1824846967
Thread-1------374756906
Thread-0------1824846967
对比饿汉式,主要有以下三点区别
a. 饿汉式声明变量同时初始化对象,懒汉式调用方法时初始化对象
b. 在第一次调用对象前,懒汉式比饿汉式节省空间,饿汉式在类加载的时候就实例化,生命周期长
c. 由于饿汉式是类加载实例化对象,所以不存在线程安全问题
(2)DCL双重锁检验懒汉式
public static LazyMan getInstance() {
if (null == lazyMan) {
synchronized (LazyMan.class) {
if (null == lazyMan) {
System.out.println("线程" + Thread.currentThread().getName() + ", 重新创建了对象");
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
我们也可以在
getInstance()
方法上加synchronized
,但是这样会大大降低执行效率,本来多个线程执行这个方法,大多数都是可以直接在第一个if就直接return掉,但在方法上加锁后,就需要集体等待锁释放。第二层的判断主要是防止,当AB两个线程都在第一层判为空,A拿到锁执行实例化对象,B在A释放锁后也执行,就会出现并发问题。
对于这种DCL模式,还有一个问题就是:指令重排序
创建对象的过程一般是如下顺序:
(1)堆中开辟空间
(2)调用构造方法初始化
(3)把地址赋值给栈中变量
但是JVM会考虑到效率问题,出现无序写入现象:赋值语句在对象实例化之前调用
,从而使顺序变为(1)、(3)、(2),可能会出现A线程执行到(3),但还未初始化属性,此时,B线程开始执行,经过第一层的if判断,lazyMan != null,直接返回了属性未初始化的lazyMan 的情况。
//增加volatile,解决指令重排序
private static volatile LazyMan lazyMan;
2.3 静态内部类
public class StaticInnerSingle {
//外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存
private static class Holder {
private static final StaticInnerSingle STATIC_INNER_SINGLE = new StaticInnerSingle();
}
//调用getInstance方法的时候,会加载内部类
public static StaticInnerSingle getInstance() {
return Holder.STATIC_INNER_SINGLE;
}
}
静态内部类保证单例的原因:类初始化阶段,JVM保证同一个类的static{}
方法只被执行一次,JVM靠类的全限定类名以及加载它的类加载器
来唯一确定一个类,并保证是同一个类。
2.4 CAS算法单例
public class CASSingle {
//AtomicReference类提供了一个可以原子读写的对象引用变量
private static final AtomicReference<CASSingle> INSTANCE = new AtomicReference<>();
private static CASSingle casSingle;
private CASSingle() {
}
public static CASSingle getInstance() {
for (;;) {
CASSingle casSingle = INSTANCE.get();
if (null != casSingle) {
return casSingle;
}
//比较&交换操作,1、获取预期值null;2、实例化新对象;3、获取内存值比较,一致,则引用
if(INSTANCE.compareAndSet(null, new CASSingle())) {
return INSTANCE.get();
}
}
}
}
CAS单例是原子操作,意味着尝试更改相同AtomicReference的多个线程,不会使AtomicReference最终
达到不一致的状态。
(1)不需要使⽤传统的加锁⽅式保证线程安全,⽽是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以⽀持较⼤的并发性
(2)缺点就是忙等,一直没有获取到就会死循环;另外就是会创建大量的CASSingle对象
2.5 枚举单例
public enum EnumSingle {
INSTANCE;
EnumSingle() {
}
public static EnumSingle getInstance() {
return INSTANCE;
}
}
枚举单例是线程安全的,但是效率相对低。
三、反射破解
3.1 反射破解方式
以懒汉式为例,进行单例反射破解
@Test
public void testReflectSingle() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
//获取私有构造方法,获取访问权限,创建对象
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
LazyMan lazyMan = LazyMan.getInstance();
LazyMan lazyManReflect = declaredConstructor.newInstance(null);
System.out.println("lazyMan = " + System.identityHashCode(lazyMan));
System.out.println("lazyManReflect = " + System.identityHashCode(lazyManReflect));
}
//打印结果
lazyMan = 366004251
lazyManReflect = 1791868405
除了枚举,其他的单例模式实现方式都是可以被破解的,主要原因在于空参的构造方法可以反射获取到
,因此我们可以使用如下的解决办法——对空参构造方法进行判断处理。
/**
* 空参构造方法,防止反射破解处理
*/
private LazyMan() {
synchronized (LazyMan.class) {
if (null != lazyMan) {
throw new RuntimeException("禁止反射破解!!");
}
}
}
//输出结果
Caused by: java.lang.RuntimeException: 禁止反射破解!!
at com.jd.domain.single.HungryMan.<init>(HungryMan.java:21)
... 27 more
但是,如果我们从一开始没有使用getInstance()
实例化对象,直接反射获取对象,那么依然无法阻止反射破解。
//直接反射创建对象
LazyMan lazyManReflect1 = declaredConstructor.newInstance(null);
LazyMan lazyManReflect2 = declaredConstructor.newInstance(null);
//打印
lazyManReflect1 = 366004251
lazyManReflect2 = 1791868405
主要是因为直接反射创建对象的时候,没有操作成员变量lazyman
的实例化,每次判断都是空,都能创建成功。
我们可以设置一个私有成员变量,第一次通过空参构造实例化对象的时候,修改掉这个变量值,如若再次通过反射实例化,可以利用这个变量进行判定。
private static boolean baaccfedaceddfa = false;
private LazyMan() {
synchronized (LazyMan.class) {
if (baaccfedaceddfa) {
throw new RuntimeException("禁止反射破解!!");
}
baaccfedaceddfa = true;
}
}
//打印结果
Caused by: java.lang.RuntimeException: 禁止反射破解!!
at com.jd.domain.single.LazyMan.<init>(LazyMan.java:19)
... 27 more
即使如此,如果我们可以获得这个变量的名称,以入可以获得访问控制,修改为原始状态,同样反射破解成功
//获取到这个成员变量名,获得访问权限,将值修改为false即可再次破解
Field baaccfedaceddfa = LazyMan.class.getDeclaredField("baaccfedaceddfa");
baaccfedaceddfa.setAccessible(true);
baaccfedaceddfa.setBoolean(LazyMan.class, false);
LazyMan lazyManReflect2 = declaredConstructor.newInstance(null);
//打印结果
lazyManReflect1 = 1791868405
lazyManReflect2 = 1260134048
3.2 枚举禁止反射破解
我们再枚举单例里定义了一个空参构造方法
public enum EnumSingle {
INSTANCE;
//空参构造
EnumSingle() {}
public static EnumSingle getInstance() {
return INSTANCE;
}
}
然后我们使用反射获取这个空参构造,进行实例化对象
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
EnumSingle enumSingleReflect = declaredConstructor.newInstance(null);
//打印结果
java.lang.NoSuchMethodException: com.jd.domain.single.EnumSingle.<init>()
出现异常,主要原因是这个枚举类并没有无参构造,这就有点黑人问好了???!!!
我们使用XJad
对这个class文件进行反编译,看一下java是否在编译过程中进行了什么神操作。
//final修饰的类,不能被继承
public final class EnumSingle extends Enum {
public static final EnumSingle INSTANCE;
......
//替换为有参构造
private EnumSingle(String s, int i) {
super(s, i);
}
public static EnumSingle getInstance() {
return INSTANCE;
}
//静态代码块,类加载时就实例化对象
static {
//有参构造内容
INSTANCE = new EnumSingle("INSTANCE", 0);
$VALUES = (new EnumSingle[] {
INSTANCE
});
}
}
可以发现,枚举类,其实就是在编译的时候继承了一个Enum
基类,也确实取消了无参构造,而实际使用的是参构造。
既然找到了有参构造的内容,即INSTANCE 和 0
,那么我们可以通过有参构造方式反射破解。
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
declaredConstructor.setAccessible(true);
EnumSingle enumSingleReflect = declaredConstructor.newInstance("INSTANCE", 0);
//打印结果:禁止反射创建枚举对象
java.lang.IllegalArgumentException: Cannot reflectively create enum objects
在JDK的反射包里,newInstance
方法中,就有对枚举的断言
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException {
......
/*如果是枚举类型,禁止反射破解*/
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
......
}
四、克隆“破解单例”
以饿汉式为例子,需要遵从序列化接口Serializable
,然后我们使用Hutool
的深度克隆进行序列化操作。
HungryMan instance = HungryMan.getInstance();
HungryMan cloneInstance = ObjectUtil.cloneByStream(instance);
//打印结果
1484594489
1758386724
很明显,不能满足单例要求。
但其实,这已经与我们使用单例的目的背道而驰了,我们使用单例,是为了保证全局唯一,而我们使用克隆,就是不想全局唯一,互不干扰。
我们点开Enum
枚举类的JDK源码,会发现,枚举是天然支持禁止序列化和反序列化的
/**
* prevent default deserialization
*/
private void readObject(ObjectInputStream in) throws IOException,
ClassNotFoundException {
throw new InvalidObjectException("can't deserialize enum");
}
private void readObjectNoData() throws ObjectStreamException {
throw new InvalidObjectException("can't deserialize enum");
}
总结:枚举单例模式更简洁,⽆偿地提供了串⾏化机制,绝对防⽌对此实例化,即使是在⾯对复杂的串⾏化或者反射攻击的时候。虽然这中⽅法还没有⼴泛采⽤,但是单元素的枚举类型已经成为实现Singleton的最佳⽅法,但是在继承情景下不适用
五、饿汉式的优势不必双重锁检验的懒汉式差
有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。
不过,我个人并不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。
采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。
如果资源不够,就会在程序启动的时候触发报错(比如Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。—— 摘自·王争《设计模式之美》
六、单例模式其他应用
6.1 ThreadLocal实现线程唯一单例
Threadlocal的使用很简单,这里不赘述,其底层维护的就是一个Map,将当前线程作为key,将Object作为value,我们可以将一个类的实现放入这个value,每次执行的时候直接从里面获取当前线程对应的Object(线程唯一),这样就保证了线程唯一单例。
6.2 进程间单例
进程间单例 |
---|
集群相当于多个进程构成,所以也是集群间唯一,方式就是将单例对象序列化到公用文件(内存)中,需要使用redis的分布式锁。
@Slf4j
@Component //必须加这个扫描注解,才能保证这个类的静态方法跟着spring注入一起进行
public class ColonySingle implements Serializable {
/**
* 固定序列号
*/
private static final long serialVersionUID = 6055841422234810284L;
private static ColonySingle instance;
@Resource
private RedisUtil redisUtil; //注入redis
private static ColonySingle colonySingle;
public ColonySingle() {
}
/**
* 该注解标识启动的时候加载,这样可以将service注入的类放到静态方法内执行
*/
@PostConstruct
public void init() {
colonySingle = this;
colonySingle.redisUtil = this.redisUtil;
}
/**
* 获取单例对象:
* 1、方法上的锁主要目的是解决线程并发问题
* 2、第二个锁的目的是控制进程,保证进程间仅有一个进程在使用这个对象
* 只要不释放,其他进程就不能访问外部文件获取对象
*/
public static synchronized ColonySingle getInstance() {
if (null == instance) {
//获取分布式锁
boolean lock = colonySingle.redisUtil.setNx("single", "1", 1000);
if (lock) {
//从外部文件读取对象
BufferedInputStream inputStream = null;
try {
inputStream = FileUtil.getInputStream("E:\\EmailMessage\\markdown\\colonySingle.class");
instance = IoUtil.readObj(inputStream);
} catch (Exception e) {
log.error("从外部文件读取对象错误!", e);
} finally {
IoUtil.close(inputStream);
}
}
}
return instance;
}
/**
* 释放对象
*/
public static void removeInstance() {
//将对象再次存入外部文件
BufferedOutputStream outputStream = null;
try {
outputStream = FileUtil.getOutputStream("E:\\EmailMessage\\markdown\\colonySingle.class");
IoUtil.writeObj(outputStream, false, instance);
} catch (IORuntimeException e) {
log.error("将对象写入外部文件错误!", e);
} finally {
IoUtil.close(outputStream);
}
//销毁对象和释放锁
instance = null;
colonySingle.redisUtil.delKey("single");
}
}
这个类里的将service注入的类可以放到静态方法执行
,除了@PostConstruct
,还可以使用如下方法:
private static RedisUtil redisUtil; //注入redis
/**
* 注入redis
*/
@Autowired
public ColonySingle(RedisUtil redisUtil) {
ColonySingle.redisUtil = redisUtil;
}
6.3 多例模式——Logger日志框架
多例模式就是一个类可以创建多个对象,比如简单工厂模式;另一种就是根据不同类型创建不同对象,比如日志框架。
public class LoggerFrame {
/**
* 不同类型的不同实例化
*/
private static final ConcurrentMap<String, LoggerFrame> instances = Maps.newConcurrentMap();
public LoggerFrame() {
}
/**
* 获取实例
*/
public static LoggerFrame getInstance(Class<?> clazz) {
String name = clazz.getName();
instances.putIfAbsent(name, new LoggerFrame());
return instances.get(name);
}
}