设计模式:单例模式

参考博客地址:https://mp.weixin.qq.com/s/IzjmCZkGS5vPKKoY6X9j5Q

单例模式的定义是确保某个类在任何情况下都只有一个实例,并且需要提供一个全局的访问点供调用者访问该实例的一种模式。要确保任何情况下都只有一个实例,则我们需要把创建对象的权限“收回来”或者进行限制,在Java中创建对象最常见的方法就是通过构造方法创建了,因此要做到限制创建对象的权限,就必须将构造方法私有化。此外又要提供一个全局的访问点,则可以通过public static方法或变量暴露该单例,供外部系统进行访问。所以单例模式必不可少的2点就是:
1.构造方法私有化
2.提供public static变量或方法暴露单例对象
大家很容易想到的就是既然构造方法私有化,那么也就是说构造方法只有在类内部才能访问到,那我们直接在类内部调用构造方法new一个对象出来再通过public static方法暴露出去不就可以了。没错,这也是我们接下来要讲的单例模式的第一种也是最简单的一种实现方式:饿汉模式。
二 饿汉式:
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getSingleton(){
return singleton;
}
}
所谓“饿汉模式”,顾名思义就是饥饿难耐,迫不及待地创建出实例对象。“饿汉模式”的缺点是不能实现懒加载,即当该类被JVM加载的时候,该单例对象就被创建出来了。如果我们要实现懒加载该怎么办呢?有的同学可能很快就会想到那就当第一次调用getSingleton()方法时再创建出来不就得了。这也是接下来将要介绍的“懒汉模式”。
三 懒汉式
public class Singleton {
private static Singleton singleton;

private Singleton() {
}

public static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
“懒汉模式”的特点就是在需要的时候才创建对象,这样可以做到起到延迟加载的效果。但是上述“懒汉模式”有一个致命的缺点是线程不安全,也就是说在多线程的情况下可能会创建不止一个对象。大家可以设想这样一种情形,假设线程A执行到

if(singleton==null)
这行代码时,singleton变量还未实例化,这时候

singleton==null
返回true,并且线程A执行到这一步的时候让出了CPU给线程B,线程B完整地执行了上述方法再让出CPU,因为线程B执行的时候singleton变量还未实例化,所以线程B会创建一个singleton对象。线程B让出CPU后线程A继续往下执行,这个时候线程A执行的下一句代码是

singleton=new Singleton();
这时候线程A也会创建一个singleton对象。在这种情况下一共创建了2个对象,因此说上述饿汉模式是线程不安全的。如果要实现线程安全的单例模式,有些同学很快就想到了synchronized关键字。接下来就我们就使用synchronized关键字实现线程安全的“懒汉模式”。

四、线程安全的“懒汉模式”
public class Singleton {
private static Singleton singleton;

private Singleton() {
}

public synchronized static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
线程安全的“懒汉模式”非常简单,就是在getSingleton()方法上加了个synchronized关键字。这样一来就能实现线程安全的“懒汉模式”了。但是这种方法还有一个缺点就是当并发量大时性能不高,因为锁是加在方法上的,意味着所有线程都要排队获取singleton对象,因此性能不高。那么有没有其他办法可以提高性能呢?当然是有的。我们再次分析上述代码,可以发现,其实只有当创建对象的时候才需要加锁,也就是这行代码

singleton=new Singleton();
需要加锁,其他代码是可以不加锁的,如果我们在创建对象的时候再加锁,而不是在整个方法上加锁,那么性能自然就提高了。接下来将介绍基于“双重校验”的“懒汉模式”。

五、基于“双重校验”的“懒汉模式”
public class Singleton {
private static Singleton singleton;

private Singleton() {
}

public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在“双重校验”的实现方式中,只有在创建singleton对象的时候需要加锁,后续线程再调用该方法时,因为singleton对象不为null,所以该方法直接返回singleton对象,不需要进入同步块,故而这种实现方案在高并发情况下性能比较高。有同学可能会有疑问,为什么需要做双重检验呢?明明在同步块外面已经对singleton对象是否为空做了判断,为何在同步块内部还需要再判断一次呢?之所以这样做,是为了防止在并发的情况下初始化了多个singleton实例。同样考虑有2个线程的情况,假如线程A第一次调用该方法时执行到以下代码

if(singleton==null)
的时候就让出了CPU,接着线程B也调用该方法,因为此时singleton对象还未实例化,假设线程B完整地执行完该方法,初始化了一次singleton对象,然后线程A继续执行,假如这个时候不对singleton再做一次非空判断而是直接实例化singleton对象的话,则singleton对象会被实例化2次。因此才需要做双重校验以防止singleton对象被实例化多次。

然而,上述“双重检验”仍然是有漏洞的。在某些情况下当singleton不等于null时,可能singleton引用的对象还未完成初始化。产生问题的原因是指令重排序。《Java并发编程的艺术》一书提到,上述

singleton=new Singleton();
这行代码可以分解成以下三行伪代码:

memory=allocate(); //1.分配对象的内存空间

constructInstance(memory); //2.初始化对象

singleton= memory; //3.设置singleton指向刚分配的内存地址
上述的三行代码的第2跟第3行代码可能发生重排序。2跟3重排序后,初始化的过程如下:

memory=allocate(); //1.分配对象的内存空间

singleton= memory; //3.设置singleton指向刚分配的内存地址。这时候
//对象还未初始化完成!

constructInstance(memory); //2.初始化对象
上述重排序在单线程情况下不会有什么问题,但是在多线程的情况下就有可能使得某些线程访问到未初始化完成的对象。假设多线程情况下线程的执行时序如下(以下图表摘自《Java并发编程的艺术》一书):

则线程B访问到的对象并没有完成初始化。
既然这个问题是指令重排序导致的,那么解决的方案还是得从指令重排序入手。这里主要介绍2种解决方案:
1.禁止指令重排序
2.允许指令重排序,但是不让其他线程“看到”这个重排序的过程
基于volatile的“双重检验”
基于volatile的“双重检验”的实现方式非常简单,首先上代码:
public class Singleton {
private volatile static Singleton singleton;

private Singleton() {
}

public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
其实就是把singleton变量声明为volatile型。关于volatile变量后续有时间会再进行介绍。在这里大家只要知道volatile变量在某些情况下会禁止指令重排序。因此实例化singleton对象时的三个步骤:

memory=allocate(); //1.分配对象的内存空间

constructInstance(memory); //2.初始化对象

singleton= memory; //3.设置singleton指向刚分配的内存地址
基于类初始化的单例模式
Java虚拟机在进行类的加载过程中,会执行类的初始化。在执行初始化期间,Java虚拟机可以同步多个线程对一个类的初始化,保证类的初始化的线程安全性。因此即使在类的初始化过程中存在指令重排序,由于Java虚拟机进行了同步,因此其他线程“看不到”这个重排序的过程。首先上代码:
public class Singleton {

private Singleton() {
}

public static Singleton getSingleton() {
return SingletonHolder.singleton;
}

private static class SingletonHolder {
private static Singleton singleton = new Singleton();
}
}
在Java中,当某个类的静态字段被使用且该字段不是常量时,将会触发类的初始化,因此当调用getSingleton()方法时将触发SingletonHolder类的初始化,故而能够实现延迟初始化。又因为Java虚拟机规范规定线程在初始化某个类时需要先获取锁,所以可以保证类初始化的线程安全性。

上述单例模式真的是“单例”的吗
写到这里,基于volatile与基于类初始化的单例模式看起来已经十分优雅了,但是上述2种实现方式真的能够保证在任何情况下只创建一个实例对象吗?别忘了,在Java中创建对象的方式可不是只有“new”这一种方式。其实通过反射也能创建对象,以上述基于volatile实现的单例模式为例,我们通过反射创建出另一个对象,首先上代码:
public static void main(String[] args) throws Exception {
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton singleton = constructor.newInstance();
Singleton singleton1 = Singleton.getSingleton();
System.out.println(singleton == singleton1);
}
程序输出 false
上述代码通过反射创建出了一个新的对象,并与原先的单例对象进行比较,发现两者内存地址不相等,因此是两个不同的对象,故而上述的单例模式看似完美,其实还是有漏洞的。

那么是否有办法防止通过反射创建出对象呢?还真有。接下来我们再介绍另外一种创建单例模式的方法。

基于枚举的单例模式
基于枚举实现单例模式非常简单,首先上代码:
public enum Singleton {

INSTANCE;
}
public static void main(String[] args) throws Exception {
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton singleton = constructor.newInstance();
}
程序报错:
Exception in thread "main" java.lang.NoSuchMethodException: org.pangu.springbootdemo.Singleton.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at org.pangu.springbootdemo.TestNIO.main(TestNIO.java:87)
由此可见基于枚举实现单例模式可以防止通过反射创建对象,但其缺点就是不能延迟初始化。

 

posted @ 2018-05-14 11:00  霸王猿  阅读(143)  评论(0编辑  收藏  举报