剑指Offer 2. 实现单例模式

单例模式及其应用。

写法1:懒汉式

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private static Singleton instance;
private Singleton(){}

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

return instance;
}
}

这种写法不是线程安全的,只能在单线程中使用。当有多个线程并发调用 getInstance() 方法的时候,有可能同时执行到 if(instance == null) 语句,所以可能创建多个实例。

测试:证明线程不安全

通过测试证明这种写法是线程不安全的。有可能需要重复运行很多次才会出现线程不安全的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Singleton{
private static Singleton instance;
private Singleton(){}

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

return instance;
}

public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new TestThread();
}

for (Thread thread : threads) {
thread.start();
}
}
}

class TestThread extends Thread {
@Override
public void run() {
int counter = 2;
while(counter-- >0){
int hash = Singleton.getInstance().hashCode();
System.out.printf("%s %d\n", this.getName(), hash);
}
}
}

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread-1 323112275
Thread-1 709937305
Thread-9 709937305
Thread-9 709937305
Thread-8 709937305
Thread-8 709937305
Thread-6 709937305
Thread-6 709937305
Thread-7 709937305
Thread-7 709937305
Thread-5 709937305
Thread-4 709937305
Thread-4 709937305
Thread-3 709937305
Thread-0 709937305
Thread-0 709937305
Thread-2 323112275
Thread-3 709937305
Thread-5 709937305
Thread-2 709937305

解释说明

从输出结果可以看出来,有两次调用获取到的实例和其他几次的调用获取到的实例是不一样的。说明这种写法不是线程安全的。

还需要注意的是,调用 getInstance() 的顺序和输出的顺序并不是一致的。所以 hashCode 相同的调用并不是连续输出的。

写法2:懒汉式改进

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton{
private Singleton(){}
private static Singleton instance;

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

return instance;
}
}

加上了 synchronized 关键字,现在可以做到线程安全,但是锁的粒度比较大,不够高效。

写法3:饿汉式,公有成员变量

把成员变量设为 public static final 类型,因为 final 修饰符的存在,可以保证这个实例是唯一不变的。

这种写法会在类加载的时候创建单例,因为类加载的过程是线程安全的,所以这种写法是线程安全的。

1
2
3
4
5
6
public class Singleton {
public static final Singleton INSTANCE = new Singleton();
private Singleton(){}

//...
}

写法4:饿汉式,静态工厂方法

1
2
3
4
5
6
7
8
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton(){}

public static synchronized Singleton getInstance() {
return instance;
}
}

这种写法是线程安全的,因为类的实例在类加载的时候就被初始化的。类的初始化是通过 ClassLoader 类的 loadClass() 方法加载的,而 loadClass() 方法是由 synchronized 修饰的。

两种饿汉式写法的比较

静态工厂方法的优点:

  1. 具有灵活性。比如,可以在不改变 API 的情况下把返回全局唯一示例改为为每个调用的线程返回唯一的实例。
  2. 可以编写泛型单例工厂。
  3. 方法引用可以当作函数式编程中的 Supplier 来使用。

公有成员变量方式的优点:简洁。

如果不需要上边提到的静态工厂方法的那些优点,应该优先采用公有成员变量的方式。

写法5:静态内部类(推荐)

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}

private Singleton (){}

public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

这种写法也是线程安全的,和工厂方法饿汉式的原因相同,因为类的实例在类加载的时候就被初始化的。类的初始化是通过 ClassLoader 类的 loadClass() 方法加载的,而 loadClass() 方法是由 synchronized 修饰的。

写法6:使用枚举

1
2
3
public enum Singleton {
INSTANCE;
}

使用的时候就通过 Singleton.INSTANCE 来获取全局唯一的实例。

枚举类中是可以添加方法的。

使用枚举的方式是线程安全的,同时在序列化问题上也具有优势。

写法7:双重校验锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private volatile static Singleton INSTANCE;
private Singleton() {}

public static Singleton getSingleton() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

这种写法也是线程安全的,可以看作是对懒汉式写法的又一次改进。之前的改进是直接对 getInstance() 方法加上 synchronized 关键字。

这种写法只有在第一次还有初始化实例的时候才会加锁,后边都是直接就返回唯一的实例了,所以比懒汉式和懒汉式的改进有更高的效率。

这种写法中要使用 volatile 修饰 INSTANCE变量,是为了防止 JVM 进行指令重排序。因为 new Singleton() 不是一个原子操作,而是分为三步:①分配内存、②初始化、③引用指向内存。如果不设置为 volatile,那么有可能发生指令重排序,导致第三步在第二步之前。也就是 INSTANCE 首先变为非 null,然后才被初始化。当一个线程在执行 new Singleton() 的时候,其他线程可能会拿到这个已经变为非 null,但是还没有初始化的对象,这个时候就会出错。

写法8:CompareAndSet(CAS)原子操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.atomic.AtomicReference;

public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();

private Singleton() {}

public static Singleton getInstance() {
for (;;) {
Singleton singleton = INSTANCE.get();
if (null != singleton) {
return singleton;
}

singleton = new Singleton();
if (INSTANCE.compareAndSet(null, singleton)) {
return singleton;
}
}
}
}

compareAndSet() 这个函数调用是一个原子操作。

INSTANCE.compareAndSet(null, singleton) 这个方法调用,对比 INSTANCE 和第一个参数,如果两者相等,那么把 INSTANCE 设置为第二个参数的值。也就是当 INSTANCE == null 时,把 INSTANCE 指向刚刚创建的 singleton 对象,同时直接返回刚刚创建的 singleton 对象。如果 INSTANCE 不为 null,那么更新失败,需要进入下一次循环,下一次循环中就可以到别的线程已经更新的值了。

这种写法的优缺点:用CAS的好处在于不需要使用传统的锁机制来保证线程安全,CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

CAS 的一个重要缺点在于如果忙等待一直执行不成功(一直在死循环中),会对CPU造成较大的执行开销。

另外,如果N个线程同时执行到singleton = new Singleton();的时候,会有大量对象创建,很可能导致内存溢出。

参考:面试官:不使用synchronized和lock,如何实现一个线程安全的单例?

关于序列化

枚举类型的写法可以在 JVM 层面保证序列化和反序列化的过程中单例的唯一性,但是其他写法不能保证,需要程序员重写一些方法来保证。具体可以看 Effecitive Java 的 Item 3 以及下边参考资料中的相关文章。

还涉及到序列化攻击等内容。

测试单例模式的代码

说明

主要对线程安全进行测试,使用 getInstance() 方法获取实例,然后对实例调用 hashCode() 方法获取 hashCode,通过 hashCode 即可知道获取的是不是同一个实例,从而知道是不是线程安全的。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Singleton{
//...

public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new TestThread();
}

for (Thread thread : threads) {
thread.start();
}
}
}

class TestThread extends Thread {
@Override
public void run() {
int counter = 2;
while(counter-- >0){
int hash = Singleton.getInstance().hashCode();
System.out.printf("%s %d\n", this.getName(), hash);
}
}
}

单例模式的应用

在线人数统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.concurrent.atomic.AtomicLong;

public class Counter {
private AtomicLong online = new AtomicLong();

private static class CounterHolder {
private static final Counter COUNTER = new Counter();
}

private Counter() {}

public static Counter getInstance() {
return CounterHolder.COUNTER;
}

public long getNum() {
return online.get();
}

public long add() {
return online.incrementAndGet();
}
}

判断是否需要懒加载

上边可能提打了懒加载的一些好处,比如使用的时候才会初始化对应的类,不会在没有用到的时候浪费资源。

但是 Java 中的类加载机制本身就是懒加载的,用到的时候才会加载,所以这个懒加载有什么用呢?

可能的原因是:

当类初始化的时候,会把类中所有静态成员变量都初始化了,如果一个单例模式的类中有一些静态方法,使用这些静态方法的时候,并不需要实例化单例模式,那么可以使用懒加载。

如果全部都是实例方法,那么不需要懒加载,直接使用饿汉式即可。

其他原因:

(1)希望单例模式初始化的时候,在构造函数中做一些特殊的操作。

单例模式和垃圾回收

java - When would the garbage collector erase an instance of an object that uses Singleton pattern? - Stack Overflow

用单例还是 Monostate

java - Why use a singleton instead of static methods? - Stack Overflow

c++ - Class with static members vs singleton - Stack Overflow

design patterns - Monostate vs. Singleton - Stack Overflow

其他

单例模式与类名静态方法调用的区别? - 知乎

参考资料

  1. Effective Java. Third Edition. Item 3.
  2. [转+注]单例模式的七种写法
  3. 深度分析Java的枚举类型—-枚举的线程安全性及序列化问题
  4. 单例与序列化的那些事儿
  5. 深度分析Java的枚举类型—-枚举的线程安全性及序列化问题
  6. 面试官:不使用synchronized和lock,如何实现一个线程安全的单例?
  7. 深度分析Java的ClassLoader机制(源码级别)
  8. 如何正确地写出单例模式 - Jark’s blog
  9. Java Singletons Using Enum
  10. 5种JAVA单例模式的实现、原理和演化
  11. Enum反序列化问题
  12. 彻头彻尾理解单例模式与多线程
  13. Effective-Java-创建和销毁对象 - 博客
  14. Why can you not inherit from a class whose constructor is private?
  15. 单例模式,你真的写对了吗? - 掘金
  16. 怎么破坏单例模式和怎么防止单例模式被破坏 - 博客
  17. 类的加载时机汇总
  18. 单例模式和DCL - 掘金
  19. volatile的使用及DCL模式 - code-craft - SegmentFault 思否
  20. 单例模式谁都会,破坏单例模式听说过吗?-51CTO.COM