1995年,GoF(Gang of Four)四人合作出版了《设计模式:可复用面向对象软件的基础》一书,里面共收录了23种设计模式,从此树立了软件设计模式领域的里程碑,人称《GoF设计模式》。《GoF设计模式》并不是一种具体技术,而是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性,以及对类的关联关系和组合关系运用的最佳实践。
今天我们选择设计模式中相对简单的单例模式来进行讲解,具体包含下面四个章节的内容:
一.单例模式的简介;
二.单例模式在JAVA实现中常见的5种写法;
三.单例模式评价指标:线程安全,懒加载,单例模式破坏与反破坏;
四.单例模式和工具类比较。
一.单例模式简介
单例模式(Singleton Pattern)是23种设计模式中最简单的设计模式之一,它属于创建型模式,用于返回该对象的实例。单例模式只跟本身的一个类有关系,该类负责创建自己的对象,同时确保只有唯一的一个对象被创建。
在Java中,实现单例模式都有两个必不可少的步骤:
1.将类的所有构造函数的可见性设置为private,避免该类的实例被外部代码创建;
2.提供一个公有的静态方法,一般方法名为getInstance,来返回该对象的唯一的对象实例。
下面具体看一下单例模式在JAVA实现中的5中常见写法。
二.单例模式的5种写法
1.饿汉
public class HungrySingleton {
private static final HungrySingleton instance = new HungrySingleton();
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return instance;
}
}
饿汉模式的缺点是没有使用懒加载,可能会导致内存的浪费。
2.懒汉
public class LazySingleTon {
private static LazySingleTon instance;
private LazySingleTon() {
}
public static synchronized LazySingleTon getInstance() {
if (instance == null) {
instance = new LazySingleTon();
}
return instance;
}
}
懒汉模式的缺点是每次获取单例对象,都需要synchronized加锁调用,性能会下降。
3.DCL(double check lock)
public class DCLSingleton {
private static volatile DCLSingleton instance;
private DCLSingleton() {
}
public static DCLSingleton getInstance() {
if (instance == null) {
synchronized (DCLSingleton.class) {
if (instance == null) {
instance = new DCLSingleton();
}
}
}
return instance;
}
}
DCL双重检查,需要注意的是instance字段需要加上volatile修饰。因为instance = new DCLSingleton()并不是一个原子操作,从字节码上来看,一共涉及4个操作(new,dup,invokespecial,putstatic),其中invokespecial是调用构造函数进行初始化,putstatic是讲new出来的引用值赋值给instance,这两个操作没有相互依赖关系。如果不加volatile修饰,在多线程并发访问的时候,可能导致重排序,先执行putstatic,后执行invokespecial,在这两个操作之间,如果另外一个线程执行getInstance,此时instance == null是不成立的,直接返回了instance,这样就获取到一个没有初始化的单例对象,后面可能会导致意外的结果。
4.内部类
public class InnerClassSingleton {
private InnerClassSingleton() {
}
public static InnerClassSingleton getInstance() {
return InnerClassSingletonHold.INSTANCE;
}
private static class InnerClassSingletonHold {
private final static InnerClassSingleton INSTANCE = new InnerClassSingleton();
}
}
内部类是个人比较喜欢的单例实现方式,使用懒加载,没有使用synchronized方法,并且代码相对简洁。
5.枚举
public enum EnumSingleTon {
INSTANCE;
public static EnumSingleTon getInstance() {
return INSTANCE;
}
}
枚举单例,是《Effective Java》中推荐的单例模式,唯一就是没有使用懒加载。
三.单例模式评价指标
1.线程安全
单例的线程安全主要通过两种方式来实现:
A.用类初始化过程的线程安全来保证单例的线程安全
JVM虚拟机规范中的Initialization这一章节详细描述了类初始化的过程,以及线程安全的讲解,有兴趣的同学,可以自己研究一下文档,这里通过代码进行验证,代码如下:
public class ClassInitializationTest {
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread A start");
System.out.println(SingletonImpl.INSTANCE);
}
});
threadA.start();
Thread.sleep(100);
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread B start");
System.out.println(SingletonImpl.INSTANCE);
}
});
threadB.start();
}
private static class SingletonImpl {
private static final SingletonImpl INSTANCE = new SingletonImpl();
private SingletonImpl() {
System.out.println("constructor start.");
try {
Thread.sleep(1000000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("constructor end.");
}
}
}
线程A先启动,打印thread A start,然后获取SingletonImpl.INSTANCE的时候,会对SingletonImpl进行类初始化,类初始化调用INSTANCE = new SingletonImpl(),然后执行SingletonImpl的构造函数,打印constructor start,然后线程A休眠。
线程B后启动,打印thread B start,在获取SingletonImpl.INSTANCE的时候,需要SingletonImpl类初始化完成,但是此时线程A已经在进行初始化了,这时候线程B只能等待。
线程A休眠了1000000毫秒后唤醒,打印constructor end。最后类初始化完成,唤醒threadB。
线程A和现场B分别打印SingletonImpl.INSTANCE。
饿汉和内部类就是使用类初始化过程的线程安全,来保证单例的线程安全。
B.使用同步锁来实现线程安全
懒汉使用synchronized同步方法来保证线程安全,而双重检查(Double Check Lock)则使用synchronized同步块加volatile来实现线程安全。
2.懒加载
懒加载和延时加载是一个意思,就是在对象必须用到的时候再去初始化,这么做的目的是为了避免已经初始化完成的对象,如果后来一次也没有被使用,白白浪费的CPU时间和内存。JDK中HashMap对table的初始化就是一种懒加载策略,构造函数执行完成后table字段还是null,只有往HashMap中put元素的时候,才对table字段进行初始化。
与懒加载相反的策略是预加载,预加载是指在服务的启动的时候,就把后面可能会用到的数据都提前都初始化了,初始化完成以后,以最好的状态对外提供服务。预加载的目的是为了解决初次访问时,由于数据初始化的额外耗时,而导致用户访问时间过长,甚至超时导致的服务不可用。
一般情况下,对内存敏感的系统,我们采用懒加载策略,用时间换空间;而对时间敏感的系统,我们采用预加载策略,用空间换时间。
3.单例模式破坏
A.通过反射调用private构造函数创建对象破坏单例
虽然单例模式中将类的构造函数设置private,但是我们还是可以通过反射的方法,去调用private的构造函数,创建出类的新对象出来,从而破坏单例唯一实例的特性,具体代码如下:
public static void main(String[] args) throws Exception {
Class clazz = HungrySingleton.class;
Constructor constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
HungrySingleton newSingleton = (HungrySingleton) constructor.newInstance();
HungrySingleton realSingleTon = HungrySingleton.getInstance();
System.out.println(newSingleton == realSingleTon);
}
最后打印false,说明newSingleton和realSingleTon是两个不同的对象。
B.不调用构造函数创建对象破坏单例
一般情况下,创建一个对象,都会调用类的构造函数进行初始化。但是有些情况,可以不调用类构造函数,就可以创建出一个对象,代码如下:
public static void main(String[] args) throws Exception {
HungrySingleton singleton = HungrySingleton.getInstance();
Constructor superConstructor = singleton.getClass().getSuperclass().getConstructor();
ReflectionFactory reflectFactory = ReflectionFactory.getReflectionFactory();
Constructor thisConstructor = reflectFactory.newConstructorForSerialization(singleton.getClass(), superConstructor);
HungrySingleton newInstance = (HungrySingleton) thisConstructor.newInstance();
System.out.println(newInstance == singleton);
}
最后打印false,说明newSingleton和realSingleTon是两个不同的对象。需要注意的是thisConstructor构建函数,并不是HungrySingleton的private构造函数。这块代码原理的解析,到时候单独写一篇文章进行讲解,本文还是主要讲单例模式。
C.通过Serializable进行单例破坏
我们先把单例对象进行序列化,然后进行反序列化,就会得到一个新的对象,前提的序列化的类实现了Serializable接口,代码如下:
public static void main(String[] args) throws Exception {
HungrySingleton singleton = HungrySingleton.getInstance();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(outputStream);
oos.writeObject(singleton);
oos.flush();
ObjectInput ois = new ObjectInputStream(new ByteArrayInputStream(outputStream.toByteArray()));
HungrySingleton deserializeSingleton = (HungrySingleton) ois.readObject();
ois.close();
System.out.println(deserializeSingleton == singleton);
}
最后打印false,说明newSingleton和realSingleTon是两个不同的对象,ObjectInputStream在readObject的时候,会使用上面的reflectFactory.newConstructorForSerialization,然后newInstance创建对象,同样不调用HungrySingleton的private构造函数。
D.通过Clone进行单例破坏
如果使用Clone进行单例破坏,单例类就必须实现Cloneable接口。既然把类设计成单例模式,再去实现Cloneable接口,就显得非常矛盾。单例模式要求只有一个实例,Clone的目的则是复制多个对象实例。单例类的设计者,不会给你这样的破坏机会。Clone破坏的实质是,在Clone对象的时候,直接进行的内存操作,并不调用构造函数,private可见效的构造函数就拦不住。
4.单例模式反破坏
A.在构造函数里面对instance进行非空验证,避免被反射调用到。对于饿汉单例模式,如果instance != null,直接抛出异常,组织对象的创建,代码如下:
public class HungrySingleton {
private static final HungrySingleton instance = new HungrySingleton();
private HungrySingleton() {
if (instance != null) {
throw new RuntimeException("this is singleton class,you cannot call private constructor method");
}
}
public static HungrySingleton getInstance() {
return instance;
}
}
B.在构造函数中对调用栈进行检查,验证调用构造函数的方法符合预期,如果不符合,就直接抛出异常。对于上面的饿汉单例,预期可以调用构造函数的方法是类的静态构造方法,验证代码如下:
public class HungrySingleton {
private static final HungrySingleton instance = new HungrySingleton();
private HungrySingleton() {
StackTraceElement traceElement = Thread.currentThread().getStackTrace()[2];
if (!(HungrySingleton.class.getName().equals(traceElement.getClassName()) && "<clinit>".equalsIgnoreCase(traceElement.getMethodName()))) {
throw new RuntimeException("this singleton class,you cannot call");
}
}
public static HungrySingleton getInstance() {
return instance;
}
}
说明一下,调用栈第0层是getStackTrace方法,第1层是构造函数<init>方法,第2层就是调用构造函数的方法。所以取Thread.currentThread().getStackTrace()[2],然后判断HungrySingleton.class.getName().equals(traceElement.getClassName()) && "<clinit>".equalsIgnoreCase(traceElement.getMethodName())是否为true,不为真就说明构造函数被意外调用,这时抛出异常,阻止实例创建。
四.单例模式和工具类比较
单例模式和工具类最大的区别就是看是否有状态,具体而言,单例模式是有状态的,而工具类是无状态的。
举例说明:
平时最常见的单例模式应用场景:任务管理器,文件管理器,配置管理器这类应用,管理的任务,文件和配置都是有状态的,选择单例更加合适。
平时最常见的工具类Math类,则它是无状态的,提供了一系列的工具方法,如sin,cos,abs等。
最后我们通过一个表格来总结一下本文主要内容:
线程安全 | 懒加载 | 单例破坏 | |
懒汉 | 是 | 否 | 是 |
饿汉 | 是 | 是 | 是 |
DCL | 是 | 是 | 是 |
内部类 | 是 | 是 | 是 |
枚举 | 是 | 否 | 否 |
我们在选择单例的实现方式时,如果不需要懒加载,最好选择枚举实现;如果要求必须懒加载,则选择内部类,但是面临单例被破坏的风险。
更多设计模式的讲解,尽在VIP课程《坦克大战》。