单例模式---面试老生常谈的问题
我觉得Java入门其实并不难,随便在一家培训机构学习几个月也就能上手了,包括我自己也有过培训的经历,我现在发现很多东西都是停留在会用的阶段,
并不了解其原理,真正想要在软件这个行业有所成就,只是会用那还远远不够。所以我决定从基础起,把以前遇到的问题和还没搞得很清楚的技术点,一样一样的学明白。
今天我们先来聊聊面试总会问的设计模式之单例模式。
作用: 单例模式的作用是为了保证在Java程序中,某个类只有一个实例存在。一些管理器和控制器常被设计成单例模式。(SpringMVC的Controller就是单例的)
单例模式有很多好处,它能够避免实例的重复创建,减少每次创建对象的时间开销,还可以节约内存空间,能够避免由于操作多个实例而导致逻辑错误。
如果一个对象能够贯穿整个应用程序,并起到一个全局统一管理的作用,那么这个时候,我们可以考虑使用单例模式。
我这里主要总结单例模式的5种写法:饿汉模式,懒汉模式,双重校验锁模式,静态内部类实现单例,枚举实现单例。
1、饿汉模式
public class Singleton{ private static Singleton instance = new Singleton(); private Singleton(){} public static Singleton newInstance(){ return instance; } }
单例模式中类的构造函数必须是私有的,就是为防止其他类不能实例化此类(说白了就是在别的类中不能去new这个类的对象出来,就不允许别人创建,你只能通过我提供的公共方法来获取实例),饿汉模式在类加载的时间就会创建这个实例,实例在整个程序周期都存在。它的好处是不存在线程安全的问题,但是缺点也很明显,即使这个实例没有被用到,也会被创建出来,这样内存就被浪费了。(饿汉模式是以空间换时间,加载时占用空间来换取使用时的快捷)。
这种方式适合单例占用内存比较小,并且在初始化的时候就会被使用到的情况。如果单例占用内存比较大,或者只在某个特定场景下才能使用,那么这个时候就要使用懒汉模式进行延迟加载了。
2、懒汉模式
public class Singleton{ private static Singleton instance = null; private Singleton(){} public static Singleton newInstance(){ if(null == instance){ instance = new Singleton(); } return instance; } }
懒汉模式是你在需要这个实例的时候才会去创建,之后再次使用这个实例的时候,就不会去重复创建了。它的好处就在于节省不必要的内存开销(以时间换空间,在第一次使用时速度会慢一些),它的缺点就在于会存在线程安全的问题。在多线程并发的情况下,会导致重复创建这个实例。可以在获取实例的方法上加锁解决线程同步的问题(synchronized关键字)。
public class Singleton { private static Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
可以看到在上面的同步代码块外面多了层instance为空的判断。由于单例对象是需要创建一次,如果后面再调用getInstance()方法时只需要返回单例对象就可以,
大部分情况下都不会执行到同步代码块,提高了程序的性能。但是在多线程并发的情况下,A,B两个线程同时执行了第一个instance不为空语句,两个线程都会认为当
前这个单例对象没有被创建,然后依次执行同步代码块,并分别创建了一个单例对象,为了解决这个问题,再同步代码块中又加了一个if (instance == null) 实例为空的判断。
双重校验锁模式实现了延时加载,线程安全,挺高了效率,是否真的就万无一失呢?
这里要提到java的指令重排优化,指令重排优化是指在不改变原语义的情况下,通过调整指令的执行顺序,从而提高程序的运行效率。JVM中并没有规定编译器优化相关的内容,也就是说JVM可以自由的进行指令重排序的优化。
这个问题的关键就在于指令重排优化的存在,会导致初始化单例类和将对象地址赋值给instance时的顺序是不正确的。在某个对象创建单例对象时,在构造方法被调用前,就为该对象分配了内存
空间,并将对象的字段设置为默认值,在分配内存的同时就可以将地址赋值给instance变量,然而实际上该对象可能没有被初始化。若紧接着另外一个线程来调用getInstanc()方法,就会获得不正确的
对象,程序就会出错。
以上就是双重校验锁会失效的原因,还好在jdk1.5及以后增加了volatile关键字,volatile的一个语义就是禁止指令重排序优化,也就保证了instance变量被赋值时,对象已经被初始化。从而避免上面所说的问题。代码如下:
public class Singleton { private static volatile Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
4、静态内部类
public class Singleton{ private static class SingletonHolder{ public static Singleton instance = new Singleton(); } private Singleton(){} public static Singleton newInstance(){ return SingletonHolder.instance; } }
这种方式同样利用了类加载机制来保证只创建了一个instance实例,它与饿汉模式一样也是利用了类加载机制,因此不存在多线程并发的安全问题。不一样的是它在内部类中去创建对象实例,
这样的话只要在应用中不使用内部类,JVM就不会去加载这个单例类,也就不会去创建单例类的对象,从而实现了懒汉模式的延时加载。也就是说这种方式可以同时保证延时加载和线程安全。
5、枚举
public enum Singleton{ instance; public void whateverMethod(){} }
上面提到的四种实现单例的方法都有共同的缺点:
1、需要额外的工作来实现序列化,否则每次反序列化一个序列化的对象时都会创建一个新的实例。
2、可以使用反射强行调用私有构造器。(如果要避免这种情况的发生,可以修改构造器,让他在创建第二个实例的时候抛出异常)
而枚举很好的解决了这两个问题,使用枚举除了线程安全和防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候去创建新的对象。
总结:
以上说到的是5种实现单例模式的方式,1,2两种并不是特别的完美,建议工作中使用3,4,5这三种。