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

简介: 本文通过一个实际案例揭示了单例模式中常见的隐藏陷阱——在析构函数中调用其他单例对象导致程序崩溃的问题。代码示例展示了因单例析构顺序不确定而引发的 `crash`,并结合 AI 的分析提出两种规避策略:一是避免析构、二是通过全局状态检测对象存活。文章指出,依赖不确定的语言特性并非良策,真正的解决之道在于合理设计,确保程序行为可预期、可控制。

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

引言

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

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

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

场景列举

问题

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

main 函数

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

单例类 SingletonB

  • SingletonB 类使用静态局部变量 instance 来确保只有一个实例。
  • 构造函数中,mpBuf 分配了内存,并将字符串 "hello" 复制到该内存中。
  • 析构函数中,释放了 mpBuf 所占用的内存,并置为nullptr。
// 单例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 来确保只有一个实例。
  • 构造函数中,简单输出创建信息。
  • 析构函数中,调用 SingletonB 的 doSomething 接口。
// 单例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了。

$ ./a.out 
SingletonA created.
SingletonB created.
exe exit!
SingletonB destroyed.
SingletonA destroy begin. call A
Segmentation fault

代码分析

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

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

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

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。
  • 尽管可以通过控制单例调用的顺序来规避此类问题。但这种方式并非最优解,而且大型项目中难以实施。另外,程序健壮性不应依赖这种非确定性的语言特性,而应通过设计优化从根本上避免此类问题。
  • 最后,代码运行的行为和结果应是可预见的,不应依赖特殊操作或非确定性行为。若有此类依赖,应是程序设计缺陷,该加以优化。
相关文章
|
Rust 安全 程序员
Rust与C++:内存管理与安全性的比较
本文将对Rust和C++两种编程语言在内存管理和安全性方面进行深入比较。我们将探讨Rust如何通过其独特的所有权系统和生命周期管理来消除内存泄漏和悬挂指针等常见问题,并对比C++在这方面的挑战。此外,我们还将讨论Rust的类型系统和编译器如何在编译时捕获许多常见的运行时错误,从而提高代码的安全性。
|
11月前
|
Dart 开发工具 Android开发
Flutter PC 应用开发指南:从环境搭建到实战避坑
本文系统介绍如何在 Windows 平台使用 Flutter 开发 PC 应用,涵盖环境搭建、项目创建、插件兼容性、原生功能调用、签名发布、常见问题解决及性能优化等全流程,助你高效构建跨平台桌面应用,少走弯路。
3164 5
|
Ubuntu
iperf3 交叉编译
iperf3 交叉编译
1597 0
url参数值中有+、空格、%20、%2b
url参数值中有+、空格、%20、%2b
1754 0
|
机器学习/深度学习 运维 算法
大模型开发:解释监督学习和非监督学习之间的区别。
监督学习与非监督学习是机器学习的两大分支。监督学习使用带标签的训练数据来学习预测模型,如线性回归、SVM,常用于分类和回归问题。非监督学习则从无标签数据中挖掘模式和结构,如聚类、PCA,适用于市场细分和异常检测。关键在于根据任务和数据选择合适的方法。
1056 1
|
安全 Linux 网络安全
VS Code通过跳板机连接服务器进行远程代码开发
VS Code通过跳板机连接服务器进行远程代码开发
3983 0
VS Code通过跳板机连接服务器进行远程代码开发
|
消息中间件 缓存 监控
Sentry 开发人员文档(中文手册,二次开发指南)
Sentry 开发人员文档(中文手册,二次开发指南)
4201 0
Sentry 开发人员文档(中文手册,二次开发指南)
|
10月前
|
数据采集 安全 数据安全/隐私保护
短效动态ip适合做什么使用?具体怎么使用?
短效动态IP是生命周期短、自动频繁切换的网络代理资源,广泛应用于数据采集、多账号运营、隐私保护等场景。其核心优势在于通过动态更换IP规避封禁、提升安全性。使用流程简单:选择正规服务商、购买套餐、配置代理即可。适合爬虫、测试及跨境业务,需根据需求选配优质服务以保障稳定与效率。(238字)
|
编译器 C++
错误 C1128 节数超过对象文件格式限制: 请使用 /bigobj 进行编译
错误 C1128 节数超过对象文件格式限制: 请使用 /bigobj 进行编译
2336 0
|
存储 数据库 Android开发
安卓Jetpack Compose+Kotlin,支持从本地添加音频文件到播放列表,支持删除,使用ExoPlayer播放音乐
为了在UI界面添加用于添加和删除本地音乐文件的按钮,以及相关的播放功能,你需要实现以下几个步骤: 1. **集成用户选择本地音乐**:允许用户从设备中选择音乐文件。 2. **创建UI按钮**:在界面中创建添加和删除按钮。 3. **数据库功能**:使用Room数据库来存储音频文件信息。 4. **更新ViewModel**:处理添加、删除和播放音频文件的逻辑。 5. **UI实现**:在UI层支持添加、删除音乐以及播放功能。