本文共 8830 字,大约阅读时间需要 29 分钟。
单例设计模式
一、主要解决的问题场景
避免⼀个全局使⽤
的类频繁的创建和消费,提升整体的代码性能,减少内存开支
二、主要实现方式
2.1 饿汉式
public class HungryMan { /** * 饿汉式,类加载的时候就实例化对象 */ private static final HungryMan HUNGRY_MAN = new HungryMan(); /** * 构造方法私有化,外部无法访问并通过空参构造创建新的对象 */ private HungryMan() { } /** * 对外只提供一个获取对象的方法,每次调用只返回同一个对象 */ public static HungryMan getInstance() { return HUNGRY_MAN; }}//测试@Testpublic 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------374756906Thread-2------374756906Thread-0------374756906Thread-3------374756906Thread-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------1824846967Thread-1------374756906Thread-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 AtomicReferenceINSTANCE = 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 反射破解方式
以懒汉式为例,进行单例反射破解
@Testpublic void testReflectSingle() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { //获取私有构造方法,获取访问权限,创建对象 ConstructordeclaredConstructor = 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 = 366004251lazyManReflect = 1791868405
除了枚举,其他的单例模式实现方式都是可以被破解的,主要原因在于空参的构造方法可以反射获取到
,因此我们可以使用如下的解决办法——对空参构造方法进行判断处理。
/** * 空参构造方法,防止反射破解处理 */private LazyMan() { synchronized (LazyMan.class) { if (null != lazyMan) { throw new RuntimeException("禁止反射破解!!"); } }}//输出结果Caused by: java.lang.RuntimeException: 禁止反射破解!! at com.jd.domain.single.HungryMan.(HungryMan.java:21) ... 27 more
但是,如果我们从一开始没有使用getInstance()
实例化对象,直接反射获取对象,那么依然无法阻止反射破解。
//直接反射创建对象LazyMan lazyManReflect1 = declaredConstructor.newInstance(null);LazyMan lazyManReflect2 = declaredConstructor.newInstance(null);//打印lazyManReflect1 = 366004251lazyManReflect2 = 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.(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 = 1791868405lazyManReflect2 = 1260134048
3.2 枚举禁止反射破解
我们再枚举单例里定义了一个空参构造方法
public enum EnumSingle { INSTANCE; //空参构造 EnumSingle() { } public static EnumSingle getInstance() { return INSTANCE; }}
然后我们使用反射获取这个空参构造,进行实例化对象
ConstructordeclaredConstructor = EnumSingle.class.getDeclaredConstructor();declaredConstructor.setAccessible(true);EnumSingle enumSingleReflect = declaredConstructor.newInstance(null);//打印结果java.lang.NoSuchMethodException: com.jd.domain.single.EnumSingle. ()
出现异常,主要原因是这个枚举类并没有无参构造,这就有点黑人问好了???!!!
我们使用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
,那么我们可以通过有参构造方式反射破解。
ConstructordeclaredConstructor = 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);//打印结果14845944891758386724
很明显,不能满足单例要求。
但其实,这已经与我们使用单例的目的背道而驰了,我们使用单例,是为了保证全局唯一,而我们使用克隆,就是不想全局唯一,互不干扰。
我们点开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的最佳⽅法,但是在继承情景下不适用
五、Spring中的单例模式应用
转载地址:https://blog.csdn.net/qq_45337431/article/details/118198331 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!