Life with teacher Lemon.

详解单例模式

2020.04.15

单例模式

什么是单例,顾名思义,只允许有一个实例。使用场景常见的有数据库连接池、多线程的线程池等。总之 JVM 中只允许存在一个对象实例的都可以选择单例模式。那单例模式的实现方式都有哪些呢?请看详解。

饿汉式

饿汉式,就是一上来就创建,实现方式如下:

public class Singleton {

    private static Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
    
}

饿汉式是基于类的加载机制,避免了多线程下的同步问题,缺点是加载慢,优点是获取对象的速度快。

懒汉式

懒汉式,就是在使用的时候才去创建,有延迟加载的意思,具体实现方式如下:

public class Singleton {

    private static Singleton INSTANCE;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
    
}

懒汉式存在的问题就是多线程场景下不能保证实例唯一,解决方式自然而然的就想到加同步锁 synchronized,如下:

public class Singleton {

    private static Singleton INSTANCE;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
    
}

但是有的小伙伴儿就会想到:如果实例已经创建过了,那么每次调用 getInstance 还需要进行同步判断,会存在性能消耗的问题,怎么解决呢?聪明的大神想到了使用 DCL 机制解决这个问题。

双重加锁式(DCL)

public class Singleton {

    private volatile static Singleton INSTANCE;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (INSTANCE == null) {
        	synchronized(Singleton.class) {
        		INSTANCE = new Singleton();
        	}
        }
        return INSTANCE;
    }
    
}

可以看到此处做了两点改动,一是多了双重判断,一是多了关键字 volatile。

双重判断是为了解决多次调用是如果实例已经存在,不用再去进行同步的问题,一部分程度上降低了性能开销。

而 volatile 是为了解决 JVM 内部指令重排的问题,简单来讲就是当 A 线程在同步代码中进行实例创建时,做了赋值操作,假如构造函数操作过多,实例还没有被创建出来的时候,B 线程进入,会因为赋初值的原因,误判实例已经创建,使用的时候就会报错。

这也就是说 synchronized 保证了原子性,但是 CPU 会对 new 做优化,会打乱调用构造函数 和 返回地址引用的顺序,导致存在,A 先返回了地址引用,但 B 误判已经创建成功的情况,因此使用 volatile 保证了使用 new 指令的时候不会发生排重排。

那么小伙伴又有问题了,懒汉式虽然实现了延迟加载,但是保证没有多线程同步问题的写法太繁琐了,我们来介绍一种优雅的写法。

静态内部类式

我们先来看一下实现。

public class Singleton {

    private Singleton() {
    }
    
    private static class SingletonHolder {
    	private static Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
}

静态内部类式,只有在类第一次调用 getInstance 方法时,JVM 才会加载静态内部类 SingletonHolder,并初始化 INSTANCE,实现了延迟加载,同时也能保证没有多线程同步的问题。

那么还有没有更简洁的方式呢?下面我们就来看一下枚举式。

枚举式

先上代码

public enum Singleton {
	INSTANCE;
	public void doBiz() {
		
	}
}

这是一种多线程安全的单例模式,并且,在任何情况下都是单例。

这里小伙伴就会产生疑问了,为什么说任何情况下呢?阅读过 Enum 源码的同学就会看到有两个阻止序列化的方法。

反序列化攻击

除了枚举式之外,如果你的单例实现了序列化接口的时候,就可以通过反序列化读取磁盘上的序列化内容,创建实例,此时就会在 JVM 上存在多个实例,如果要避免这种问题,就需要重写 readResolve() 方法,直接返回实例或者抛异常。

private Object readResolve() throws ObjectStreamException {  
    return INSTANCE;
}

那么除了反序列化攻击之外,还有没有其他攻击方式呢?

反射攻击

我们大家都知道反射机制可以调用类的私有方法,反射攻击单例就是利用了该特性。因此我们可以在构造方法中判断单例是否存在,如果存在就抛异常的方式来避免反射攻击的发生。

下面是防止反射攻击的静态内部类式单例。

public class Singleton {

    private Singleton() {
    	if (null != SingletonHolder.INSTANCE) {
    		throw new RuntimeException();
    	}
    }
    
    private static class SingletonHolder {
    	private static Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
}

同理,除了枚举式之外的其他模式都可以被反射和序列化攻击,改造方式相同,不再赘述。