Java源码解析第27讲:单例模式的实现方式有几种?它们有什么优缺点?

单例模式是 Java 中最简单的设计模式之一,它是指一个类在运行期间始终只有一个实例,我们就把它称之为单例模式。它不但被应用在实际的工作中,而且还是面试中最常考的题目之一。通过单例模式我们可以知道此人的编程风格,以及对于基础知识的掌握是否牢固。

我们本课时的面试题是,单例的实现方式有几种?它们有什么优缺点?

典型回答

单例的实现分为饿汉模式懒汉模式。顾名思义,饿汉模式就好比他是一个饿汉,而且有一定的危机意识,他会提前把食物囤积好,以备饿了之后直接能吃到食物。对应到程序中指的是,在类加载时就会进行单例的初始化,以后访问时直接使用单例对象即可。

饿汉模式的实现代码如下:

public class Singleton {
    // 声明私有对象
    private static Singleton instance = new Singleton();    
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        return instance;
    }
    private Singleton() {
    }
    // 方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        // 调用单例对象
        Singleton singleton = Singleton.getInstance();
        // 调用方法
        singleton.sayHi();
    }
}

以上程序的执行结果为:

Hi,Java.

从上述结果可以看出,单例对象已经被成功获取到并顺利地执行了类中的方法。它的优点是线程安全,因为单例对象在类加载的时候就已经被初始化了,当调用单例对象时只是把早已经创建好的对象赋值给变量;它的缺点是可能会造成资源浪费,如果类加载了单例对象(对象被创建了),但是一直没有使用,这样就造成了资源的浪费。

懒汉模式也被称作为饱汉模式,顾名思义他比较懒,每次只有需要吃饭的时候,才出去找饭吃,而不是像饿汉那样早早把饭准备好。对应到程序中指的是,当每次需要使用实例时,再去创建获取实例,而不是在类加载时就将实例创建好。

懒汉模式的实现代码如下:

public class Singleton {
    // 声明私有对象
    private static Singleton instance;
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
    private Singleton() {
    }
    // 方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.sayHi();
    }
}

以上程序的执行结果为:

Hi,Java.

从上述结果可以看出,单例对象已经被成功获取到并顺利地执行了类中的方法,它的优点是不会造成资源的浪费,因为在调用的时候才会创建被实例化对象;它的缺点在多线程环境下是非线程是安全的,比如多个线程同时执行到 if 判断处,此时判断结果都是未被初始化,那么这些线程就会同时创建 n 个实例,这样就会导致意外的情况发生。

考点分析

使用单例模式可以减少系统的内存开销,提高程序的运行效率,但是使用不当的话就会造成多线程下的并发问题。饿汉模式为最直接的实现单例模式的方法,但它可能会造成对系统资源的浪费,所以只有既能保证线程安全,又可以避免系统资源被浪费的回答才能彻底地征服面试官。

和此知识点相关的面试题还有以下这些:

  • 什么是双重检测锁?它是线程安全的吗?
  • 单例的还有其他实现方式吗?

知识扩展

双重检测锁

为了保证懒汉模式的线程安全我们最简单的做法就是给获取实例的方法上加上 synchronized(同步锁)修饰,如下代码所示:

public class Singleton {
    // 声明私有对象
    private static Singleton instance;
    // 获取实例(单例对象)
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}

这样虽然能让懒汉模式变成线程安全的,但由于整个方法都被 synchronized 所包围,因此增加了同步开销,降低了程序的执行效率。

于是为了改进程序的执行效率,我们将 synchronized 放入到方法中,以此来减少被同步锁所修饰的代码范围,实现代码如下:

public class Singleton {
    // 声明私有对象
    private static Singleton instance;
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                instance = new Singleton();
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}

细心的你可能会发现以上的代码也存在着非线程安全的问题。例如,当两个线程同时执行到「if (instance == null) { 」判断时,判断的结果都为 true,于是他们就排队都创建了新的对象,这显然不符合我们的预期。于是就诞生了大名鼎鼎的双重检测锁(Double Checked Lock,DCL),实现代码如下:

public class Singleton {
    // 声明私有对象
    private static Singleton instance;
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        // 第一次判断
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次判断
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}

上述代码看似完美,其实隐藏着一个不容易被人发现的小问题,该问题就出在 new 对象这行代码上,也就是 instance = new Singleton() 这行代码。这行代码看似是一个原子操作,然而并不是,这行代码最终会被编译成多条汇编指令,它大致的执行流程为以下三个步骤:

  1. 给对象实例分配内存空间;
  2. 调用对象的构造方法、初始化成员字段;
  3. 将 instance 对象指向分配的内存空间。

但由于 CPU 的优化会对执行指令进行重排序,也就说上面的执行流程的执行顺序有可能是 1-2-3,也有可能是 1-3-2。假如执行的顺序是 1-3-2,那么当 A 线程执行到步骤 3 时,切换至 B 线程了,而此时 B 线程判断 instance 对象已经指向了对应的内存空间,并非为 null 时就会直接进行返回,而此时因为没有执行步骤 2,因此得到的是一个未初始化完成的对象,这样就导致了问题的诞生。执行时间节点如下表所示:

时间点线程执行操作
t1Ainstance = new Singleton() 的 1-3 步骤,待执行步骤 2
t2Bif (instance == null) { 判断结果为 false
t3B返回半初始的 instance 对象

为了解决此问题,我们可以使用关键字 volatile 来修饰 instance 对象,这样就可以防止 CPU 指令重排,从而完美地运行懒汉模式,实现代码如下:

public class Singleton {
    // 声明私有对象
    private volatile static Singleton instance;
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        // 第一次判断
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次判断
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
单例其他实现方式

除了以上的 6 种方式可以实现单例模式外,还可以使用静态内部类枚举类来实现单例。静态内部类的实现代码如下:

public class Singleton {
    // 静态内部类
    private static class SingletonInstance {
        private static final Singleton instance = new Singleton();
    }
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        return SingletonInstance.instance;
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}

从上述代码可以看出,静态内部类和饿汉方式有异曲同工之妙,它们都采用了类装载的机制来保证,当初始化实例时只有一个线程执行,从而保证了多线程下的安全操作。JVM 会在类初始化阶段(也就是类装载阶段)创建一个锁,该锁可以保证多个线程同步执行类初始化的工作,因此在多线程环境下,类加载机制依然是线程安全的。

但静态内部类和饿汉方式也有着细微的差别,饿汉方式是在程序启动时就会进行加载,因此可能造成资源的浪费;而静态内部类只有在调用 getInstance() 方法时,才会装载内部类从而完成实例的初始化工作,因此不会造成资源浪费的问题。由此可知,此方式也是较为推荐的单例实现方式。

单例的另一种实现方式为枚举,它也是《Effective Java》作者极力推荐地单例实现方式,因为枚举的实现方式不仅是线程安全的,而且只会装载一次,无论是序列化、反序列化、反射还是克隆都不会新创建对象。它的实现代码如下:

public class Singleton {
    // 枚举类型是线程安全的,并且只会装载一次
    private enum SingletonEnum {
        INSTANCE;
        // 声明单例对象
        private final Singleton instance;
        // 实例化
        SingletonEnum() {
            instance = new Singleton();
        }
        private Singleton getInstance() {
            return instance;
        }
    }
    // 获取实例(单例对象)
    public static Singleton getInstance() {
        return SingletonEnum.INSTANCE.getInstance();
    }
    private Singleton() {
    }
    // 类方法
    public void sayHi() {
        System.out.println("Hi,Java.");
    }
}
class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.sayHi();
    }
}

以上程序的执行结果为:

Hi,Java.

小结

本课时我们讲了 8 种实现单例的方式,包括线程安全但可能会造成系统资源浪费的饿汉模式,以及懒汉模式和懒汉模式变种的 5 种实现方式。其中包含了两种双重检测锁的懒汉变种模式,还有最后两种线程安全且可以实现延迟加载的静态内部类的实现方式和枚举类的实现方式,其中比较推荐使用的是后两种单例模式的实现方式。


精选评论

**辉:

老师,枚举类是怎么保证单例的,底层的内存模型是怎么支持的呢?😀

    讲师回复:

    这个可以通过查看枚举类的字节码发现秘密所在哦,枚举类最终会被编译为 final 类型的并且将枚举值标识为 static 类型的,因为 static 类型的属性会在类被加载之后被初始化,所以枚举类就是线程安全的了。

**远:

synchronized已经可以防止指令重排了, 不需要加volatile

**5968:

老师,3楼应该是想问,双重检测下,synchronize中,相当于上锁了,一个线程锁不释放的情况下,其他线程是进不来的,为什么会同时有多个线程来判断是否为空,导致要考虑指令重排问题。我觉得只有当线程释放锁才会出现多个线程同时判空,请问理解是否正确。

    讲师回复:

    即使使用了 synchronized 依然要使用双重判断的,你试想一下,假如多个线程都先经过了判断到达了 synchronized 的代码,那么他们因为已经进行了判空操作了,那么拿到的结果都是 false 也就是没有创建对象,那么即使排队执行方法内的代码,如果没有再次判断都会重新再创建一个新的对象,所以双重判断是不能省略的。

*宇:

老师我有个疑惑哈,双重检测的单例模式下,第二次检测是被synchronized修饰的代码块,那说明在这一段代码块中只能单线程运行吧,单线程的指令重排序是不会影响执行结果的吧?为什么还要用volatile修饰呢?明明synchronized就已经保证了可见性呀。麻烦老师解答一下,谢谢。

    讲师回复:

    单线程下没问题,有问题都是发生在多线程环境下,当两个线程同时执行到「if (instance == null) { 」判断时,判断的结果都为 true,于是它们就排队都创建了多个新的对象,所以就有问题。

Gyoung:

学习了

**国:

synchronized 的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。volatile的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。

**国:

synchronized 是不可以保证防止指令重排的,请不要误导别人。见《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》第三版:Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-SerialSemantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

    讲师回复:

    是这样,文中也是这个意思“我们可以使用关键字 volatile 来修饰 instance 对象,这样就可以防止 CPU 指令重排”,没有说 synchronized 可以防止指令重排。

**0298:

为什么线程A拿到了锁却不执行完三步再释放锁?其实,这里B并未获取锁​,也并未创建对象,只是获得了一个未初始化完成的对象而已。

    讲师回复:

    这是CPU的运行规则,为了让所有的应用都雨露均沾的运行,因为运行应用(或线程)的数量远远大于CPU核心数,所以每个应用或线程只能得到一段时间的执行权,那么就有可能出现一个线程运行了一半,另一个线程有开始运行的情况。

*涛:

老师,5楼应该是想问,synchronize代码块中已经保证了单线程,为啥还会存在多线程同时访问了new方法的情况。难道在汇编new第二步或者第三步的时候,就已经放开synchronize锁了吗?双重判断的逻辑是合理的

    讲师回复:

    synchronized 如果修饰的是方法,那么是线程安全的,但效率太低,所以我们通常会把 synchronized 移动到方法中(见正文),这个时候就要双重判断了。

*滔:

懒汉模式的方法不用加synchroned么?多线程会有问题吧

    讲师回复:

    饿汉方式不需要加 synchronized 因为只在类加载的时候执行一次,不会有线程问题;懒汉模式必须加哈。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

源码头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值