Java中的单例模式

单例模式是 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();
    }
}
Java

以上程序的执行结果为:

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();
    }
}
Java

以上程序的执行结果为:

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.");
    }
}
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.");
    }
}
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.");
    }
}
Java

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

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

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

为了解决此问题,我们可以使用关键字 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.");
    }
}
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.");
    }
}
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();
    }
}
Java

以上程序的执行结果为:

Hi,Java.

小结

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

作者:柯广的网络日志

微信公众号:Java大数据与数据仓库