单例模式在高并发下如何保证线程安全

1,074 阅读9分钟

前言

什么是单例模式?相信大家都不陌生,简单的说就是类的实例化只有一次。单例模式在日常开发中是一种常见的设计模式,一般用来全局的对象管理,比如对文件的读写、由系统来配置类实例(Spring容器管理单例实例)、数据库的链接池实例、任务调度器实例等等。我们熟悉的单例类按照实例化的时机来划分为:饿汉式、懒汉式。

下面会通过几个示例来实现这两种方式的单例模式:

简单的饿汉式

饿汉式单例是指在类加载时就直接被初始化了。

代码示例

/**
 * 简单的饿汉单例模式
 *
 * @author 云飞飞飞
 */
public class HungrySingleton {

    //私有构造,不允许外部调用者直接new
    private HungrySingleton() {

    }

    //静态成员,类加载时直接完成初始化了
    private static final HungrySingleton instance = new HungrySingleton();

    //获取实例
    public static HungrySingleton getInstance() {
        return instance;
    }

}

测试结果

相信上面的这种单例模式大家都写过,这种写法的优点就是代码实现简单,安全。但是有会有一个问题就是单例对象在类加载的时候就直接被初始化了,往往很多时候,在类被加载的时并不需要进行单例的初始化,所以我们需要对单例的初始化做一个后置处理,也就是我什么时候用什么时候才初始化。 由此产生了懒汉单例模式

懒汉式(非线程安全)

在使用单例类时才进行初始化就是懒汉单例模式

代码示例

/**
 * 懒汉单例模式(非线程安全)
 *
 * @author 云飞飞飞
 */
public class LazySingleton {

    //私有构造,不允许外部调用者直接new
    private LazySingleton() {

    }

    private static LazySingleton singleton;

    public static LazySingleton getInstance() {
        //多线程下这里会有线程安全的隐患
        if (null == singleton) {
            singleton = new LazySingleton();
        }

        return singleton;
    }
}

以上的懒汉式在单线程下是没问题的,但是正常线上的应用你可能无法保证一直是单线程的,为什么说会存在线程安全的问题说呢,你可以设想一下多线程的场景两个线程同一时间调用getInstance()方法来实例化会出现什么样的结果?写个测试用例来测试一下就知道了

测试结果


//调整了一下代码结构方便打印多线程的时的信息
public static LazySingleton getInstance() {
    if (null == singleton) {
        System.out.println("LazySingleton被线程 " + Thread.currentThread().getName() + " 实例化了");
        singleton = new LazySingleton();
        return singleton;
    } else {
        System.out.println("线程 " + Thread.currentThread().getName() + " 拿到了已经实例化的LazySingleton");
        return singleton;
    }
}

public static void main(String[] args) {
    //模拟5个线程同时去拿单例类
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            LazySingleton instance = LazySingleton.getInstance();
            //测试hashCode是否相同
            //System.out.println("线程 " + Thread.currentThread().getName() + " 的LazySingleton hashCode=" + instance.hashCode());
        }).start();
    }
}

执行结果

从上面的测试结果来看,显然多线程下是不安全的 LazySingleton 毫无防备的被实例化了多次,这一现象就违背了单例模式的设计初衷。那么如何确保单例类只被实例化一次呢?换一个角度我们把多线程并发的行为用synchronized 同步锁改为串行是不是就可以解决这个单例类创建多次的问题

懒汉式升级1(内置锁保证线程安全)

使用 synchronized 同步锁将 getInstance() 方法改为同步方法,确保多线程场景下只有一个线程进入临界区执行

代码示例

//在方法上加了synchronized修饰
public synchronized static LazySingleton getInstance() {
    if (null == singleton) {
        System.out.println(LocalDateTime.now()+ " - LazySingleton被线程 " + Thread.currentThread().getName() + " 实例化了");
        singleton = new LazySingleton();
        return singleton;
    } else {
        System.out.println(LocalDateTime.now()+ " - 线程 " + Thread.currentThread().getName() + " 拿到了已经实例化的LazySingleton");
        return singleton;
    }
}

//测试代码
public static void main(String[] args) {
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            LazySingleton instance = LazySingleton.getInstance();
            //System.out.println(LocalDateTime.now()+ " - 线程 " + Thread.currentThread().getName() + " 的LazySingleton hashCode=" + instance.hashCode());
        }).start();
    }
}

测试结果

image.png

从测试结果来看 LazySingleton 在多线程下只被实例化了一次,达到了我们最初的设想。但是这种写法的问题是:每次通过 getInstance() 拿实例都是走的同步,实际上单例类只有第一次实例化时需要加锁,之后的获取就没必要加锁了,设想下在争用激烈的情况下,太多线程阻塞在此同步锁会升级为重量级锁,增加了系统开销同时却降低了性能,所以在高并发场景下不推荐使用这种方式创建单例。

那么我们能否只在第一次实例化时进行加锁,之后的获取不加锁?

懒汉式升级2(双重检查锁单例模式)

代码示例

public static LazySingleton getInstance() {
        if (null == singleton) {//1
            synchronized (LazySingleton.class) {
                if (null == singleton) {//2
                    System.out.println(LocalDateTime.now() + " - LazySingleton被线程 " + Thread.currentThread().getName() + " 实例化了");
                    singleton = new LazySingleton();
                } else {
                    //3 线程数较少时还是走的这里
                    System.out.println(LocalDateTime.now() + " - 线程 " + Thread.currentThread().getName() + " 拿到了已经实例化的LazySingleton - 3");
                }
            }
        } else {
            //4 加大线程数会看到这里的打印
            System.out.println(LocalDateTime.now() + " - 线程 " + Thread.currentThread().getName() + " 拿到了已经实例化的LazySingleton - 4");
        }
        return singleton;
    }

    public static void main(String[] args) {
        int t = 5;
        //加大线程数模拟抢占激烈的场景会看4的打印结果
//        int t = 400;
        for (int i = 0; i < t; i++) {
            new Thread(() -> {
                LazySingleton instance = LazySingleton.getInstance();
                //System.out.println(LocalDateTime.now()+ " - 线程 " + Thread.currentThread().getName() + " 的LazySingleton hashCode=" + instance.hashCode());
            }).start();
        }
    }

测试结果

10个线程争用的场景

少量线程场景

400个线程争用的场景

大量线程争用

小结

双重检查锁的单例模式主要包括以几步:

  1. 第一个 null == singleton 检查是否被初始化,如果已经初始化就可以立即返回,不需要加锁
  2. 如果单例类没有实例化,就会进入临界区 synchronized (LazySingleton.class) 同步代码进行初始化操作,此时才会去获取锁
  3. 进入临界区之后,再次对单例类进行检查(第二次),如果发现已经被初始化也是立即返回,如果还没有就进行初始化

疑惑点解答

为什么进入临界区之后还要进行一次检查? 其实我刚接触时也是有这个疑问,后面理解了 synchronized 的原理后我找到了原因,在多线程争用getInstance()的场景下,可能不止一个线程同时通过了第一次 null == singleton 检查,此时第一个通过第一次检查的线程会先进入临界区(同步代码块),而其他通过第一次检查的线程将被阻塞在外面,只有在第一个线程对单例类进行实例化完了之后(也就是同步代码块中的逻辑执行完了)其他线程才可能获取到锁进入到临界区,但此时单例类LazySingleton已经被实例化,所以其他线程就无法通过第二次检查,也就避免的重复初始化的问题。

小伙伴看到这里是不是会以为 双重检查锁 就是单例模式的最优方式了,一开始我也是这么想的,直到我在Spring的源码中发现了这段代码:

image.png

image.png

不禁感叹自己还是太年轻了,哈哈哈!!这里用的也是双重检查锁,但为什么要加一个 volatile 关键字呢?(我这该死的好奇心... )

懒汉式升级3(双重检查锁+volatile)

从代码层面,使用上面双重检查锁的单例模式一切看起来都是那么的友好,很完美。当我去了解为什么要加volatile 后发现其实并不是这样的,问题主要是出在下面这行代码:

//来自getInstance()中的实例化代码
singleton = new LazySingleton();

这段实例化代码在转换成汇编指令(具有原子性的指令)后,大致会细分成三个指令:

  1. 分配一块内存M
  2. 在内存M上初始化LazySingleton对象
  3. 将M的地址赋值给singleton变量

编译器、CPU都可能会对没有内存屏障、数据依赖关系的操作进行重排序,上述的三个指令优化后可能就编程了这样:

  1. 分配一块内存M
  2. 将M的地址赋值给singleton变量
  3. 在内存M上初始化LazySingleton对象

指令重排之后,初始化和赋值的顺序发生了变化,在遇到多线程获取单例可能会导致出问题,假设两个线程A、B按下面的次序执行:

  1. 线程A先执行到 getInstance() 方法,当指令执行到分配一块内存并将M的地址赋值给singleton变量后,这时正好发生了 线程切换 (重点就是在这里)。此时,线程A还没来得及将M指向的内存初始化
  2. 线程切换后就轮到线程B出场了,他在进入到 getInstance() 方法,判断null == singleton是否为空,此时 singleton 不为空,这就导致线程B直接拿到了一个未初始化的变量

可以想象一下线程B拿到的是一个未初始化完全的对象,你再去访问他的成员变量是会发生什么异常(BUG来的就是这么突然)

这个时候就体现了 volatile 的重要性,加上他的目的就是为了禁止指令重排

实现方式也很简单,就跟上面截图一样加上volatile关键字就可以了,最主的是理解加了和没加的区别

饱汉式(静态内部类)

上面的双重检查锁+volatile相结合虽然是可以实现高性能,线程安全的单例模式。但其底层原理方面会比较复杂,写法也相对复杂一点,推荐一个我常用的静态内部类的写法,代码简单也比较容易理解。

代码示例

/**
 * 饱汉式使用静态内部类
 *
 * @author 云飞飞飞
 */
public class FullHanSingleton {

    private FullHanSingleton() {

    }

    /**
     * 内部类的方式(饱汉式)
     * <p>
     * 利用JVM保证,类静态初始化的时候只会执行一次
     */
    public static class InnerClassSingleton {

        public static FullHanSingleton instance = new FullHanSingleton();
    }

    public static FullHanSingleton getInstance() {
        return InnerClassSingleton.instance;
    }
}

这种写法通过静态内部类实现的单例模式只有在调用getInstance()才会去加载内部类InnerClassSingleton并且完成初始化,这种方式即解决了线程安全的问题,写法也比较简单。

总结

  1. 单例类的无参构造方法一定是私有的
  2. 单例模式类的实例只有一个,比如读取配置的场景
  3. 单例模式可以避免资源的占用,比如写文件场景
  4. 单例模式可以在系统建立全局的访问点,对象管理,共享资源访问
  5. 单例模式一定要注意线程安全问题