单例模式中的隐藏陷阱:你真的了解单例吗?

单例模式中的隐藏陷阱:你真的了解单例吗?

引言

  单例模式是日常开发中较常用的一种设计模式,它能够确保一个类只有一个实例,并提供一个全局访问入口。

  在之前的开发过程中,观察到进程退出时偶现 crash 现象。由于该问题仅发生在生命周期末端且未影响核心功能,并未深入排查原因。

近期看到技术公众号分析单例模式潜在问题的,意识到此前的crash大致就是因此导致的。

场景列举

问题

先看代码,看看执行会发生什么?

main 函数

  • 首先获取 SingletonB 的实例。
  • 然后获取 SingletonA 的实例。
  • 最后输出退出信息。
c++ 复制代码
int main() {
    SingletonA::getInstance();
    SingletonB::getInstance();
    std::cout << "exe exit!" << std::endl;
    return 0;
}

单例类 SingletonB

  • SingletonB 类使用静态局部变量 instance 来确保只有一个实例。
  • 构造函数中,mpBuf 分配了内存,并将字符串 "hello" 复制到该内存中。
  • 析构函数中,释放了 mpBuf 所占用的内存,并置为nullptr
c++ 复制代码
// 单例B
class SingletonB {
public:
    ~SingletonB() {
        std::cout << "SingletonB destroyed." << std::endl;
        delete []mpBuf;     // 回收资源
        mpBuf = nullptr;
    }

    static SingletonB* getInstance() {
        static SingletonB instance;
        return &instance;
    }
    void doSomething() {
        printf("mpBuf[0] = %c\n", mpBuf[0]);
    }

private:
    SingletonB() {
        std::cout << "SingletonB created." << std::endl;
        mpBuf = new (std::nothrow) char[10];   // 创建资源
        memcpy(mpBuf, "hello", 5);
        mpBuf[5] = '\0';
    }

private:
    char *mpBuf;
};

单例类 SingletonA

  • SingletonA 类同样使用静态局部变量 instance 来确保只有一个实例。
  • 构造函数中,简单输出创建信息。
  • 析构函数中,调用 SingletonBdoSomething 接口。
cpp 复制代码
// 单例A
class SingletonA {
private:
    SingletonA() {
        std::cout << "SingletonA created." << std::endl;
    }
    ~SingletonA() {
        std::cout << "SingletonA destroy begin. call A" << std::endl;
        SingletonB::getInstance()->doSomething();
        std::cout << "SingletonA destroyed end." << std::endl;
    }


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

输出

程序在退出时,发生crash了。

c++ 复制代码
$ ./a.out 
SingletonA created.
SingletonB created.
exe exit!
SingletonB destroyed.
SingletonA destroy begin. call A
Segmentation fault

代码分析

 上述的实现,很明显可以看出因为调用了已析构的对象,导致异常crash

 但是在实际项目开发过程中,单例模式的调用分散在各个文件中,由于初始化顺序难以控制,很容易出现调用已析构对象的情况,进而导致程序崩溃。这类问题一旦发生,往往难以迅速定位和解决。

  看看AI怎么评价这段代码:

c++ 复制代码
Me: 魔镜啊魔镜,上述代码有什么问题?

魔镜:来了,老弟!这代码毛病可不小呐~
     那 `SingletonA` 析构的时候,还跑去调用 `SingletonB` 的方法。
     程序结束的时候,静态对象是后构造的先析构,这 SingletonB 不妥妥先析构了,mpBuf也回收了。
     SingletonA 再去调用 SingletonB::doSomething,指定下机啊!
     哪个损粗写的这代码呐!回炉重造吧,赶紧的啊~
  
Me: 乡村爱情看多了吧你,口音听的上头。
    正经点,那怎么规避这类问题?

魔镜:闲来无事,网上爬了几集,真好看~
    有很多种方式可以避免这种问题,我就说两种,好好听,好好学!
  
    ① 避免单例对象析构,如下写法:
    SingletonA* SingletonA::getInstance() {
        static SingletonA *instance = new (std::nothorw) SingletonA();
        return instance;
    }
  
    ② 增加全局变量,检测对象是否析构。每次获取实例时,特别是析构中获取单例时,先判空。
    static std::atomic<bool> gObjAlive(true);
    
    SingletonA::~SingletonA() {
        gObjAlive = false;
    }
  
    SingletonA* SingletonA::getInstance() {
        if (!gObjAlive) {
            return nullptr;
        }
  
        static SingletonA instance;
        return &instance;
    }

  通过与AI的友好沟通,规避的方案有很多。上述的两种方案,各有利弊:

  • 第一种方案
    优点:写法简洁。
    缺点:由于不会调用析构函数,除内存会随进程退出释放外,析构函数中涉及的硬件资源释放等操作无法执行,可能引发潜在问题。
  • 第二种方案
    优点:安全性较高,可自动释放所持资源。
    缺点:用时不够便捷,每次调用单例对象(尤其是在析构阶段)都需要进行判空操作。

总结

  • 结合全文,crash的问题根源在于单例析构函数中调用其他单例对象的接口。
    进程退出阶段,全局单例对象的析构顺序未明确,可能导致调用了已释放的单例对象接口,引发crash
  • 尽管可以通过控制单例调用的顺序来规避此类问题。但这种方式并非最优解,而且大型项目中难以实施。另外,程序健壮性不应依赖这种非确定性的语言特性,而应通过设计优化从根本上避免此类问题。
  • 最后,代码运行的行为和结果应是可预见的,不应依赖特殊操作或非确定性行为。若有此类依赖,应是程序设计缺陷,该加以优化。
相关推荐
JustHappy3 小时前
古法编程秘籍(七):互联网到底是什么?把两台电脑怎么说话搞懂就够了
前端·后端·网络协议
Hommy883 小时前
【剪映小助手】添加图片接口(Add Images)
后端·github·剪映小助手·视频剪辑自动化
GetcharZp4 小时前
别再盲目用 OpenCV 读图了,这才是 CV 预处理的终极杀手锏!
后端
IT_陈寒8 小时前
Vite热更新失效?可能你在用Windows
前端·人工智能·后端
椰椰椰耶9 小时前
[SpringCloud][14]OpenFeign参数传递方法
后端·spring·spring cloud
onething3659 小时前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 3 —— 消息表设计 + 级联删除 + 事务管理
人工智能·后端
荣江9 小时前
Hermes Agent 代码仓库打包工具使用指南(repomix-rs 高性能版)
后端
王某某人9 小时前
LangChain4j 入门:Java 程序员的第一个 AI 对话程序
人工智能·后端
码农刚子9 小时前
从零开始:在 Windows 服务器上部署 Node.js 项目(小白实战教程)
后端·node.js
Cache技术分享9 小时前
435. Java 日期时间 API - Clock 灵活获取当前时间
前端·后端