单例模式的四种写法

介绍单例模式的饿汉式、懒汉式、内部静态类、枚举类四种写法

Posted by GrayWind on October 15, 2018

为什么要使用单例

众所周知,类实例化后对象存在在Java堆中。而面向对象有三大特性,分别是:封装、继承、多态。封装要求把客观事物封装成抽象的类,在一个设计良好的程序中,一些工具性质的功能应该被抽象到一个单独的工具类中,便于在程序中可以复用。但是,类的使用需要先经过实例化也就是new一个对象,如果一个类在多个不同的类中被使用,每处都实例化一个对象,会增加实例化和对象回收的开销。当然,Java中可以使用static静态方法来避免初始化,static修饰的静态方法会在类定义时被分配和装载入内存,导致存在几个问题:

  1. 因为初始化时期不同,静态不能引用非静态,包括方法和变量,并且静态方法不能被子类重写,导致灵活性上存在一些限制
  2. 过多的静态方法会加重类加载的负担,可能导致虚拟机启动慢
  3. 静态类不宜用于维护状态信息,尤其是在多线程的环境下

如果只初始化一个对象一次,令它保留在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()并非是一个原子性操作,这里面大致上有三个动作:

  1. 按照Singleton的类描述分配内存空间
  2. 在分配的内存空间上使用构造函数来初始化成员变量,创建实例
  3. 修改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;
	}
}

关于类加载的时间,有且仅有:

  1. JVM启动时加载main方法所在的主类
  2. new一个未被加载类对象,读取或设置一个类的静态字段,调用一个类的静态方法
  3. 使用反射创建未被加载类对象实例
  4. 子类被加载时,如果父类还没被加载会加载父类
  5. 使用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》推荐使用的静态内部类和枚举类两种写法,这样单例模式一共就是四种写法了(懒汉式真正线程安全且高效的是最后一种双检查版本)。很巧的是,鲁迅说过:”茴字有四种写法“。这

head

参考内容

  1. Hi,我们再来聊一聊Java的单例吧
  2. 《Effective Java》