<单例模式>几乎成为面试官张口就来的一个问题,特别是在面试初级岗位的java开发者时,大部分人都会被问到单例模式的相关内容。
这是为什么呢?原来,单例模式虽然听上去简单,但它在实现的过程中实则包含有线程安全、类加载机制、内存模型等比较核心的知识,通过对单例模式的介绍和作答,面试官就能立马辨别出面试者基本功是否扎实。
往往技术底子不够扎实的同学,通常都会在回答问题的时候零零散散简单说下,以为这样就可以了,殊不知自己已经踩到了面试官挖好的居坑里面。
基于此,今天汇智妹就来带大家认识单例模式,看看怎样介绍单例模式才能征服自己的面试官?
一.什么是单例模式?
单例模式指的是在程序的整个运行时域,一个类只有一个实例对象对外部提供调用访问。
通俗的理解那就是在古代,整个王朝只应该有一个皇帝。而如何确保一个皇帝?这就是单例模式了。
单例设计模式的特点:
1. 单例类只能有一个实例对象;
2. 单例类外部无法创建对象只能由本类创建唯一实例对象;
3. 单例类要提供对外访问的公共方式。
单例设计模式的好处和不足:
好处:因为单例只为类创建一个对象资源消耗较少,避免了类频繁的创建对象导致的内存飙升、耗费时间等问题,提高了对象的访问速度,降低了系统内存的使用频率,减轻了GC(java中的垃圾回收器)压力。
例如:我们程序访问数据库的操作,创建连接数据库对象是一个非常耗时耗资源的过程,如果我们把这个对象设计为单例模式,那么我们就创建一次并重复使用这个对象即可,特别在高并发访问下大大提高了效率。
并且程序中也会出现某些特定场合一个类只能创建使用一个对象的情况,例如: 证券系统在整个系统中只能有一个证券交易类负责交易等相关操作。
不足:单例类没有抽象类不能扩展,不适用于变化的对象,并且根据单例实现方式的不同可能存在多线程访问下安全和效率问题。
那么在Java中,怎么去实现单例模式呢?
二.单例的实现
单例模式因其创建对象时机的不同,它的实现主要分为懒汉式和饿汉式两种类型:
饿汉式: 在类加载的时候就创建对象;
懒汉式: 类加载的时候不创建对象,在外部第一次调用的时候才创建对象。
下面我们先看下最基本的饿汉式和懒汉式的实现。
1. 饿汉式
分析:饿汉式中SingleTon 类的构造方法使用了private修饰,那么其他的类没办法通过new来创建singleton对象实例,其他类只能通过调用静态的方法getInstance来访问,这样就可以保证singleTon的实例对象只有一个singleTon类中创建的。
特点:饿汉式是典型的牺牲空间换取时间的方式,类一加载就创建了实例对象,也不管我们在程序中是否使用。所以会占用更多的内存,但是访问对象的速度比较快,并且在多线程访问情况下也没有线程安全的问题。
2. 懒汉式(简单实现)
分析:懒汉式跟饿汉式不同的地方是懒汉式在getInstance方法中创建的对象,即在使用对象的时候才去创建对象,这种加载对象的方式也被称为懒加载。
特点:
(1)懒汉式因为是在外部使用的时候才调用,所以要更加节省内存一些,但是第一次访问的时候因为需要创建对象所以要比饿汉式慢。
(2)线程不安全
多线程访问下,如果出现两个以上线程在没有new SingleTon()的时候就进行了singleTon == null的判断都会返回true,那么就会出现创建了多个实例的情况,这样就违反了单例模式的设计思想。
那么怎么才能使其线程安全呢?有的人就说了,这还不简单?加上 synchronized就好了,确实,这样可以解析线程安全的问题。
懒汉式—synchronized同步锁:
分析:在getInstance添加了synchronized 确实保证了线程安全,但是因为 synchronized加到方法上,一次性把整个方法给加上了锁,锁的粒度有点大。
这样意味 着在多线程访问情况下如果有一个线程访问了方法getInstance获取了锁,其他线程 就要处于等待状态,这样就大大降低了访问效率,所以实际开发中这种实现方式是不可 取的。
那有没有效率更高的实现方式呢?
懒汉式—DCL双重校验锁(推荐)
Synchronized同步方法的实现效率低是因为锁的粒度太大,那能不能通过减小锁的粒度来提高效率呢?这时候可以使用DCL(Double Check Lock)双重校验锁的方式来实现。
代码如下:
分析:DCL的实现加锁的粒度变小,在多线程访问getInstance方法的时候不需要竞争获取锁,都可以进入getInstance方法。
此时执行代码1进行第一次判空,如果对象实例还没创建那么开始竞争获取锁,竞争到锁的线程A就进行创建singleTon 对象。
如果当线程A刚获取锁的同时另外一个线程B也正好符合代码1的判空,那么线程A创建了singleTon 对象之后线程B也要获取锁并创建对象,为了解决这个问题就加入了代码3进行了第二次判空处理。
DCL的实现锁粒度小允许多线程访问getInstance方法,所以效率比同步方法的实现要高。
但是上面的代码真的完美吗?如果是老的程序员看到这个代码就会眉头一皱,心里不禁的会想到。
那么,上面到底问题出在了哪里呢?
这里就要说了JVM给出的happens-before通用原则,这里就不详细介绍happens-before原则了,它主要规定了jvm多线程原子性、可见性和有序性的一些原则。
而上面DCL代码实现中singleTon = new SingleTon();在指令操作中不是一步完成的不属于原子性操作,它的指令操作分为下面三步:
(1)memory =allocate(); 先为singleTon 分配内存空间;
(2)ctorInstance(memory); 然后初始化singleton对象;
(3)instance = memory;最后将singleton指向分配好的内存空间;
在真正执行时,JVM虚拟机为了提高执行效率,在保证结果的情况下可能会进行指令重排。
比如JVM认为指令按照 1->3->2执行效率会更高,如果按照这个顺序,假设线程A刚好执行到第三步指令的时候那么此时singleTon 还未初始化依旧是null,此时线程B执行到了代码1,判断singleTon ==null返回的却是false认为已经创建了singleTon ,那么此时就出现了一个严重的问题,线程B直接return返回了null,出现了线程不安全的情况。
那么怎么解决呢?
为了防止这种指令重排的现象,java提供了volatile关键字用来保证指令执行的顺序,被volatile 修饰的变量那么在指令操作层也不会出现指令重排的现象。
所以此时我们把代码稍微改正下就完美了,如下:
上面的代码已经很好的解决了线程安全和效率的问题,就是代码有点多,那么有没有更简单代码点的实现方式呢?
静态内部类
分析:静态内部类不会随着外部类的加载而加载 ,只有静态内部类的静态成员第一次被调用时才会被加载 。
即当getInstance()方法被调用时,SingleTonHoler才在SingleTon的运行时常量池里,把符号引用替换为直接引用,这时静态对象INSTANCE也真正被创建 。
这种实现方式是巧妙地利用了JVM类加载机制的特性,保证了线程安全的问题。
<未完待续>
Tips:本期分享的干货中,其实也存在实现方式的一些问题。
想要避免和其他求职者千篇一律的面试作答,我们下期文章会结合更多实操案例和大家做进一步探讨扩展,从而让你出众的回答给面试官留下更深刻印象哦!