为什么要使用单例
众所周知,类实例化后对象存在在Java堆中。而面向对象有三大特性,分别是:封装、继承、多态。封装要求把客观事物封装成抽象的类,在一个设计良好的程序中,一些工具性质的功能应该被抽象到一个单独的工具类中,便于在程序中可以复用。但是,类的使用需要先经过实例化也就是new一个对象,如果一个类在多个不同的类中被使用,每处都实例化一个对象,会增加实例化和对象回收的开销。当然,Java中可以使用static静态方法来避免初始化,static修饰的静态方法会在类定义时被分配和装载入内存,导致存在几个问题:
- 因为初始化时期不同,静态不能引用非静态,包括方法和变量,并且静态方法不能被子类重写,导致灵活性上存在一些限制
- 过多的静态方法会加重类加载的负担,可能导致虚拟机启动慢
- 静态类不宜用于维护状态信息,尤其是在多线程的环境下
如果只初始化一个对象一次,令它保留在Java堆中,供其他代码复用,这样就节省了开销并且避免了静态方法的限制,这就是单例模式。
单例(Singleton)对象必须保证只有一个实例存在。根据单例初始化的时间可以分为两类实现方式:
- 懒汉式:单例对象的实例在第一次被使用时构建
- 饿汉式:单例对象的实例在类装载时构建
懒汉式单例
懒汉式单例就是单例模式有别于静态类的一个特点,可以在第一次使用时初始化而不是和类加载绑定。
单线程版本
最简单的单例版本如下,检查单例是否已经被初始化,如果没有则实例化,如果有则直接返回实例。为了禁止外部类调用构造方法,把默认构造类设定为private。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}
在单线程环境下,这样的写法是肯定正确的,但是在多线程就不是了。可能同时有多个线程判断if(instance == null)成果,并且都初始化了一个单例,这就违反了单例模式。
synchronized版本
那么,为了保证线程安全,我们可以给它加上同步锁,这样就可以保证只会生成一个实例。但是,这样写法的问题是并发性能不佳,对于非第一次调用的情况我们只需要直接返回实例就行了,并不需要保证同步。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}
双重检查版本
上面说了,唯一需要考虑线程安全的地方是,在还没有完成初始化时的并发调用,只需要由一个线程来完成创建实例,其他线程只需要等待并返回就行了。所以采用两次判断的方式,如果第一次检查发现还没有初始化,则尝试竞争锁,竞争锁后再次检查有没有初始化,若还没有则新建一个实例对象。所以,实例由第一个竞争到锁的线程来负责创建。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
注意到给实例添加了volatile修饰,原因在于instance = new Singleton()并非是一个原子性操作,这里面大致上有三个动作:
- 按照Singleton的类描述分配内存空间
- 在分配的内存空间上使用构造函数来初始化成员变量,创建实例
- 修改instance指向分配的内存空间
JVM可以保证1一定是最先执行的,因为2和3都需要先获知分配的内存空间地址,但是2和3的顺序是不确定的,所以有可能存在先3后2的情况,然后恰好又有另一个线程直接返回了instance,调用instance方法的程序会抛出NLP异常,并且这种情况发生的概率很低,很难重现。
饿汉式单例
由于类装载的过程是由类加载器(ClassLoader)来执行的,这个过程也是由JVM来保证同步的,所以这种方式先天就有一个优势——能够免疫许多由多线程引起的问题。但是,这会引起类加载负担增加,使得启动变慢。此外,如果初始化依赖一些其他的运行时获取的数据,那么饿汉式可能不能使用。
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
关于类加载的时间,有且仅有:
- JVM启动时加载main方法所在的主类
- new一个未被加载类对象,读取或设置一个类的静态字段,调用一个类的静态方法
- 使用反射创建未被加载类对象实例
- 子类被加载时,如果父类还没被加载会加载父类
- 使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
静态内部类
《Effective Java》第一版推荐了以下写法:
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
可以看到getInstance这个方法是调用了一个内部类SingletonHolder,并且这个内部类只在这个方法中被调用,所以SingletonHolder的类加载时机是第一次调用getInstance,这是一个懒汉式实现。对于SingletonHolder来说,它的类加载会由类加载器保证同步,而INSTANCE的初始化与类加载绑定,所以从内部类来看这是一个饿汉式实现。
枚举
《Effective Java》第二版新增了下面这种方法:
public enum Singleton {
INSTANCE;
private Singleton() {}
public void fun1() {
//do something
}
}
乍一看,wocao这是啥连Class都没了。看一下下面这段测试方法,直接可以调用里面的方法,初始化在第一次调用时被初始化。创建枚举实例的过程是线程安全。
public enum Singleton {
INSTANCE;
private Singleton() {
System.out.println("初始化实例");
}
public void fun1() {
System.out.println("调用了方法1");
}
public static void main(String args[]) {
Singleton.INSTANCE.fun1();
Singleton.INSTANCE.fun1();
}
//初始化实例
//调用了方法1
//调用了方法1
}
作者评价:这种写法在功能上与共有域方法相近,但是它更简洁,无偿地提供了序列化机制,绝对防止对此实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。
但是,枚举类不能被继承。
总结
除了常见的饿汉式和懒汉式,加上《Effective Java》推荐使用的静态内部类和枚举类两种写法,这样单例模式一共就是四种写法了(懒汉式真正线程安全且高效的是最后一种双检查版本)。很巧的是,鲁迅说过:”茴字有四种写法“。这
参考内容
- Hi,我们再来聊一聊Java的单例吧
- 《Effective Java》