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

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

引言

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

在之前的开发过程中,观察到进程退出时偶现 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
  • 尽管可以通过控制单例调用的顺序来规避此类问题。但这种方式并非最优解,而且大型项目中难以实施。另外,程序健壮性不应依赖这种非确定性的语言特性,而应通过设计优化从根本上避免此类问题。
  • 最后,代码运行的行为和结果应是可预见的,不应依赖特殊操作或非确定性行为。若有此类依赖,应是程序设计缺陷,该加以优化。
相关推荐
Marktowin4 小时前
Mybatis-Plus更新操作时的一个坑
java·后端
赵文宇4 小时前
CNCF Dragonfly 毕业啦!基于P2P的镜像和文件分发系统快速入门,在线体验
后端
程序员爱钓鱼5 小时前
Node.js 编程实战:即时聊天应用 —— WebSocket 实现实时通信
前端·后端·node.js
Libby博仙5 小时前
Spring Boot 条件化注解深度解析
java·spring boot·后端
源代码•宸6 小时前
Golang原理剖析(Map 源码梳理)
经验分享·后端·算法·leetcode·golang·map
小周在成长6 小时前
动态SQL与MyBatis动态SQL最佳实践
后端
瓦尔登湖懒羊羊6 小时前
TCP的自我介绍
后端
小周在成长6 小时前
MyBatis 动态SQL学习
后端
子非鱼9216 小时前
SpringBoot快速上手
java·spring boot·后端