Java中单例模式是一种应用非常广泛的设计模式,它主要用来保证java的某个类只有一个实例存在, 可以避免实例对象的重复创建,从而节约时间、空间,并且可以避免由于操作多个实例带来的逻辑错误。如果一个对象的使用贯穿整个应用程序,而且起到了全局统一管控的作用,那么单例模式也许是一种不错的选择。
单例模式虽然有多种写法,但大部分写法都有不足, 下面逐一介绍。
1. 饿汉模式
public class Singleton{
private Singleton(){}
private static Singleton singleton = new Singleton();
public static Singleton getInstance() {
return singleton;
}
}
首先看到构造函数必须是private的,保证其他类不能直接实例化此类,而是通过静态方法返回一个静态实例;
饿汉模式是在类加载的时候就对实例进行创建,实例在整个程序周期都存在。它的好处是只有类加载的时候创建一次实例,不会存在多个线程创建多个实例的情况,避免了多线程同步问题。 但是它的问题也很明显,即使这个实例没有用到也会被创建,而且在类加载后就创建到了,内存就被浪费了。
这种实现方式适合单例占用内存比较小, 在初始化时就会被用到的情况。但是, 如果单例占用内存比较大,或单例只是在某个特定场景下用到,使用饿汉模式就不合适了,这时候就需要用到懒汉模式进行延迟加载。
2. 懒汉模式
public class Singleton {
private Singleton(){}
private static Singleton singleton;
publice static getInstance() {
if(singleton == null){
singleton = new Singleton();
}
return singleton;
}
}
懒汉模式中的实例是在用到的时候才去创建,如果单例使用次数少,并创建实例消耗资源较多,那么可以选择懒汉模式;但是懒汉模式没有考虑线程安全问题,在多线程环境下, 可能会创建多个实例,因此需要加锁来解决线程同步问题。如下:
public class Singleton {
private Singleton() {}
private static Singleton singleton;
private static synchronized Singleton getInstance() {
if(singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
加锁后的懒汉模式解决了线程安全问题,但是它存在着性能问题,synchronized修饰的同步方法比一般方法要慢很多,下一个线程想要获取实例必须要等上一个线程释放锁后才可以,如果调用次数较多,就造成了较大的性能损耗。它也不够完美,不推荐用。因此有了双重校验锁的实现方式。
3. 双重校验锁
public class Singleton{
private Singleton() {}
private static Singleton singleton;
public static Singleton getInstance() {
if(singleton == null) {
synchronized(Singleton.class) {
if(singleton == null) { //2
singleton = new Singleton();
}
}
}
return singleton;
}
}
可以看到上面的同步代码块外多了一层判空操作,由于单例对象只需要创建一次,如果后面再次调用getInstance()只需要直接返回实例对象。因此大部分情况下,都不会执行到同步代码块,从而提高了性能。
不过还需要考虑一种情况,假如两个线程A、B,A执行了if(singleton == null)语句, 它认为单例没有创建,此时B也执行到了同样语句, B也认为单例对象没有创建,然后两个线程依次执行同步代码块,并分别创建了一个单例对象。为了解决这个问题,我们还需要在同步代码块中增加一次判空操作,如上面代码块中的//2;
我们看到双重校验锁既实现了延迟加载,又解决了线程并发的问题,同时还解决了执行效率问题,是否就真的万无一失了呢?
这里要提到Java中的指令重排优化,所谓指令重排优化就是指在不改变原语意的情况下, 通过调整指令的执行顺序让程序运行的更快。JVM中没有规定编译器优化相关的内容,也就是说JVM可以自由的进行指令重排优化。
这个问题的关键就在指令重排的存在,会导致初始化Singleton和将对象地址赋给singleton字段的顺序不确定。在某个线程创建单例对象时, 在构造方法被调用之前,就为该对象分配了了内存空间并将对象的字段设置为默认值。此时就可能将分配的内存地址赋值给了singleton字段了,然而该对象还没有初始化。若紧接着另一个线程来调用getInstance(), 通过singleton字段取到的实例就不是正确的Singleton实例。
以上就是双重校验锁会失效的原因,不过还好在JDK1.5及以后版本增加了volatile关键字。volatile的一个语义就是禁止指令重排优化,也就保证了instance变量被赋值的时候对象已经是初始化过的,从而避免了上面说到的问题。增加了volatile关键字的代码如下:
public class Singleton{
private Singleton() {}
private static volatile Singleton singleton;
public static Singleton getInstance() {
if(singleton == null) {
synchronized(Singleton.class) {
if(singleton == null) { //2
singleton = new Singleton();
}
}
}
return singleton;
}
}
以上就是完善的双重校验锁单例模式,也是比较推荐使用的一种。
4.静态内部类
public class Singleton{
private Singleton() {}
private static class SingletonHolder {
public static Singleton singleton = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.singleton;
}
}
这种方式利用类加载机制来保证只创建一个实例,与饿汉模式一样,也是利用类加载机制,因此不存在多线程并发问题。
不一样的是,它是在内部类里面去创建实例,这样的话,只要应用中不使用内部类,JVM就不会去加载这个类,也就不会创建单例对象,从而实现延迟加载。同时它的实现方式也比较简单,推荐使用。
5. 枚举
public enum Singleton {
singleton;
public void whateverMethod() {}
}
上面提到的四种实现单例方式都有共同的缺点:
(1)需要额外的工作来实现序列化,否则每次反序列化一个序列化的对象都会创建一个新的实例。
(2)可以使用反射强制调用私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候报错)。
而枚举类很好地解决了这两个问题,使用枚举除了线程安全和防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。但是实际工作中,很少见有人使用枚举单例。