概述
单例模式可以说是老生常谈的面试考点。从最经典的单例模式实现开始,我们逐步提供了多种单例模式的实现方式,并一一分析它们的优缺点。
方案一:教科书经典方案
使用一个静态指针管理对象, 当第一次需要时创建此对象。
// 在此略去构造函数等无关代码,仅讨论单例模式本身
class Singleton{
public:
static Singleton& getInstance(){
if(instance_ == nullptr)
instance_ = new Singleton{};
return *instance_;
}
private:
static Singleton* instance_;
};
显然,这种经典的实现方式存在诸多问题:
- 仅能单线程下正确运行,多线程时不保证对象唯一。
- 需要手动释放申请的资源,以防内存泄漏。
- 每次调用都存在一次判断的开销(几乎无法避免)。
首先,针对多线程正确申请资源这一点,可以使用DCL(Double check lock),即使用一个mutex
保护资源的申请过程,并且通过两次判断避免争抢。这就引入了第二个方案。
方案二:DCL
双重锁定检查(DCL)在两次判断中进行一个加锁操作,首先第一次判断,确定了此时对象还未构造,故申请互斥锁并进入临界区完成对象构造。而如果发生争抢,也会被互斥量阻拦,随着构造完成,第二次检查时将发现对象已存在,保证了线程安全。
class Singleton {
public:
static Singleton& getInstance() {
if(instance_ == nullptr) {
mutex_.lock();
if(instance_ == nullptr){
instance_ = new Singleton();
}
mutex_.unlock();
}
return *instance_;
}
private:
static std::mutex mutex_;
static Singleton instance_;
};
这种方案通过牺牲一点微不足道的创建时间,来获得线程安全性保障。但是在C++中使用DCL其实是不安全的,这种不安全性是因为语句 instance_ = new Singleton();
并不是一条原语,而是三个顺序调用
-
分配
Singleton
需要的内存 -
在内存上调用此对象的构造函数
-
将内存块的首地址赋予指针
C++编译器允许乱序执行这三条无关指令,指令执行的顺序不一定是1,2,3,也可能是1,3,2。在这种情况下,可能出现构造线程在3和2之间被挂起,而其他线程访问了此单例——线程安全性被破坏。
因此,即使使用DCL技术,也无法保证单例创建的线程安全,我们需要的是操作系统层面的保证。
方案三:std::call_once
在C++标准库中,为我们提供了 std::call_once
函数,此函数提供了在多线程下只进行一次调用的保证,这正是我们在单例模式中需要的。
class Singleton{
public:
static Singleton& getInstance(){
if(!instance_)
call_once(flag_, Singleton::create);
return *instance_
}
private:
static void create(){
instance_ = new Singleton();
}
static Singleton* instance_;
static std::once_flag flag_;
};
通过 std::call_once
调用,我们可以很轻松实现线程安全的单例模式。同样,POSIX标准库也要求了一个 pthread_once
调用,用于实现相同的功能。
到目前为止,我们还只解决了三个问题中的第一个,对于后两个问题,有一个简单的解决方案——静态局部变量。
方案四:静态局部变量
静态局部变量是指被 static
修饰的局部变量,只有当函数首次调用时,会创建这个变量,随后的函数调用都将沿用这个变量,这与单例模式的需要不谋而合。
class Singleton{
public:
static Singleton& getInstance(){
static Singleton instance{};
return instance;
}
};
此方案可以算得上优雅且简易的单例模式实现了。使用这个函数不会出现创建多个对象的问题,也不需要检查和释放资源。不过,依然存在一点点小问题——当多个线程同时调用此函数时,此对象也许会被多次初始化?好在C++11标准解决了局部变量初始化的线程安全问题,明确了一个对象只能初始化一次。
此外,这种申请方式在首次调用函数前,就已经完成了内存申请,并且在程序结束前,释放该内存。对于生命周期贯穿程序的变量来说,使用此方案并无不妥,但对于一些仅在特定时间使用的单例来说,也算是一种变相的内存泄漏了。
为了提供更灵活的内存控制方式,可以使用 std::shard_ptr
和 std::weak_ptr
来管理对象,实现内存的自动释放。
方案五:结合方案二和智能指针
在智能指针中,可以使用 std::make_shared
方法将内存分配和构造顺序化,这样就解决了DCL中的线程安全问题,随后引入智能指针管理内存,如没有线程使用此单例,则此单例自动析构。
class Singleton {
public:
static std::shared_ptr<Singleton> getInstance() {
if (instance_.expired()) {
std::lock_guard<std::mutex> lock(mutex_);
if (instance_.expired())
instance_ = std::make_shared<Singleton>();
}
return instance_.lock();
}
private:
static std::weak_ptr<Singleton> instance_;
static std::mutex mutex_;
};
当然,这里也存在几个实现细节问题。首先是因为使用了智能指针,因此构造函数和析构函数都必须是public
的,或者为其提供特定的deleter
,无论如何都需要暴露接口。其次,这种实现可能导致对象的频繁创建和释放,并且不能记录状态,有悖单例模式的应用场景。最后,没能解决每次调用都会发生的存在检查环节。
总结
在C++中,想要正确实现单例模式,还是很困难的。一点不注意就会引起线程安全问题。对于使用C++11及以后新标准的实现,可以采用方案四,对于未更新编译器的情况,则只能采用DCL。
最后
以上就是懦弱汽车为你收集整理的浅析C++单例模式的实现的全部内容,希望文章能够帮你解决浅析C++单例模式的实现所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复