一、设计模式的底层逻辑

关于设计模式,大家可能会说:

  • 学习了很多设计模式的课程和文章,依然不会用设计模式;

  • 设计模式适用场景没有设计原则多;

  • 设计模式入门简单,精通很难;

  • 设计模式太复杂看不懂;

  • 面试前才会看设计模式;

  • 设计模式不如面向搜索编程有用;

不可否认,一方面大家都很重视设计模式的学习,另一方面却又总是被设计模式搞晕,原因就在于没有真正明白设计模式最核心的到底是什么,也就是它的底层逻辑是什么。

其实,关于这个问题,《设计模式:可复用面向对象的基础》这本书中早已回答过:

在设计中思考什么应该变化,并封装会发生变化的概念。

简单来说,就是八个字:找到变化,封装变化

实际上设计模式提出的初衷并不是为了解决类似用什么算法实现“1+1=2”的问题,而是告诉你应该如何用计算机的思维来思考该怎么让“1+1=2”正确运行起来。

学习设计模式真正的好处并不在于学会“如何使用”它们,而是在于通过分析学到“如何找到变化,如何封装变化”的思想精髓,并最终通过实践融合到实际编程中,对实际编码设计有帮助。

二、单例模式分析

在 GoF 的书中,单例模式最早的定义如下:

单例模式(Singleton)允许存在一个和仅存在一个给定类的实例。它提供一种机制让任何实体都可以访问该实例。

UML图如下:

单例模式:如何有效进行程序初始化?_单例模式

图中,单例模式(Singleton)类声明了一个名为 _instance 的静态对象和名为 getInstance() 的静态方法,静态对象用来存储对象自身的属性和方法,静态方法用来返回其所属类的一个相同实例。这里我们以单例模式经典的懒汉式初始化方式为例,其代码实现如下:

public class Singleton {
		//用于存储单一实例的静态对象
    private static Singleton _instance; 
		//私有的空构造函数
    private Singleton(){
    };
		//通过判断静态对象是否被初始化来选择是否创建对象
    public static Singleton getInstance(){
        if (null == _instance){
            _instance = new Singleton();
        }
        return _instance;
    }
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

通过分析上面的定义和代码,我们可以得出单例模式包含三个要点:

  • 一个单例类只能有一个实例;

  • 单例类必须自行创建这个实例;

  • 单例类必须保证全局其他对象都能唯一访问到它。

其实,这三个要点就是单例模式所要应对的变化,也就是:

  • 对象实例数量受到限制的事实;

  • 对象实例的构造与销毁;

  • 需要保证对象实例成为“线程安全”的某种机制。

从上面那段示例代码我们还可以看出,单例模式的对象职责有两个:

  • 保证一个类只有一个实例;

  • 为该实例提供一个全局访问节点。

单例类的默认构造函数和静态对象都是内部调用,之所以将默认构造函数设为私有,是为了防止其他对象使用单例类的 new 运算符。然后,提供一个对外的公共方法来获取唯一的对象实例。实际上,单例模式就类似于全局变量或全局函数的角色,可以使用它来代替全局变量

三、常见场景和解决方案

单例模式更多是在程序一开始进行初始化时使用的,接下来,我们就来看看有哪些比较常用的场景和解决方案。

常见的单例模式应用和使用的解决方案有:饿汉式初始化、懒汉式初始化、同步信号、双重锁定和使用 ThreadLocal。其中,懒汉式初始化的代码实现在前面我们已经介绍过了,饿汉式、同步信号、双重锁定网上资料有很多,并且也不难理解,就不再赘述。

这里我们重点介绍一下使用 ThreadLocal 的方式,比如,下面这个 AppContext 代码示例:

import java.util.HashMap;
import java.util.Map;
public class AppContext {
    private static final ThreadLocal<AppContext> local = new ThreadLocal<>();
    private Map<String,Object> data = new HashMap<>();
    public Map<String, Object> getData() {
        return getAppContext().data;
    }
    //批量存数据
    public void setData(Map<String, Object> data) {
        getAppContext().data.putAll(data);
    }
    //存数据
    public void set(String key, String value) {
        getAppContext().data.put(key,value);
    }
    //取数据
    public void get(String key) {
        getAppContext().data.get(key);
    }
    //初始化的实现方法
    private static AppContext init(){
        AppContext context = new AppContext();
        local.set(context);
        return context;
    }
    //做延迟初始化
    public static AppContext getAppContext(){
        AppContext context = local.get();
        if (null == context) {
            context = init();
        }
        return context;
    }
    //删除实例
    public static void remove() {
        local.remove();
    }
}

  • 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.

上面的代码实现实际上就是懒汉式初始化的扩展,只不过用 ThreadLocal 替换静态对象来存储唯一对象实例。之所会选择 ThreadLocal,就是因为 ThreadLocal 相比传统的线程同步机制更有优势。

在传统的同步机制中,我们通常会通过对象的锁机制来保证同一时间只有一个线程访问单例类。这时该类是多个线程共享的,我们都知道使用同步机制时,什么时候对类进行读写、什么时候锁定和释放对象是有很烦琐要求的,这对于一般的程序员来说,设计和编写难度相对较大。

而 ThreadLocal 则会为每一个线程提供一个独立的对象副本,从而解决了多个线程对数据的访问冲突的问题。正因为每一个线程都拥有自己的对象副本,也就省去了线程之间的同步操作。

所以说,现在绝大多数单例模式的实现基本上都是采用的 ThreadLocal 这一种实现方式。

四、为什么使用单例模式?

通过上面的分析,现在我们就可以来回答这个问题了:为什么要使用单例模式?

第一,系统某些资源有限。比如,控制某些共享资源(例如,数据库或文件)的访问权限。资源有限就会带来访问冲突的问题,如果不限制实例的数量,那么很快有限的资源就会耗尽,同时造成大量的对象处于等待资源中。再比如,同时读写同一个超大的 AI 模型文件,或使用外部进程式服务,如果不使用单例模式,随着用户进程数开启越多,系统原有的进程处理资源就会变得越少,这不仅会导致操作系统处理速度变慢,同时也会影响用户进程自身的处理速度。

第二,需要表示为全局唯一的对象。 比如,系统要求提供一个唯一的序列号生成器。客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。在一个系统中要求一个类只有一个实例时才应当使用单例模式。反过来,如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式。

五、单例模式的优缺点是什么?

我们先来看使用单例模式的优势,也就是通过它我们能收获什么呢。

  • 对有限资源的合理利用,保护有限的资源,防止资源重复竞抢。

  • 更高内聚的代码组件,能提升代码复用性。

  • 具备全局唯一访问点的权限控制,方便按照统一规则管控权限。

  • 从负载均衡角度考虑,我们可以轻松地将 Singleton 扩展成两个、三个或更多个实例。由于封装了基数问题,所以在适当的时候可以自由更改实例的数量。

除了优势,使用单例模式当然也会带来一些劣势,也就是我们会损失一些东西或特点。

  • 作为全局变量使用时,引用的对象越多,代码修改影响的范围也越大。

  • 作为全局变量时,在全局变量中使用状态变量时,会造成加/解锁的性能损耗。

  • 即便能扩展多实例,但耦合性依然很高,因为隐蔽了不同对象之间的调用关系。

  • 不支持有参数的构造函数。

文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发