38、单例模式(中)

1、单例存在哪些问题

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如:配置信息类、连接池类、ID 生成器类
单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了
但是这种使用方法有点类似硬编码(hard code),会带来诸多问题,接下来我们就具体看看到底有哪些问题

1.1、OOP 特性的支持不友好

public class Order {
    public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // ...
    }
}

public class User {
    public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // ...
    }
}

IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性
如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法,比如订单 ID 和用户 ID 采用不同的 ID 生成器来生成
为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大

public class Order {
    public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // 需要将上面一行代码替换为下面一行代码
        long id = OrderIdGenerator.getIntance().getId();
        // ...
    }
}

public class User {
    public void create(...) {
        // ...
        long id = IdGenerator.getInstance().getId();
        // 需要将上面一行代码替换为下面一行代码
        long id = UserIdGenerator.getIntance().getId();
        // ...
    }
}

除此之外,单例对继承、多态特性的支持也不友好
从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差,不明白设计意图的人,看到这样的设计,会觉得莫名其妙
所以一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性

1.2、隐藏类之间的依赖关系

通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来
但是单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了
如果代码比较复杂,这种调用关系就会非常隐蔽,在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类

1.3、对代码的扩展性不友好

单例类只能有一个对象实例,如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动
你可能会说,会有这样的需求吗,既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢
实际上这样的需求并不少见,我们拿数据库连接池来举例解释一下

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗,所以我们把数据库连接池类设计成了单例类
但之后我们发现,系统中有些 SQL 语句运行得非常慢,这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应
为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行
可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行

如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说:单例类在某些情况下会影响代码的扩展性、灵活性
所以数据库连接池、线程池这类的资源池,最好还是不要设计成单例类,实际上一些开源的数据库连接池、线程池也确实没有设计成单例类

1.4、不支持有参数的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小
针对这个问题,我们来看下都有哪些解决方案

第一种解决思路

创建完实例之后,再调用 init() 函数传递参数
需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常

public class Singleton {

    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;

    private Singleton(int paramA, int paramB) {
        this.paramA = paramA;
        this.paramB = paramB;
    }

    public static Singleton getInstance() {
        if (instance == null) {
            throw new RuntimeException("Run init() first.");
        }
        return instance;
    }

    public synchronized static Singleton init(int paramA, int paramB) {
        if (instance != null) {
            throw new RuntimeException("Singleton has been created!");
        }
        instance = new Singleton(paramA, paramB);
        return instance;
    }
}

Singleton.init(10, 50); // 先 init, 再使用
Singleton singleton = Singleton.getInstance();

第二种解决思路

将参数放到 getIntance() 方法中

public class Singleton {

    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;

    private Singleton(int paramA, int paramB) {
        this.paramA = paramA;
        this.paramB = paramB;
    }

    public synchronized static Singleton getInstance(int paramA, int paramB) {
        if (instance == null) {
            instance = new Singleton(paramA, paramB);
        }
        return instance;
    }
}

Singleton singleton = Singleton.getInstance(10, 50);

上面的代码实现稍微有点问题
如果我们如下两次执行 getInstance() 方法,那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50
也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户

Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);

第三种解决思路

将参数放到另外一个全局变量中:Config 是一个存储了 paramA 和 paramB 值的全局变量
里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到,这种方式是最值得推荐的

public class Config {
    public static final int PARAM_A = 123;
    public static final int PARAM_B = 245;
}
public class Singleton {

    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;

    private Singleton() {
        this.paramA = Config.PARAM_A;
        this.paramB = Config.PARAM_B;
    }

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

2、有何替代解决方案

业务上有表示全局唯一类的需求,如果不用单例,怎么才能保证这个类的对象全局唯一呢
除了使用单例,我们还可以用静态方法来实现,这也是项目开发中经常用到的一种实现思路
比如上一节课中讲的 ID 唯一递增生成器的例子,用静态方法实现一下

// 静态方法实现方式
public class IdGenerator {
    private static AtomicLong id = new AtomicLong(0);

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

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

实际上,单例除了我们之前讲到的使用方法之外,还有另外一个种使用方法

// 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 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)

posted @ 2023-06-26 15:19  lidongdongdong~  阅读(11)  评论(0编辑  收藏  举报