Java 设计模式之单例模式

设计模式系列

创建型设计模式

Java 设计模式之单例模式

Java 设计模式之静态工厂方法模式

Java 设计模式之工厂方法模式

Java 设计模式之抽象工厂模式

Java 设计模式之Builder模式

Java 设计模式之静态工厂、工厂方法、抽象工厂和 Builder 模式的区别

结构型设计模式

Java 设计模式之代理模式

Java 设计模式之外观模式

“设计模式的重要性不用我多说,如果你想成为优秀的 Android 工程师,设计模式是必须要掌握的。” —— 《 Android 进阶之光 》 刘望舒

写在前面

单例模式,称得上最简单、最常见的设计模式了,当我打算从单例模式开始写几篇设计模式的文章时,我是有些犹豫的,“这么简单的设计模式,人人都会,还有必要写吗?”

后来,我问了自己三个问题:

1、单例模式共有 7 种实现形式,我能顺畅的说出来吗?

2、7 种实现形式的区别,以及为什么有这些区别,我能可浅可深的说出来吗?

3、7 种实现形式,手写代码的话,我能够写出来吗?

答案是否定的,眼高手低要不得啊!

与大家共勉。

什么是单例模式


单例模式是 Gof 提出的 23 种设计模式之一,是 5 种创建型设计模式的一种。

单例模式用来保证一个类只有一个实例,自行实例化此实例,并提供一个访问此实例的全局访问点。

贴一个单例模式的 UML 类图,如图 1 。
单例模式的UML类图

图 1    单例模式的 UML 类图


单例模式的 7 种写法


1、饿汉模式

先贴代码:

程序清单 1     单例模式之饿汉模式

public class Singleton {
    
    private static Singleton instance = new Singleton();

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        return instance;
    }

}

这种写法被称为“饿汉模式”,可我感觉,完全饿不到!

我们都知道,类的加载是由 Java 虚拟机在初始化应用程序时完成的,而类加载时,会立刻执行静态代码。也就是说,只要 Java 虚拟机启动了应用程序,Singleton 类就会被实例化,不管 Singleton 类会不会被用到 —— 不管你吃不吃,饭都在那里。

所以说,我们可以总结出来,饿汉模式的单例模式有以下几个特征:

1、类加载时还要进行实例化,导致类加载速度变慢,但获取对象时速度很快;

2、没有实现懒加载,如果从始至终未使用此类,却默认进行实例化,会造成内存的浪费;

3、由于类的加载是在 Java 虚拟机初始化应用程序时完成的,这时候应用程序实际还未启动,且一个类只会加载一次,避免了多线程的同步问题。

后续的各种单例模式的写法,都是基于饿汉模式发展而来,大家理解了饿汉模式的上述特征的话,后续的各种写法的理解就是水到渠成了。

2、懒汉模式(线程不安全)

先贴代码:

程序清单 2     单例模式之懒汉模式(线程不安全)

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

大家可以很容易的发现,与饿汉模式相比,懒汉模式最大的改进是实现了懒加载。也就是说,此类不用就不加载,用的话在第一次调用时会进行实例化。所以说,可以节省内存资源,但第一次调用时速度会慢一些。

另外,大家还要注意的是,在多线程的情况下,此种写法有可能产生两种问题:

1、创建了多个实例;

2、多线程的安全性问题,可能导致严重的后果,至于问题的原因,留在后文讲“DCL失效问题”时一起详解。

众所周知,移动端的应用程序高并发的情况较少,所以这种写法的单例模式实际上是可以用的,只是确实没有后续的写法优秀。

3、懒汉模式(线程安全)

既然上一种写法的懒汉模式最大的问题是线程不安全,那咱们就来解决下这个问题。


程序清单 3     单例模式之懒汉模式(线程安全)

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static synchronized Singleton newInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

很简单,给 newInstance() 方法加了同步锁 synchronized

然而事实是,这种写法是最不建议的用法。因为无论是不是多线程的环境,每次调用 newInstance() 方法都要进行同步,造成了不必要的同步开销。

4、双重检查模式(DCL)

这种写法就很优秀了,不过也存在一些问题,先看代码吧。


程序清单 4     单例模式之双重检查模式(Double Check Lock)

public class Singleton {

    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

咱们看这段代码 12 ~ 14 行,自然会为了实现懒加载;

而代码 11 行,则是为了解决懒加载的线程不安全的问题;

亮点在代码 10 行,在进行同步前,先判空,如果类已经实例化,则不再进行同步,解决了每次调用 newInstance() 方法都要同步造成的不必要的同步开销问题。

综上,DCL 的写法一下子解决了上述 3 种单例模式写法的所有问题。

那 DCL 的写法存在的问题是什么呢?

DCL 失效问题(划重点)!

看代码 13 行 instance = new Singleton() ,我们都知道,这不是一条原子操作,拆分来看的话,大致分为三个步骤:

1、给 Singleton 的实例分配内存空间;

2、调用 Singleton 的构造函数,初始化其成员变量;

3、将 instance 对象指向分配的内存空间(此时,instance 就不是 null 了)。

由于 Java 编译器允许处理器乱序执行(原因一),以及在 JDK 1.5 之前,JMM 中 Cache、寄存器到主内存回写顺序的规定(原因二),导致上述 2、3 步骤顺序无法保证,也就是说执行的顺序可能是 1-2-3,或者是 1-3-2。

1-2-3 的执行顺序没什么问题,我们来关注下 1-3-2 的执行顺序。

假设线程 A 正在对 Singleton 进行实例化,执行了步骤 1 和 3,还没有执行 2。这时候线程 B 进来了,判断 instance 不是 null,就取走了 instance 进行使用,可是 Singleton 的构造函数都还没执行,成员变量也没有初始化,就出错了。

这就是 DCL 的失效问题。

那怎么解决 DCL 的失效问题呢?

在 JDK 1.5 及 1.5 以前,无解;

在 JDK 1.6 及 1.6 以后,JVM 进行了调整,具体化了关键字 volatile ,我们可以在上述代码第 3 行 声明 instance 属性时加入此关键字 private static volatile Singleton instance ,就可以保证 instance 对象每次都是从主内存中读取到的。

我们来简单解释一下:

在 JMM 中,有主内存与工作内存的区分,一般声明的变量会在主内存中存在,并在工作内存中存在其一个拷贝。当工作内存对变量的拷贝进行修改时,会从工作内存同步到主内存。可是当对变量进行非原子性操作时,变量从工作内存到主内存的同步也是非原子性操作,在多线程的情况下,如果存与取同时发生,就会出现线程不安全的问题。

volatile 关键字,具备 “有序性” 和 “可见性” 的特性。

“有序性”,禁止指令重排序,解决导致 DCL 失效的原因一;

“可见性”,当某一工作内存对变量的拷贝进行修改时,会立即同步到主内存,同时将所有工作内存中的变量的拷贝置为无效状态,则工作内存要取用此变量就要从主内存中同步过来,解决导致 DCL 失效的原因二。

这样, volatile 关键字基本可以解决 DCL 失效的问题。(JDK <= 1.5 很少用了,可以忽略)

我们知道,使用同步锁 synchronized 进行同步操作是比较耗费资源的,volatile 要比 synchronized 节俭一些,不过还是对性能有所损耗,那有没有更优秀的单例模式写法呢?有的,往下看吧!

5、使用静态内部类实现单例

这种写法可以说一枝独秀了,先看下代码吧。


程序清单 5     使用静态内部类实现单例

public class Singleton{
    private Singleton(){
    }

    public static Singleton newInstance(){
        return SingletonHelper.instance;
    }

    private static class SingletonHelper{
        private final static Singleton instance = new Singleton();
    }

}

要搞懂这种模式,首先要搞懂 “饿汉模式”,咱们一起来理解下吧。

看这段代码 9 ~ 11 行,静态内部类 SingletonHelper 中的静态属性 instance 是随着 SingtonHelper 的加载而加载的,也就是说 一旦静态内部类 SingletonHelper 加载,就会对 Singleton 类进行实例化

那问题来了,静态内部类 SingletonHelper 什么时候加载呢?

要注意的是,静态内部类不同于静态属性,不会随着宿主类(Singleton)的加载而加载,是在第一次调用静态内部类的时候再由 Java 虚拟机进行加载(代码第 6 行),这样就避免了多线程的同步问题。

同时,这种写法也实现了懒加载,如果不调用 newInstance() 方法,就不会调用静态内部类对 Singleton 进行实例化。

可序列化的单例类的反序列化问题


以上,我们一起分析了 5 种单例模式的写法,实际上他们都存在一个相同的问题:可序列化的单例类的反序列化问题。

存在此问题的前提是,单例类实现了 Serializable 接口,是可序列化的。

这样,我们通过序列化可将单例类的实例对象写到磁盘,然后再读回来,及进行反序列化,就可以得到此单例类的一个实例。即使构造函数是私有的,也是有办法获得单例类的一个新的实例的。

那怎么解决此问题呢?两种方案:

方案 1、利用类中可用的一个私有的钩子函数 readResolve() ,用来控制类的反序列化。看下代码示例吧。


程序清单 6     钩子函数解决单例类的反序列化问题

import java.io.ObjectStreamException;
import java.io.Serializable;

public class Singleton implements Serializable{

    private static final long serialVersionUID = 0L;

    private static volatile Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    // 钩子函数,用来控制类的反序列化操作
    private Object readResolve() throws ObjectStreamException {
        return instance;
    }

}

看这段代码 26 ~ 28 行,通过此钩子函数,当对类进行反序列化时,返回我们的单例的实例 instance ,就避免了反序列化时创建新实例的问题。

方案 2:就是下面咱们要说的第 6 种实现单例模式的写法:枚举模式。


6、使用枚举实现单例

我们知道,枚举在 Java 中与普通的类是一样的,可以有自己的属性,还可以有自己的方法,最重要的是,默认枚举实例的创建是线程安全的,并且在任何情况下它都是一个单例(任何情况包括反序列化)

让我们来看下代码示例吧。


程序清单 7     使用枚举实现单例

public enum Singleton {
    INSTANCE;
}

哈哈哈,有没有被惊掉下巴?没错,枚举实现单例的代码就是这么简单!

那怎么获得枚举实现的单例类实例呢?也是十分简单。

Singleton instance = Singleton.INSTANCE;

如果哪位小伙伴对枚举不熟悉的,文后有一个枚举详解的文章链接,很简单,学一下吧。

7、使用容器实现单例

这算是一种比较另类的写法了。还是先来看代码示例,至于这种写法的优劣,请各位自判吧。

程序清单 8     使用容器实现单例

import java.util.HashMap;
import java.util.Map;

public class SingletonManager {

	// 存储单例类的容器
    private static Map<String, Object> mSingletonMap = new HashMap<>();

    // 不需要对此 单例管理类 进行实例化
    private SingletonManager() {
    }

    // 向容器中注册单例类
    public static void registerSingleton(String key, Object instance) {
        if (!mSingletonMap.containsKey(key)) {
            mSingletonMap.put(key, instance);
        }
    }

    // 从容器中获取单例类
    public static Object getSingleton(String key) {
        return mSingletonMap.get(key);
    }
}

在程序初始时,会将多种单例类型的对象注入到容器中,当需要使用时通过 key 从容器中取出相应的单例类型的对象。

这种写法,很明显可以很好对单例类型进行统一管理,存取操作对用户是透明且低耦合的,问题是容器中初始的单例类型会消耗资源,无论是否会被用到。

在 Android 系统中,系统级别的服务用的就是这种方案,如 AMS、WMS、LayoutInflater 等服务,会在合适的时候以单例的形式注册到系统中,当需要调用相应服务的时候,会通过 Context 的 getSystemService(String name) 获取。

总结

至此,本文要介绍的 7 种单例模式的写法就介绍完了。

无论是哪种实现方式,核心原理都是首先将构造函数私有化,然后通过静态方法得到相应类的实例。

至于选用哪一种,大家要根据相应单例类的应用场景决定,比如是否是高并发环境、JDK 版本是否 <= 1.5、相应类的实例对资源的消耗情况、对相应类实例的取用频率等。

单例模式的使用场景

单例的模式的应用范围很广,具体的使用场景有以下几个:

1、整个项目需要一个共享访问点或者需要共享数据;

2、创建相应对象需要耗费大量资源,如 I/O 操作、数据库连接等;

3、工具类对象;

等。

后记

单例模式写到这里,真心体会到认为简单的东西不见得真的简单,做技术是一定要沉下心的,不仅知道怎么用,还要知道为什么。

另外,无论是看技术博客,还是看技术书籍,一定要多方比对着看,互相印证,理解更深,且可以发现有些博文,甚至有些书籍常会存在解释不准确的情况。

感谢

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值