一、单例模式的概念
单例模式是一种设计模式,目的是为了确保一个类只有一个实例,并提供一个全局访问点让其他对象可以获取该实例。单例模式在软件设计中起到了重要的作用,通过限制只有一个实例的存在并提供全局访问点,可以有效地管理和控制对象的创建和访问,提高系统的灵活性、可维护性和性能。被广泛应用于需要限制某个类只能创建一个对象的场景。
- 单例模式可以保证系统中只有一个实例存在,避免了多次实例化造成的资源浪费和不一致性问题。
- 通过单例模式,其他对象可以直接访问单例对象,提供了一种方便的全局访问方式,简化了对象之间的通信和数据共享。
- 单例模式可以方便地实现对共享资源的集中管理,确保资源的线程安全性。
- 控制实例化过程:单例模式可以控制实例化过程,例如延迟实例化、懒加载等,提升系统的性能和效率。
二、饿汉式(Lazy initialization)
饿汉式(Eager Initialization)是一种简单的单例模式实现方法,在类加载时就创建唯一实例。
代码语言:javascript复制class Singleton {
private:
// 将构造函数、拷贝构造函数和赋值运算符设为私有,防止外部实例化和复制
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
static Singleton& getInstance() {
static Singleton instance; // 在静态函数中创建唯一实例
return instance;
}
};
Singleton
类中的构造函数、拷贝构造函数和赋值运算符被私有化,并且使用了delete
关键字,这样可以防止外部直接实例化对象或进行拷贝。而getInstance()
方法是静态方法,它返回一个指向唯一实例的引用。
在getInstance()
方法中,我们使用了局部静态变量instance
来保存唯一的实例。由于局部静态变量的特性,它只会在首次调用getInstance()
方法时创建,之后的调用都会直接返回该实例。这样能够保证在程序启动时就创建了单例对象。
通过调用Singleton::getInstance()
就可以获取到全局唯一的Singleton
实例。
说明:
- 饿汉式的特点是在类加载的时候就创建实例,所以称为"饿汉式",因为它比较"急切"地去创建实例。
- 饿汉式的实现通过一个静态变量
instance
来持有唯一实例,因为静态变量在程序启动时就会初始化。 - 由于在程序启动时就创建实例,所以不存在多线程并发访问创建实例的问题,这种方式是线程安全的。
- 饿汉式的缺点是无法实现延迟加载,即使在某些情况下没有使用到该单例对象,它仍然会被创建和占用内存。
此外,由于静态变量的生命周期与程序的生命周期相同,如果应用程序中从未使用过该单例对象,那么它可能会浪费一些内存资源。
饿汉式是一种简单但不够灵活的单例模式实现方法。它适用于单例对象的创建成本较低的场景。
三、懒汉式(Lazy initialization)
C 中的懒汉式(Lazy Initialization)是一种延迟加载的单例模式实现方式。它只有在需要使用单例对象时才进行创建,而不是在类加载时就创建实例。
代码语言:javascript复制class Singleton {
private:
// 私有的静态成员指针,用于存储单例对象
static Singleton* instance;
// 将构造函数、拷贝构造函数和赋值运算符私有化,防止外部实例化和复制
Singleton() {}
Singleton(const Singleton& other) {}
Singleton& operator=(const Singleton& other) {}
public:
// 静态成员函数,用于获取单例对象的引用
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
};
// 初始化静态成员变量为nullptr
Singleton* Singleton::instance = nullptr;
Singleton
类具有一个私有的静态成员指针instance
,并通过调用静态成员函数getInstance()
返回单例对象的引用。在首次调用getInstance()
时,会检查instance
是否为null,如果是,则创建一个新的Singleton
对象并赋值给instance
,否则直接返回现有的instance
。
懒汉式单例模式延迟了对象的创建时间,当第一次使用单例对象时才进行实例化。这种延迟加载的方式可以节省资源并满足按需创建的需求。但是需要注意的是,在多线程环境下,懒汉式单例模式可能会引发线程安全问题,因为多个线程可能同时访问到getInstance()
方法,从而导致创建多个实例。为了解决这个问题,可以使用线程安全的技术(如加锁)来保证只创建一个实例。
C 懒汉式(Lazy Initialization)在多线程环境下可能存在线程安全性问题。当多个线程同时调用实例获取方法时,可能会导致创建多个实例,违背了单例模式的初衷。
有两种常见的解决方案:
- 加锁:使用互斥锁(mutex)来保证在实例创建过程中只有一个线程能够进入关键代码段,其他线程需要等待。在懒汉式实例获取方法中加入互斥锁可以解决线程安全性问题。 class Singleton { private: static Singleton* instance; static std::mutex mutex; Singleton() {} Singleton(const Singleton& other) {} Singleton& operator=(const Singleton& other) {} public: static Singleton* getInstance() { std::lock_guard<std::mutex> lock(mutex); // 加锁 if (instance == nullptr) { instance = new Singleton(); } return instance; } }; Singleton* Singleton::instance = nullptr; std::mutex Singleton::mutex; 这种方式确保了只有一个线程能够创建实例,但带来了一定的性能开销。
- 双重检查锁定(Double-Checked Locking):双重检查锁定是一种优化的加锁方式,在加锁前后都进行了判断,减少了不必要的锁开销。 class Singleton { private: static Singleton* instance; static std::mutex mutex; Singleton() {} Singleton(const Singleton& other) {} Singleton& operator=(const Singleton& other) {} public: static Singleton* getInstance() { if (instance == nullptr) { std::lock_guard<std::mutex> lock(mutex); // 加锁 if (instance == nullptr) { instance = new Singleton(); } } return instance; } }; Singleton* Singleton::instance = nullptr; std::mutex Singleton::mutex; 双重检查锁定通过两次判断实例指针,可以减少大部分情况下的加锁开销,提高性能。但需要注意,双重检查锁定在C 11之前可能存在一些细微的问题,因为编译器可能会对代码进行优化,导致内存读写顺序不一致。可通过设置合适的内存屏障或使用原子操作等技术来解决这个问题。
四、双检锁机制
C 双检锁机制(Double-checked locking)是一种常用的实现单例模式的方法,旨在提高多线程环境下的性能。
实现双检锁机制的基本思路:
- 声明一个静态的指针实例变量,并初始化为nullptr;
- 在获取实例的方法中进行第一次检查:如果实例已经被创建,直接返回实例指针,否则进入下一步;
- 加锁,确保只有一个线程能够进入临界区;
- 再次检查实例是否已经被创建:在前面的加锁过程中,可能有其它线程在等待,如果已经被创建,则释放锁并返回实例指针,否则继续下一步;
- 创建实例并将指针赋值给实例变量;
- 释放锁;
- 返回实例指针。
示例代码:
代码语言:javascript复制class Singleton {
private:
static Singleton* instance;
static std::mutex mutex;
Singleton() {}
Singleton(const Singleton& other) {}
Singleton& operator=(const Singleton& other) {}
public:
static Singleton* getInstance() {
if (instance == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mutex); // 加锁
if (instance == nullptr) { // 第二次检查
instance = new Singleton(); // 创建实例
}
}
return instance;
}
};
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;
双检锁机制通过在加锁前后进行两次检查,避免了大部分情况下的锁开销,提高了性能。同时,使用互斥锁保证了在多线程环境下只有一个线程能够进入关键代码段。
注意:在C 11之前的标准中,并不能确保双检锁机制的正确性,因为编译器可能会对代码进行优化,导致内存读写顺序不一致。这样就可能出现在第一次检查时判断实例为空,但实际上还没有完成初始化的情况。可以通过设置合适的内存屏障或使用原子操作等技术来防止编译器优化,保证内存访问的顺序一致性。
C 11引入了线程安全的局部静态变量初始化特性,可以更简单地实现线程安全的延迟初始化,取代了双检锁机制的复杂性。使用该特性可以直接在函数内部声明并初始化静态局部变量,编译器会保证其线程安全性。
代码语言:javascript复制class Singleton {
private:
Singleton() {}
Singleton(const Singleton& other) {}
Singleton& operator=(const Singleton& other) {}
public:
static Singleton* getInstance() {
// C 11线程安全的局部静态变量初始化
static Singleton instance;
return &instance;
}
};
这种方式更加简洁,且能够线程安全地延迟初始化实例,并且避免了双检锁机制中锁的开销。推荐在C 11及以上标准中使用此方法实现单例模式。
五、静态成员变量
C 中使用静态成员变量可以实现单例模式,静态成员变量在类的所有对象中只有一份拷贝,且该拷贝在类的所有实例之前初始化。这种方法可以保证在多线程环境下只有一个实例被创建。
示例代码:
代码语言:javascript复制class Singleton {
private:
static Singleton* instance;
Singleton() {}
Singleton(const Singleton& other) {}
Singleton& operator=(const Singleton& other) {}
public:
static Singleton* getInstance() {
return instance;
}
};
Singleton* Singleton::instance = new Singleton();
在上述示例中,instance
是 Singleton
类的静态成员变量,它在类定义外部进行了初始化。由于此变量为静态,因此无论创建多少个 Singleton
类的对象,instance
都只会有一份。
当调用 getInstance()
方法时,直接返回 instance
指针,即可获得单例实例。
使用静态成员变量实现单例模式的原理在于,静态成员变量会在程序执行过程中在类的对象创建之前进行初始化。因此,在第一次访问 getInstance()
方法时,静态成员变量 instance
已经被初始化,并且之后的每次调用都会返回同一个实例指针。
需要注意的是,静态成员变量的初始化是在程序启动时进行的,因此会占用一定的内存空间。另外,该方法并不能保证在多线程环境下的线程安全性,如果在多线程环境下使用静态成员变量实现单例模式,可能需要通过加锁等机制来保证只有一个线程能够创建实例。
可以结合互斥锁或原子操作等技术,在 getInstance()
方法中进行加锁处理,确保只有一个线程能够进入关键代码段,从而实现线程安全的单例模式。
六、局部静态变量
C 中,使用局部静态变量实现单例模式是一种常见且简洁的方式。局部静态变量指的是在函数内部定义的静态变量,这种变量在程序执行过程中只会被初始化一次。
示例代码:
代码语言:javascript复制class Singleton {
private:
Singleton() {}
Singleton(const Singleton& other) {}
Singleton& operator=(const Singleton& other) {}
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
};
getInstance()
方法中定义了一个局部静态变量 instance
,该变量在第一次调用该方法时进行初始化,之后的每次调用都会返回同一个实例引用。
局部静态变量的初始化在程序首次进入包含该变量定义的代码块时进行。由于静态变量的生命周期与程序运行期间的整个时间段相对应,可以确保只有一个实例被创建。而且,局部静态变量的初始化是线程安全的,因为 C 标准规定了线程安全的局部静态变量初始化的机制。
当调用 getInstance()
方法时,会返回静态局部变量 instance
的引用,从而获取到单例实例。
使用局部静态变量实现单例模式的优点在于代码简洁,且在多线程环境下是线程安全的。不需要手动处理线程同步问题,C 编译器会自动确保静态局部变量只被初始化一次。
注意:使用局部静态变量实现单例模式时,如果需要进行单例对象的销毁操作,可能会有问题。因为局部静态变量的销毁时机是在程序结束后,而不是在单例对象不再使用时。如果需要显式地销毁单例对象,可考虑使用其他方式实现单例模式。
七、Meyers' Singleton
Meyers’ Singleton 是一种使用静态局部变量实现的单例模式。它是由 Scott Meyers 提出的一种线程安全且高效的单例模式实现方法。
示例代码:
代码语言:javascript复制class Singleton {
private:
Singleton() {}
Singleton(const Singleton& other) = delete;
Singleton& operator=(const Singleton& other) = delete;
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
};
在 Meyers’ Singleton 中,getInstance()
方法也使用了局部静态变量。与之前的示例不同,这里我们删除了复制构造函数和赋值运算符,以防止通过复制或赋值创建多个实例。
Meyers’ Singleton 的原理是利用了 C 11 标准的静态局部变量初始化的线程安全性质。C 11 规定对于静态局部变量的初始化是线程安全的,并且只会在第一次调用该函数时进行初始化。因此,无需额外的线程同步措施,能够确保只有一个实例被创建。
当调用 getInstance()
方法时,静态局部变量 instance
会被初始化,并返回该实例的引用。由于静态局部变量的生命周期在程序运行期间持续存在,所以每次调用 getInstance()
方法都会返回同一个实例。
Meyers’ Singleton 方法的优点在于简洁、线程安全,并且能够自动管理单例对象的生命周期。但需要注意的是,由于静态局部变量的初始化顺序是不确定的,如果有其他类依赖于这个单例对象,可能会出现初始化顺序问题。
八、使用智能指针
在 C 中,可以使用智能指针(Smart pointers)来实现单例模式,其中最常用的是使用 std::shared_ptr
。
使用 std::shared_ptr
实现单例模式的示例代码:
class Singleton {
private:
Singleton() {}
public:
static std::shared_ptr<Singleton> getInstance() {
static std::shared_ptr<Singleton> instance(new Singleton());
return instance;
}
};
getInstance()
方法返回一个 std::shared_ptr<Singleton>
类型的指针,而该指针指向静态局部变量 instance
。静态局部变量的生命周期会延长至整个程序运行期间。
当第一次调用 getInstance()
方法时,静态局部变量 instance
会被初始化为指向 Singleton
对象的 std::shared_ptr
。接下来的每次调用都会返回同一个共享指针,这样可以确保只有一个实例被创建和共享。
std::shared_ptr
使用引用计数的方式管理内存,当没有任何指针引用该对象时,内存会自动释放。因此,即使多个地方都持有该单例的引用,也不会导致对象被提前销毁。
使用智能指针实现单例模式的优点在于简化了内存管理,避免了手动释放对象的责任,并且能够处理多线程环境下的并发访问。
注意:使用 std::shared_ptr
实现单例模式会导致对象的生命周期延长至整个程序运行期间,即使不再使用该对象。这可能会占用额外的内存资源,因此在设计时需评估对象的生命周期和资源管理的成本。
九、总结
如果希望简单、线程安全且无延迟加载,可以使用饿汉式实现;如果希望延迟加载并考虑线程安全性,可以使用懒汉式或 Meyers' Singleton;如果希望自动管理对象生命周期,可以考虑使用智能指针。
在多线程环境下,无论采用哪种实现方法,都需要确保线程安全性,例如使用互斥锁、双重检查锁或原子操作等。此外,还要评估所选实现方法对资源占用的影响,避免出现内存泄漏或资源浪费的情况。