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

19

我的具体问题是,在C++中实现单例类时,以下两种代码在性能、副作用或其他方面是否有实质性差异:

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;
    }
    // ...
};

(Note that在基于堆的实现中取消引用不应影响性能,因为据我所知,取消引用不会生成任何额外的机器代码。这似乎只是一种从指针中区分出语法的问题。) 更新: 我收到了一些有趣的答案和评论,我在这里尝试总结它们。(对于那些感兴趣的人,建议阅读详细的答案。):
  • 在使用静态局部变量的单例模式中,类析构函数会在进程终止时自动调用,而在动态分配的情况下,您必须以某种方式在某个时间管理对象的销毁,例如使用智能指针:
    static singleton& getInstance() {
        static std::auto_ptr<singleton> instance (new singleton());
        return *instance.get(); 
    }
  • 使用动态分配的单例比静态单例变量更加“懒惰”,因为在后一种情况下,单例对象所需的内存在进程启动时(作为加载程序所需的整个内存的一部分)被预留,并且只有在调用getInstance()时才会延迟调用单例构造函数。当sizeof(singleton)很大时,这可能很重要。

  • C++11中两者都是线程安全的。但对于早期版本的C ++,具体实现可能不同。

  • 动态分配案例使用一级间接访问单例对象,而在静态单例对象案例中,在编译时确定并硬编码对象的直接地址。


你比较过这两个的汇编代码了吗? - Some programmer dude
不是的。如果你指的是这两种不同实现的生成汇编代码,它们显然是不同的,因为一个在堆上分配,而另一个则在加载/调用时初始化。如果你指的是解引用的生成汇编代码,我没有进行比较。我只是猜测。 - Masood Khaari
2个回答

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

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

    • 要小心某些静态/命名空间范围对象的析构函数在静态局部实例的析构函数运行后不会调用您的单例...如果您担心这一点,应该阅读有关单例生命周期和管理方法的更多内容。Andrei Alexandrescu的《现代C++设计》有一个非常易懂的处理方式。
  • 在C++03下,它是实现定义的,无论哪种都不是线程安全的。(我相信GCC倾向于线程安全,而Visual Studio则不倾向,欢迎评论确认/更正。)

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

关于编译时与运行时分配及初始化的讨论

从您的摘要和一些评论的措辞来看,我怀疑您是否完全理解了静态变量分配和初始化的微妙方面...

假设您的程序具有3个不同函数中的本地静态32位int - abc - 编译器可能会编译一个告诉操作系统加载程序时为这些静态变量保留3x32位= 12个字节的二进制文件。编译器决定每个变量的偏移量:它可以将a放在数据段中的1000十六进制偏移处,将b放在1004处,并将c放在1008处。当程序执行时,操作系统加载程序不需要单独为每个静态变量分配内存-它只知道总共有12个字节,它可能已经被要求特别初始化为零,但也许希望这样做以确保进程看不到其他用户程序的剩余内存内容。程序中的机器代码指令通常会将偏移量1000、1004、1008硬编码为访问abc -因此不需要运行时分配这些地址。

动态内存分配不同,因为指针(例如p_ap_bp_c)将像刚刚描述的那样在编译时给出地址,但此外:

  • 必须在运行时(通常是静态函数首次执行时,但编译器可以根据其他答案中的评论提前执行)找到指向的内存(每个abc),并且
    • 如果操作系统当前分配给进程的内存太少,无法进行动态分配,则程序库将向操作系统请求更多内存(例如使用sbreak())-操作系统通常会出于安全原因清除该内存。
    • abc分配的动态地址必须复制回指针p_ap_bp_c

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


不错的观点。您所说的“编译时内存分配”,是指在链接和加载时保留所需的内存空间,但初始化被推迟到函数调用时吗?(否则,您的第一个观点似乎是错误的) - Masood Khaari
C++03很可怕。我将所有答案总结在主帖中,以便更轻松地跟踪。 - Masood Khaari
谢谢您的解释。不过我已经知道您所讲的内容了。也许我没有用正确的词语来表达我的目的,现在我想要澄清一下… - Masood Khaari
1
“通过‘静态初始化’,我不是指[X],而是指[Y]”……这个短语可能会让人感到困惑,但是C++社区所知道的是,你所不指的内容被称为“静态初始化”,而你所指的“调用类构造函数”则被称为静态变量的动态初始化。因此,“静态初始化”并不是对静态变量的初始化;-o。在C++和计算机科学中,‘static’有很多含义,这使得术语变得如此棘手,这是不幸的。 - Tony Delroy
是的,static 在计算机科学中有很多解释,为了掌握 C++ 社区的术语,我不得不寻找几个高级学习资源。感谢您的关心。 - Masood Khaari
显示剩余3条评论

3
主要区别在于使用本地的static对象会在关闭程序时被销毁,而堆分配的对象将被遗弃而不被销毁。
请注意,在C++中,如果您在函数内部声明静态变量,它将在第一次进入作用域时进行初始化,而不是在程序启动时进行初始化(与全局静态持续时间变量相反)。
总的来说,多年来我已经从使用惰性初始化转向显式控制初始化,因为程序启动和关闭是微妙且相当难以调试的阶段。如果您的类没有执行任何复杂操作,并且只是不能失败(例如,它只是一个注册表),那么即使使用惰性初始化也是可以的...否则,控制将为您节省很多问题。
main的第一条指令之前或执行main的最后一条指令之后崩溃的程序更难调试。 使用延迟构建单例的另一个问题是,如果您的代码是多线程的,则必须注意同时初始化单例的并发线程的风险。在单线程上下文中执行初始化和关闭更简单。 自C++11以来,多线程代码中的函数级静态实例初始化可能的竞争已得到解决,当语言添加了官方的多线程支持时:对于正常情况,编译器会自动添加适当的同步保护,因此在C++11或更高版本的代码中不需要担心这个问题。但是,如果函数a中静态对象的初始化调用函数b,反之亦然,则可能会在不同线程同时第一次调用两个函数时出现死锁(只有编译器为所有静态变量使用单个互斥体时才不存在此问题)。还要注意,不允许从静态对象的初始化代码中递归调用包含静态对象的函数。

是的,我也在使用单例模式来处理相对简单的对象。但这个“显式控制初始化”的实现方式听起来很有趣,你能提一些具体的方法吗? - Masood Khaari
很好指出了破坏性问题,我之前没有注意到。因此在这种情况下,静态初始化似乎是更好的选择。 - Masood Khaari
它会在第一次进入作用域时初始化 - 有时 / C++11 6.7.4:“实现可以在与命名空间作用域(3.6.2)中的静态变量相同的条件下,在其他块作用域具有静态或线程存储期的变量进行早期初始化。否则,这样的变量将在控制流第一次经过其声明时初始化;这样的变量被认为在其初始化完成时已初始化。” - Tony Delroy
@MassoodKhaari:我的意思是,在主函数中,复杂的子系统按特定顺序初始化,并在退出时按特定顺序关闭。在main开始之前或结束之后发生的事情在语言上有点模糊(例如,您已经/仍然可以使用哪些标准库函数和哪些子系统?)。 - 6502

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接