C++ 单例类实例的堆/动态与静态内存分配

Posted

技术标签:

【中文标题】C++ 单例类实例的堆/动态与静态内存分配【英文标题】:Heap/dynamic vs. static memory allocation for C++ singleton class instance 【发布时间】:2013-02-10 08:22:00 【问题描述】:

我的具体问题是,在 C++ 中实现singleton class 时,以下两个代码在性能、附带问题或其他方面是否存在实质性差异:

class singleton

    // ...
    static singleton& getInstance()
    
        // allocating on heap
        static singleton* pInstance = new singleton();
        return *pInstance;
    
    // ...
;

还有这个:

class singleton

    // ...
    static singleton& getInstance()
    
        // using static variable
        static singleton instance;
        return instance;
    
    // ...
;

(请注意,基于堆的实现中的取消引用不应影响性能,因为 AFAIK 没有为取消引用生成额外的机器代码。与指针区分开似乎只是语法问题。)

更新:

我有一些有趣的答案和 cmets,我试图在这里对其进行总结。 (建议有兴趣的人阅读详细答案。):

在使用 static 局部变量的单例中,类析构函数在进程终止时自动调用,而在 动态分配 的情况下,您必须以某种方式管理对象销毁有时,例如通过使用智能指针:
    static singleton& getInstance() 
        static std::auto_ptr<singleton> instance (new singleton());
        return *instance.get(); 
    

使用动态分配的单例比静态单例变量“懒惰”,因为在后一种情况下,单例对象所需的内存(总是?)在进程启动时保留-up(作为加载程序所需的整个内存的一部分)并且仅调用单例构造函数被推迟到getInstance() 调用时间。当sizeof(singleton) 很大时,这可能很重要。

两者在 C++11 中都是线程安全的。但对于早期版本的 C++,它是特定于实现的。

动态分配情况使用一级间接访问单例对象,而在静态单例对象情况下,对象的直接地址是在编译时确定和硬编码的。

P.S.:我已根据 @TonyD 的回答更正了我在原始帖子中使用的术语。

【问题讨论】:

你比较过两者生成的程序集吗? 没有。如果您的意思是为两种不同的实现生成程序集,那么它们显然是不同的,因为一个在堆上分配,一个在加载/调用时初始化。如果您的意思是为取消引用而生成的程序集,不,我没有比较过。我只是这么想。 【参考方案1】:

new 版本显然需要在运行时分配内存,而非指针版本在编译时分配内存(但两者都需要做相同的构造)

李>

new 版本不会在程序终止时调用对象的析构函数,但非new 版本会:您可以使用智能指针来纠正此问题

您需要注意一些静态/命名空间范围对象的析构函数在其静态本地实例的析构函数运行后不会调用您的单例...如果您对此感到担忧,您或许应该阅读更多关于单例生命周期和管理它们的方法。 Andrei Alexandrescu 的现代 C++ 设计具有非常易读的处理方式。

在 C++03 下,是否线程安全由实现定义。 (我相信 GCC 往往是,而 Visual Studio 往往不是 -cmets 来确认/纠正赞赏。)

在 C++11 下,它是安全的:6.7.4 “如果在初始化变量时控制同时进入声明,则并发执行应等待初始化完成。” (无递归)。

讨论重新编译时与运行时分配和初始化

从你的总结和一些 cmets 的措辞来看,我怀疑你并没有完全理解静态变量的分配和初始化的一个微妙方面......

假设您的程序有 3 个本地静态 32 位 ints - abc - 在不同的功能中:编译器可能会编译一个二进制文件,告诉操作系统加载程序离开 3x32-位 = 12 字节的内存用于这些静态数据。编译器决定每个变量的偏移量:它可以将a 放在数据段中的十六进制偏移量 1000 处,b 放在 1004 处,c 放在 1008 处。当程序执行时,操作系统加载程序不会不需要为每个单独分配内存 - 它所知道的只是总共 12 个字节,它可能会或可能不会被专门要求进行 0 初始化,但它可能无论如何都想做以确保进程看不到从其他用户的程序中遗留下来的内存内容。程序中的机器代码指令通常会对访问 abc 的偏移量 1000、1004、1008 进行硬编码 - 因此在运行时不需要分配这些地址。

动态内存分配的不同之处在于指针(比如p_ap_bp_c)将在编译时被赋予地址,如前所述,但另外:

必须在运行时找到指向的内存(abc)(通常是在静态函数第一次执行但编译器允许根据我的评论另一个答案),和 如果操作系统当前为进程分配的内存太少而无法成功进行动态分配,则程序库将向操作系统请求更多内存(例如使用sbreak())——操作系统通常会清除这些内存出于安全原因 必须将分配给abc 的动态地址复制回指针p_ap_bp_c

这种动态方法显然更加复杂。

【讨论】:

好点。 “编译时的内存分配”是指在链接和加载时保留所需的内存空间,但初始化推迟到函数调用时? (否则你的第一点似乎是错误的) 我刚刚注意到来自 C++11 6.7.4 的 your quotation。但是 C++03 或更早的版本呢? @MassoodKhaari:重新分配:是的,静态内存(需要的数量、段、偏移量)的决定是在编译时做出的,二进制图像将充分说明它(例如内存的总大小区域)供操作系统加载程序将内存放在一边。重新并发 - 根据我的回答 - 它是定义的实现(如果有的话)...... C++ 03 标准根本没有提到线程,因此由实现决定是否以及如何支持它们。跨度> C++03 很吓人。为了便于跟踪,我将答案全部汇总在主帖中。 @MassoodKhaari “通过“静态初始化”,我的意思不是 [X,我的意思是 Y”......这个短语可能会让人困惑,但你的意思不是 是 被 C++ 社区称为“静态初始化”,而您的 Y——“调用类构造函数”——被称为静态变量的 动态 初始化。因此,“静态初始化”不是静态的初始化;-o。不幸的是,static 在 C++ 和计算科学中具有如此多的含义,使得术语如此繁琐。【参考方案2】:

主要区别在于使用本地static 对象会在关闭程序时被销毁,而堆分配的对象将被丢弃而不被销毁。

请注意,在 C++ 中,如果您在函数内声明静态变量,它将在您第一次进入作用域时被初始化,而不是在程序启动时(就像全局静态持续时间变量一样)。

总的来说,这些年来我从使用延迟初始化切换到显式控制初始化,因为程序启动和关闭是一个微妙的阶段,而且很难调试。如果你的类没有做任何复杂的事情并且不能失败(例如,它只是一个注册表),那么即使是惰性初始化也可以......否则控制会为你节省很多问题。

在进入main 的第一条指令之前或在执行main 的最后一条指令之后崩溃的程序更难调试。

使用延迟构造单例的另一个问题是,如果您的代码是多线程的,您必须注意并发线程同时初始化单例的风险。在单线程上下文中进行初始化和关闭更简单。

自 C++11 以来,多线程代码中的函数级静态实例初始化期间可能出现的竞争已得到解决,当时该语言添加了官方多线程支持:在正常情况下,编译器会自动添加适当的同步保护,因此这不是C++11 或更高版本代码中的一个问题。但是,如果函数a 中的静态初始化调用函数b,反之亦然,如果这两个函数由不同的线程第一次同时调用,则可能会出现死锁(仅当编译器对所有静态变量使用单个互斥锁)。另请注意,不允许从静态对象的初始化代码中递归调用包含静态对象的函数。

【讨论】:

是的,我也将单例用于相当简单的对象。但这似乎很有趣;你能提到一些实现“显式控制初始化”的方法吗? 很高兴指出破坏问题。我没有注意到这一点。因此在这种情况下,静态初始化似乎是更好的选择。 "它会在你第一次进入作用域时被初始化" - 有时 / C++11 6.7.4: "一个实现被允许使用静态或其他块作用域变量进行早期初始化线程存储持续时间在相同的条件下允许实现在命名空间范围内静态初始化具有静态或线程存储持续时间的变量(3.6.2)。否则,这样的变量在控制第一次通过其声明时被初始化;这样的变量在其初始化完成后被视为已初始化。” @MassoodKhaari:我的意思是复杂的子系统在 main 中以特定顺序初始化,并在退出时以特定顺序关闭。在main 开始之前或main 结束之后发生的事情有点像语言的灰色区域(例如,您已经/仍在使用哪些标准库函数以及哪些子系统?)。

以上是关于C++ 单例类实例的堆/动态与静态内存分配的主要内容,如果未能解决你的问题,请参考以下文章

Java中的堆内存与栈内存

C与C++申请动态内存空间的异同

C++ 动态内存分配(6种情况,好几个例子)

C++面向对象编程:对象的内存分配与静态成员

C++面向对象编程:对象的内存分配与静态成员

C++数组在内存中的分配