当前位置: 首页 > 科技观察

用Dcl写了一个单例模式,阿里面试官不满意!

时间:2023-03-22 15:02:04 科技观察

前言单例模式可以说是设计模式中最简单最基础的设计模式了。就算是初级开发,问到用过哪些设计模式,估计大部分都会说单例模式。但是你认为这样一个基本的“单例模式”真的有那么简单吗?也许你会问:“一个简单的单例模式应该是什么样的?”哈哈,话不多说,让我们拭目以待,坚持看完,相信你一定有所收获!饿了么中式饿了么中式是最常见也是最不需要考虑太多单例模式的,因为它不存在线程安全问题,饿了么也是在类实例对象创建时加载的。饿了么写法如下:publicclassSingletonHungry{privatestaticSingletonHungryinstance=newSingletonHungry();privateSingletonHungry(){}privatestaticSingletonHungrygetInstance(){returninstance;}}测试代码如下:classA{publicstaticvoidmain(String[]args){IntStream1,5rangeClosed(.forEach(i->{newThread(()->{SingletonHungryinstance=SingletonHungry.getInstance();System.out.println("instance="+instance);}).start();});}}结果优势:线程安全,不用关心并发问题,写法也是最简单的。缺点:在加载类的时候会创建对象,也就是说无论你是否使用对象都会创建对象,浪费内存空间。在线程的情况下,这种方式是完美的,但是我们实际的程序执行基本不可能是单线程的,所以这种写法肯定存在线程安全问题if(null==instance){returnnewSingletonLazy();}returninstance;}}多线程执行的演示classB{publicstaticvoidmain(String[]args){IntStream.rangeClosed(1,5).forEach(i->{newThread(()->{SingletonLazyinstance=SingletonLazy.getInstance();System.out.println("instance="+instance);}).start();});}}结果很明显,得到的实例对象是不是单例的。也就是说,这种写法不是线程安全的,DCL(doublechecklock)不能用于多线程的情况。DCL,即DoubleCheckLock,就是在创建实例时进行双重检查。首先,检查实例对象是否为空。如果不为空,则锁定当前类,然后判断实例是否为空,如果仍然为空,则创建实例;代码如下:}}}returninstance;}}测试代码如下:classC{publicstaticvoidmain(String[]args){IntStream.rangeClosed(1,5).forEach(i->{newThread(()->{SingleTonDclinstance=SingleTonDcl.getInstance();System.out.println("instance="+instance);}).start();});}}因此,相信大部分初学者在接触到这种写法的时候就已经有了“高大上”的感觉。首先要判断实例对象是否为空。如果为空,则使用对象的Class作为锁,保证同一时间只能有一个线程访问,然后再次判断实例对象是否为空,最后初始化创建实例对象。一切看起来都没有破绽,但是当你学会了JVM,你可能一眼就看出其中的窍门了。没错,问题出在instance=newSingleTonDcl();因为这不是一个原子操作,所以这句话的执行在JVM层面分为以下三个步骤:1.为SingleTonDcl分配内存空间2.初始化SingleTonDcl实例3.设置实例对象指向分配的内存空间(实例为空)。一般情况下,以上三个步骤是顺序执行的,但实际上,JVM可能会“热情地”优化我们的代码,可能的执行顺序是1、3、2,如下代码所示==实例){同步(SingleTonDcl.class){如果(空==实例){1。为SingleTonDcl分配内存空间3.将实例对象指向分配的内存空间(instance不再为null)2.初始化SingleTonDcl实例}}}returninstance;}假设现在有两个线程t1,t2如果t1执行到上面第3步被挂起,然后t2进入getInstance方法,因为t1执行了第3步,此时instance不再为空,所以if(null==instance)的条件不为空,instance为直接返回了,但是因为t1还没有执行到step2,此时的instance其实是一个半成品,会导致不可预知的风险!如何解决?由于问题在于指令可能重新排序,因此不让它重新排序就足够了。volatile不就是为了这个吗?我们可以在实例变量前面加一个volatile修饰符画外音:volatile的作用1.保证对象内存可见性2.防止指令重排序优化代码如下publicclassSingleTonDcl{privateSingleTonDcl(){}//在前面加volatile关键字对象的volatileprivatestaticSingleTonDclinstance=null;publicstaticSingleTonDclgetInstance(){if(null==instance){synchronized(SingleTonDcl.class){if(null==instance){instance=newSingleTonDcl();}}}returninstance;}}这里好像已经解决了问题,双锁机制+volatile其实基本解决了线程安全问题,保证是“真正的”单身人士,但真的是这样吗?继续往下看静态内部类,先看代码}}测试代码如下:.out.println("instance="+instance);}).start();});}}静态内部类的特点:这种写法使用JVM类加载机制,保证线程安全;由于SingleTonStaticInnerClass是私有的,除了getInstance()之外没有办法访问它,所以它是惰性的;同时读取实例时没有同步,没有性能缺陷;它不依赖于JDK版本;然而,它仍然不完美。不安全的单例实现并不完美,主要有两个原因:1.反射攻击首先要提到java中让人又爱又恨的反射机制。说明一下,这里是一个DCL的例子(为什么选择DCL是因为很多人认为DCL的写法最高....这里开始“打脸”),修改上面的DCL测试代码如下:classC{publicstaticvoidmain(String[]args)throwsNoSuchMethodException,IllegalAccessException,InvocationTargetException,InstantiationException{ClasssingleTonDclClass=SingleTonDcl.class;//获取类的构造函数Constructorconstructor=permissionsingleTonDclClass.getDeclared);/Constructor(Letgoofconstructor.setAccessible(true);//反射创建实例注意反射创建一定要放在前面,攻击才会成功,因为如果反射攻击在后面,如果按正常方式创建实例首先可以在构造函数中进行判断,防止反射攻击和抛出异常,//因为实例已经按照正常方式先创建了,所以会进入ifSingleTonDclinstance=constructor.newInstance();//正常的获取实例的方法放在反射创建的实例后面,所以当反射创建成功后,单例对象中的引用实际上是空的,反射攻击可以成功SingleTonDclinstance1=SingleTonDcl.getInstance();System.out.println("instance1="+instance1);System.out.println("instance="+instance);}}其实是两个对象!内心是不是异常的平静?果然和你想的不一样?其他方法基本类似,通过反射销毁单例即可。2、序列化攻击下面以“HungrySingletonHungryinstance”为例,演示序列化和反序列化攻击代码。首先在HungrySingletonHungryinstance对应的类中添加实现Serializable接口的代码,publicclassSingletonHungryimplementsSerializable{privatestaticSingletonHungryinstance=newSingletonHungry();privateSingletonHungry(){}privatestaticSingletonHungrygetInstance(){returninstance;}}然后看如何使用序列化和反序列化进行攻击SingletonHungryinstance=SingletonHungry.getInstance();ObjectOutputStreamoos=newObjectOutputStream(newFileOutputStream("singleton_file")));//序列化[写]操作oos.writeObject(instance);Filefile=newFile("singleton_file");ObjectInputStreamois=newObjectInputStream(newFileInputStream(file))//反序列化[读]操作SingletonHungrynewInstance=(SingletonHungry)ois.readObject();System.out.println(实例);System.out.println(newInstance);System.out.println(实例==newInstance);看结果图,有两个不同的物体!这个反序列化攻击的解决方案其实也很简单。只需重写要在反序列化期间调用的readObject方法即可。PrivateObjectreadResolve(){returninstance;}这样反序列化的时候总是只读取一个instance实例,保证了单实例的实现。真正安全的单例:枚举方法publicenumSingleTonEnum{/***实例对象*/INSTANCE;publicvoiddoSomething(){System.out.println("doSomething");}}调用方法publicclassMain{publicstaticvoidmain(String[]args){SingleTonEnum.INSTANCE.doSomething();}}枚举模式实现的单例是真正的单例模式,完美实现。可能有人会问:难道枚举也可以通过反射破坏它的单例实现吗?什么?试一下,修改枚举的测试类;SingleTonEnumsingleTonEnum=declaredConstructor.newInstance();SingleTonEnuminstance=SingleTonEnum.INSTANCE;System.out.println("instance="+instance);System.out.println("singleTonEnum="+singleTonEnum);}}结果是没有无参构造?我们使用javap工具来检查字节码,看看有没有什么玄机。好家伙,我们找到一个参数构造函数StringInt,那就试试吧//获取构造函数时,修改为这个ChildConstructordeclaredConstructor=singleTonEnumClass.getDeclaredConstructor(String.class,int.class);结果好家伙,抛出异常,异常信息是这样写的:“无法反射创建枚举对象”源码下没有什么秘密,我们来看看newInstance()到底做了什么?为什么用反射创建枚举会抛出这样的异常?真相大白!如果是枚举,则不允许通过反射创建。这是唯一使用enum创建单例的方法,可以说是真正安全的理由!结束语以上是关于单例模式的一些知识点的总结。你真的不想低估这个小单身人士。面试的时候,这么简单的单例,大部分考生都写错了,正确的大多只有DCL,但是当被问到有没有不安全的地方,如何用enum写一个安全的单例时,几乎没有人能答对!有人说写个DCL就够了,何必呢?但我想说的是,正是这种锋芒毕露的精神,让你逐渐积累技术深度,成为专家。如果你有毅力去了解技术,为什么不成为专家呢?联络密码海公众号。