单例设计模式
Tim Chen(motion$) Lv5

写在前面的话

  • 本次笔记是在看了《毕向东Java基础》视频之后写的,对于毕老师讲课的优点,我想听过他的课的同学都应该知道的,所以我也不想多说了,但我还是忍不住再说一下,就是幽默风趣,讲课条理清晰,了解学生学习的难点,并能引领大家的思路。
  • 另外,由于这是基础视频,所以讲的内容还是比较简单的。因此,我在课外进行了补充学习,希望能把这个单例模式问题学得更深更透!

什么是单例设计模式

什么是设计模式?

  • 首先第一个问题就是:什么是单例设计模式?要弄懂这个问题,我们首先要理解什么是设计模式。设计模式原本并不是软件开发领域首创的,它是建筑领域的一套理论知识。所谓设计模式,就是解决同一类问题的一种方法,它面向的是高层的抽象理念,和Java中的类和实例之间的关系有相似之处。

单例设计模式解决的问题

  • 单例设计模式面向的问题是什么呢?保证内存中某个类的实例的唯一性。即是说,无论什么时候,在内存中只能存在一个实例。

哪里应用了单例设计模式

  • 这样的设计需要有哪些呢?举些例子来说吧,操作系统当中的垃圾桶,它在系统运行过程中都只有一个实例存在,无论你在C盘删除文件,还是在D盘删除文件,文件垃圾都是保存到同一个垃圾桶实例当中。这个最简单的验证就是:若果你把文件属性中的隐藏文件选择显示,那么你会在系统的任何地方都有一个垃圾桶文件夹,而这并不是说有多个垃圾桶实例,而是一个垃圾桶实例的多个引用。
  • 另外的例子还包括软件的配置文件,其实也是应用了单例设计模式。试想下,若果软件的配置的配置有多个,那么软件在下次运行时应该选择哪么配置文件呢?比如eclipse,你在使用时发现没有显示行数,那么狠明显你是修改配置,行号就可以显示出来,而当你下次启动eclipse时,行号还是显示的。所有配置文件只有一个,你任何的修改都是对一个配置文件进行,这样才能保证下次启动软件时持续生效。

单例设计模式的实现(Java)

单例设计模式的思路

  • 为了避免其他程序过多的建立该类对象。先禁止其他程序建立该类对象
  • 为了让其他程序可以访问到该类对象,只好在本类中,自定义一个对象。
  • 为了方便其他程序对自定义对象的访问,可以对外提供一些访问方式。

单例设计模式的代码实现步骤

  • 将构造函数私有化
  • 在类中创造一个本类对象
  • 提供一个方法可以获取到该对象

实现代码

1
2
3
4
5
6
7
8
9
10
public class SingletonDemo {
// 将构造函数私有化
private SingletonDemo() {}
// 在类中创造一个本类对象
private static SingletonDemo s = new SingletonDemo();
// 提供一个方法可以获取到该对象
public static SingletonDemo getInstance() {
return s;
}
}

单例设计模式就那么简单吗?

  • 咋看,觉得代码那么简单,其实并不是那么简单,其中的坑还是不少的。让我们逐层逐层揭开它的神秘面纱吧。
  • 单例设计模式一般有2种形式:懒汉式和饿汉式

懒汉式单例设计模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 懒汉式
class Singleton {
// 私有化构造函数
private Singleton() {}
// 创建空引用
private static Singleton instance = null;
// 创建实例,并返回对象
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
  • 上面的代码看上去似乎没什么问题?但其实暗藏了一个安全问题,因为在getInstance()的方法中包含2个语句,并不是原子操作,存在线程安全的问题,导致可能产生多个实例对象。
  • 线程安全的懒汉式单例设计模式(只改getInstance方法)
    1
    2
    3
    4
    5
    6
    public static synchronized Singleton getInstance() {
    if (instance == null) {
    instance = new Singleton();
    }
    return instance;
    }
  • 以上的代码,在方法前面加了synchronized关键字,表示了线程安全。但是把整个方法都同步了之后,程序的运行速度变得更慢了。为了解决慢的问题,又引出了双重检验锁。
  • 双重检验锁(double checked locking pattern)的懒汉式单例设计模式(只改getInstance方法)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static Singleton getSingleton() {
    if (instance == null) { // Single checked
    synchronized (Singleton.class) {
    if (instance == null) { // Double checked
    instance = new Singleton();
    }
    }
    }
    return instance;
    }

  • 这段代码貌似很完美了,但很遗憾地,它还是有问题的。主要是instance = new Singleton()这句,这并非是一个原子操作,事实上在JVM中执行这个语句时做了以下3个事情。
  1. 给instance分配内存
  2. 调用Singleton的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步instance才是非空)
  • 但在JVM的即时编译器中存在指令重排序的优化。也就是上面的第2步和第3步的顺序是不能保证的,最终的执行顺序可能是1-2-3,也可能是1-3-2.如果是后者,则在3执行完毕、2未执行前,被线程二抢占了,这时的instance已经是非null(但却没有初始化),所以线程二会直接返回instance,然后使用,然后顺理成章地报错了。(摘抄自大牛原文)
  • 那我们应该怎么办呢?我们把instance变量声明为volatile就可以了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {}

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

  • (大牛解释)
  • 有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
  • 但是特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。
  • 相信你不会喜欢这种复杂又隐含问题的方式,当然我们有更好的实现线程安全的单例模式的办法。

饿汉式单例设计模式

1
2
3
4
5
6
7
8
9
10
11
// 饿汉式
class Singleton {
// 类加载时就初始化
private static final Singleton instance = new Singleton();

private Singleton() {}

public static Singleton getInstance() {
return instance;
}
}
  • 这种方法非常简单,因为单例的实例被声明成static和final变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。
  • 但是这种方法还是有问题的,它的缺点是它不是一种懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使客户端没有调用getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:譬如Singleton实例的创建时依赖参数或者配置文件的,在getInstance()之前必须调用某个方法设置参数给它,那么这种单例写法就无法使用了。

静态内部类的单例设计模式

1
2
3
4
5
6
7
8
9
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton () {}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
  • 这种写法仍然使用JVM本身机制保证了线程安全问题;由于SingletonHolder 是私有的,除了getInstance()之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖JDK版本。

枚举Enum

  • 用枚举来实现单例真的太简单了!
    1
    2
    3
    public enum EasySingleton {
    INSTANCE;
    }
  • 我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。但是还是很少看到有人这样写,可能是因为不太熟悉吧。

后记

 评论