设计模式 - 单例模式

单例模式是校招面试最容易提问的设计模式之一,其他常见的设计模式包括观察者模式、工厂模式(简单工厂模式、工厂方法模式、抽象工厂模式)

1、什么是单例模式

关键词:单例模式、线程安全、内存泄漏、magic static、

定义保证一个类仅有一个实例,并提供一个访问它的全局访问点。即为了保证在一个进程中,某个类有且仅有一个实例。

实现要点

  • 全局只有一个实例:static 特性,同时禁止用户自己声明并定义实例(把构造函数设为 private)
  • 线程安全
  • 禁止赋值和拷贝
  • 用户通过接口获取实例:使用 static 类成员函数

实现方式

使用一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。

具体运用场景如:

  1. 设备管理器,系统中可能有多个设备,但是只有一个设备管理器,用于管理设备驱动;
  2. 数据池,用来缓存数据的数据结构,需要在一处写,多处读取或者多处写,多处读取;
  3. 参考Unix V6++中的单例模式;

优点:

  • 你可以保证一个类只有一个实例。
  • 你获得了一个指向该实例的全局访问节点。
  • 仅在首次请求单例对象时对其进行初始化。

缺点:

  • 违反了_单一职责原则_。 该模式同时解决了两个问题(保证一个类只有一个实例、为该实例提供一个全局访问节点)。
  • 单例模式可能掩盖不良设计, 比如程序各组件之间相互了解过多等。
  • 该模式在多线程环境下需要进行特殊处理, 避免多个线程多次创建单例对象。
  • 单例的客户端代码单元测试可能会比较困难, 因为许多测试框架以基于继承的方式创建模拟对象。 由于单例类的构造函数是私有的, 而且绝大部分语言无法重写静态方法, 所以你需要想出仔细考虑模拟单例的方法。 要么干脆不编写测试代码, 或者不使用单例模式。

分类:根据实例对象创建时间,可分为饿汉模式、懒汉模式;根据实例对象的位置(性质),分为局部静态变量、全局指针两种。

其他:编程实现一个单例模式(基础版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// version 1.0
class Singleton
{
private:
static Singleton* instance;
private:
Singleton() {}; // 构造函数设为private
~Singleton() {};
Singleton(const Singleton&);
Singleton& operator=(const Singleton&);
public:
static Singleton* getInstance()
{
if(instance == NULL)
instance = new Singleton();
return instance;
}
};

// init static member
Singleton* Singleton::instance = NULL;

参考:C++ 单例模式总结与剖析首选

注:关于静态成员变量的定义

其实这句话“静态成员变量是需要初始化的”是有一定问题的,应该说“静态成员变量需要定义”才是准确的,而不是初始化。

两者的区别在于:初始化是赋一个初始值,而定义是分配内存。

静态成员变量在类中仅仅是声明,没有定义,所以要在类的外面定义,实际上是给静态成员变量分配内存。

2、C++ 实现单例的几种方式

2.1 有缺陷的懒汉式

懒汉式(Lazy-Initialization)的方法是直到使用时才实例化对象,也就说直到调用get_instance() 方法的时候才 new 一个单例的对象, 如果不被调用就不会占用内存。

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
#include <iostream>
// version1:
// with problems below:
// 1. thread is not safe
// 2. memory leak

class Singleton {
private:
Singleton() {
std::cout << "constructor called!" << std::endl;
}
Singleton(Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* m_instance_ptr;
public:
~Singleton() {
std::cout << "destructor called!" << std::endl;
}
static Singleton* get_instance() {
if (m_instance_ptr == nullptr) {
m_instance_ptr = new Singleton;
}
return m_instance_ptr;
}
void use() const { std::cout << "in use" << std::endl; }
};

Singleton* Singleton::m_instance_ptr = nullptr;

int main() {
Singleton* instance = Singleton::get_instance();
Singleton* instance_2 = Singleton::get_instance();
delete instance;
return 0;
}

运行的结果是constructor called!
可以看到,获取了两次类的实例,却只有一次类的构造函数被调用,表明只生成了唯一实例,这是个最基础版本的单例实现,他有哪些问题呢?

线程安全的问题,当多线程获取单例时有可能引发竞态条件:第一个线程在if中判断 m_instance_ptr是空的,于是开始实例化单例;同时第2个线程也尝试获取单例,这个时候判断m_instance_ptr还是空的,于是也开始实例化单例;这样就会实例化出两个对象,这就是线程安全问题的由来; 解决办法:加锁(mutex),防止多次访问,第一次判断不为空不加锁,若为空,再进行加锁判断是否为空,若为空则生成对象。

内存泄漏. 注意到类中只负责new出对象,却没有负责delete对象,因此只有构造函数被调用,析构函数却没有被调用;因此会导致内存泄漏。解决办法: 使用共享指针(shared_ptr);

因此,这里提供一个改进的,线程安全的、使用智能指针的实现;

2.2 线程安全、内存安全的懒汉式单例 (智能指针,锁)

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
44
#include <iostream>
#include <memory> // shared_ptr
#include <mutex> // mutex

// version 2:
// with problems below fixed:
// 1. thread is safe now
// 2. memory doesn't leak

class Singleton{
public:
typedef std::shared_ptr<Singleton> Ptr;
~Singleton(){
std::cout<<"destructor called!"<<std::endl;
}
Singleton(Singleton&)=delete;
Singleton& operator=(const Singleton&)=delete;
static Ptr get_instance(){
// "double checked lock"
if(m_instance_ptr==nullptr){
std::lock_guard<std::mutex> lk(m_mutex);
if(m_instance_ptr == nullptr){
m_instance_ptr = std::shared_ptr<Singleton>(new Singleton);
}
}
return m_instance_ptr;
}
private:
Singleton(){
std::cout<<"constructor called!"<<std::endl;
}
static Ptr m_instance_ptr;
static std::mutex m_mutex;
};

// initialization static variables out of class
Singleton::Ptr Singleton::m_instance_ptr = nullptr;
std::mutex Singleton::m_mutex;

int main(){
Singleton::Ptr instance = Singleton::get_instance();
Singleton::Ptr instance2 = Singleton::get_instance();
return 0;
}

运行结果如下,发现确实只构造了一次实例,并且发生了析构。constructor called! destructor called!
shared_ptr和mutex都是C++11的标准,以上这种方法的优点是

基于 shared_ptr, 用了C++比较倡导的 RAII思想,用对象管理资源,当 shared_ptr 析构的时候,new 出来的对象也会被 delete掉。以此避免内存泄漏。
加了锁,使用互斥量来达到线程安全。这里使用了两个 if判断语句的技术称为双检锁;好处是,只有判断指针为空的时候才加锁,避免每次调用 get_instance的方法都加锁,锁的开销毕竟还是有点大的。
不足之处在于: 使用智能指针会要求用户也得使用智能指针,非必要不应该提出这种约束; 使用锁也有开销; 同时代码量也增多了,实现上我们希望越简单越好。

还有更加严重的问题,在某些平台(与编译器和指令集架构有关),双检锁会失效!具体可以看这篇文章,解释了为什么会发生这样的事情。

因此这里还有第三种的基于 Magic Staic的方法达到线程安全。

注:设计模式中的线程安全单例可能发生内存泄漏

2.3 最推荐的懒汉式单例(magic static )——局部静态变量

其实就是把静态全局变量改为静态局部变量,利用了C++11的magic static特性

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
#include <iostream>

class Singleton
{
public:
~Singleton() {
std::cout << "destructor called!" << std::endl;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton& get_instance() {
static Singleton instance;
return instance;
}
private:
Singleton() {
std::cout << "constructor called!" << std::endl;
}
};

int main(int argc, char* argv[])
{
// 注:Singleton对象的实例化应该用引用或者指针,如果没有禁用对象赋值或者拷贝构造,则默认是内存拷贝,会产生新的实例对象,也不符合单例模式的要求,比如 `Singleton instance1 = Singleton::Instance();`就不应该出现
// 注:指针也不行,因为如果delete instance(instance是指向该类的唯一实例的指针)会运行报错,因为实例instance不是new出来的
Singleton& instance_1 = Singleton::get_instance();
Singleton& instance_2 = Singleton::get_instance();
return 0;
}

这种方法又叫做 Meyers’ SingletonMeyer’s的单例, 是著名的写出《Effective C++》系列书籍的作者 Meyers 提出的。所用到的特性是在C++11标准中的Magic Static特性:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。

这样保证了并发线程在获取静态局部变量的时候一定是初始化过的,所以具有线程安全性。

C++静态变量的生存期 是从声明到程序结束,这也是一种懒汉式。

这是最推荐的一种单例实现方式:

  1. 通过局部静态变量的特性保证了线程安全 (C++11, GCC > 4.3, VS2015支持该特性);
  2. 不需要使用共享指针,代码简洁;
  3. 注意在使用的时候需要声明单例的引用 Single& 才能获取对象。

另外网上有人的实现返回指针而不是返回引用

1
2
3
4
static Singleton* get_instance(){
static Singleton instance;
return &instance;
}

这样做并不好,理由主要是无法避免用户使用delete instance导致对象被提前销毁。还是建议大家使用返回引用的方式。

3、何时应该使用或者不使用单例

You need to have one and only one object of a type in system你需要系统中只有唯一一个实例存在的类的全局变量的时候才使用单例。

如果使用单例,应该用什么样子的
越小越好,越简单越好,线程安全,内存不泄露。

其他:单例模板、CRTP 奇异递归模板模式实现,参考面试中的Singleton(这里的单例模式是不全的,需要禁用赋值和拷贝)和C++ 单例模式总结与剖析(首选)

面试题

单例模式的线程安全问题:1、静态局部变量,基于magic static机制避免多线程的同步问题)、2、加锁(双重锁、mutex)

单例模式有哪几种创建方式:饿汉方式(指全局的单例实例在类装载时构建,即Singleton* Singleton::singleton_ = new Singleton;,无参构造函数必须定义且私有)、懒汉方式(指全局的单例实例在第一次被使用时构建)参考:单例模式的几种创建方式其他几种方式、C++单例模式(懒汉/饿汉)C++饿汉

如何保证单例模式只有唯一实例:将该类的构造方法定义为私有方法(或者将构造方法删除,用delete关键字),这样其他处的代码就无法通过调用该类的构造方法来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例;在该类内提供一个静态方法,当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用。


设计模式 - 单例模式
http://franktjp.com/2021/10/06/设计模式-单例模式/
作者
Franktjp
发布于
2021年10月6日
许可协议