在 C++ 中,单例模式的核心思想非常简单:
一个类只允许创建一个对象,并提供一个统一的全局访问入口。
单例模式主要分为两种经典实现方式:
-
饿汉模式:程序启动运行时,提前创建好单例对象。
-
懒汉模式:首次调用使用时,才创建单例对象。
但如果只死记"饿汉提前创建,懒汉延迟创建"的表层结论,后续遇到 static 关键字、对象指针、new 关键字时,很容易产生认知混淆。
因此本文不止讲解单例模式本身,更会先夯实基础,彻底厘清一个核心问题:
什么时候真正创建了对象?什么时候仅仅创建了一个指针变量?
吃透这个基础知识点后,饿汉、懒汉单例的底层逻辑会变得清晰易懂。
目录
[三、为什么 A* p 不会创建 A 对象?](#三、为什么 A* p 不会创建 A 对象?)
[四、new 才会真正创建动态对象](#四、new 才会真正创建动态对象)
[七、理解了对象和指针,再来看 static](#七、理解了对象和指针,再来看 static)
[十六、这个懒汉和 new 懒汉有什么区别?](#十六、这个懒汉和 new 懒汉有什么区别?)
[传统 new 懒汉](#传统 new 懒汉)
[C++11 局部静态懒汉](#C++11 局部静态懒汉)
[二十二、单例模式中 static 的核心本质](#二十二、单例模式中 static 的核心本质)
[为什么单例对象本身也必须是 static?](#为什么单例对象本身也必须是 static?)
[static 如何彻底解决嵌套问题?](#static 如何彻底解决嵌套问题?)
[单例中两个 static 的核心作用拆解](#单例中两个 static 的核心作用拆解)
[1. 静态对象 static Singleton _instance](#1. 静态对象 static Singleton _instance)
[2. 静态函数 static Singleton& GetInstance()](#2. 静态函数 static Singleton& GetInstance())
[懒汉模式的 static 逻辑完全一致](#懒汉模式的 static 逻辑完全一致)
[补充:构造函数为什么不需要 static?](#补充:构造函数为什么不需要 static?)
一、先不谈单例:什么叫"创建对象"?
先看最基础的变量定义代码:
cpp
int a;
这行代码完成了一个 int 类型对象的创建。
可以直观理解为内存中的存储形态:
cpp
a
┌──────────┐
│ int 对象 │
└──────────┘
再看赋值场景:
cpp
int a = 10;
此时的 a 是真实存在、占用内存的 int 对象,具备独立的变量空间和数值。
自定义类的对象创建逻辑完全一致:
cpp
class A
{
public:
A()
{
std::cout << "A()" << std::endl;
}
};
A a;
这行 A a; 会直接创建一个 A 类对象,自动调用类的默认构造函数,控制台会输出:
cpp
A()
二、对象和对象指针不是一回事
我们对比下面这行代码:
cpp
A* p;
A 这行代码没有创建任何 类对象。
它仅仅创建了一个 A* 类型的指针变量 p,内存形态如下:
cpp
p
┌────────────────┐
│ 地址值 │
└────────────────┘
指针变量的核心作用只有一个:存储对应类型对象的内存地址。
由此可以明确两组代码的本质区别:
cpp
A a; // 创建真实的 A 类对象
A* p; // 创建 A 类型指针变量,无 A 对象
三、为什么 A* p 不会创建 A 对象?
很多初学者会误解指针的定义逻辑,认为 A* p 会自动创建一个 A 对象。
实际上,定义中的 * 仅代表修饰属性:pA 表示 是一个指向 类型对象的指针,不具备创建对象的功能。
指针只负责存放地址,不占用对象的内存空间,也不会触发构造函数。看完整示例:
cpp
A a;
A* p = &a;
内存指向关系如下:
cpp
a
┌──────────────┐
│ A 对象 │
└───────┬──────┘
↑
│
p ──────┘
其中,A a; 是唯一创建 A 对象的代码,A* p = &a; 只是让指针 p 存储了对象 a 的内存地址,仅此而已。
四、new 才会真正创建动态对象
只有配合 new 关键字,指针才会关联真实对象,示例如下:
cpp
A* p = new A;
这行代码实际完成了两步操作:
第一步:创建指针变量
cpp
A* p;
第二步:动态创建对象
cpp
new A;
第二步会在堆内存中开辟空间,创建一个真实的 A 对象,并将该对象的内存地址赋值给指针 p。
完整内存模型:
cpp
p
┌───────────────┐
│ 0x12345678 │
└───────┬───────┘
│
↓
堆空间
┌───────────────┐
│ A 对象 │
└───────────────┘
由此必须严格区分两种指针写法:
-
A* p;:仅有指针变量,无A对象,无堆内存占用 -
A* p = new A;:既有指针变量,也有堆上真实的A对象
五、指针并不会"自动随机指向一块内存"
这是初学者高频误区:认为未初始化的指针会随机指向一块有效内存。实际逻辑分两种场景,界限非常清晰。
局部指针
cpp
void func()
{
A* p;
}
函数内部的局部指针 p 不会自动初始化,其存储的地址值是随机不确定的脏数据。
内存形态:
cpp
p
┌───────────────┐
│ 不确定的值 │
└───────────────┘
此时的指针指向的内存地址无效,执行 *p 解引用操作,属于未定义行为,会引发程序崩溃。
全局或静态指针
如果指针定义为全局变量,或用 static 修饰:
cpp
A* p; // 全局指针
static A* p; // 静态指针
编译器会自动将其初始化为 nullptr(空指针)。
内存形态:
cpp
p
┌───────────────┐
│ nullptr │
└───────────────┘
核心结论:指针变量是否初始化、初始值是什么,和指针本身的类型是完全独立的两个概念。
六、对象和指针的大小也不是一回事
另一处极易混淆的知识点:对象大小与指针大小的区别。定义测试类:
cpp
class A
{
int x;
};
A a; 的大小由类 A 的成员布局、内存对齐规则决定,是对象本身占用的内存大小。
A* p; 的大小与类 A 的结构无关,只由系统架构决定。在 64 位 Linux/Windows 环境下,所有类型的指针大小均为 8 字节。
重点误区纠正:这 8 字节是指针变量自身存储地址所需的空间 ,和A 对象的内存空间毫无关联,绝对不是创建了 8 字节的 A 对象。
最终区分:
-
指针变量大小:存储内存地址所需的空间,固定为系统位数对应大小
-
对象大小:类实例化后,对象自身占用的内存空间,由类结构决定
七、理解了对象和指针,再来看 static
static 在不同场景下语义不同,我们先从最简单的函数局部静态变量入手。
cpp
void func()
{
static int x = 10;
}
很多人误以为 static 变量会在程序启动时直接初始化,实际规则是:函数内部的局部静态变量,仅在第一次执行到对应代码行时才会初始化,具备全局静态存储期。
替换为自定义类对象,逻辑完全一致:
cpp
void func()
{
static A a;
std::cout << "hello" << std::endl;
}
执行时序:
-
程序启动、进入
main函数,未调用func()时:A对象未初始化,不存在 -
第一次调用
func():执行到static A a;,创建并初始化A对象 -
后续多次调用
func():不会重复创建对象,始终使用第一次初始化的对象
且该静态对象的生命周期贯穿整个程序运行周期,程序结束后才会销毁。
八、静态成员变量又是什么情况?
当 static 修饰类的成员变量时,语义发生变化:
cpp
class A
{
public:
static int x;
};
此时的x 是类静态成员变量,不属于任何一个类实例对象,而是属于整个类,全局共享一份内存。
C++ 语法要求,类内仅声明静态成员变量,必须在类外完成定义和初始化:
cpp
int A::x = 10;
核心区别:
-
普通成员变量:每个类对象独立拥有一份变量,互不干扰
-
静态成员变量:整个类仅有一份,所有对象共享访问
九、静态成员变量是对象,还是指针?
结合前文的对象、指针知识点,可快速区分两种静态成员:
场景一:静态对象成员
cpp
class A
{
public:
static A obj;
};
static A obj; 定义的是一个真实的 A 类静态对象,占用对象内存,会触发构造函数。
场景二:静态指针对象成员
cpp
class A
{
public:
static A* obj;
};
static A* obj; 仅定义了一个静态指针变量 ,不会创建 A 对象,仅用于存储地址。
初始状态下:
cpp
obj
┌──────────────┐
│ nullptr │
└──────────────┘
A 对象:不存在
只有执行动态创建代码后,才会生成真实对象:
cpp
obj = new A;
内存形态更新为:
cpp
obj
┌──────────────┐
│ A对象地址 │
└──────┬───────┘
↓
┌───────┐
│ A对象 │
└───────┘
十、回到单例模式:饿汉模式
掌握上述基础后,饿汉、懒汉单例的底层逻辑一目了然。先看经典饿汉单例实现:
cpp
class Singleton
{
private:
// 私有构造,禁止外部实例化
Singleton() {}
public:
static Singleton& GetInstance()
{
return _instance;
}
private:
static Singleton _instance;
};
// 类外初始化静态对象(程序启动时执行)
Singleton Singleton::_instance;
关键代码 Singleton Singleton::_instance; 会在程序静态初始化阶段 ,创建一个真实的 Singleton 单例对象。
这意味着:程序还未执行 main 函数业务逻辑,单例对象就已经创建完成。后续所有 GetInstance() 调用,只需直接返回已存在的对象即可,无需重复创建。
十一、为什么叫"饿汉"?
饿汉的核心特质:极度"急切",无论是否使用,程序启动必创建对象。
完整执行时间线:
cpp
程序启动
↓
静态初始化:创建 Singleton::_instance 对象
↓
进入 main() 执行业务逻辑
↓
调用 GetInstance()
↓
直接返回已存在的对象,无需创建
这也是饿汉模式调用时无需 new 的核心原因:对象早已初始化完成。
十二、懒汉模式
懒汉模式与饿汉完全相反,核心是延迟加载、按需创建:程序启动不创建对象,第一次调用获取实例时才初始化。
传统懒汉单例实现:
cpp
class Singleton
{
private:
Singleton() {}
public:
static Singleton& GetInstance()
{
if (_instance == nullptr)
{
_instance = new Singleton();
}
return *_instance;
}
private:
static Singleton* _instance;
};
// 初始化为空指针,无对象创建
Singleton* Singleton::_instance = nullptr;
程序启动时,仅初始化了一个空的静态指针变量,没有任何单例对象创建,内存状态:
cpp
_instance → nullptr
Singleton 对象:不存在
十三、第一次调用懒汉单例
首次调用 GetInstance() 时,会触发对象创建逻辑:
cpp
if (_instance == nullptr)
{
_instance = new Singleton();
}
此时 new Singleton() 才会在堆内存中创建真实的单例对象,并将地址赋值给指针。
执行流程:
cpp
第一次调用 GetInstance()
↓
判断指针为空
↓
new 创建 Singleton 堆对象
↓
指针存储对象地址
↓
返回对象引用
第二次及后续调用
↓
指针非空,跳过创建逻辑
↓
直接返回已有对象
懒汉的核心本质:将对象创建时机延迟到第一次使用时,避免无效初始化。
十四、为什么"指针不创建对象"?
结合所有知识点,可精准解答这个核心问题:
Singleton* p; 的语义是:定义一个可存储 Singleton 对象地址的指针变量,仅开辟指针内存,无任何对象创建逻辑。
而 new Singleton() 的语义是:主动在堆内存开辟空间、调用构造函数,创建一个真实的 Singleton 对象。
最终对比:
-
Singleton* p;:指针存在,对象不存在 -
Singleton* p = new Singleton;:指针存在 + 堆对象存在
十五、C++11之后更推荐的懒汉写法
C++11 标准优化了局部静态变量的初始化规则,提供了更简洁、线程安全的懒汉单例实现:
cpp
class Singleton
{
private:
Singleton() {}
public:
static Singleton& GetInstance()
{
// 函数局部静态对象
static Singleton instance;
return instance;
}
};
很多人疑惑:同样是 static,为什么这是懒汉模式?
核心原因:这是函数内部的局部静态变量 ,而非类静态成员。它不会在程序启动时初始化,仅在第一次执行到该代码行时才会创建对象。
执行时序:
cpp
程序启动 → 进入main函数
↓
未调用 GetInstance():instance 对象未初始化
↓
第一次调用 GetInstance()
↓
执行 static Singleton instance; 完成对象创建
↓
后续调用直接复用该对象
十六、这个懒汉和 new 懒汉有什么区别?
两种懒汉模式均实现延迟加载,但底层内存、生命周期、安全性差异显著:
传统 new 懒汉
对象创建在堆内存,由开发者手动管理生命周期,无自动线程安全,需手动加锁保证多线程安全。
C++11 局部静态懒汉
对象为静态存储区对象,由编译器自动管理生命周期,无需手动释放。
核心优势 :C++11 标准强制保证,局部静态变量的初始化过程是天然线程安全的,无需额外加锁,代码更简洁、安全性更高,是现代 C++ 首选懒汉实现。
十七、为什么懒汉可能需要考虑线程安全?
传统 new 懒汉的空指针判断+创建逻辑,存在严重的多线程安全隐患:
cpp
if (_instance == nullptr)
{
_instance = new Singleton();
}
多线程同时首次调用时,可能出现并发判断:
cpp
线程1:判断指针为空,准备创建对象
线程2:同时判断指针为空,也准备创建对象
最终结果:两个线程各自new,生成两个对象,破坏单例唯一性
因此传统懒汉必须通过互斥锁、双重检查锁定等方式保证线程安全。
而 C++11 局部静态变量的初始化,由语言标准保证原子性,从根源上解决了线程安全问题。
十八、饿汉和懒汉真正区别是什么?
摒弃"饿汉直接返回、懒汉需要new"的浅层记忆,掌握核心本质区别:
饿汉模式
基于类静态成员对象 实现:static Singleton _instance;
对象在**程序静态初始化阶段(main函数执行前)**创建,全程唯一。调用实例接口时直接返回已初始化对象。
懒汉模式
基于函数局部静态对象 实现:static Singleton instance;
对象在第一次调用实例接口时才初始化,实现延迟加载,按需创建。
十九、饿汉和懒汉的实际区别
两种模式的核心取舍在于资源占用与使用场景:
若单例对象创建成本高(占用内存大、需要加载配置、建立网络/文件资源),且程序可能全程不使用该对象:
饿汉模式
cpp
程序启动强制创建对象 → 占用系统资源 → 全程未使用 → 资源浪费
懒汉模式
cpp
程序启动不创建 → 无资源占用 → 全程未使用 → 零资源损耗
二十、但如果一定会使用呢?
若业务逻辑保证程序必然会使用该单例对象,且对象创建成本低:
饿汉模式
程序启动一次性初始化,运行时无任何判断、创建开销,逻辑简单、执行高效,无线程安全问题。
懒汉模式
首次调用时触发初始化,存在一次初始化开销,相比饿汉无优势。
结论:必用、轻量单例优先用饿汉;可能不用、重资源单例优先用C++11懒汉。
二十一、单例模式的几个关键知识点
梳理全文核心逻辑,形成完整知识链路:
cpp
单例模式
│
┌─────────┴─────────┐
↓ ↓
饿汉 懒汉
│ │
程序启动时创建 第一次使用时创建
│ │
↓ ↓
static Singleton 函数内 static
_instance instance
│ │
↓ ↓
GetInstance() GetInstance()
│ │
↓ ↓
直接返回 第一次初始化
所有单例逻辑的底层根基,都是对象与指针的核心区别:
cpp
对象
│
├── A a;
│ ↓
│ 直接创建 A 对象,占用对象内存
│
├── A* p;
│ ↓
│ 只创建 A* 指针,无对象、无对象内存
│
└── A* p = new A;
↓
指针变量 + 堆上真实 A 对象
二十二、单例模式中 static 的核心本质
严格来说,单例模式并不是"所有东西都必须是 static"。
单例使用 static 的唯一核心原因:
外部在没有提前创建对象的前提下,必须能够找到并获取那个唯一的单例对象。
这是理解单例所有 static设计的底层核心,彻底打通之前所有知识点。
普通成员函数的致命问题
假设我们不使用静态函数,实现单例获取接口:
cpp
class Singleton
{
public:
Singleton& GetInstance()
{
return instance;
}
};
我们想要通过类直接调用:Singleton::GetInstance();,会直接编译报错。
原因非常简单:普通成员函数必须依赖实例对象才能调用,正确调用方式只能是:
cpp
Singleton s;
s.GetInstance();
但这和单例模式的核心设计矛盾:
单例模式为了保证全局唯一,会将构造函数私有化,禁止外部随意创建对象:
cpp
class Singleton
{
private:
Singleton() {} // 私有构造,外部无法实例化
};
此时就形成了死循环矛盾:
cpp
想要调用 GetInstance() 获取唯一对象
↓
普通成员函数必须依赖对象才能调用
↓
单例私有构造,外部无法创建对象
↓
完全无法调用接口,单例彻底失效
唯一的解决方案:将获取接口改为 静态成员函数:
cpp
static Singleton& GetInstance();
静态函数属于类、不属于对象,支持无对象调用:
cpp
Singleton::GetInstance();
为什么单例对象本身也必须是 static?
以经典饿汉单例为例,我们思考核心问题:为什么全局唯一的 _instance 必须是静态成员,不能是普通成员?
cpp
// 正确写法
static Singleton _instance;
// 错误写法
Singleton _instance;
普通成员变量的本质:属于类的实例对象,每个对象都会独立拥有一份。
如果 _instance 是普通成员,内存关系为:
cpp
一个 Singleton 实例对象
│
└── 包含一个 Singleton 类型的 _instance 成员
│
└── 又包含一个 Singleton 类型的 _instance
│
└── 无限嵌套
这种写法是语法非法,和如下代码报错逻辑完全一致:
cpp
class A
{
A a; // ❌ 非法,对象无法无限嵌套,内存大小无法确定
};
static 如何彻底解决嵌套问题?
static 修饰的成员,会彻底脱离「对象从属关系」,从属于对象 变成属于类。
普通成员内存关系(错误):
cpp
Singleton对象
┌─────────────────┐
│ │
│ _instance │ // 内嵌对象,无限嵌套
│ │
└─────────────────┘
静态成员内存关系(正确):
cpp
类 Singleton(全局唯一)
│
├──── 静态对象 _instance(全局唯一)
│
└──── 静态函数 GetInstance()(全局唯一入口)
静态成员不依赖任何实例对象,独立存在、全局仅此一份,完美契合单例「全局唯一」的核心要求。
单例中两个 static 的核心作用拆解
1. 静态对象 static Singleton _instance
核心作用:存储全局唯一的单例对象。
归属于类、全局只存在一份,不会因为实例化对象而重复创建,从内存层面保证唯一性。
2. 静态函数 static Singleton& GetInstance()
核心作用:提供无对象调用的全局访问入口。
解决了「没有实例对象时,无法获取单例」的核心矛盾,支持通过类名直接调用。
完整调用流程:
cpp
外部业务代码(无任何对象)
│
↓
Singleton::GetInstance() // 静态函数,无需对象
│
↓
返回全局唯一的静态 _instance 对象
懒汉模式的 static 逻辑完全一致
C++11 推荐的懒汉写法,底层 static 原理没有任何变化:
cpp
class Singleton
{
private:
Singleton() {}
public:
static Singleton& GetInstance()
{
// 函数局部静态对象
static Singleton instance;
return instance;
}
};
1、GetInstance() 必须是静态函数:程序启动无任何对象,只能通过类名调用;
2、函数内部 static instance 必须是静态变量:保证全局唯一、首次调用初始化、后续复用。
懒汉完整执行逻辑:
cpp
程序启动:无任何单例对象
│
↓
首次调用 Singleton::GetInstance()(无对象调用)
│
↓
执行 static 局部变量初始化,创建唯一对象
│
↓
后续所有调用,直接复用该对象,不再创建
单例解决的本质问题
单例模式的所有设计,都是为了解决一个悖论:
对象不存在时,如何调用函数创建并获取这个对象?
普通成员函数:依赖对象,无解。
静态成员函数:依托类存在,无需对象,完美解决。
补充:构造函数为什么不需要 static?
既然接口和对象都是 static,为什么构造函数不需要、也不能是 static?
核心原因:
1、C++ 语法规定:构造函数、析构函数不能被 static 修饰,它们是专属对象的初始化方法;
2、单例私有构造的作用是:禁止类外创建对象,不禁止类内创建对象。
静态对象、局部静态变量的初始化,属于类内行为,可以正常调用私有构造函数,完全合法。
完整链路验证
标准懒汉单例的完整闭环逻辑:
cpp
class Singleton
{
private:
Singleton() {} // 私有构造:锁死外部实例化
public:
// 静态接口:无对象即可调用
static Singleton& GetInstance()
{
// 静态对象:全局唯一、首次初始化
static Singleton instance;
return instance;
}
};
外部调用最终保证唯一性:
cpp
int main()
{
Singleton& s1 = Singleton::GetInstance();
Singleton& s2 = Singleton::GetInstance();
// s1 和 s2 指向同一个对象
return &s1 == &s2; // 结果为 true
}
单例使用 static不是语法规定,而是逻辑刚需,两个核心作用缺一不可:
1. static 接口函数:解决「无对象时,无法访问、创建对象」的问题,提供全局入口;
2. static 单例对象:解决「对象无法唯一、无限嵌套」的问题,从内存层面保证全局唯一。
一句话贯穿所有单例逻辑:单例使用 static,是为了在没有实例对象的前提下,依然能通过类本身,管理、创建、获取唯一的全局对象。
二十三、最后总结
吃透单例模式的前提,是彻底分清五类变量定义的本质,以及两种单例的核心逻辑:
-
A a;普通对象定义:直接创建真实对象,调用构造函数 -
A* p;指针定义:仅创建指针变量,无对象创建 -
A* p = new A;动态对象:指针+堆对象,真正实例化 -
static A a;静态对象:真实对象,仅初始化时机不同(局部静态延迟初始化) -
static A* p = nullptr;静态指针 :仅空指针,无对象,需new才会创建对象 -
饿汉单例:类静态对象,启动即创建,高效无线程问题
-
C++11 懒汉单例:函数局部静态对象,按需创建,天然线程安全
最终核心准则:
变量类型决定本质:A 是对象、A* 是指针;static 仅改变存储期与初始化时机,不直接创建对象;真正的对象创建,仅由对象定义或 new 操作完成。
吃透这套逻辑,单例的线程安全、生命周期、堆与静态存储区差异、静态变量特性等衍生知识点,都能轻松理解。