单例模式
单例模式定义
保证一个类仅有一个实例,并提供一个访问它的全局接口。
单例模式的类图
单例模式示例
饿汉模式
public class Singleton1 {
private final static Singleton1 singleton = new Singleton1();
private Singleton1() {
}
public static Singleton1 getSingleton() {
return singleton;
}
}
饿汉模式是比较简单的一种单例实现,利用类静态属性的特征,在类加载时就实例化单例对象。
优点:利用类静态属性的特征,解决了后继单例对象初始化的逻辑判断,及多线程情况下资源征用的问题。
缺点:类加载的时就会创建对象,如果短时间内单例对象不被使用,单例对象就会一直占用内存。如果单例对象保存的信息特别多的话,过早加载到内存是对内存的浪费。
懒汉模式
public class Singleton2 {
private static Singleton2 singleton = null;
private Singleton2() {
}
public static synchronized Singleton2 getSingleton() {
if(null == singleton){
singleton = new Singleton2();
}
return singleton;
}
}
懒汉模式的单例模式是在首次调用
的时候才会创建单例对象, 以后多次调用不会再次创建,内存利用率较高。
优点: 需要的时候才创建单例对象,内存利用率高。
缺点: 为了解决多线程环境下有可能会创建多个实例的问题,必须在接口处声名类同步
,这无疑增加了系统调用开销,调用增加了synchronized
关键字的方法,要比没有synchronized
关键字的方法开销大很多。
双重校验 【错误】
public class Singleton3 {
private static Singleton3 singleton = null;
private Singleton3() {
}
public static Singleton3 getSingleton() {
// 首次 Check
if (null == singleton) {
// 加锁
synchronized (Singleton3.class) {
// 二次 check
if (null == singleton) {
singleton = new Singleton3();
}
}
}
return singleton;
}
}
单例对象只被创建一次,当单例对象为空时,在首次check
处会有多线程并发问题, 因此会有多个线程到达加锁
处,在加锁
处只会有一个线程成功获得同步锁
,其他线程只能在加锁
处挂起等待,由于是首次创建对象,首个获取锁的线程成功创建对象返回并释放同步锁
。其他在同步锁
外等待的线程会陆续成功获取同步锁,由于singleton
已经不为null
,这些后续线程进入同步块之后,singleton == null
的条件已经不成立,就释放了同步锁。
双重检查的方式即实现了单例对象的延迟创建
,也没有方法同步synchronized
的开销,看起来很完美。但是在 Java1.4之前,这样写是不对,因为 JVM 为了提高程序的执行效率,会在内部对 JVM 指令重排优化。
所谓指令重排优化是 JVM 在不改变程序结果的情况下,通过调整指令的顺序,让程序执行的更快。而这个问题的关键就点是: 指令重排使得初始化 Singleton
对象,将Singleton对象地址赋给 Singleton 字段
两者的顺序是不确定的,即singleton = new Singleton()
语句的执行并不是原子性的。
在 Java 中创建一个对象分为3个步骤:
- 分配对象的内存空间。
- 调用构造器方法,执行初始化
- 设置
singleton
指向刚分配的地址
在 JVM 中会对上面的执行顺序做优化,指令的执行顺序可能不是1,2,3,有可能是1,3,2。再试想一个场景,有A, B 两个线程,A 先调用getSingleton()
函数,A 成功创建了对象,执行了1,3步,B 线程在 A 调用之后的很短时间内,也调用getSingleton()
函数,进行singleton == null
的判断,发现 singleton
不为空,就直接使用singleton
对象,但由于第2步还未执行,即对象还没有初始化,所以 B 线程进行调用时,就会引发异常。
上面就是双重检查锁在 Java1.4及之前的问题。
上面的问题在Java1.5及之后使用volatile
关键字修饰singleton
字段即可解决。
volatile 关键字有两个作用:
- 通过volatile修饰的变量,保证对所有线程的可见性。
- volatile 禁止指令重排优化,所以其他线程不会访问一个没有初始化完成的对象。
正确的代码:
public class Singleton3 {
private static volatile Singleton3 singleton = null;
private Singleton3() {
}
public static Singleton3 getSingleton() {
// 首次 Check
if (null == singleton) {
// 加锁
synchronized (Singleton3.class) {
// 二次 check
if (null == singleton) {
singleton = new Singleton3();
}
}
}
return singleton;
}
}
静态内部类的方式
public class Singleton4 {
private static final class Singleton4Holder{
private static final Singleton4 singleton4 = new Singleton4();
}
private static Singleton4 singleton = null;
private Singleton4() {
}
public static Singleton4 getSingleton() {
return Singleton4Holder.singleton4;
}
}
静态内部类的实现方式也是利用了Java 类的加载机制来保证只创建一个对象。这种方式看起来和饿汉模式
有点类似,但还是有不一样的地方。Java 中类是延迟加载的,只有在首次被使用时才会加载。而静态部类模式正是使用了这个特性。当有在线程访问getSingle()
方法时,JVM会判断Singleton4Holder
类是否已经被加载,没有加载就执行类加载所有操作,并初始化类中的静态资源。静态内部类的方式巧妙的实现了懒汉模式
的延迟加载,又实现了饿汉模式
的资源征用的开销。
通过枚举方式
public class Singleton5 {
private Singleton5() {
}
private enum Singleton5Enum {
SINGLETON_5_ENUM;
private Singleton5 singleton;
Singleton5Enum() {
singleton = new Singleton5();
}
public Singleton5 getSingletong5() {
return singleton;
}
}
public static Singleton5 getSingleton() {
return Singleton5Enum.SINGLETON_5_ENUM.singleton;
}
}
Java 的枚举类型是功能齐全的类,可以有自己的属性和方法。
Java 的枚举类型的每个实例都是 public static final 类型的,类的每个实例只会被实例化一次。
相比于上4种单例模式,枚举模式最大的优势主要有2点:
- 序例化机制:JVM 层为枚举类的序例化提供了根本的保障,避免了被实例化多次。
- 强行调用私有构造函数: 利用 Java 强大的反射机制,可以设置构造函数属性
cons.setAccessible(true)
来强行调用构造函数构造对象。枚举类则没有这个问题。80%的Java程序员不知道反射强行调用私有构造器这事儿
总得来说枚举类很好的解决了两个问题,使用枚举除了线程安全,防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。