设计模式之单例模式

简介

在实际开发中,为了节约系统资源,有时需要确保系统中某个类只有唯一一个实例,当这个唯一实例创建成功之后,无法再创建一个同类型的其他对象,所有的操作都只能基于这个唯一实例。为了确保对象的唯一性,可以通过单例模式来实现,这就是单例模式的动机所在。

单例模式的定义:确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。单例模式是一种对象创建型模式。

实现过程

如下代码模拟了 Windows 任务管理器:

class TaskManager {
public:
  TaskManager();
  void displayProcesses();
  void displayServices();
};

为了实现任务管理器的唯一性,通过以下 3 步对其进行重构:

(1) 由于每次实例化 TaskManager 对象时都会产生一个新的对象,为了确保唯一性,需要禁止类的外部直接使用 new 来创建对象,因此需要将其构造函数设为私有:

class TaskManager {
public:
  void displayProcesses();
  void displayServices();

private:
  TaskManager();
};

(2) 此时虽然类的外部不能再使用 new 来创建对象,但是在类内部还是可以创建对象的。因此,可以在 TaskManager 中创建并保存此唯一实例。为了让外界可以访问这个唯一实例,需要在 TaskManager 类中定义一个静态的 TaskManager 类型的私有成员变量:

class TaskManager {
public:
  void displayProcesses();
  void displayServices();

private:
  TaskManager();
  static TaskManager* tm;
};

TaskManager* TaskManager::tm = nullptr;

(3) 为了保证成员变量的封装性,将类中的 tm 对象设置为私有性,但是外界却无法访问该变量,为此需要增加一个共有的静态方法:

class TaskManager {
public:
  void displayProcesses();
  void displayServices();
  static TaskManager* getInstance() {
    if (tm == nullptr) {
      tm = new TaskManager();
    }
  }

private:
  TaskManager();
  static TaskManager* tm;
};

TaskManager* TaskManager::tm = nullptr;

需要注意 getInstance() 方法的修饰符,首先它是一个 public 方法,以便外界其他对象使用,其次它使用了 static 关键字,即它是一个静态方法,在类外可以直接通过类名来访问,而无需创建 TaskManager 对象。事实上,在类外也无法创建 TaskManager 对象,因为构造函数是私有的。

单例模式有 3 个要点:

  1. 某个类只能有一个实例;
  2. 它必须自行创建这个实例;
  3. 它必须自行向整个系统提供这个实例;

其结构如图所示:

饿汉式与懒汉式

单例类通常由两种不同的实现方式:

  • 饿汉式单例类
  • 懒汉式单例类

饿汉式

饿汉式单例类的结构如图所示:

从图中观察可知,由于在定义静态变量的时候实例化单例类,因此在类加载的时候就已经创建了单例对象,其代码如下:

class EagerSingleton {
public:
  static EagerSingleton* getInstance() { return instance; }

private:
  EagerSingleton();
  static EagerSingleton* instance;
};

EagerSingleton* EagerSingleton::instance = new EagerSingleton();

当类被加载时,静态变量 instance 会被初始化,此时类的私有构造函数会被调用,单例类的唯一实例将被创建。

懒汉式与线程锁定

除了饿汉式单例,还有一种经典的懒汉式单例,其结构如图所示:

从图中可以看出,懒汉式单例在第一次调用 getInstance() 方法时实例化,在类加载时并不自行实例化,这种技术又称为延迟加载技术,即在需要的时候再加载实例,为了避免多个线程同时调用,可以使用互斥锁来保证线程安全,代码如下:

class LazySignleton {
public:
  static LazySignleton* getInstance() {
    if (instance == nullptr) {
      mutex.lock();
      instance = new LazySignleton();
      mutex.unlock();
    }
    return instance;
  }

private:
  LazySignleton() {}
  static LazySignleton* instance;
  static pthread_mutex_t mutex;
};

LazySignleton* LazySignleton::instance = nullptr;
pthread_mutex_t LazySignleton::mutex = PTHREAD_MUTEX_INITIALIZER;

尽管在如上代码中使用互斥锁来保证线程安全,但是还是会存在单例对象不唯一的情况。

加入某一瞬间线程 A 和线程 B 都在调用 getInstance() 方法,此时 instance 对象为空,均能通过 instance == nullptr 的判断。此时线程 A 率先进入临界区,而线程 B 则阻塞等待互斥锁解锁。然后线程 A 创建单例对象,但是当线程 A 离开临界区时,线程 B 被唤醒,此时线程 B 并不知道单例对象已经创建了,于是继续创建新的实例,导致产生多个实例对象,违背了单例模式的设计思想,因此需要进一步改进,在临界区中再进行一次 instance == nullptr 判断,这种方式称为双重检查锁定。其代码如下:

class LazySignleton {
public:
  static LazySignleton* getInstance() {
    if (instance == nullptr) {
      mutex.lock();
      if (instance == nullptr) {
        instance = new LazySignleton();
      }
      mutex.unlock();
    }
    return instance;
  }

private:
  LazySignleton() {}
  static LazySignleton* instance;
  static pthread_mutex_t mutex;
};

LazySignleton* LazySignleton::instance = nullptr;
pthread_mutex_t LazySignleton::mutex = PTHREAD_MUTEX_INITIALIZER;

饿汉式与懒汉式比较

饿汉式单例类在类加载时就将自己实例化,它的优点在于无需考虑多线程访问问题,可以确保实例的唯一性;从调用速度和反应速度来看,由于单例对象一开始就的一创建,因此要优于懒汉式单例。但是无论系统在运行时是否需要使用该单例对象,由于在类加载时该对象就需要创建,因此从资源利用效率角度来讲,饿汉式单例不及懒汉式单例,而且在系统加载时由于需要创建饿汉式单例对象,加载时间可能会比较长。

懒汉式单例类在第一次使用时创建,无须一直占用系统资源,实现了延迟加载,但是必须处理好多个线程同时访问的问题,特别是当单例类作为资源控制器,在实例化时必然涉及资源初始化,而资源初始化很可能耗费大量时间,这意味着出现多线程同时首次引用此类的几率变得很大,需要通过双检锁等级制进行控制,这将导致系统性能收到一定影响。

更好的单例实现方法

饿汉式单例类不能实现延迟加载,不管将来用不用,它始终占据内存;懒汉式单例类线程安全控制繁琐,而且性能受影响。可见无论是饿汉式单例还是懒汉式单例都存在问题。

而有一种称为 Initalization on Demand Holder(IoDH) 的技术可以克服上述两种方式的缺点。它在实现时,需要在单例类中增加一个静态的局部类,在该局部类中创建单例对象,再将该单例对象通过 getInstance() 方法返回给外部使用:

class Singleton {
public:
  static Singleton* getInstance() {
    static Singleton instance;
    return &instance;
  }

private:
  Singleton() {}
};

通过该方式,既可以实现延迟加载,又可以保证线程安全,不影响系统性能。其缺点是与编程语言本身的特性有关,很多面向对象语言不支持 IoDH。

总结

优点

  1. 单例模式提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它。
  2. 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统性能。
  3. 允许可变数目的实例。基于单例模式,开发人员可以进行扩展,使用与控制单例对象相似的方法来获得指定个数的实例对象,即节省系统资源,又解决了由于单例对象共享过多有损性能的问题。

缺点

  1. 由于单例模式没有抽象层,因此单例类的扩展有很大的困难。
  2. 单例类的职责过重,在一定程度上违背了单一职责原则。因为单例类既提供了业务方法,又提供了创建对象的方法,将对象的创建和对象本身的功能耦合在一起。

适用场景

  1. 系统只需要一个实例对象。
  2. 客户调用类的单个实例只允许使用一个公共访问点,除了公共访问点外,不能通过其他途径访问该实例。

所有代码见 Kohirus-Github

posted @ 2022-10-20 17:58  Leaos  阅读(44)  评论(0编辑  收藏  举报