跳至主要內容

单例模式

AruNi_Lu设计模式设计模式与范式约 6702 字大约 22 分钟

本文内容

1. 为什么需要单例模式?

单例设计模式 很简单,意思就是 一个类只能有一个唯一的实例(对象)。

那么为什么需要单例模式呢?或者说,什么情况下需要使用到单例模式?下面通过两个例子来说明。

1.1 资源访问冲突

如果我们要自定义实现一个 Logger 类,用于往文件中打印日志。这个 Logger 类的设计如下:

public class Logger {
  private FileWriter writer;
  
  public Logger() {
    File file = new File("/Users/AruNi/log.txt");
    writer = new FileWriter(file, true); 	// true 表示追加写入
  }
  
  // 记录日志,往文件中写入 message
  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!");
  }
}

// Logger类的应用示例:
public class OrderController {
  private Logger logger = new Logger();
  
  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    logger.log("Created an order: " + order.toString());
  }
}

不知道你有没有发现一个问题,我们 所有的日志都是写到同一个文件 /Users/AruNi/log.txt,而在 UserController 和 OrderController 中分别 创建了两个 Logger 对象,在 多线程 环境下,如果两个线程分别执行 login()create() 方法,并 同时写日志到 log.txt 文件中,那就可能会存在 日志信息互相覆盖 的问题。如下图所示(log.txt 是共享资源):

image-20230330142754745

想解决这个问题也很简单,只需要在写文件的时候,加上一把锁即可,就像下面这样:

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/AruNi/log.txt");
    writer = new FileWriter(file, true); 	// true表示追加写入
  }
  
  // 记录日志,往文件中写入 message
  public void log(String message) {
    synchronized(Logger.class) { 	// 类级别的锁
      writer.write(mesasge);
    }
  }
}
  • 需要注意的是,由于存在 多个 Logger 对象实例,所以这里需要加 类级别的锁,而不是对象级别的锁。

虽然加类锁能解决上面资源访问冲突的问题,但有没有更好的解决方案呢?可以发现,上面造成写 log.txt 文件冲突的原因是有两个 Logger 实例对象去同时写这个文件,那我们能否 只创建一个 Logger 对象实例,然后让所有线程共享这个 Logger 对象,这样再写文件的时候不就能避免多线程环境下的覆盖写入问题了吗?

这里需要提一下,FileWrite 内部本身会对写文件的操作加锁(对象级别),由于我们只保证有一个 Logger 对象,所以对象级别的锁足够了。

因此,我们可以把 Logger 类设计成一个单例类,如下:

public class Logger {
  private FileWriter writer;
  // 在类加载时就创建好实例对象
  private static final Logger instance = new Logger();

  private Logger() {
    File file = new File("/Users/AruNi/log.txt");
    writer = new FileWriter(file, true); 	// true表示追加写入
  }
  
  public static Logger getInstance() {
    return instance;
  }
  
  // 记录日志,往文件中写入 message
  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());
  }
}

由于所有线程都共享这一个 Logger 对象实例,所以在写入时,由于 FileWrite 会对写操作加锁,这样便能保证 同一时刻只能由一个线程在执行写入操作,从而避免了写入覆盖。

而且只创建一个对象实例可以大大 节约内存空间

1.2 全局唯一类

在业务系统中,如果某些类对象只应该存在一份,那么也可以设计成单例类。

例如配置信息类,在系统中应该只有一份配置文件,所以当配置文件被加载到内存之后,也应该保证只有一份配置文件对象实例。

还有唯一递增的 ID 生成器,如果程序中有两个对象,那么就可能存在生成重复 ID 的情况,所以也应该设计成单例。如下所示:

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

// IdGenerator使用举例
long id = IdGenerator.getInstance().getId();

我们通过上面两个例子,说明了为什么需要使用单例模式。但其实,上面的两个例子的设计都不优雅,还有一些问题,后面会讲解。

2. 如何实现单例?

单例模式的实现有很多种,每一种都有各自的优缺点。总的概括起来说,实现单例需要关注的点如下:

  • 构造函数需要使用 private 修饰,避免外部通过 new 创建对象;
  • 考虑是否支持 懒加载(延迟加载);
  • 考虑 创建对象时的线程安全问题
  • 考虑 getInstance() 的性能问题(是否加锁)。

下面就来介绍一下有哪几种实现单例的方式。

2.1 饿汉式

饿汉式单例,顾名思义,在类加载的时候,实例就已经创建并初始化好了,后续直接获取即可。

特点:

  • 类加载就初始化,浪费内存;但实例在创建过程是线程安全的;
  • 不加锁,效率较高;
  • 不支持懒加载(延迟加载)。

实现代码如下:

public class HungrySingleton {

    private static final HungrySingleton instance = new HungrySingleton();

    private HungrySingleton() {
        System.out.println(Thread.currentThread().getName() +  "调用了无参构造器");
    }

    public static HungrySingleton getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(HungrySingleton::getInstance).start();
        }
    }
}

输出:

main调用了无参构造器

Process finished with exit code 0

由于实例对象在类加载时就创建了,所以可以发现构造器只被 main 线程调用了一次,其他线程获取到的都是同一个实例对象。

饿汉式不支持懒加载、占用资源多,要避免使用?

很多人觉得饿汉式单例不好,因为不支持懒加载,如果实例占用的资源多(如占用内存多)或初始化耗时(如需要加载各种配置文件),因此要避免这种方式,最好在需要的时候再去加载。

但其实,如果初始化比较耗时,那我们更不应该等到需要用的时候才去执行这个耗时的初始化了,因为这影响的是系统的性能,比如导致响应时间增加。所以 应该把耗时的初始化过程,在程序启动时就加载好

而实例占用资源多的问题,按照 fail-fast 原则(有问题及早暴露),我们也应该在程序启动时就将实例初始化好,如果资源不够,在程序启动时就报错,让我们能提前去修复。而不是等到程序上线,突然因为初始化过程占用的资源过多,而导致系统崩溃,影响系统的可用性。

2.2 懒汉式

懒汉式 相比饿汉式,就 支持懒加载(延迟加载)了,实现代码如下:

public class LazySingleton {

    private static LazySingleton instance;

    private LazySingleton() { }

    // 注意:需要加锁
    public static synchronized LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

但是懒汉式的缺点很明显,我们需要给 getInstance() 加一把大锁(synchronized),导致这个方法的并发度很低

加锁是为了保证多线程环境下不会创建出多个实例,否则可能存在多个线程同一时刻进入 getInstance() 方法,在判断 instance 是否为空时,就都会认为 instance 为空,然后各自创建一个实例。

2.3 双重检测锁

饿汉式不支持懒加载,懒汉式又有性能问题,所以就出现了 双重检测锁(DCL:Double Check Lock)的方式,它 既支持延迟加载、又能保证高并发下的安全问题

DCL 方式的实现如下:

public class LazySingletonDCL {

    /**
     * new LazySingletonDCL() 分为三步:
     * 1. 在堆中分配空间
     * 2. 初始化对象,创建实例
     * 3. 将堆中的地址值赋给 instance
     * 所以需要使用 volatile 来避免上面三个步骤的重排序。
     * 否则如果线程 A 的执行顺序为 1->3->2,这样其他线程就可能获取到一个 null 实例。
     */
    private static volatile LazySingletonDCL instance;

    private LazySingletonDCL() { }

    public static LazySingletonDCL getInstance() {
        if (instance == null) {
            synchronized (LazySingletonDCL.class) { // 类级别的锁
                if (instance == null) {
                    instance = new LazySingletonDCL();
                }
            }
        }
        return instance;
    }
}

可以发现,只有当判断 instance 为空时,才会加锁进行进一步的判断,否则 getInstance() 方法就不会加锁,大大提高了该方法的性能。

除此之外,细心的你可能还发现了,instance 变量是使用 volatile 关键字修饰的,这是用来 禁止指令重排序的,具体原因也写在代码中了。

2.4 静态内部类

其实,还有一种比 DCL 更简单的单例实现方法,那就是 静态内部类,它虽然有点像饿汉式,但却能支持懒加载

实现代码如下:

public class LazySingletonInnerStatic {

    private LazySingletonInnerStatic() { }

    /**
     * 一个静态内部类,在调用 getInstance() 是,才会加载此类,然后进行实例化。
     * instance 的唯一性,创建过程中的线程安全,都由 JVM 来保证。
     * 具体来说:在类加载的最后一个阶段,即类的初始化阶段时,会执行构造器的 <clinit> 方法,
     * 该方法由类里面所有的类变量的赋值动作和静态代码块组成的。JVM内部会保证一个类的 <clinit> 方法
     * 在多线程环境下被正确的加锁同步,从而保证了线程安全的创建实例对象。
     * <p/>
     * 另外,实例变量不用使用 volatile,因为只有一个线程会执行具体的类的初始化代码 <clinit>,
     * 所以即使有指令重排序,也根本不会影响到其他线程(其他线程都在等待,初始化后再唤醒它们)。
     */
    private static class SingletonHolder {
        private static final LazySingletonInnerStatic instance = new LazySingletonInnerStatic();
    }

    public static LazySingletonInnerStatic getInstance() {
        return SingletonHolder.instance;
    }

}

SingletonHolder 是 LazySingletonInnerStatic 的静态内部类,当外部类 LazySingletonInnerStatic 被加载时,并不会创建 SingletonHolder 实例,只有在调用 getInstance() 方法时,SingletonHolder 才会被加载,然后创建 instance 返回

静态内部类的方式是 通过 JVM 来保证 instance 的唯一性和创建过程的线程安全性的,所以不需要像 DCL 那样使用 volatile 和加锁。具体原因在代码中也有提到,这需要具备一点 JVM 的知识。

2.5 枚举

由于 Java 枚举类型本身的特性(能保证实例的唯一性和创建过程中的线程安全性),可以说枚举是一种最简单的实现单例的方式了。

前面创建单例的方式有什么问题?

其实,我们之前讲的单例模式的实现方式,都可以 通过反射来暴力获取到类的私有的构造器,从而创建新的实例,破坏了单例性

下面就拿 DCL 的方式来举例,看看反射是如何破坏单例性的:

public class LazySingletonDCL {

    private static volatile LazySingletonDCL instance;

    private LazySingletonDCL() { }

    public static LazySingletonDCL getInstance() {
        if (instance == null) {
            synchronized (LazySingletonDCL.class) { // 类级别的锁
                if (instance == null) {
                    instance = new LazySingletonDCL();
                }
            }
        }
        return instance;
    }

    // 通过反射暴力获取私有构造器,从而创建新实例,破坏单例
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        LazySingletonDCL instance1 = LazySingletonDCL.getInstance();
        LazySingletonDCL instance2 = LazySingletonDCL.getInstance();
        // 获取到无参构造器
        Constructor<LazySingletonDCL> declaredConstructor = LazySingletonDCL.class.getDeclaredConstructor(null);
        declaredConstructor.setAccessible(true);    // 暴力破解,无视私有构造器
        // 通过反射创建的对象
        LazySingletonDCL reflectInstance = declaredConstructor.newInstance();
        System.out.println(instance1);
        System.out.println(instance2);
        System.out.println(reflectInstance);
    }

}

输出:

singleton.LazySingletonDCL@404b9385
singleton.LazySingletonDCL@404b9385
singleton.LazySingletonDCL@6d311334

Process finished with exit code 0

可以发现,通过反射创建的对象并不是 DCL 方式获取的对象,说明反射破坏了单例模式的安全性。

通过枚举来解决上述问题

枚举类型就能有效的防止反射暴力破解,因为 JDK 中明确给出不能通过反射来调用私有构造方法,否则会抛出异常。反射的 newInstance() 源码如下:

image-20230330160140891

实现代码如下:

public enum HungrySingletonEnum {

    INSTANCE;

    public HungrySingletonEnum getInstance() {
        return INSTANCE;
    }

}

怎么样,是不是非常的简单,不过枚举方式虽然 能绝对的保证实例的单一,但是不支持懒加载

3. 为什么不推荐使用单例模式?

尽管单例模式在实际开发中是一个常用的设计模式,但是有些人认为单例是一种反模式(anti-pattern),并不推荐使用。

那么为什么会这么认为呢?是单例模式有什么问题吗?那不用单例,又如何表示全局唯一的类呢?有什么更好的替代方案吗?

3.1 单例存在什么问题?

我们项目中使用单例模式的时候,一般都是用来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类等。虽然它的编码简单,使用方便,在需要使用时可直接通过类似 IdGenerator.getInstance().getId() 的方式调用。但是,这种使用方法有点类似 硬编码(hard code),相当于把代码写死了,会带来一些问题。

问题一:单例对 OOP 特性的支持不友好

单例的设计其实对抽象、继承、多态都支持得不是很好,比如我们上面那个 IdGenerator 的例子。如果我们需要让不同的业务采用不同的 ID 生成器(对应不同的 ID 生成算法),这样我们就需要修改所有使用到了 IdGenerator 类的地方,代码改动会比较大。比如:

public class Order {
  public void create(...) {
    //...
    long id = IdGenerator.getInstance().getId();
    // 不同的业务采用不同的 ID 生成器时,需要将上面一行代码,替换为下面一行代码
    long id = OrderIdGenerator.getIntance().getId();
    //...
  }
}

public class User {
  public void create(...) {
    // ...
    long id = IdGenerator.getInstance().getId();
    // 不同的业务采用不同的 ID 生成器时,需要将上面一行代码,替换为下面一行代码
    long id = UserIdGenerator.getIntance().getId();
  }
}

不知道你发现没有,其实单例模式的设计是 违背了 “基于接口而非实现的设计原则”,所以违背了广义上的抽象特性。

而比较好的设计是将 IdGenerator 抽象成一个接口,里面提供一个获取 ID 的方法,让不同的 ID 生成器去实现该方法,提供不同的逻辑。然后在需要使用 ID 生成器的地方,通过依赖注入的方式,将具体的 ID 生成器传入,具体实现如下:

// 抽象出来的接口
public interface IdGenerator {
    long getId();
}

// User 的 ID 生成器
public class UserIdGenerator implements IdGenerator {
    @Override
    public long getId() {
        // UserId 生成策略...
        System.out.println("UserId 生成策略...");
        return 1L;
    }
}

// Order 的 ID 生成器
public class OrderIdGenerator implements IdGenerator {
    @Override
    public long getId() {
        // OrderId 生成策略...
        System.out.println("OrderId 生成策略...");
        return 2L;
    }
}

// User 类
public class User {
    // 通过依赖注入,将 IdGenerator 传进来
    public void create(IdGenerator idGenerator, String... otherArgs) {
        // ...
        long id = idGenerator.getId();
        // ...
    }
}

// Order 类
public class Order {
    // 通过依赖注入,将 IdGenerator 传进来
    public void create(IdGenerator idGenerator, String... otherArgs) {
        // ...
        long id = idGenerator.getId();
        // ...
    }
}

上面这样是比较好的设计,当需求改变时,我们也无需改变 User 和 Order 中 create() 方法的实现。

在外部需要使用什么 ID 生成器的时候,通过函数参数的方式,把具体的 XxxIdGenerator 传进去即可:

public class UsedByOutside {
    public static void main(String[] args) {
        User user = new User();
        // 外部使用时,通过传入不同的 ID 生成器来走不同的方法
        user.create(new UserIdGenerator(), "userName", "userType");

        Order order = new Order();
        order.create(new OrderIdGenerator(), "orderName", "orderType");
        
        /* 输出:
           UserId 生成策略...
           OrderId 生成策略...

           Process finished with exit code 0
         */
    }
}

除此之外,单例对继承、多态也不友好,虽然单例类也可以被继承,实现多态,但是会非常奇怪,会导致代码的可读性变差。

问题二:单例会隐藏类之间的依赖关系

我们在阅读代码的时候,通常希望一眼就看出类于类之间的依赖关系,从而快速地了解这个类依赖了哪些外部类。

通过构造器、参数传递等方式声明类之间地依赖关系,我们通过查看函数的定义,就很容易看出来(例如上面的实现例子)。而单例则是直接在函数中调用,如果代码很复杂,那么我们很难看出调用关系,需要仔细查看每个函数的具体实现,才能知道这个类到底依赖了哪些单例类。

3.2 有何替代单例的方案?

上面讲到的单例会隐藏类之间的依赖关系,其实可以通过依赖注入的方式解决,把单例类作为参数传递到需要使用的地方即可,就像下面这样:

// 1. 老的使用方式
public demofunction() {
  //...
  long id = IdGenerator.getInstance().getId();
  //...
}

// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
  long id = idGenerator.getId();
}

// 外部调用 demofunction() 的时候,传入 idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

不过,还是没办法解决对 OOP 特性不友好的问题。实际上,类对象的全局唯一性可以通过多种方式来保证,例如:

  • 使用单例模式强制保证;
  • 通过工厂模式、IOC 容器(如 Spring IOC 容器)来保证;
  • 通过程序员自己来保证(编码时保证不要创建两个类对象)。

关于工厂模式和 IOC 容器具体是如何保证的,在后续的工厂模式中会讲解。

4. 如何实现集群环境下的分布式单例?

在搞清楚怎么实现集群环境下的分布式单例模式之前,我们需要先搞清楚单例模式中 单例的唯一性以及其作用范围

4.1 单例模式中的唯一性

回顾一下单例的定义:“一个类只允许创建唯一一个实例(对象)”。

定义中提到 唯一实例,那这个唯一性的作用范围是什么呢?是线程内只允许创建一个对象,还是进程内只允许创建一个对象?

答案是后者,也就是说,单例模式创建的对象是进程唯一的

进程之间是不共享地址空间的,如果在一个进程中创建另外一个进程(比如使用 fork() 创建一个新的子进程),操作系统会给新的进程分配新的地址空间,并将父进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,包括代码、数据(比如 user 临时变量、User 对象)。

所以,单例类在父进程中只存在一个对象,在新的子进程中也只存在一个对象,并且这两个对象不是同一个对象。所以说 单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的

实际上,在 Java 中单例类对象的唯一性的作用范围不是进程内,而是类加载器(ClassLoader)

这是因为在 Java 中,对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 JVM 中的唯一性。也就是说,同一个类如果用不同的类加载器加载,得到的其实是不同的类对象。

Java 的类加载是基于双亲委派模型实现的,它的工作流程如下:

  • 如果一个类加载器收到了类加载请求,它自己不会立即去加载该类,而是会先把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,所以这个请求最后会到达最顶层的启动类加载器中。只有当父类加载器无法完成这个类加载请求时,子加载器才会去尝试完成加载。

这说明任意一个类的加载都是从最顶层的启动类加载器往下进行加载,如果上层的类加载器已经检测到自己加载了这个类了,就不会二次加载,下层的类加载器也不会进行该类的加载。从而保证了该类不会被重复加载,以及在各个类加载器环境中都是同一个类

所以,Java 中的单例类对象的唯一性的作用范围其实是类加载器,因为使用双亲委派模型的话,类加载一次之后就不会重复加载了,保证了单例类的进程内的唯一性,也可以认为是类加载器内的唯一性。当然,如果没有双亲委派模型,那么就算是同一个类,由多个类加载器加载,就会有多个实例,无法保证唯一性。

4.2 如何实现线程唯一的单例?

类比进程唯一,线程唯一指的就是线程内唯一,线程间不唯一

实现线程唯一的单例比较简单,通过一个 Map 就能实现,其中 key 为线程 ID,value 为对象。其实,Java 中的 ThreadLocal 工具类就是线程唯一的单例。

使用 Map 实现线程唯一的单例如下:

public class SingletonThreadOnly {

    private static final Map<Long, SingletonThreadOnly> instance = new ConcurrentHashMap<>();

    private SingletonThreadOnly() { }

    public static SingletonThreadOnly getInstance() {
        long id = Thread.currentThread().getId();
        instance.putIfAbsent(id, new SingletonThreadOnly());
        return instance.get(id);
    }
}

4.3 如何实现集群环境下的单例?

类比进程唯一和线程唯一,集群唯一就是指进程内唯一、进程间也唯一,因为集群相当于多个进程构成的一个集合。

经典的单例模式是进程内唯一的,想要实现进程间也唯一,是有些难度的。我们可以这样想:

  • 线程唯一的单例是通过一个 线程内共享、线程间不共享的容器—Map(每个线程对应一个哈希槽位);
  • 进程唯一的单例是通过一个 进程内共享、进程间不共享的容器—内存

那么进一步,想让进程间也共享,我们就需要提供一个 进程间也共享的容器 来实现。具体来说,我们需要把这个单例对象序列化并存储到一个 外部的共享存储区(比如文件)。进程在使用这个单例对象时,需要先从外部共享存储区中将它读到内存,并反序列化成对象,然后进行使用,使用完之后还要再存储回外部共享存储区

那么为了保证在任意时刻,在进程间都只有一份对象存在,一个进程在获取到对象后,需要对对象加锁,避免其他进程再获取。在进程使用完后,需要显式地将此对象从内存中删除,并释放对对象的加锁。

按照上面的思路,实现的伪代码如下:

public class SingletonClusterOnly {

    private static SingletonClusterOnly instance;
    // 外部共享存储区
    private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略,比如文件地址*/);
    // 分布式锁
    private static DistributedLock lock = new DistributedLock();

    private SingletonClusterOnly() { }

    public synchronized static SingletonClusterOnly getInstance() {
        // 各进程进来,发现实例为空才从文件获取,否则就直接返回实例
        if (instance == null) {
            // 加锁,避免其他进程再从文件中获取实例
            lock.lock();
            instance = storage.load(SingletonClusterOnly.class);
        }
        return instance;
    }

    /**
     * 某进程使用完毕后,对实例进行释放,让其他进程能从文件中获取实例
     */
    public synchronized void freeInstance() {
        storage.save(this, SingletonClusterOnly.instance);
        instance = null;
        lock.unlock();
    }
}

// 使用示例
SingletonClusterOnly singleton = SingletonClusterOnly.getInstance();
// ...使用单例 singleton...
singleton.freeInstance();

4.4 如何实现多例模式?

单例指的是一个类只能创建一个对象,那 多例就是一个类可以创建多个对象,但是个数是有限制的

简单实现如下:

public class BackendServer {
  private long serverNo;
  private String serverAddress;

  private static final int SERVER_COUNT = 3;
  private static final Map<Long, BackendServer> serverInstances = new HashMap<>();

  static {
    serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
    serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
    serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
  }

  private BackendServer(long serverNo, String serverAddress) {
    this.serverNo = serverNo;
    this.serverAddress = serverAddress;
  }

  public static BackendServer getInstance(long serverNo) {
    return serverInstances.get(serverNo);
  }

  public static BackendServer getRandomInstance() {
    Random r = new Random();
    int no = r.nextInt(SERVER_COUNT)+1;
    return serverInstances.get(no);
  }
}

对于多例模式,还有一种理解就是:同一类型的只能创建一个对象,不同类型的可以创建多个对象

这里的类型,指的就是 用于标识多例模式中实例的类型,比如上面的实现中,有三个实例类型,第一个是 ServerNo = 1 的实例,第二个是 ServerNo = 2 的实例,第三个是 ServerNo = 3 的实例。例如下面这样获取实例:

// server1 == server2,server1 != server3
BackendServer server1 = BackendServer.getInstance(1L);
BackendServer server2 = BackendServer.getInstance(1L);
BackendServer server3 = BackendServer.getInstance(2L);

一个更加形象的例子如下:

public class Logger {
  private static final ConcurrentHashMap<String, Logger> instances
          = new ConcurrentHashMap<>();

  private Logger() {}

  // 通过 loggerName 来区分不同的类型
  public static Logger getInstance(String loggerName) {
    instances.putIfAbsent(loggerName, new Logger());
    return instances.get(loggerName);
  }

  public void log() {
    //...
  }
}

// l1 == l2,l1 != l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

有没有发现,这种多例模式的理解方式有点像工厂模式,它跟工厂模式的不同之处是(这在工厂模式中会讲解):

  • 多例模式创建的对象都是同一个类的对象,只是我们规定的类型不一样;
  • 而工厂模式创建的是不同子类的对象。

而且实际上也有点类似享元模式,这在后续讲解享元模式时再进行讲解。

而且,其实枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。

5. 总结

本篇文章涵盖了单例模式比较多的内容,从为什么需要单例模式,引出单例模式的定义。接着讲解了几种实现单例的方式,以及它们的优缺点。

然后又讲解了单例模式存在什么问题,以及如何解决。最后还扩展到了单例模式的唯一性的作用范围,如何实现线程唯一的单例、集群环境下的单例、还有多例。

上次编辑于: