欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

多线程安全的单例模式

程序员文章站 2022-07-14 09:16:29
...

什么是单例模式?

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

单例模式的实现思路:

1.一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);
2.当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;
3.同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

在了解了单例模式的实现思路后,我们不难写出如下代码:

public class Singleton{
    private static Singleton instance;
    private Singleton(){} //将构造方法私有 
    public static Singleton getInstance(){
        if(instance == null)
            instance = new Singleton();
        return instance;
    }
}

以上代码即实现了一个懒汉式的单例模式,但它是线程不安全的。当有多个线程同时访问,那么就会创建多个实例。此时可以对getInstance方法加锁:

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

这样每次只允许一个线程调用该方法,那么就可以实现线程的安全性,但是也正由于每次只允许一个线程访问,该方法的效率十分低下,但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。

所以就引出了双重检验锁:
双重检验锁

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance ==null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的if,如果在同步块内不进行二次检验的话就会生成多个实例了。

实现代码如下:

public static Singleton getInstance(){
    if(instance == null){              //Single Checked
        synchronized(Singleton.class){
            if(instance == null)       //Double Checked
                instance = new Singleton();
        }
    }
    return instance;
}

接下来分析这段代码:这段代码看起来十分完美,当实例为空时线程进入同步代码区域,此时其他线程是阻塞的。进入同步代码区的线程再次判断实例是否为空,如果为空则创建并返回。此时即使有阻塞的进程进入同步区但是判断了实例不为空,那么也就不会再创建新的对象了。但是它任然是有问题的:主要问题就在instance = new Singletoe();这段代码。这段代码并非一个原子操作,执行这一行时,JVM大概做了如下工作:

1.给instance分配内存
2.调用Singleton的构造方法来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这一步instance就不为空了)

但是JVM的即时编译器中存在指令重排序优化,也就是上面的2和3的执行顺序是不保证的,最终的执行顺序可能是1-2-3也可能是1-3-2,如果是后者那么在3执行完毕、2未执行之前被线程2抢占了此时instance已经是非null了(但却没有初始化),所以线程2会直接返回instance,然后使用就会报错。
那么只需要将instance声明为volatile就可以了。

引用自Jark’s Blog 有些人认为使用 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。

接下来看一种较为简单的线程安全单例模式:

class Singleton{
    private static final Singleton INSTANCE = new Singleton();
    private Singleton(){}
    public Singleton getInstance(){
        return INSTANCE;
    }
}

由于实例被声明为静态和final变量了,在第一次加载类在内存中就会初始化,所以创建实例本身是线程安全的。也正由于在类加载时期就创建了对象,所以形象的称为“饿汉式”。
但是他的缺点也是明显的,由于它即使客户端没有调用该方法,类的实例就已经被创建了,一定程度上也造成了资源的浪费。同时他在一些使用场景中也无法使用:例如实例的创建是依赖参数或者是配置文件的,在getInstance之前必须调用某个方法设置参数给它,那么这种单例模式就无法使用了。
此时(当当当当!!!),就应该使用我们的撒手锏了–静态内部类。

public class Singleton {  
    private static class SingletonCreater{
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton(){}
    public static final Singleton getInstance(){
        return SingletonCreater.INSTANCE;
    }
}

为什么静态内部类可以实现线程安全的调用?先看内部类加载的时机:

1.调用外部类的静态变量和静态方法可以让外部类被加载到内存中,不过被调用的外部类的内部类(不论是否静态)不会被加载。
2.加载静态内部类之前会先加载外部类,静态内部类或非静态内部类在使用它们的成员时才加载。
3.内部类可以随意使用外部类的成员对象和方法(即使私有),而不用生成外部类对象

那么当线程调用getInstance方法,由于调用了静态内部类的成员,会使内部类被加载到内存,而内部类的成员此时也被加载并初始化了,这样返回的就是外部类的实例了。这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。

还有更简单的实现方式吗?有的!!!!枚举!

public enum EasySingleton{
    INSTANCE;
}

是不是超级简单?!!!由于枚举类的实例创建过程就是线程安全的,我们可以用EasySingleton.INSTANCE来调用该实例。

说到这里顺便回顾一下枚举
枚举的格式:

//只有枚举项的枚举类
public enum 枚举类名 {
    枚举项1,枚举项2,枚举项3…;
}
  • 1
  • 2
  • 3
  • 4

这里每个枚举项都是枚举类的一个实例,也可以写一个带参数的枚举类型:

public enum 枚举类名 {
    枚举项1("100-90"),枚举项2("90-80"),举项3("80-70")...;
    String value;
    枚举类名(String str){    //构造方法
        this.value = str;
    }
}

带抽象方法的枚举:在创建枚举对象时要实现抽象方法

public enum 枚举类名 {
    枚举项1("100-90"){
        public String localValue(){
            return "优秀";
        }
    },枚举项2("90-80"){
        ...
    },枚举项3("80-70"){
        ...
    };
    String value;
    枚举类名(String str){    //构造方法
        this.value = str;
    }
    public abstract String localValue();
}