Skip to content

单例模式

单例模式是23种常用设计模式中最简单的设计模式之一,它提供了一种创建对象的方式,确保只有单个对象被创建。这个设计模式主要目的是想在整个系统中只能出现类的一个实例,即一个类只有一个对象。

单例类要点

  1. 将构造函数私有,同时提供获取到该类对象的静态方法。
  2. 将拷贝构造和赋值重载私有或者删除,保证只能通过提供的静态方法获取到该类对象。

单例的两种模式

  • 懒汉模式:当使用对象的时间才创建该对象。
  • 饿汉模式:一开始就创建对象,无论使不使用。

示例要点

  • 为了便于对比和分析,我们在构造和析构函数中分别向控制台输出信息。
  • 示例场景以模拟公司员工使用打印机场景,打印机可以打印员工要输出的内容,并且可以累积打印机使用次数为前提编写示例代码。

测试用例代码:

cpp
// #include "./singletonHeapEager.h" // 在堆区的饿汉模式
#include "./singletonHeapLazy.h" // 在堆区的懒汉模式

#include <thread>

#define NUM 1000000

void dowork(int n) {
    Printer *printer = Printer::getPrinter();

    for (int i = 0; i < NUM; ++i) {
        printer->print("in th" + std::to_string(n) + ": " + std::to_string(rand() % 100));
    }
}

int main() {
    Printer *printer = Printer::getPrinter();

    std::thread th1(dowork, 1);
    std::thread th2(dowork, 2);
    std::thread th3(dowork, 3);
    std::thread th4(dowork, 4);
    std::thread th5(dowork, 5);

    for (int i = 0; i < NUM; ++i) {
        printer->print("in main: " + std::to_string(rand() % 100));
    }

    th1.join();
    th2.join();
    th3.join();
    th4.join();
    th5.join();
    printer->showNums();

    return 0;
}

通过引入不同的头文件,编译时和不同的源文件一起编译来对比测试各个情况下的单例模式。

饿汉模式

cpp
#include <iostream>
#include <string>

class Printer {
public:
    static Printer *getPrinter();
    void print(std::string str);
    void showNums();

private:
    Printer();
    ~Printer();
    Printer(const Printer &) = delete;
    Printer &operator=(const Printer &) = delete;

private:
    static Printer *_printer; // 当前类的唯一对象
    int _num;                 // 记录打印次数
};
cpp
#include "singletonHeapEager.h"

// 打印时需要向屏幕输出内容,也要给打印次数+1,所以多线程环境下需要加锁
#include <mutex>
std::mutex mtx; // 初始化互斥锁

Printer *Printer::_printer = new Printer;

Printer::Printer() : _num(0) { std::cout << "call Printer()" << std::endl; }

Printer::~Printer() { std::cout << "call ~Printer()" << std::endl; }

Printer *Printer::getPrinter() {
    return _printer;
}

void Printer::print(std::string str) {
    mtx.lock();
    std::cout << str << std::endl;
    ++_num;
    mtx.unlock();
}

void Printer::showNums() {
    std::cout << "The printer has been used " << _num << " times" << std::endl;
}
bash
$ g++ main.cpp singletonHeapEager.cpp -o main
$ ./main
call Printer()
in th1: 49
in th1: 30
in th3: 72
...
in th5: 40
in main: 7
in main: 65
in th4: 23
The printer has been used 6000000 times

懒汉模式

cpp
#include "singletonHeapLazy.h"

// 打印时需要向屏幕输出内容,也要给打印次数+1,所以多线程环境下需要加锁
#include <mutex>
std::mutex mtx; // 初始化互斥锁

// 一开始将单例对象指针置空,当获取单例对象的时间再申请,这就是懒汉模式
Printer *Printer::_printer = nullptr;

Printer::Printer() : _num(0) { std::cout << "call Printer()" << std::endl; }

Printer::~Printer() { std::cout << "call ~Printer()" << std::endl; }

Printer *Printer::getPrinter() {
    if (_printer == nullptr) {
        mtx.lock();
        if (_printer == nullptr) {
            _printer = new Printer;
        }
        mtx.unlock();
    }
    return _printer;
}

void Printer::print(std::string str) {
    mtx.lock();
    std::cout << str << std::endl;
    ++_num;
    mtx.unlock();
}

void Printer::showNums() {
    std::cout << "The printer has been used " << _num << " times" << std::endl;
}
cpp
#include <iostream>
#include <string>

class Printer {
public:
    static Printer *getPrinter();
    void print(std::string str);
    void showNums();

private:
    Printer();
    ~Printer();
    Printer(const Printer &) = delete;
    Printer &operator=(const Printer &) = delete;

private:
    static Printer *_printer; // 当前类的唯一对象
    // 可以使用volatile来修饰单例对象的指针变量来防止编译器优化
    // static Printer *volatile _printer; // 当前类的唯一对象
    int _num; // 记录打印次数
};
bash
$ g++ main.cpp singletonHeapLazy.cpp -o main
$ ./main
call Printer()
in th1: 49
in th1: 30
in th3: 72
...
in th5: 40
in main: 7
in main: 65
in th4: 23
The printer has been used 6000000 times

观察饿汉模式和懒汉模式发现,饿汉模式一开始就对静态对象进行了初始化,但是并没有合适的清理时机,在程序结束时析构函数没有得到调用;而懒汉模式虽然是获取单例对象时才进行初始化申请空间,但是也没有释放内存的机会。

在Linux使用 进行内存泄露检测也可发现内存泄露的问题

bash
$ g++ main.cpp singletonHeapEager.cpp -o main -g
$ valgrind --tool=memcheck --leak-check=full ./main
==24952== 
==24952== HEAP SUMMARY:
==24952==     in use at exit: 4 bytes in 1 blocks
==24952==   total heap usage: 13 allocs, 12 frees, 76,396 bytes allocated
==24952== 
==24952== LEAK SUMMARY:
==24952==    definitely lost: 0 bytes in 0 blocks
==24952==    indirectly lost: 0 bytes in 0 blocks
==24952==      possibly lost: 0 bytes in 0 blocks
==24952==    still reachable: 4 bytes in 1 blocks
==24952==         suppressed: 0 bytes in 0 blocks
==24952== Reachable blocks (those to which a pointer was found) are not shown.
==24952== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==24952== 
==24952== For lists of detected and suppressed errors, rerun with: -s
==24952== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

虽然使用单例模式一般不考虑单例对象的释放问题,但是我们要养成申请使用了指针就考虑释放的习惯。

上面的两种方式是线程安全的

饿汉模式一开始就创建了单例对象,所以是线程安全的;懒汉模式在获取单例对象时通过双重判断和加锁机制来保证线程安全问题。

在静态区的单例

上面两个实例中不管是懒汉还是饿汉模式,都要在堆区申请空间来存储单例对象,其实我们还可以把单例对象保存在静态区。

cpp
#include <iostream>
#include <string>

class Printer {
public:
    static Printer *getPrinter();
    void print(std::string str);
    void showNums();

private:
    Printer();
    ~Printer();
    Printer(const Printer &) = delete;
    Printer &operator=(const Printer &) = delete;

private:
    int _num; // 记录打印次数
};
cpp
#include "singletonStaticLazy.h"

// 打印时需要向屏幕输出内容,也要给打印次数+1,所以多线程环境下需要加锁
#include <mutex>
std::mutex mtx; // 初始化互斥锁

Printer::Printer() : _num(0) { std::cout << "call Printer()" << std::endl; }

Printer::~Printer() { std::cout << "call ~Printer()" << std::endl; }

Printer *Printer::getPrinter() {
    static Printer printer;
    return &printer;
}

void Printer::print(std::string str) {
    mtx.lock();
    std::cout << str << std::endl;
    ++_num;
    mtx.unlock();
}

void Printer::showNums() {
    std::cout << "The printer has been used " << _num << " times" << std::endl;
}
bash
$ g++ main.cpp singletonStaticLazy.cpp -o main
$ ./main
call Printer()
in th2: 7
in th2: 49
in th1: 73
in th3: 72
in th5: 78
...
in main: 40
in th1: 44
in th4: 65
in th3: 23
in th5: 9
The printer has been used 6000000 times
call ~Printer()

Tips

C++ 11以后,编译器会保证局部作用域内静态变量初始化的线程安全问题。

思考:如果单例对象所占空间较大,可能会对静态区造成内存压力。

应用场景

  1. 有频繁实例化然后销毁的情况,也就是频繁的 new 对象,可以考虑单例模式;
  2. 创建对象时耗时过多或者耗资源过多,但又经常用到的对象;
  3. 当某个资源需要在整个程序中只有一个实例时,可以使用单例模式进行管理(全局资源管理)。例如 数据库连接池、日志记录器等;
  4. 当需要读取和管理程序配置文件时,可以使用单例模式确保只有一个实例来管理配置文件的读取和写入操作(配置文件管理);
  5. 在多线程编程中,线程池是一种常见的设计模式。使用单例模式可以确保只有一个线程池实例,方便管理和控制线程的创建和销毁;
  6. GUI应用程序中的全局状态管理:在GUI应用程序中,可能需要管理一些全局状态,例如用户信息、应用程序配置等。使用单例模式可以确保全局状态的唯一性和一致性。