在Java中如何写一个正确的单例模式

201 阅读13分钟

今天我们一起来探讨下单例模式,可以说,单例模式是面试常客,如果考察你对设计模式的理解程度,那么有很大可能会考察到,因为单例模式虽然看似简单,每个人都可能写出来。但如果往深了挖,又能考察出面试候选人对于并发、类加载、序列化等重要知识点的掌握程度和水平。单例模式有很多种写法,那么哪种写法更好呢,为什么?

要想知道哪种写法好,首先我们需要知道什么是单例模式,单例模式指的是,保证一个类只有一个实例,并且提供一个全局可以访问的入口。

举个例子,这就好比是“分身术”,但是每个“分身”其实都对应同一个“真身”。

那么我们为什么需要单例呢,其中一个理由,那就是为了节省内存、节省计算。很多情况下,我们只需要一个实例就够了,如果出现了更多的实例,反而属于浪费。举个例子,我们就拿一个初始化比较耗时的类来说:

public class ExpensiveResource {
    public ExpensiveResource() {
        field1 = // 查询数据库
        field2 = // 然后对查到的数据做大量计算
        field3 = // 加密、压缩等耗时操作
    }
}

这个类在构造的时候,需要查询数据库并对查到的数据做大量计算,所以在第一次构造时,我们花了很多时间来初始化这个对象。但是假设我们数据库里的数据是不变的,并把这个对象保存在了内存中,那么以后就用同一个实例了,如果每次都重新生成新的实例,实在是没必要。

接下来看看需要单例的第二个理由,那就是为了保证结果的正确。比如我们需要一个全局的计数器,用来统计人数,那么如果有多个实例,反而会造成混乱。

另外呢,就是为了方便管理。很多工具类,我们只需要一个实例,那么我们通过统一的入口,比如通过 getInstance 方法去获取这个单例是很方便的,太多实例不但没有帮助,反而会让人眼花缭乱。

在了解了单例模式的好处之后,我们接下来就来探讨一下单例模式有哪些适用场景。

无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是它帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储任何状态,这时候我们就只需要一个实例对象。

全局信息类:比如我们在一个类上记录网站的访问次数,并且不希望有的访问被记录在对象 A 上,有的却被记录在对象 B 上,这时候我们就可以让这个类成为单例,需要计数的时候拿出来用即可。

常见的写法又有哪些呢,我认为有这么 5 种:饿汉式、懒汉式、双重检查式、静态内部类式、枚举式。

我们按照写法的难易度来逐层递讲,先来看下相对简单的饿汉式写法具体是什么样的。

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

用 static 修饰我们的实例,并把构造函数用 private 修饰。这是最直观的写法。由 JVM 的类加载机制保证了线程安全。

这种写法的缺点也比较明显,那就是在类被加载时便会把实例生成出来,所以假设我们最终没有使用到这个实例的话,便会造成不必要的开销。

下面我们再来看下饿汉式的变种——静态代码块形式。

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

这种写法把新建对象的相关代码转移到了静态代码块里,在原理上和上面那一种“饿汉式”的写法是比较相近的,所以同样会在类加载的时候完成实例的创建。

在了解了饿汉式的写法后,再来看下第二种写法,懒汉式。

public class Singleton {
    private static Singleton singleton;
    private Singleton() {}
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

这种写法的优点在于,只有在 getInstance 方法被调用的时候,才会去进行实例化,所以不会造成资源浪费,但是在创建的过程中,并没有考虑到线程安全问题,如果有两个线程同时执行 getInstance 方法,就可能会创建多个实例。所以这里需要注意,不能使用这种方式,这是错误的写法。

为了避免发生线程安全问题,我们可以对前面的写法进行升级,那么线程安全的懒汉式的写法是怎样的呢。

public class Singleton {
    private static Singleton singleton;
    private Singleton() {}
    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

我们在 getInstance 方法上加了 synchronized 关键字,保证同一时刻最多只有一个线程能执行该方法,这样就解决了线程安全问题。但是这种写法的缺点也很明显:如果有多个线程同时获取实例,那他们不得不进行排队,多个线程不能同时访问,然而这在大多数情况下是没有必要的。

为了提高效率,缩小同步范围,就把 synchronized 关键字从方法上移除了,然后再把 synchronized 关键字放到了我们的方法内部,采用代码块的形式来保护线程安全。

public class Singleton {
    private static Singleton singleton;
    private Singleton() {}
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                singleton = new Singleton();
            }
        }
        return singleton;
    }
}

这种写法是错误的。它的本意是想缩小同步的范围,但是从实际效果来看反而得不偿失。因为假设有多个线程同时通过了 if 判断,那么依然会产生多个实例,这就破坏了单例模式。

所以,为了解决这个问题,在这基础上就有了“双重检查模式”。

public class Singleton {
    private static volatile Singleton singleton;
    private Singleton() {}
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton =  new Singleton();
                }
            }
        }
        return singleton;
    }
}

这种写法的优点就是不仅做到了延迟加载,而且是线程安全的,同时也避免了过多的同步环节。我们重点来看一下 getInstance 方法,这里面有两层 if 判空,下面我们分别来看一下每个 if 的作用。

这里涉及到一个常见的问题,面试官可能会问你,“为什么要 double-check?去掉第二次的 check 行不行?” 这时你需要考虑这样一种情况,有两个线程同时调用 getInstance 方法,并且由于 singleton 是空的,所以两个线程都可以通过第一个 if。

然后就遇到了 synchronized 锁的保护,假设线程 1 先抢到锁,并进入了第二个 if,那么线程 1 就会创建新实例,然后退出 synchronized 代码块。接着才会轮到线程 2 进入 synchronized 代码块,并进入第二层 if,此时线程 2 会发现 singleton 已经不为 null,所以直接退出 synchronized 代码块,这样就保证了没有创建多个实例。假设没有第二层 if,那么线程 2 也可能会创建一个新实例,这样就破坏了单例,所以第二层 if 肯定是需要的。

而对于第一个 check 而言,如果去掉它,那么所有线程都只能串行执行,效率低下,所以两个 check 都是需要保留。

相信你可能看到了,我们在双重检查模式中,给 singleton 这个对象加了 volatile 关键字,那 为什么要用 volatile 呢? 这是因为 new 一个对象的过程,其实并不是原子的,至少包括以下这 3 个步骤:

  1. 给 singleton 对象分配内存空间;
  2. 调用 Singleton 的构造函数等,来进行初始化;
  3. 把 singleton 对象指向在第一步中分配的内存空间,而在执行完这步之后,singleton 对象就不再是 null 了。

这里需要留意一下这 3 个步骤的顺序,因为存在重排序,所以上面所说的三个步骤的顺序,并不是固定的。虽然看起来是 1-2-3 的顺序,但是在实际执行时,也可能发生 1-3-2 的情况,也就是说,先把 singleton 对象指向在第一步中分配的内存空间,再调用 Singleton 的构造函数。

如果发生了 1-3-2 的情况,线程 1 首先执行新建实例的第一步,也就是分配单例对象的内存空间,然后线程 1 因为被重排序,所以去执行了新建实例的第三步,也就是把 singleton 指向之前的内存地址,在这之后对象不是 null,可是这时第 2 步并没有执行。假设这时线程 2 进入 getInstance 方法,由于这时 singleton 已经不是 null 了,所以会通过第一重检查并直接返回 singleton 对象并使用,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错。

最后,线程 1“姗姗来迟”,才开始执行新建实例的第二步——初始化对象,可是这时的初始化已经晚了,因为前面已经报错了。

到这里关于“为什么要用 volatile”问题就讲完了,使用 volatile 的意义,我认为主要在于呢,它可以防止刚讲到的重排序的发生,也就避免了拿到没完成初始化的对象。

接下来要讲到的这种方式,静态内部类的写法,利用了类装载时由 JVM 所保证的单线程原则,进而保证了线程安全。

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

相比于饿汉式在类加载时就完成实例化,这种静态内部类的写法并不会有这个问题,这种写法只有在调用 getInstance 方法时,才会进一步完成内部类的 singleton 的实例化,所以不存在内存浪费的问题。

这里简单做个小总结,静态内部类写法与双重检查模式的优点一样,都是避免了线程不安全的问题,并且延迟加载,效率高。

可以看出,静态内部类和双重检查的写法都是不错的写法,但是它们不能防止被反序列化生成多个实例,那有没有更好的写法呢?最后我们来看枚举方式的写法。

public enum Singleton {
    INSTANCE;
    public void myMethod() {
    }
}

这就是枚举方式的写法,下面我们会对这种写法进行展开分析。

前面我们讲了饿汉式、懒汉式、双重检查、静态内部类、枚举这 5 种写法,有了这么多方法可以实现单例,这时你可能会问了,那我该怎么选择,用哪种单例的实现方案最好呢?

Joshua Bloch(约书亚·布洛克)在《Effective Java》一书中明确表达过一个观点:“使用枚举实现单例的方法,虽然还没有被广泛采用,但是单元素的枚举类型已经成为了实现 Singleton 的最佳方法。”

为什么他会更为推崇枚举模式的单例呢?这就不得不回到枚举写法的优点上来说了,枚举写法的优点有这么几个:

首先就是写法简单。枚举的写法不需要我们自己考虑懒加载、线程安全等问题。同时,代码也比较“短小精悍”,比任何其他的写法都更简洁,很优雅。

第二个优点是线程安全有保障,枚举类的本质也是一个 Java 类,但是它的枚举值会在枚举类被加载时完成初始化,所以依然是由 JVM 帮我们保证了线程安全。

前面几种实现单例的方式,其实是存在隐患的,那就是可能被反序列化生成新对象,产生多个实例,从而破坏了单例模式。接下来要说的枚举写法的第 3 个优点,它恰恰解决了这些问题。

对 Java 官方文档中的相关规定翻译如下:“枚举常量的序列化方式不同于普通的可序列化或可外部化对象。枚举常量的序列化形式仅由其名称组成;该常量的字段值不存在于表单中。要序列化枚举常量,ObjectOutputStream 将写入枚举常量的 name 方法返回的值。要反序列化枚举常量,ObjectInputStream 从流中读取常量名称;然后,通过调用 java.lang.Enum.valueOf 方法获得反序列化常量,并将常量的枚举类型和收到的常量名称作为参数传递。”

也就是说,对于枚举类而言,反序列化的时候,会根据名字来找到对应的枚举对象,而不是创建新的对象,所以这就防止了反序列化导致的单例破坏问题的出现。

对于通过反射破坏单例而言,枚举类同样有防御措施。反射在通过 newInstance 创建对象时,会检查这个类是否是枚举类,如果是,就抛出 IllegalArgumentException(“Cannot reflectively create enum objects”) 异常,反射创建对象失败。

可以看出,枚举这种方式,能够防止序列化和反射破坏单例,在这一点上,与其他的实现方式比,有很大的优势。安全问题不容小视,一旦生成了多个实例,单例模式就彻底没用了。

所以结合讲到的这 3 个优点,写法简单、线程安全、防止反序列化和反射破坏单例,枚举写法最终胜出。

今天的分享到这里就结束了,最后我来总结一下。今天我讲解了单例模式什么是,它的作用、用途,以及 5 种经典写法,其中包含了饿汉式、懒汉式、双重检查方式、静态内部类方式和枚举的方式,最后我们还经过对比,看到枚举方式在写法、线程安全,以及避免序列化、反射攻击上,都有优势。

这里也跟大家强调一下,如果使用线程不安全的错误的写法,在并发情况下可能产生多个实例,那么不仅会影响性能,更可能造成数据错误等严重后果。

如果是在面试中遇到这个问题,那么你可以从一开始的饿汉式、懒汉式说起,一步步分析每种写法的优缺点,并对写法进行演进,然后重点讲一下双重检查模式为什么需要两次检查,以及为什么需要 volatile 关键字,最后再说到枚举类写法的优点和背后的原理,相信这一定会为你的面试加分。

另外在工作中,要是遇到了全局信息类、无状态工具类等场景的时候,推荐使用枚举的写法来实现单例模式。