如何实现一个单例模式?

本文主要介绍了实现单例模式的一些方法、利弊等。

前言

网上已经有太多的文章介绍如何实现一个单例模式了,但是请记住,无论怎么实现,我们需要关注的点无外乎下面几个:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过new创建实例;
  • 该类的私有静态变量也需要private,这是该类的唯一实例;
  • 需要有返回该类唯一实例的静态public方法,这是外部获取实例的全局访问点;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑getInstance()性能是否高(是否加锁)

在知悉以上几点后,下面我们开始列举实现单例模式的一些方法。

实现单例模式的方法

1. 饿汉式(Eager initialization)

在饿汉式单例中,实例的创建是在类加载的时候就已经创建并初始化好了,所以instance实例的创建过程是线程安全的,这也是最简单的一种实现单例的方式。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.geek.design.patterns.singleton;

public class EagerInitializedSingleton {

    private static final EagerInitializedSingleton instance = new EagerInitializedSingleton();
    // 将构造器初始化防止客户端new实例
    // private constructor to avoid client applications to use constructor
    private EagerInitializedSingleton() {
    }
    
    public static EagerInitializedSingleton getInstance() {
        return instance;
    }
}

饿汉式由于不支持懒加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。

但是也有人不这么认为,采用饿汉式的实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。所以具体要不要使用这种方式,要根据具体的情况去分析,而不是一昧的否决。

2. 静态代码块(Static block initialization)

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.geek.design.patterns.singleton;


public class StaticBlockSingleton {

    private static StaticBlockSingleton instance;

    private StaticBlockSingleton() {
    }
    //static block initialization for exception handling
    static {
        try {
            instance = new StaticBlockSingleton();
        } catch (Exception e) {
            throw new RuntimeException("Exception occured in creating singleton instance");
        }
    }

    public static StaticBlockSingleton getInstance() {
        return instance;
    }
}

静态代码块的实现方式类似饿汉式,都会在使用之前就初始化实例,区别主要在于这种方式可以捕获处理创建实例过程中的异常。

3. 懒汉式(Lazy Initialization)

相比于饿汉式单例,懒汉式的优势是支持延迟加载。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.geek.design.patterns.singleton;

public class LazyInitializedSingleton {
    private static LazyInitializedSingleton instance;

    private LazyInitializedSingleton() {
    }

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

上面的代码中为了实现线程安全,所以在getInstance()方法上加了synchronized关键字(当然也可以不加synchronized,但这样就不是线程安全的了),这会导致这个函数的并发度很低。如果这个单例偶尔被用到还好,但如果这个函数被频繁调用,那么频繁加锁、释放锁及并发度很低等问题,会导致性能瓶颈。为了解决这个问题,我们可以使用双重检测机制,在if判断逻辑中对整个类加锁,这样只要instance被创建过之后,即使再调用getInstance()函数也不会再加锁了。

4. 双重检测(Double Checked Locking)

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.geek.design.patterns.singleton;

public class ThreadSafeSingleton {

    private static volatile ThreadSafeSingleton instance;

    private ThreadSafeSingleton() {
    }
    public static ThreadSafeSingleton getInstanceUsingDoubleLocking() {
        if (instance == null) {
            synchronized (ThreadSafeSingleton.class) {
                if (instance == null) {
                    instance = new ThreadSafeSingleton();
                }
            }
        }
        return instance;
    }
}

上面的代码中对instance成员变量增加了volatile关键字,在早些版本的JDK中,因为指令重排序,可能会导致ThreadSafeSingleton对象刚被new出来,并且赋值给instance之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了,所以需要给instance成员变量加上volatile关键字,禁止指令重排序才行。

另外,还有另外一种实现方式,可以先看看对比下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.geek.design.patterns.singleton;

public class DoubleCheckedSingleton {

    private static volatile DoubleCheckedSingleton instance;
    private static Object mutex = new Object();

    private DoubleCheckedSingleton() {
    }

    public static DoubleCheckedSingleton getInstance() {
        DoubleCheckedSingleton tempInstance = instance;
        if (null == tempInstance) {
            synchronized (mutex) { // 这里加锁的为什么是mutex而不是DoubleCheckedSingleton.class?
                tempInstance = instance; // 这里为什么要第二次赋值?
                if (null == tempInstance) {
                    instance = tempInstance = new DoubleCheckedSingleton();
                }
            }
        }
        return tempInstance; // return出去的是临时变量
    }

}

除了上面的链接,Spring源码中的ReactiveAdapterRegistry以及JDK源码中的AbstractQueuedSynchronizer都采用了类似的写法,使用局部变量来接收静态的成员变量。

另外,synchronized的对象是静态私有变量而不是类本身,关于这几点,我查找了一些资料,得到了初步的答案,有兴趣的可以跳转到原文查看。

  1. 第一个问题,为什么要使用局部变量来接收静态成员变量?

    首先是《Effective Java》第二版(P283)以及第三版(Item83)中都对此中关于这一点的解释:

    以下内容摘自Effective Java第三版:

    This code may appear a bit convoluted. In particular, the need for the local variable (result) may be unclear. What this variable does is to ensure that field is read only once in the common case where it’s already initialized. While not strictly necessary, this may improve performance and is more elegant by the standards applied to low-level concurrent programming. On my machine, the method above is about 1.4 times as fast as the obvious version without a local variable.

    另外,维基百科Double-checked locking词条也引用了这一点。主要的原因就是volatile修饰的变量保证了可见性,每当有线程去读取这个变量时都会直接从主内存读取,相比于读取CPU缓存,无疑是更浪费性能的(这里涉及到CPU缓存和JMM内存模型的知识,感兴趣的可以先查阅一下volatile的原理),在实例第一次初始化完成后,后面每次调用getinstance()方法时,由于使用了用临时变量接收volatile变量,所以只会读取一次volatile变量(原始的写法需要两次读取,一次在check null的时候,一次在return的时候),是能够提升性能的。

  2. 第二个问题,锁定的是静态私有变量而不是类本身?

    根据StackOverflow的这个问题的这个问题中的说法,这种方式锁定的是一个没有在类外的成员变量,可以排除不必要的干扰。至于深层的原因,等后面学习的更深入了过来补充。

    以下内容摘自原帖回答(Method 3和The reason #3 都指的是上面的这种写法)

    Method 3 is the best in many cases because the lock object is not exposed outside of your class.

    The reason #3 is the best is that any random bit of code could synchronize on Test.class and potentially spoil your day. Also, class initialization runs with a lock on the class held, so if you've got crazy class initializers you can give yourself headaches.

  3. 此外,在第二次校验之前二次赋值的原因是:在获取锁之后,刷新本地引用为最新值,因为这时候volatile变量可能会被其他线程修改。

5. 静态内部类(Bill Pugh Singleton)

在Java5之前,Java内存模型有很多问题,在某些情况下,如果有太多的线程试图同时尝试获取Singleton类的实例,上述方法就会失败。因此,Bill Pugh提出了一种使用静态内部类来创建Singleton实例的放方法。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.geek.design.patterns.singleton;

public class BillPughSingleton {
    private BillPughSingleton() {
    }

    private static class SingletonHelper {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

在这种静态内部类的实现中,当外部类BillPughSingleton被加载的时候,并不会创建SingletonHelper,只有当调用getInstance()方法时,SingletonHelper才会被加载,然后才会创建INSTANCE。instance的唯一性、创建过程的线程安全性,都由JVM来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

6. 枚举(Enum Singleton)

最后,介绍一种基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。

代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.geek.design.patterns.singleton;

import java.util.concurrent.atomic.AtomicLong;

public enum EnumSingleton {
    INSTANCE;
    private AtomicLong id = new AtomicLong(0);

    public long getId() {
        return id.incrementAndGet();
    }
}

单例的枚举实现在Effective Java一书中提到,因为其功能完善,使用简单,并且无偿地提供了序列化机制,在面对复杂的序列化或者反射攻击时任然可以绝对防止多次实例化等优点,因而被作者所推崇。

下面的表格总结了这几种方法的优劣:

方法支持懒加载线程安全性能问题
饿汉式启动时加载可能会浪费不必要的资源
懒汉式使用synchronized关键字虽然实现了线程安全,但是会导致性能瓶颈
双重检测第一次初始化时才会加类锁,推荐
静态内部类利用静态内部类的特性,推荐
枚举利用枚举的特性

参考资料

Java Singleton Design Pattern Example Best Practices

Double-checked locking - Wiki

Threadsafe Singleton Design Pattern Java

Thread Safety in Java Singleton Classes

updatedupdated2023-06-032023-06-03
加载评论