【C++】深度解析--单例模式(面试常考,小白一看就懂!!)

目录

一、前言

二、什么是单例模式? 

三、为什么要有单例模式 

四、单例模式的详细实现步骤

五、单例模式的详解 

💢 懒汉模式💢

🔥什么是线程安全 

🍍普通懒汉式单例(线程不安全) 

🍇 加锁的懒汉式单例(推荐)

💢饿汉模式💢 

💢两种模式之间的对比💢

六、总结 

七、共勉 


一、前言

单例模式】相信大家都有所听闻,甚至也写过不少了,在面试中也是考得最多的其中一个设计模式,面试官常常会要求写出两种类型的单例模式并且解释其原理,废话不多说,我们开始学习如何很好地回答这一道面试题吧。

二、什么是单例模式? 

单例模式】是指在 内存 中 只会创建且仅创建一次对象的设计模式在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。 

单例模式的目标很简单确保一个类在程序运行期间只能有一个实例(对象),并提供一个方法,让你可以随时访问这个唯一的实例。


三、为什么要有单例模式 

 我们先来举一个生活中的例子来说明,让大家更好的理解 ----- 【单例模式

场景描述 客户服务中心通常由一个客服代表来处理所有的客户请求和问题。如果每个请求都由不同的代表处理,可能会导致客户信息的分散和服务效率低下。 

如果没有单例模式:

  • 多个代表:每个客服代表可能处理不同的客户请求,导致客户信息的分散和服务不一致。例如,一个代表处理订单问题,另一个代表处理技术支持问题,这样客户需要重复提供信息。
  • 增加负担:客户需要在不同的代表之间切换,增加了服务的复杂性和时间成本。

单例模式类比一个唯一的客户服务代表就像单例模式中的唯一实例,确保所有客户请求的处理是统一的,避免了多个代表处理问题带来的信息分散和效率低下。 


使用单例模式的原因: 

  • 资源控制:单例模式可以用来控制系统中的资源,例如数据库连接池或线程池,确保这些关键资源不会被过度使用。
  • 内存节省:当需要一个对象进行全局访问,但创建多个实例会造成资源浪费时,单例模式可以确保只创建一个实例,节省内存。
  • 共享:单例模式允许状态或配置信息在系统的不同部分之间共享,而不需要传递实例。
  • 延迟初始化:单例模式支持延迟初始化,即实例在首次使用时才创建,而不是在类加载时。
  • 一致的接口:单例模式为客户端提供了一个统一的接口来获取类的实例,使得客户端代码更简洁。
  • 易于维护:单例模式使得代码更易于维护,因为所有的实例都使用相同的实例,便于跟踪和修改变更。

单例模式的应用场景: 

  • 配置管理器:在应用程序中,配置信息通常只需要读取一次,并全局使用。单例模式用于确保配置管理器只被实例化一次。
  • 日志记录器:一个系统中通常只需要一个日志记录器来记录所有的日志信息,使用单例模式可以避免日志文件的重复写入。
  • 数据库连接池:数据库连接是一种有限的资源,使用单例模式可以确保数据库连接池的唯一性,并且能够重用连接,减少连接创建和销毁的开销。
  • 线程池:类似于数据库连接池,线程池也是有限的资源,使用单例模式可以避免创建过多的线程,提高应用程序的并发性能。
  • 任务调度器:在需要全局调度和管理的场景下,如定时任务调度器,单例模式提供了一个集中的管理方式。
  • 网站的计数器:一般也是采用单例模式实现,否则难以同步。

四、单例模式的详细实现步骤

 单例模式的基本要点:

  • 唯一性:单例模式确保某个类只有一个实例。就像你家里只有一个路由器来提供网络连接。

  • 全局访问点:这个唯一的实例可以从程序的任何地方访问。就像无论你在家里的哪个房间,都可以连接到同一个路由器。

三大实现步骤: 

  • 私有化构造函数

    • 平常我们创建对象时,使用 new 关键字,比如 MyClass* obj = new MyClass();
    • 但是在单例模式中,我们不希望其他代码可以随意创建新的实例。所以我们把类的构造函数设为 private ,这样别人就不能通过 new 创建新的对象了。
  • 私有静态实例指针

    • 我们在类里面定义一个静态指针,它用来存储唯一的实例。这是全局唯一的地方,用来保存实例的引用。
  • 公共静态方法(通常叫 getInstance

    • 这个方法会检查静态实例指针是否已经指向了一个实例。
    • 如果没有,它就会创建一个新的实例,并将指针指向它。
    • 如果已经有了实例,它就直接返回这个实例。

五、单例模式的详解 

单例模式有两种类型: 

  • 懒汉式:真正需要使用对象时才去创建该单例类对象
  • 饿汉式:类加载时已经创建好该单例对象,等待被程序使用

💢 懒汉模式💢

系统运行中,实例并不存在,只有当需要使用该实例时,才会去创建并使用实例。这种方式要考虑线程安全。

🔥什么是线程安全 

在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。 

如何保证线程安全?

  1. 给共享的资源加把锁,保证每个资源变量每时每刻至多被一个线程占用。
  2. 让线程也拥有资源,不用去共享进程中的资源。如:使用threadlocal可以为每个线程维护一个私有的本地变量。

🍍普通懒汉式单例(线程不安全) 

多线程环境中,如果多个线程几乎同时调用 Singleton::getInstance() 方法而没有适当的同步机制,就可能会产生线程安全问题。以下是一个简单的例子,演示了在没有线程同步保护的懒汉模式中,如何可能出现线程安全问题。 

#include <iostream>
#include <thread>  // 引入多线程支持的头文件

class Singleton 
{
private:
    // 私有化构造函数,防止外部实例化
    Singleton() 
    {
        // 模拟一些初始化操作,例如加载配置文件、连接数据库等
        std::cout << "Constructor called!" << std::endl;
    }

    // 禁止拷贝构造和赋值操作,以防止通过拷贝生成多个实例
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 静态指针,用于存储单例的唯一实例
    static Singleton* instance;

public:
    // 静态方法,用于获取单例的唯一实例
    static Singleton* getInstance() 
    {
        // 检查实例是否已存在
        if (instance == nullptr) 
        {
            // 如果实例尚不存在,则创建新的实例
            instance = new Singleton();
        }
        // 返回实例指针
        return instance;
    }
};

// 在类外初始化静态成员变量
Singleton* Singleton::instance = nullptr;

// 函数用于创建Singleton实例,并打印实例的地址
void createSingletonInstance() 
{
    Singleton* singleton = Singleton::getInstance();
    std::cout << singleton << std::endl;  // 打印实例的地址,验证是否为同一个实例
}

int main() 
{
    // 启动多个线程,几乎同时调用 getInstance() 方法
    std::thread t1(createSingletonInstance);
    std::thread t2(createSingletonInstance);

    // 等待两个线程执行完成
    t1.join();
    t2.join();

    return 0;
}

 代码解析:

  • 私有化构造函数

    • Singleton() 构造函数是私有的,确保外部无法直接创建该类的实例。这是单例模式的核心,确保只有一个实例存在。
  • 禁止拷贝构造和赋值操作

    • 通过将拷贝构造函数和赋值操作符删除 (delete),防止通过拷贝或赋值的方式创建多个实例。
  • 静态实例指针

    • static Singleton* instance 是一个静态成员变量,用于保存唯一的实例指针。静态成员意味着它属于类,而不是类的某个对象。
  • getInstance() 方法

    • 这个方法是获取单例实例的唯一途径。如果实例还没有创建(即 instance == nullptr),则创建新的实例并将其指针赋值给 instance。否则,直接返回已创建的实例。
  • 多线程问题

    • main() 函数中,两个线程几乎同时调用 getInstance()。由于 getInstance() 中的 if (instance == nullptr) 判断并没有加锁保护,这可能导致多个线程同时判断 instancenullptr,从而同时创建多个实例。这会破坏单例模式的唯一性。

 可能出现的问题

在上面的代码中,假设 t1t2 是两个线程,它们几乎同时调用 Singleton::getInstance() 方法。如果没有加锁,可能会发生以下情况:

  1. 线程 t1 和 t2 都检查 instance == nullptr:因为两个线程几乎同时执行,所以它们都可能检测到 instance 还没有被创建(即 instance == nullptr)。

  2. 两个线程都进入 if 语句内部:由于都认为 instance == nullptr,所以两个线程都进入了 if 语句内部。

  3. 线程 t1 和 t2 分别创建自己的 Singleton 实例:此时,由于没有同步机制,两个线程可能几乎同时执行 new Singleton() 操作。这会导致两个不同的 Singleton 实例被创建。

  4. Singleton::instance 被两次赋值:最终,Singleton::instance 可能被其中一个线程的实例覆盖,而另一个实例则被“丢弃”。

代码结果: 

Constructor called!
Constructor called!
0x55c5a7e6f9e0
0x55c5a7e6f9f0

这表明构造函数被调用了两次,并且输出了两个不同的指针地址,这意味着有两个不同的实例被创建了,这违反了单例模式的基本原则(全局只有一个实例)

总结: 

  • 这个示例说明了在没有锁保护的懒汉模式下,如何在多线程环境中出现线程安全问题。为了防止这种情况发生,我们需要在 getInstance() 方法中添加互斥锁来确保只有一个线程能够进入关键区域,从而保证 Singleton 实例的唯一性。

🍇 加锁的懒汉式单例(推荐)

如果在 Singleton::getInstance() 方法中加上锁,可以确保线程安全,避免多个线程同时创建多个实例的情况。加锁后的代码会使每个线程在进入关键区之前进行等待,确保只有一个线程能够创建实例。其他线程只能使用已创建的单例实例。 

#include <iostream>
#include <thread>
#include <mutex>

class Singleton 
{
private:
    // 私有化构造函数,防止外部直接实例化
    Singleton() 
    {
        // 模拟一些初始化操作,例如打开文件或连接数据库
        std::cout << "Constructor called!" << std::endl;
    }

    // 禁止拷贝构造和赋值操作,以防止创建多个实例
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 静态实例指针,指向唯一的 Singleton 实例
    static Singleton* instance;

    // 静态互斥锁,用于线程安全的加锁操作
    static std::mutex mtx;

public:
    // 静态方法,返回唯一的 Singleton 实例
    static Singleton* getInstance() 
    {
        // 第一次检测:如果实例已经存在,直接返回,避免不必要的加锁
        if (instance == nullptr) 
        {
            std::lock_guard<std::mutex> lock(mtx);  // 加锁保护,确保线程安全

            // 第二次检测:加锁后再次检查实例是否存在,以防止多个线程在第一次检测后都通过判断
            if (instance == nullptr) 
            {
                instance = new Singleton();  // 如果实例仍然不存在,创建新的实例
            }
        }
        return instance;  // 返回唯一的实例
    }

    // 单例类中的其他方法
    void doSomething() 
    {
        std::cout << "Doing something..." << std::endl;
    }
};

// 初始化静态成员变量
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mtx;  // 初始化静态互斥锁

void createSingletonInstance() 
{
    Singleton* singleton = Singleton::getInstance();
    std::cout << singleton << std::endl;  // 输出实例的指针地址,验证是否为同一实例
}

int main() 
{
    // 启动多个线程,几乎同时调用 getInstance()
    std::thread t1(createSingletonInstance);
    std::thread t2(createSingletonInstance);

    t1.join();
    t2.join();

    return 0;
}

加锁后的结果 

Constructor called!
0x55c5a7e6f9e0
0x55c5a7e6f9e0

 结果解释

  1. 构造函数只调用一次:由于加了锁,只有一个线程能够成功创建 Singleton 实例。其他线程在进入 getInstance() 方法时,如果发现实例已经存在,就直接使用这个实例,而不会再创建新的实例。因此,构造函数只会被调用一次。

  2. 两个线程得到相同的实例:所有调用 getInstance() 的线程都获取到了相同的 Singleton 实例,所以输出的指针地址是相同的。

 总结

  • 加锁后:能够确保 Singleton 类的实例在多线程环境下仍然是唯一的,从而实现线程安全的懒汉模式。
  • 避免了竞争条件:加锁的机制确保了只有一个线程可以创建实例,其它线程会等待并使用已经创建的实例。这避免了多个线程同时创建多个实例的情况。


💢饿汉模式💢 

系统一运行,就初始化创建实例,当需要时,直接调用即可。这种方式本身就线程安全,没有多线程的线程安全问题。 

#include <iostream>
#include <thread>  // 引入多线程支持的头文件

class Singleton 
{
private:
    // 私有化构造函数,防止外部实例化
    Singleton() 
    {
        // 模拟一些初始化操作,例如加载配置文件、连接数据库等
        std::cout << "Constructor called!" << std::endl;
    }

    // 禁止拷贝构造和赋值操作,以防止通过拷贝生成多个实例
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 饿汉模式下,静态实例指针在类加载时就完成实例化
    static Singleton* instance;

public:
    // 静态方法,用于获取单例的唯一实例
    static Singleton* getInstance() 
    {
        return instance;  // 直接返回已经实例化的对象指针
    }

    // 单例类中的其他方法
    void doSomething() 
    {
        std::cout << "Doing something..." << std::endl;
    }
};

// 在类外初始化静态成员变量并立即实例化单例对象
Singleton* Singleton::instance = new Singleton();  // 饿汉模式的关键

void createSingletonInstance() 
{
    Singleton* singleton = Singleton::getInstance();
    std::cout << singleton << std::endl;  // 打印实例的地址,验证是否为同一个实例
}

int main() 
{
    // 启动多个线程,几乎同时调用 getInstance() 方法
    std::thread t1(createSingletonInstance);
    std::thread t2(createSingletonInstance);

    // 等待两个线程执行完成
    t1.join();
    t2.join();

    return 0;
}

代码解析 :

  • 饿汉模式的实现

    • 在饿汉模式中,单例实例 instance 在类加载时(即在 Singleton 类的静态成员初始化时)就被创建。这意味着无论程序是否使用该实例,都会提前初始化。
    • static Singleton* instance = new Singleton(); 这行代码在程序启动时就会被执行,确保 instance 指针在任何时候都已经被初始化。
  • 线程安全

    • 由于实例在类加载时就已经创建,因此不存在多个线程同时创建实例的问题。这使得饿汉模式天生具备线程安全性。
  • getInstance() 方法

    • 由于实例已经提前创建,getInstance() 方法只需返回已经存在的实例,不需要执行任何额外的判断或加锁操作。

对应的输出结果 :

Constructor called!
0x55b77d2e5e70
0x55b77d2e5e70
  • Constructor called! 这一行输出表示构造函数被调用,并且是在程序启动时就被调用的。这表明实例在程序启动时已经被创建。
  • 0x55b77d2e5e70 是实例的内存地址,两次输出的地址相同,说明不同线程获取的都是同一个实例。

💢两种模式之间的对比💢

生活中的例子

  • 就像提前预热好一壶水,不管你什么时候需要茶,水壶里已经有热水了(这就是饿汉模式)。而懒汉模式则是当你真正需要茶的时候才开始烧水。如果你经常需要茶,饿汉模式效率更高,但如果你不常喝茶,提前烧水反而是一种浪费。

  • 懒汉模式:

    • 实例在第一次使用时创建。
    • 需要考虑线程安全问题,因此使用了加锁机制。
    • 适用于实例创建开销大且不一定需要每次都创建的场景。
  • 饿汉模式:

    • 实例在类加载时创建。
    • 不需要加锁,线程安全。
    • 适用于实例创建开销小且一定会被使用的场景。

在实际开发中,选择哪种模式取决于你的应用程序对资源和性能的需求懒汉模式更加节省资源,但在多线程环境中需要小心处理。饿汉模式更加简单且安全,但可能会浪费一些资源。希望这些解释能帮助你更好地理解这两种单例模式及其实现方法!


六、总结 

(1)单例模式常见的写法有两种:懒汉式、饿汉式

(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题

(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。

(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;

(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题


七、共勉 

以下就是我对 【C++】单例模式 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新对 【C++】 ,请持续关注我哦!!!  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值