高并发设计模式-安全的单例

486 阅读6分钟

这是我参与8月更文挑战的第14天,活动详情查看:8月更文挑战

单例模式是开发中经常会遇到的一种设计模式,一般我们对于创建的对象比较大或者创建的对象相对消耗CPU资源,而这个创建好的对象的方法又是线程安全的,那么这种对象我们就需要对它构建一个单例对象,从而实现对象的复用。

比如我们开发中常用的RestTemplate,Gson等~

本文以创建Foo对象为例,展开讨论,如何优雅的创建一个单例对象

public class Foo{}

饿汉式

  1. 这种方式是最简单的创建单例对象的模式,因为是在ClassLoader加载这个对象的工厂类的时候就实例化了该对象,所以不涉及多线程同时创建,所以也是线程安全的。

  2. 这种方式缺点也很明显,就是没有懒加载,如果我们的应用程序从JVM启动到JVM停机,全程都没有使用到这个对象,那么这个对象就会白白占用内存空间,造成浪费。所以一般情况下,不会使用这种方式。

public class FooFactory {

    private static final Foo foo = new Foo();
    
    public static Foo instance() {
        return foo;
    }
}

懒汉式

”懒汉式“顾名思义就是只有到使用到这个单例对象的时候才会创建,可以做到”按需加载“的目的。

线程不安全的饿汉式

public class FooFactory {
    
    private static Foo foo;
    
    public static Foo instance() {
        if(foo == null) {
            foo = new Foo();
        }
        return foo;
    }
}
  1. 首先判断单例是否为空,为空进行初始化,然后直接返回创建好的单例对象,这种实现本身并没有什么问题,但是如果在多线程环境下,多个线程同时进入到if判断,那么每个线程都会执行单例对象的初始化操作,最终导致创建多个单例对象,所以这种方式是线程不安全的。

加锁改进饿汉式

我们可以通过在构建对if语句通过加同步代码块的形式让其变成线程使其达到线程安全的效果。

public class FooFactory {
    
    private static Foo foo;
    
    public static Foo instance() {
        synchronized (FooFactory.class) {
            if(foo == null) {
                foo = new Foo();
            }
        }
        return foo;
    }
}
  1. 线程在进入if条件判断的时候,需要先拿到同步锁,然后才能进入到同步代码块,这种方式可以确保每次只有一个线程能进入if代码块,所以,可以很好的保证线程安全性。
  2. 这种方式也有缺点,就是每次获取单例对象的时候,都需要进行加锁,在高并发场景下,会降低我们程序的吞吐量。

双重检测锁改进饿汉式

public class FooFactory {

    private static Foo foo;

    public static Foo instance() {
        if (foo == null) {
            synchronized (FooFactory.class) {
                if (foo == null) {
                    foo = new Foo();
                }
            }
        }
        return foo;
    }
}
  1. 检查单例对象是否被初始化,如果已被初始化,则立即返回单例对象。这是第一次检 查,对应于示例代码中的检查1,此次检查不需要使用锁进行线程同步,用于提高获取单例对象 的性能。

  2. 如果单例没有被初始化,则试图去进入临界区进行初始化操作,此时才去获取锁。

  3. 进入临界区之后,再一次检查单例对象是否已经被初始化,如果还没被初始化,就初始化一个实例。这是第二次检查,对应于代码中的检查2,此次检查在临界区内进行。

为什么在临界区内,还需要执行一次检查呢?答案是:在多个线程竞争的场景下,可能同时 不止一个线程通过了第一次检查(检查1),此时第一个通过“检查1”的线程将首先进去临界 区,而其他的通过“检查1”的线程将被阻塞,在第一个线程实例化单例对象释放锁之后,其他 线程可能获取到锁进入临界区,实际上单例已经被初始化了,所以哪怕是进入了临界区,其他线 程并没有办法通过“检2”的条件判断,无法执行重复的初始化。

双重检查不仅仅避免了单例对象在多线程场景中的反复初始化,而且除了初始化的时候需要 现加锁,后续的所有调用不需要加锁而直接返回单例,从而提升了获取单例时的性能

volatile + 双重检测锁

使用双重检查锁机制的单例模式一切看上去都很完美了,其实并不是这样。 初始化对象的代码是foo = new Foo(),最终翻译成字节码后,他的初始话步骤一共有三步

  1. 分配一块内存 M。
  2. 在内存 M 上初始化 Singleton 对象。
  3. M 的地址赋值给 instance 变量。 编译器、处理器都可能对没有内存屏障、数据依赖关系的操作做重排序,上序的三个指令优 化后可能就变成了这样:
  4. 分配一块内存 M。
  5. 将 M 的地址赋值给 instance 变量。
  6. 在内存 M 上初始化 Singleton 对象。

指令重排之后,获取单例是可能导致问题的发生,这里假设两个线程以下面的次序执行:

  1. 线程 A 先执行 instance()方法,当执行到分配一块内存并将地址赋值给 M 后,恰好发生了线程切换。此时,线程 A 还没有来得及将 M 指向的内存初始化。

  2. B 刚进入到 instance() 方法,判断 if 语句 instance 是否为空;此时的 instance 不 为空,线程 B 直接获取到了未初始化的 instance 变量。

  3. 由于线程 B 得到的是一个初始化完全的对象,访问 foo 成员变量的时候就可能发生异常。如何确保线程 B 获取的是一个完成初始化的单例呢?可以通过 volatile 禁止指令重排。双重检查锁 + volatile 相结合的单例模式实现

public class FooFactory {

    private static volatile Foo foo;

    public static Foo instance() {
        if (foo == null) {
            synchronized (FooFactory.class) {
                if (foo == null) {
                    foo = new Foo();
                }
            }
        }
        return foo;
    }
}

静态内部类实现懒汉式

虽然使用volatile + 双重检测锁可以很好的实现一个线程安全的单例,但是代码总体上实现起来比较复杂,我们可以换一种思路,使用静态内部类的方式实现一个线程安全的懒汉式单例

public class FooFactory {

    private static class Inner {
        private static final Foo foo = new Foo();
    }

    public static Foo instance() {
        return Inner.foo;
    }
}

只有调用instance()方法时,静态内部类才会被加载,这种方式实现起来比较简单,也可以达到我们的要求。