# 《Java极简设计模式》第01章:单例模式(Singleton)

沉淀,成长,突破,帮助他人,成就自我。

  • 本章难度:★★☆☆☆
  • 本章重点:介绍创建Java单例对象的七种方式,重点掌握哪些创建方式是线程安全的,哪些方式是线程不安全的,并能够在实际项目中灵活运用设计模式,编写可维护的代码。

# 单例设计模式

# 为什么要使⽤单例

单例设计模式(Singleton Design Pattern)理解起来⾮常简单。⼀个类只允许创建⼀个对象(或者实例),那这个类就是⼀个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

# 案例⼀:处理资源访问冲突

在这个例⼦中,我们⾃定义实现了⼀个往⽂件中打印⽇志的 Logger 类。具体的代码实现如下所示:

public class Logger {
 private FileWriter writer;
 
 public Logger() {
 File file = new File("/Users/wangzheng/log.txt");
 writer = new FileWriter(file, true); //true表示追加写⼊
 }
 
 public void log(String message) {
 writer.write(message);
 }
}

// Logger类的应⽤示例:
public class UserController {
 private Logger logger = new Logger();
 
 public void login(String username, String password) {
 // ...省略业务逻辑代码...
 logger.log(username + " logined!");
 }
}

public class OrderController {
 private Logger logger = new Logger();
 
 public void create(OrderVo order) {
 // ...省略业务逻辑代码...
 logger.log("Created an order: " + order.toString());
 }
}
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

在上⾯的代码中,我们注意到,所有的⽇志都写⼊到同⼀个⽂件 /var/log/app.log 中。在UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的Servlet 多线程环境下,如果两个 Servlet 线程同时分别执⾏ login() 和 create() 两个函数,并且同时写⽇志到log.txt ⽂件中,那就有可能存在⽇志信息互相覆盖的情况

image-20241119140703627

在类对象层⾯(通过添加类对象的锁解决争执问题)上锁

public class Logger {
 private FileWriter writer;
 public Logger() {
 File file = new File("/Users/wangzheng/log.txt");
 writer = new FileWriter(file, true); //true表示追加写⼊
 }
 
 public void log(String message) {
 synchronized(Logger.class) { // 类级别的锁
 writer.write(mesasge);
 }
 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

除了使⽤类级别锁之外,实际上,解决资源竞争问题的办法还有很多。不过,实现⼀个安全可靠、⽆bug、⾼性能的分布式锁,并不是件容易的事情。除此之外,并发队列(⽐如 Java 中的BlockingQueue)也可以解决这个问题:多个线程同时往并发队列⾥写⽇志,⼀个单独的线程负责将并发队列中的数据,写⼊到⽇志⽂件。这种⽅式实现起来也稍微有点复杂。单例模式的解决思路就简单⼀些了。单例模式相对于之前类级别锁的好处是,不⽤创建那么多 Logger对象,⼀⽅⾯节省内存空间,另⼀⽅⾯节省系统⽂件句柄(对于操作系统来说,⽂件句柄也是⼀种资源,不能随便浪费)。

我们将 Logger 设计成⼀个单例类,程序中只允许创建⼀个 Logger 对象,所有的线程共享使⽤的这⼀个Logger 对象,共享⼀个FileWriter 对象,⽽ FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写⽇志会互相覆盖的问题。

public class Logger {
 private FileWriter writer;
 private static final Logger instance = new Logger();
 private Logger() {
 File file = new File("/Users/wangzheng/log.txt");
 writer = new FileWriter(file, true); //true表示追加写⼊
 }
 
 public static Logger getInstance() {
 return instance;
 }
 
 public void log(String message) {
 writer.write(mesasge);
 }
}
// Logger类的应⽤示例:
public class UserController {
 public void login(String username, String password) {
 // ...省略业务逻辑代码...
 Logger.getInstance().log(username + " logined!");
 }
}
public class OrderController { 
 public void create(OrderVo order) {
 // ...省略业务逻辑代码...
 Logger.getInstance().log("Created a order: " + order.toString());
 }
}
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

# 案例⼆:表示全局唯⼀类

从业务概念上,如果有些数据在系统中只应保存⼀份,那就⽐较适合设计为单例类。⽐如,配置信息类。在系统中,我们只有⼀个配置⽂件,当配置⽂件被加载到内存之后,以对象的形式存在,也理所应当只有⼀份。

⽐如id⽣成器也很适合

import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
    //AtomicLong多线程安全
 private AtomicLong id = new AtomicLong(0);
 //在初始化操作提前到类加载时完成
 private static final IdGenerator instance = new IdGenerator();
 private IdGenerator() {}
 public static IdGenerator getInstance() {
 return instance;
 }
 public long getId() {
 return id.incrementAndGet();
 }
}
// IdGenerator使⽤举例
long id = IdGenerator.getInstance().getId();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 如何实现⼀个单例

概括起来,要实现⼀个单例,需要关注的点有下⾯⼏个:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否⽀持延迟加载;
  • 考虑 getInstance() 性能是否⾼(是否加锁)。

# 饿汉模式

类加载时直接实例化单例对象是线程安全的。主要原因确实是因为类只会加载一次,并且类的初始化过程是由JVM保证其线程安全性的

具体来说,当一个类第一次被加载到JVM中时,会执行该类的静态初始化代码。这个过程包括分配内存给静态变量以及执行静态初始化块中的代码。根据Java语言规范,每个类或接口的初始化都是完全线程安全的,这意味着即使多个线程同时尝试初始化同一个类,也只会有一个线程能够成功地执行初始化代码,其他线程则会等待直到初始化完成.在饿汉式单例模式中,由于单例对象是在类加载时就创建好的,所以当多线程环境下的不同线程访问这个单例对象时,它们看到的都是已经被正确初始化的对象,而不会出现重复创建对象的问题,也不会导致数据不一致的情况发生。不过,这样的实现⽅式不⽀持延迟加载(在真正⽤到IdGenerator 的时候,再创建实例),从名字中我们也可以看出这⼀点。具体的代码实现如下所示:

public class IdGenerator {
 private AtomicLong id = new AtomicLong(0);
 private static final IdGenerator instance = new IdGenerator();
 private IdGenerator() {}
 public static IdGenerator getInstance() {
 return instance;
 }
 public long getId() {
 return id.incrementAndGet();
 }
}
1
2
3
4
5
6
7
8
9
10
11

单例实例在类装载的时候(使用静态代码块)进行创建,是线程安全的

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 饿汉模式,单例实例在类装载的时候进行创建,是线程安全的
 */
public class SingletonExample6 {

    private SingletonExample6(){}

    private static SingletonExample6 instance = null;

    static {
        instance = new SingletonExample6();
    }

    public static SingletonExample6 getInstance(){
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

有⼈觉得上面实现⽅式不好,因为不⽀持延迟加载,如果实例占⽤资源多(⽐如占⽤内存多)或初始化耗时⻓(⽐如需要加载各种配置⽂件),提前初始化实例是⼀种浪费资源的⾏为。最好的⽅法应该在⽤到的时候再去初始化。

如果初始化耗时⻓,那我们最好不要等到真正要⽤它的时候,才去执⾏这个耗时⻓的初始化过程,这会影响到系统的性能(⽐如,在响应客户端接⼝请求的时候,做这个初始化操作,会导致此请求的响应时间变⻓,甚⾄超时)。采⽤饿汉式实现⽅式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运⾏的时候,再去初始化导致的性能问题。如果实例占⽤资源多,按照 fail-fast的设计原则(有问题及早暴露),那我们也希望通过饿汉式在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(⽐如Java中的PermGen Space OOM),我们可以⽴即去修复。这样也能避免在程序运⾏⼀段时间后,突然因为初始化这个实例占⽤资源过多,导致系统崩溃,影响系统的可⽤性

有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是⽀持延迟加载

# 懒汉模式

懒汉模式,单例实例在第一次使用的时候进行创建,这个类是线程安全的,但是这个写法不推荐

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 懒汉模式,单例实例在第一次使用的时候进行创建,这个类是线程安全的,但是这个写法不推荐
 */
public class SingletonExample3 {

    private SingletonExample3(){}

    private static SingletonExample3 instance = null;

    public static synchronized SingletonExample3 getInstance(){
        if (instance == null){
            instance = new SingletonExample3();
        }
        return instance;
    }
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

不过懒汉式的缺点也很明显,我们给 getInstance()这个⽅法加了⼀把⼤锁(synchronzed),导致这个函数的并发度很低。量化⼀下的话,并发度是1,也就相当于串⾏操作了。⽽这个函数是在单例使⽤期间,⼀直会被调⽤。如果这个单例类偶尔会被⽤到,那这种实现⽅式还可以接受。但是,如果频繁地⽤到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现⽅式就不可取了。

这个类是懒汉模式,并且是线程不安全的

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 懒汉模式,单例实例在第一次使用的时候进行创建,这个类是线程不安全的
 */
public class SingletonExample1 {

    private SingletonExample1(){}

    private static SingletonExample1 instance = null;

    public static SingletonExample1 getInstance(){
        //多个线程同时调用,可能会创建多个对象
        if (instance == null){
            instance = new SingletonExample1();
        }
        return instance;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

饿汉式不⽀持延迟加载,懒汉式有性能问题,不⽀持⾼并发。那我们再来看⼀种既⽀持延迟加载、⼜⽀持⾼并发的单例实现⽅式,也就是双重检测实现⽅式。

在这种实现⽅式中,只要 instance 被创建之后,即便再调⽤ getInstance()函数也不会再进⼊到加锁逻辑

中了。所以,这种实现⽅式解决了懒汉式并发度低的问题。具体的代码实现如下所示:

懒汉模式(双重锁同步锁单例模式),单例实例在第一次使用的时候进行创建,但是,这个类不是线程安全的!!!!!

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 懒汉模式(双重锁同步锁单例模式)
 *              单例实例在第一次使用的时候进行创建,这个类不是线程安全的
 */
public class SingletonExample4 {

    private SingletonExample4(){}

    private static SingletonExample4 instance = null;

    //线程不安全
    //当执行instance = new SingletonExample4();这行代码时,CPU会执行如下指令:
    //1.memory = allocate() 分配对象的内存空间
    //2.ctorInstance() 初始化对象
    //3.instance = memory 设置instance指向刚分配的内存
    //单纯执行以上三步没啥问题,但是在多线程情况下,可能会发生指令重排序。
    // 指令重排序对单线程没有影响,单线程下CPU可以按照顺序执行以上三个步骤,但是在多线程下,如果发生了指令重排序,则会打乱上面的三个步骤。

    //如果发生了JVM和CPU优化,发生重排序时,可能会按照下面的顺序执行:
    //1.memory = allocate() 分配对象的内存空间
    //3.instance = memory 设置instance指向刚分配的内存
    //2.ctorInstance() 初始化对象


    //假设目前有两个线程A和B同时执行getInstance()方法,A线程执行到instance = new SingletonExample4(); B线程刚执行到第一个 if (instance == null){处,
    //如果按照1.3.2的顺序,假设线程A执行到3.instance = memory 设置instance指向刚分配的内存,此时,线程B判断instance已经有值,就会直接return instance;
    //而实际上,线程A还未执行2.ctorInstance() 初始化对象,也就是说线程B拿到的instance对象还未进行初始化,这个未初始化的instance对象一旦被线程B使用,就会出现问题。


    public static SingletonExample4 getInstance(){
        if (instance == null){
            synchronized (SingletonExample4.class){
                if(instance == null){
                    instance = new SingletonExample4();
                }
            }
        }
        return instance;
    }
}
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
35
36
37
38
39
40
41
42
43

这种实现⽅式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance之后,还没来得及初始化(执⾏构造函数中的代码逻辑),就被另⼀个线程使⽤了。要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁⽌指令重排序才⾏。实际上,只有很低版本的 Java才会有这个问题。我们现在⽤的⾼版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的⽅法很简单,只要把对象 new操作和初始化操作设计为原⼦操作,就⾃然能禁⽌重排序)

为什么需要两次判断if(instance==null)?

第⼀次校验:

由于单例模式只需要创建⼀次实例,如果后⾯再次调⽤getInstance⽅法时,则直接返回之前创建的实例,因此⼤部分时间不需要执⾏同步⽅法⾥⾯的代码,⼤⼤提⾼了性能。如果不加第⼀次校验的话,那跟上⾯的懒汉模式没什么区别,每次都要去竞争锁。

第⼆次校验:

如果没有第⼆次校验,假设线程t1执⾏了第⼀次校验后,判断为null,这时t2也获取了CPU执⾏权,也执⾏了第⼀次校验,判断也为null。接下来t2获得锁,创建实例。这时t1⼜获得CPU执⾏权,由于之前已经进⾏了第⼀次校验,结果为null(不会再次判断),获得锁后,直接创建实例。结果就会导致创建多个实例。所以需要在同步代码⾥⾯进⾏第⼆次校验,如果实例为空,则进⾏创建

懒汉模式(双重锁同步锁单例模式)单例实例在第一次使用的时候进行创建,这个类是线程安全的,使用的是 volatile + 双重检测机制来禁止指令重排达到线程安全

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 懒汉模式(双重锁同步锁单例模式)
 *              单例实例在第一次使用的时候进行创建,这个类是线程安全的
 */
public class SingletonExample5 {

    private SingletonExample5(){}

    //单例对象  volatile + 双重检测机制来禁止指令重排
    private volatile static SingletonExample5 instance = null;

    public static SingletonExample5 getInstance(){
        if (instance == null){
            synchronized (SingletonExample5.class){
                if(instance == null){
                    instance = new SingletonExample5();
                }
            }
        }
        return instance;
    }
}
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

静态内部类

再来看⼀种⽐双重检测更加简单的实现⽅法,那就是利⽤ Java 的静态内部类。它有点类似饿汉式,但⼜能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现

public class IdGenerator {
 private AtomicLong id = new AtomicLong(0);
 private IdGenerator() {}
 private static class SingletonHolder{
 private static final IdGenerator instance = new IdGenerator();
 }
 
 public static IdGenerator getInstance() {
 return SingletonHolder.instance;
 }
 public long getId() {
 return id.incrementAndGet();
 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

SingletonHolder 是⼀个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建SingletonHolder 实例对象。只有当调⽤ getInstance() ⽅法时,SingletonHolder才会被加载,这个时候才会创建 instance。instance 的唯⼀性、创建过程的线程安全性,都由 JVM来保证。所以,这种实现⽅法既保证了线程安全,⼜能做到延迟加载

枚举方式进行实例化,是线程安全的,此种方式也是线程最安全的

package io.binghe.design.singleton;
/**
 * @author binghe
 * @version 1.0.0
 * @description 枚举方式进行实例化,是线程安全的,此种方式也是线程最安全的
 */
public class SingletonExample7 {

    private SingletonExample7(){}

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

    private enum Singleton{
        INSTANCE;
        private SingletonExample7 singleton;

        //JVM保证这个方法绝对只调用一次
        Singleton(){
            singleton = new SingletonExample7();
        }
        public SingletonExample7 getInstance(){
            return singleton;
        }
    }
}
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