彻头彻尾理解单例模式与多线程

x33g5p2x  于2021-11-18 转载在 其他  
字(10.6k)|赞(0)|评价(0)|浏览(390)

摘要:
  
  本文首先概述了单例模式产生动机,揭示了单例模式的本质和应用场景。紧接着,我们给出了单例模式在单线程环境下的两种经典实现:饿汉式懒汉式,但是饿汉式是线程安全的,而懒汉式是非线程安全的。在多线程环境下,我们特别介绍了五种方式来在多线程环境下创建线程安全的单例,即分别使用 synchronized方法、synchronized块、静态内部类、双重检查模式 和 ThreadLocal 来实现懒汉式单例,并总结出实现效率高且线程安全的懒汉式单例所需要注意的事项。

友情提示:

本文所介绍的关于单例模式的所有实现的源码均可以在我的Github上找到,对应链接为:https://github.com/githubofrico/ThreadsafeSingletonAndUnthreadsafeSingleton

版权声明:

本文原创作者:书呆子Rico
作者博客地址:http://blog.csdn.net/justloveyou_/

一. 单例模式概述

单例模式(Singleton),也叫单子模式,是一种常用的设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候,整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,显然,这种方式简化了在复杂环境下的配置管理。

特别地,在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。事实上,这些应用都或多或少具有资源管理器的功能。例如,每台计算机可以有若干个打印机,但只能有一个 Printer Spooler (单例) ,以避免两个打印作业同时输出到打印机中。再比如,每台计算机可以有若干通信端口,系统应当集中(单例) 管理这些通信端口,以避免一个通信端口同时被两个请求同时调用。总之,选择单例模式就是为了避免不一致状态,避免政出多头。

综上所述,单例模式就是为确保一个类只有一个实例,并为整个系统提供一个全局访问点的一种方法。

二. 单例模式及其单线程环境下的经典实现

单例模式应该是23种设计模式中最简单的一种模式了,下面我们从单例模式的定义、类型、结构和使用要素四个方面来介绍它。

1、单例模式理论基础

定义: 确保一个类只有一个实例,并为整个系统提供一个全局访问点 (向整个系统提供这个实例)。

类型: 创建型模式

结构:

特别地,为了更好地理解上面的类图,我们以此为契机,介绍一下类图的几个知识点:

  • 类图分为三部分,依次是类名、属性、方法;
  • 以<<开头和以>>结尾的为注释信息;
  • 修饰符+代表public,-代表private,#代表protected,什么都没有代表包可见;
  • 带下划线的属性或方法代表是静态的。

三要素:

  • 私有的构造方法;
  • 指向自己实例的私有静态引用;
  • 以自己实例为返回值的静态的公有方法。

2、单线程环境下的两种经典实现

在介绍单线程环境中单例模式的两种经典实现之前,我们有必要先解释一下 立即加载延迟加载 两个概念。

  • 立即加载 : 在类加载初始化的时候就主动创建实例;
  • 延迟加载 : 等到真正使用的时候才去创建实例,不用时不去主动创建。

在单线程环境下,单例模式根据实例化对象时机的不同,有两种经典的实现:一种是 饿汉式单例(立即加载),一种是懒汉式单例(延迟加载)。饿汉式单例在单例类被加载时候,就实例化一个对象并交给自己的引用;而懒汉式单例只有在真正使用的时候才会实例化一个对象并交给自己的引用。代码示例分别如下:

饿汉式单例:

// 饿汉式单例
public class Singleton1 {

    // 指向自己实例的私有静态引用,主动创建
    private static Singleton1 singleton1 = new Singleton1();

    // 私有的构造方法
    private Singleton1(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton1 getSingleton1(){
        return singleton1;
    }
}

我们知道,**类加载的方式是按需加载,且加载一次。**因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用;而且,由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例。

懒汉式单例:

// 懒汉式单例
public class Singleton2 {

    // 指向自己实例的私有静态引用
    private static Singleton2 singleton2;

    // 私有的构造方法
    private Singleton2(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton2 getSingleton2(){
        // 被动创建,在真正需要使用时才去创建
        if (singleton2 == null) {
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}

我们从懒汉式单例可以看到,单例实例被延迟加载,即只有在真正使用的时候才会实例化一个对象并交给自己的引用。

总之,从速度和反应时间角度来讲,饿汉式(又称立即加载)要好一些;从资源利用效率上说,懒汉式(又称延迟加载)要好一些。

3、单例模式的优点

我们从单例模式的定义和实现,可以知道单例模式具有以下几个优点:

  • 在内存中只有一个对象,节省内存空间;
  • 避免频繁的创建销毁对象,可以提高性能;
  • 避免对共享资源的多重占用,简化访问;
  • 为整个系统提供一个全局访问点。

4、单例模式的使用场景

由于单例模式具有以上优点,并且形式上比较简单,所以是日常开发中用的比较多的一种设计模式,**其核心在于为整个系统提供一个唯一的实例,**其应用场景包括但不仅限于以下几种:

  • 有状态的工具类对象;
  • 频繁访问数据库或文件的对象;

5、单例模式的注意事项

在使用单例模式时,我们必须使用单例类提供的公有工厂方法得到单例对象,而不应该使用反射来创建,否则将会实例化一个新对象。此外,在多线程环境下使用单例模式时,应特别注意线程安全问题,我在下文会重点讲到这一点。

三. 多线程环境下单例模式的实现

在单线程环境下,无论是饿汉式单例还是懒汉式单例,它们都能够正常工作。但是,在多线程环境下,情形就发生了变化:由于饿汉式单例天生就是线程安全的,可以直接用于多线程而不会出现问题;但懒汉式单例本身是非线程安全的,因此就会出现多个实例的情况,与单例模式的初衷是相背离的。下面我重点阐述以下几个问题:

  • 为什么说饿汉式单例天生就是线程安全的?
  • 传统的懒汉式单例为什么是非线程安全的?
  • 怎么修改传统的懒汉式单例,使其线程变得安全?
  • 线程安全的单例的实现还有哪些,怎么实现?
  • 双重检查模式、Volatile关键字 在单例模式中的应用
  • ThreadLocal 在单例模式中的应用

特别地,为了能够更好的观察到单例模式的实现是否是线程安全的,我们提供了一个简单的测试程序来验证。该示例程序的判断原理是:

开启多个线程来分别获取单例,然后打印它们所获取到的单例的hashCode值。若它们获取的单例是相同的(该单例模式的实现是线程安全的),那么它们的hashCode值一定完全一致;若它们的hashCode值不完全一致,那么获取的单例必定不是同一个,即该单例模式的实现不是线程安全的,是多例的。注意,相应输出结果附在每个单例模式实现示例后。
  
  若看官对上述原理不够了解,请移步我的博客《Java 中的 ==, equals 与 hashCode 的区别与联系》。

public class Test {
    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new TestThread();
        }

        for (int i = 0; i < threads.length; i++) {
            threads[i].start();
        }
    }

}

class TestThread extends Thread {
    @Override
    public void run() {
        // 对于不同单例模式的实现,只需更改相应的单例类名及其公有静态工厂方法名即可
        int hash = Singleton5.getSingleton5().hashCode();  
        System.out.println(hash);
    }
}

1、为什么说饿汉式单例天生就是线程安全的?

// 饿汉式单例
public class Singleton1 {

    // 指向自己实例的私有静态引用,主动创建
    private static Singleton1 singleton1 = new Singleton1();

    // 私有的构造方法
    private Singleton1(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton1 getSingleton1(){
        return singleton1;
    }
}/* Output(完全一致): 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 *///:~

我们已经在上面提到,类加载的方式是按需加载,且只加载一次。因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用。换句话说,在线程访问单例对象之前就已经创建好了。再加上,由于一个类在整个生命周期中只会被加载一次,因此该单例类只会创建一个实例,也就是说,线程每次都只能也必定只可以拿到这个唯一的对象。因此就说,饿汉式单例天生就是线程安全的。

2、传统的懒汉式单例为什么是非线程安全的?

// 传统懒汉式单例
public class Singleton2 {

    // 指向自己实例的私有静态引用
    private static Singleton2 singleton2;

    // 私有的构造方法
    private Singleton2(){}

    // 以自己实例为返回值的静态的公有方法,静态工厂方法
    public static Singleton2 getSingleton2(){
        // 被动创建,在真正需要使用时才去创建
        if (singleton2 == null) {
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}/* Output(不完全一致): 1084284121 2136955031 2136955031 1104499981 298825033 298825033 2136955031 482535999 298825033 2136955031 *///:~

上面发生非线程安全的一个显著原因是,会有多个线程同时进入 if (singleton2 == null) {…} 语句块的情形发生。当这种这种情形发生后,该单例类就会创建出多个实例,违背单例模式的初衷。因此,传统的懒汉式单例是非线程安全的。

3、实现线程安全的懒汉式单例的几种正确姿势

1)、同步延迟加载 — synchronized方法

// 线程安全的懒汉式单例
public class Singleton2 {

    private static Singleton2 singleton2;

    private Singleton2(){}

    // 使用 synchronized 修饰,临界资源的同步互斥访问
    public static synchronized Singleton2 getSingleton2(){
        if (singleton2 == null) {
            singleton2 = new Singleton2();
        }
        return singleton2;
    }
}/* Output(完全一致): 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 *///:~

该实现与上面传统懒汉式单例的实现唯一的差别就在于:是否使用 synchronized 修饰 getSingleton2()方法。若使用,就保证了对临界资源的同步互斥访问,也就保证了单例。

从执行结果上来看,问题已经解决了,但是这种实现方式的运行效率会很低,因为同步块的作用域有点大,而且锁的粒度有点粗。同步方法效率低,那我们考虑使用同步代码块来实现。

更多关于 synchronized 关键字的介绍, 请移步我的博文《Java 并发:内置锁 Synchronized》。

2)、同步延迟加载 — synchronized块

// 线程安全的懒汉式单例
public class Singleton2 {

    private static Singleton2 singleton2;

    private Singleton2(){}

    public static Singleton2 getSingleton2(){
        synchronized(Singleton2.class){  // 使用 synchronized 块,临界资源的同步互斥访问
            if (singleton2 == null) { 
                singleton2 = new Singleton2();
            }
        }
        return singleton2;
    }
}/* Output(完全一致): 16993205 16993205 16993205 16993205 16993205 16993205 16993205 16993205 16993205 16993205 *///:~

该实现与上面synchronized方法版本实现类似,此不赘述。从执行结果上来看,问题已经解决了,但是这种实现方式的运行效率仍然比较低,事实上,和使用synchronized方法的版本相比,基本没有任何效率上的提高。

3)、同步延迟加载 — 使用内部类实现延迟加载

// 线程安全的懒汉式单例
public class Singleton5 {

    // 私有内部类,按需加载,用时加载,也就是延迟加载
    private static class Holder {
        private static Singleton5 singleton5 = new Singleton5();
    }

    private Singleton5() {

    }

    public static Singleton5 getSingleton5() {
        return Holder.singleton5;
    }
}
/* Output(完全一致): 482535999 482535999 482535999 482535999 482535999 482535999 482535999 482535999 482535999 482535999 *///:~

如上述代码所示,我们可以使用内部类实现线程安全的懒汉式单例,这种方式也是一种效率比较高的做法,它与饿汉式单例的区别就是:这种方式不但是线程安全的,还是延迟加载的,真正做到了用时才初始化。

当客户端调用getSingleton5()方法时,会触发Holder类的初始化。由于singleton5是Hold的类成员变量,因此在JVM调用Holder类的类构造器对其进行初始化时,虚拟机会保证一个类的类构造器在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器,其他线程都需要阻塞等待,直到活动线程执行方法完毕。在这种情形下,其他线程虽然会被阻塞,但如果执行类构造器方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行类构造器,因为 在同一个类加载器下,一个类型只会被初始化一次,因此就保证了单例。

更多关于 内部类 的介绍, 请移步我的博文《 Java 内部类综述 》。

更多关于 类加载与初始化 的介绍, 请移步我的博文《 JVM类加载机制概述:加载时机与加载过程 》。

关于使用双重检查、ThreaLocal实现线程安全的懒汉式单例分别见第四节和第五节。

四. 单例模式与双重检查(Double-Check idiom)

使用双重检测同步延迟加载去创建单例的做法是一个非常优秀的做法,其不但保证了单例,而且切实提高了程序运行效率,对应的代码清单如下:

// 线程安全的懒汉式单例
public class Singleton3 {

    //使用volatile关键字防止重排序,因为 new Instance()是一个非原子操作,可能创建一个不完整的实例
    private static volatile Singleton3 singleton3;

    private Singleton3() {
    }

    public static Singleton3 getSingleton3() {
        // Double-Check idiom
        if (singleton3 == null) {
            synchronized (Singleton3.class) {       // 1
                // 只需在第一次创建实例时才同步
                if (singleton3 == null) {       // 2
                    singleton3 = new Singleton3();      // 3
                }
            }
        }
        return singleton3;
    }
}/* Output(完全一致): 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 1104499981 *///:~

如上述代码所示,为了在保证单例的前提下提高运行效率,我们需要对 singleton3 进行第二次检查,目的是避开过多的同步(因为这里的同步只需在第一次创建实例时才同步,一旦创建成功,以后获取实例时就不需要同步获取锁了)。这种做法无疑是优秀的,但是我们必须注意一点:必须使用volatile关键字修饰单例引用。

那么,如果上述的实现没有使用 volatile 修饰 singleton3,会导致什么情形发生呢? 为解释该问题,我们分两步来阐述:

(1)、当我们写了 new 操作,JVM 到底会发生什么?

首先,我们要明白的是: **new Singleton3() 是一个非原子操作。**代码行singleton3 = new Singleton3(); 的执行过程可以形象地用如下3行伪代码来表示:

memory = allocate();        //1:分配对象的内存空间
ctorInstance(memory);       //2:初始化对象
singleton3 = memory;        //3:使singleton3指向刚分配的内存地址

但实际上,这个过程可能发生无序写入(指令重排序),也就是说上面的3行指令可能会被重排序导致先执行第3行后执行第2行,也就是说其真实执行顺序可能是下面这种:

memory = allocate();        //1:分配对象的内存空间
singleton3 = memory;        //3:使singleton3指向刚分配的内存地址
ctorInstance(memory);       //2:初始化对象

这段伪代码演示的情况不仅是可能的,而且是一些 JIT 编译器上真实发生的现象。

(2)、重排序情景再现
  
  了解 new 操作是非原子的并且可能发生重排序这一事实后,我们回过头看使用 Double-Check idiom 的同步延迟加载的实现:

我们需要重新考察上述清单中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 singleton3 来引用此对象。这行代码存在的问题是,在 Singleton 构造函数体执行之前,变量 singleton3 可能提前成为非 null 的,即赋值语句在对象实例化之前调用,此时别的线程将得到的是一个不完整(未初始化)的对象,会导致系统崩溃。下面是程序可能的一组执行步骤:

1、线程 1 进入 getSingleton3() 方法;
  2、由于 singleton3 为 null,线程 1 在 //1 处进入 synchronized 块;
  3、同样由于 singleton3 为 null,线程 1 直接前进到 //3 处,但在构造函数执行之前,使实例成为非 null,并且该实例是未初始化的;
  4、线程 1 被线程 2 预占;
  5、线程 2 检查实例是否为 null。因为实例不为 null,线程 2 得到一个不完整(未初始化)的 Singleton 对象;
  6、线程 2 被线程 1 预占。
  7、线程 1 通过运行 Singleton3 对象的构造函数来完成对该对象的初始化。

显然,一旦我们的程序在执行过程中发生了上述情形,就会造成灾难性的后果,而这种安全隐患正是由于指令重排序的问题所导致的。让人兴奋地是,volatile 关键字正好可以完美解决了这个问题。也就是说,我们只需使用volatile关键字修饰单例引用就可以避免上述灾难。

特别地,由于 volatile关键字的介绍和 类加载及对象初始化顺序两块内容已经在我之前的博文中介绍过,再此只给出相关链接,不再赘述。

更多关于volatile关的介绍, 请移步我的博文《 Java 并发:volatile 关键字解析》。

更多关于类加载及对象初始化顺序的介绍, 请移步我的博文《 Java 继承、多态与类的复用》。

五. 单例模式 与 ThreadLocal

借助于 ThreadLocal,我们可以实现双重检查模式的变体。我们将临界资源instance线程私有化(局部化),具体到本例就是将双重检测的第一层检测条件 if (instance == null) 转换为线程局部范围内的操作,对应的代码清单如下:

public class Singleton {

    // ThreadLocal 线程局部变量,将单例instance线程私有化
    private static ThreadLocal<Singleton> threadlocal = new ThreadLocal<Singleton>();
    private static Singleton instance;

    private Singleton() {

    }

    public static Singleton getInstance() {

        // 第一次检查:若线程第一次访问,则进入if语句块;否则,若线程已经访问过,则直接返回ThreadLocal中的值
        if (threadlocal.get() == null) {
            synchronized (Singleton.class) {
                if (instance == null) {  // 第二次检查:该单例是否被创建
                    instance = new Singleton();
                }
            }
            threadlocal.set(instance); // 将单例放入ThreadLocal中
        }
        return threadlocal.get();
    }
}/* Output(完全一致): 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 1028355155 *///:~

借助于 ThreadLocal,我们也可以实现线程安全的懒汉式单例。但与直接双重检查模式使用,本实现在效率上还不如后者。

更多关于ThreadLocal 的介绍, 请移步我的博文《 Java 并发:深入理解 ThreadLocal》。

六. 小结

本文首先介绍了单例模式的定义和结构,并给出了其在单线程和多线程环境下的几种经典实现。特别地,我们知道,**传统的饿汉式单例无论在单线程还是多线程环境下都是线程安全的,但是传统的懒汉式单例在多线程环境下是非线程安全的。**为此,我们特别介绍了五种方式来在多线程环境下创建线程安全的单例,包括:

  • 使用synchronized方法实现懒汉式单例;
  • 使用synchronized块实现懒汉式单例;
  • 使用静态内部类实现懒汉式单例;
  • 使用双重检查模式实现懒汉式单例;
  • 使用ThreadLocal实现懒汉式单例;

当然,实现懒汉式单例还有其他方式。但是,这五种是比较经典的实现,也是我们应该掌握的几种实现方式。从这五种实现中,我们可以总结出,要想实现效率高的线程安全的单例,我们必须注意以下两点:

  • 尽量减少同步块的作用域;
  • 尽量使用细粒度的锁。

七. 更多

本文涉及内容比较广,涉及到 hashcode、synchronized 关键字、内部类、 类加载及对象初始化顺序、volatile关键字 和 ThreadLocal 等知识点,这些知识点在我之前的博文中均专门总结过,现附上相关链接,感兴趣的朋友可以移步到相关博文进行查看。

更多关于 类加载与初始化 的介绍, 请移步我的博文《 JVM类加载机制概述:加载时机与加载过程 》。

更多关于 hashCode 与相等的介绍,请移步我的博客《Java 中的 ==, equals 与 hashCode 的区别与联系》。

更多关于synchronized 关键字的介绍, 请移步我的博文《Java 并发:内置锁 Synchronized》。

更多关于 内部类的介绍, 请移步我的博文《 Java 内部类综述 》

更多关于 volatile关键字 的介绍, 请移步我的博文《 Java 并发:volatile 关键字解析》。

更多关于类加载及对象初始化顺序的介绍, 请移步我的博文《 Java 继承、多态与类的复用》。

更多关于ThreadLocal的介绍, 请移步我的博文《 Java 并发:深入理解 ThreadLocal》。

此外,更多关于 Java SE 进阶 方面的内容,请关注我的专栏 《Java SE 进阶之路》。本专栏主要研究Java基础知识、Java源码和设计模式,从初级到高级不断总结、剖析各知识点的内在逻辑,贯穿、覆盖整个Java知识面,在一步步完善、提高把自己的同时,把对Java的所学所思分享给大家。万丈高楼平地起,基础决定你的上限,让我们携手一起勇攀Java之巅…

更多关于 Java 并发编程 方面的内容,请关注我的专栏 《Java 并发编程学习笔记》。本专栏全面记录了Java并发编程的相关知识,并结合操作系统、Java内存模型和相关源码对并发编程的原理、技术、设计、底层实现进行深入分析和总结,并持续跟进并发相关技术。

引用

Java 中的双重检查(Double-Check)
单例模式与双重检测
用happen-before规则重新审视DCL
JAVA设计模式之单例模式
23种设计模式(1):单例模式

相关文章