Fork me on GitHub

啃内存池前思考(从分离写法衍生出的思考:类/模版类型、强弱符号穷举思考static和inline等组合、实体、友元、RVO优化历史、volatile、原子、单例(从懒汉饿汉,追到懒汉局部static主流,废弃的DCL双重检查锁+原子+内存序的裸指针修正版、CAS(自旋锁、单生产单消费者、思考多线程并发会产生的问题)、fetch_add、mutex区别、条件变量、租的腾讯云崩了从SSH折腾到VNC)

代码随想录的《内存池项目》我的思考:

同样是static但为啥

static void initMemoryPool();.h没完整写、

static MemoryPool& getMemoryPool.h没完整写、

static void* useMemory(size_t size){.h完整写了、

static void freeMemory(void* ptr, size_t size){,在.h完整写了

newElementdeleteElement也完整写了。

想理解这个先看一些细节、术语(开始一顿追问豆包)(这个知识点其实此文搜“定义计数器,然后(写法一)”也说过,这里再次完善巩固下):

1、类的完整结构体(成员变量、成员函数声明)必须全部写在头文件,这不是单纯 “声明”,是完整类型定义,不能只丢一句前置声明即class A;比有{}

2、类里面成员函数的函数体(实现代码)可以写在.cpp

头文件 A.h(必须完整写类,不能只前置声明)

struct A {
    int num;       // 成员变量属于类型布局,必须放h
    void test();   // 成员函数声明放h
};

A.cpp(仅放成员函数实现,类本体不在这)

#include "A.h"
void A::test() {
    std::cout << 10;
}

纠正你的核心误区:你说 “类声明放 h、实现放 cpp”,错在把整个类等同于普通函数:普通函数只有「声明 / 实现」两部分拆分;类本身是类型载体,整体骨架不能拆分出去,只有内部函数逻辑能剥离到 cpp。

 

类和函数表层分工逻辑相似(头文件.h对外暴露信息、.cpp源文件存放实体实现),底层本质完全不同:

关于 类 / 模板类型涉及到不考的,非C++标准,属于编译器行为的 inline 一事,此文搜“规则,不是C++”):

定义仅存编译期布局信息,不占用运行时内存,多文件复制无冲突,即类普通成员无独立全局内存。比如类内写了int num,多个文件包涵这个类的头文件也没事,因为类内int num只是记录类内存布局的类型信息,不分配独立全局内存  

仅static成员、完整函数实现才分配全局内存,因为static成员是全局实体,多文件复制会重定义,C++17 前只能类外单文件定义,C++17 用inline static直接写类内:

写法一、C++17 之前(老式写法,类内仅声明,cpp 单独定义)

查看代码
// A.h
struct A{
    static int val; // 只是声明,不分配内存
};

// A.cpp
int A::val = 100; // 唯一实体定义,分配内存。这个初始化属于定义一部分,必须放全局,不可以放main里

————————————————————

//就算同一个.cpp文件,也要类外初始化
#include <iostream>
using namespace std;

// 类内静态成员变量(仅声明,必须类外定义初始化)
struct A {
    static int num; // 仅声明
};
// 类外全局域定义初始化,必须写这个初始化,且必须在这里写
int A::num = 100;

int main() {
    std::cout << A::num<<endl;
}

写法二、C++17 及以后 inline static(头文件直接定义,多文件包含无报错)

查看代码
//.h
struct A{
    inline static int val = 100; // 自带inline,允许多处复制自动合并
};

//.cpp
int main(){
    A::v = 20;   
    /*
    能在函数内修改值
    全局域只允许声明 / 定义,赋值属于执行语句只能写在函数体内,比如main里
    初始化是定义的一部分,属于声明语法可以在全局
    赋值是运行执行语句,二者语法规则不同。
    */
}

写法三、static constexpr(C++17 默认隐式 inline,不用手写 inline)

查看代码
// .h
struct A{
    static constexpr int val = 100;
};

//.cpp
int main(){
    B::val = 20;  //报错:编译期只读常量,任何位置都不允许修改
}

static constexpr静态成员隐式自带 inline,整型 / 浮点全都允许类内直接初始化;

普通static const:仅整型 / 枚举能类内初始化,浮点 / 自定义类型禁止类内初始化,且无隐式 inline,必须类外单独定义。

查看代码
#include <iostream>
struct Test{
    // 整型static const,C++17前就允许类内初始化
    static const int num = 10;

    // 浮点static const,不能类内给初始值
    static const double val;
};

// 浮点必须类外定义初始化
const double Test::val = 3.14;

int main(){
    std::cout << Test::num << " " << Test::val;
}

以上都是类内存布局这件事,放在.h里,多个cpp包涵都没事,会自动去重,

就算多个函数,,成员函数只声明无实现:class A{int x; void f();};头文件多文件包含无任何链接报错,无重复符号,不会生成.text 实体。

多个实现class A{int x; void f(){} };多文件包含依旧无问题,函数自动为 inline 弱符号,多份.text 实例链接器自动合并。

类不占内存,理解为图纸,但这仅仅是说跨.cpp文件可以重复包涵,比如:a.cppb.cpp#include "test.h",各自粘贴一份 class A,此时两个 cpp 是两个独立翻译单元,C++ ODR 规则允许对类有豁免:不同翻译单元中,可以重复写同一个类的定义,编译器分别编译 a.ob.o,各自存一份类的布局信息,到链接阶段,类只是类型信息,不产生二进制符号,不存在冲突,不需要任何 “去重操作”,可以说编译器自动去重,没任何报错。

所以多个cpp可以重复引入类定义(无论成员函数有无实现)!

但同一个.cpp文件多次引入同一个.h编译报错(语法不允许),必须靠头文件守卫解决,有#ifndef守卫就只会展开一次:

查看代码
//1.h
#ifndef H1
#define H1
#include "2.h"
class X{};
#endif

//2.h
#ifndef H2
#define H2
#include "1.h"
class Y{};
#endif

//main.cpp
#include "1.h"
int main(){}



/*
去掉
#ifndef H2
#define H2

#endif
编译时预处理无限递归粘贴,直接报头文件嵌套超限错误
*/

还有个#pragma once是编译器扩展,不是标准语法,极少数老编译器不识别,写起来更简短。

 

代码里的「符号」和数学运算符的+-*/完全两码事,

编译链接层面说的符号(symbol):就是代码里有实体、能被外部文件找到、会被写到目标文件 (.o/.obj) 里的名字,你写的变量名、函数名,编译器会把它打包成符号塞进.o 文件,链接器靠符号匹配不同文件之间的调用关系。

哪些东西会产生符号(有符号)

1、全局普通函数void func(){} // 生成强符号 func(只写()叫声明,写花括号{}叫定义)

2、全局变量:int g_val; // 强符号 g_val

3、类外面定义的静态成员变量

struct A { static int x; };
int A::x = 1; // 强符号 A::x

4、实例化后的模板函数:template<typename T> void t_func(){} // 实例化后弱符号,跨多翻译单元重复定义不会链接报错,无需手动加inline

头文件里写template<typename T> void t_func(){},任意文件包含后触发隐式实例化,生成的实例符号天然弱符号;不是手动单独实例化 

5、inline函数:

inline void inl_func(){} // 弱符号 inl_func

链接器铁则:同一个名字的强符号,所有翻译单元合起来只能出现一份。

两个.cpp 都定义 void func(){},两个.o 都有强符号 func,链接直接报错多重定义。

哪些东西完全没有符号(不会进.o)

1、单纯的类 / 结构体定义、类型名struct Test {}; // 只是编译期布局描述,无符号,不占目标文件空间

2、局部变量(函数内部定义)void f(){ int a; } // a只在函数栈,不出符号

3、函数形参、typedef、using 别名、宏

4、只声明不定义的东西void f(); // 仅声明,没有函数实体,不生成符号

inline修饰的函数、模板实例化函数、const 全局常量(部分平台),生成弱符号。

链接器规则:同一个名字允许多份弱符号,链接时自动只保留一份,丢弃其余副本,不会报错。

这就是为什么头文件写 inline 函数,多个 cpp 包含也不会链接报错。

 

符号是给链接器看的名字标签,不代表指令本身,编译器把变量名、函数名记录成符号表,存在.o文件单独段里;符号只解决「文件之间互相找名字」的问题,不参与 CPU 执行。也不是汇编,C++ 写void func(){}编译器生成汇编指令 + 在符号表插入func强符号,调用func()时,汇编会生成call func,链接器靠符号表找到其他.o 里同名符号,补全地址。

汇编 = 汇编是底层指令,对应机器码,描述 CPU 要干什么,简单说就是用于执行的指令

符号 = 指令块 / 全局数据的名字索引,用来跨文件寻址。符号依附汇编存在,但本身不等于汇编。

 

还有就是,类没有强弱符号一说,强弱符号只针对会生成二进制实体(变量 / 函数代码) 的东西:

  • int a;:强符号,多文件重复链接冲突

  • inline int b = 1;:弱符号,多文件重复没事

  • 类定义只是类型图纸,不产生符号,自然不分强弱。

类可以文件重复其实是C++豁免规则,对类、inline 函数、const 变量允许多文件重复定义,和类不占内存、不分强弱无关,不管是类还是int a,同一翻译单元(.cpp)里,任何东西只能定义一次,类完整定义在main里写2次也不行。但类可以引入多次

类里成员函数:

1、只写声明不写函数体:不会生成任何机器码,exe 里没有这段函数代码;只有调用func()才会链接报错。

2、若函数写在头文件里的类内部void func() {}:特殊豁免,允许多文件重复包涵这个头文件,函数代码被编译器自动标记为inline弱符号,可以被多文件重复包含,链接无冲突

普通成员函数区别:void func(){}头文件重复包含 = 每个 cpp 生成一份代码,链接多重定义报错,只能放 cpp 源文件,

// a.h
void test(){} // 普通全局函数,强符号

// a.cpp
#include "a.h"


// b.cpp
#include "a.h"


// main.cpp
int main(){}

g++ a.cpp b.cpp main.cpp -o test会报错重定义,多文件各生成独立.o,链接器汇总符号才查出重复 image,表现为/usr/bin/ldmultiple重复定义,ld是Linux链接器程序,代表链接阶段报错,或者undefined reference找不到对应定义也是链接错。

而如main.cpp里定义两次int a,属于单文件编译器单次扫描,直接编译就发现重定义,表现为编译期间报错行带main.cpp:行号:列号 image

main.cpp里引入两次头文件a.h靠守卫即可解决。

.h里只有声明class Test;编译器不知道类完整大小、构造函数,不能实例化对象(创建对象)、调用构造,只能用指针 / 引用(因为指针只存目标内存起始地址,不需要知道目标类完整尺寸、成员布局)。强行搞对象就编译报错,因为编译器不知道 A 占用多大内存,无法为栈变量 a 分配空间。编译根据类确定对象大小,运行真正造对象。

.h里只要有完整类定义class Test{};,只要写个花括号就算,就可以搞对象,

.h里有具体布局的写法class Test{ public: int x; void print(); };也是一样可以搞对象,但都不能调用这个函数因为函数没花括号,结果是能过编译,但卡在链接即链接报错,编译阶段只干两件事:
  1. 当前文件里有没有对应声明、语法是否合规

  2. 把代码翻译成 .o 目标文件

A a;能编译过的原因:class A {int x; void f();}; 是完整类定义,编译器能算出 A 占用内存大小,分配栈 / 堆内存完全不需要 f 的代码,语法、类型校验全部通过,编译放行。

a.f(); 编译时只做标记

编译器看到调用函数,不会去找函数实现代码,只做两件事:
  1. 校验函数签名匹配(确实类里声明了 void f());
  2. .o 文件里记下一个未解析符号 _Z1A1f,意思是:这里要跳转到 f 函数的地址,等链接器填地址。

    只要签名对,编译直接通过,不找函数实体。

链接阶段才去找函数机器码 

链接器拿所有 .o、库文件扫描全部 .text 代码段,匹配符号:
  • 如果某处有 void A::f(){} 实现,就把 f 的指令地址填到调用位置;

  • 全程找不到 f 的函数实体、没有对应 .text 指令,符号无法解析,直接抛出 undefined reference,卡在链接环节。

我之前误以为链接就是把其他.o的也搞过来,误以为【链接是一个cpp里没定义就会立马编译不过,然后多个cpp才是链接不过】。但其实编译器不会跨文件找函数实现,它只管单文件语法合法性;找不到函数实体这件事,是链接器的工作,编译阶段完全察觉不到,所以无论几个cpp,该什么阶段报错就是什么阶段报错。

其实没头文件的文件也可以编译运行,int main() {}仅这段代码,无任何#include,单独编译运行完全合法。#include不是写代码的强制前提,只是拿来复用别人写好的声明 / 类型。

头文件本质就是批量存放各类声明的文本,只是省去你手动逐条写声明所需要的比如cout这些库函数的重复劳动,不是编译必需项。

再来,

main.cpp(无任何 #include,纯手写声明)

void func(); // 手动写外部函数声明,替代头文件
int main() {
    func();
}

func.cpp(函数定义)

#include <iostream>
void func() {
    std::cout << "无头文件也能正常编译链接" << std::endl;
}

编译运行命令

# 编译两个文件并链接
g++ main.cpp func.cpp -o run
./run

执行结果

无头文件也能正常编译链接

编译器只看当前文件内有没有对应名字的声明,不关心声明来自头文件还是你手写。

#include只是文本粘贴,等价于把头文件里的声明复制到当前 cpp

main.cpp 里手写void func();,编译器知道存在这个函数,语法校验直接通过;链接阶段再去 func.o 找函数实体,比如这里:

编译阶段:g++ 分别处理 main.cpp、func.cpp:

  • 读 main.cpp,看到 void func(); 声明,确认调用 func() 语法合法,生成 main.o;

  • 读 func.cpp,识别 func 函数完整定义,生成 func.o。

链接阶段:收集 main.o、func.o 内所有符号,main.o 需要 func 符号,func.o 提供 func 符号,匹配成功,生成可执行文件 run。

所以这里去掉func.cpp里的定义,编译一样可以过,只编译 main.cpp(g++ -c main.cpp):编译正常生成 main.o,编译器只校验本地声明,不管外部有无实现,但执行g++ main.cpp -o run(无 func.cpp):编译环节走完,链接阶段找不到 func 符号,直接链接失败,根本不会生成程序,谈不上运行报错,所以缺实现不会走到运行阶段,报错卡死在链接步骤

猛然惊醒,完美闭环!《程序员自我修养》!全对上了!image ,我是否学多了?豆包说“Linux C++高性能后端面试必考编译链接分层、声明/定义、符号匹配整套逻辑,必须吃透”。

3、若.h头文件类内的成员函数不写{},即没定义,然后在类外.cpp写void Test::print(){};,多文件包含头文件无冲突,本质是类定义没重复,只在这个.cpp里定义了。

而如果把实现void Test::print(){};写到.h里的类外,无inline,多cpp包含.h直接链接重定义报错

注意: 类本身不占全局内存,我经常称呼为没实体,但豆包说标准称呼就是“类型实体”,

  • int a;:真的会在程序里开辟一块全局内存,占全局数据段,多文件重复就多块内存,链接冲突。

  • class A {};:只是一张图纸,布局描述,不会自动开辟任何全局内存,每个 cpp 各自存一份图纸不冲突,ODR 允许。

图纸本身(类定义、模板)不算程序运行时占内存的数据,不会在 exe 数据段分配空间,只有你创建A obj;对象时,才临时分配内存,int a;一出现就直接分配全局内存。

声明写几次都行没任何限制。

再说下exe、代码段、运行内存这件事:

  • 写代码时的{}、函数有无实现、类结构、模板定义(模板结构:成员变量、函数声明)只影响编译链接阶段,决定会不会生成机器码、会不会存在.text段、有没有符号,和运行内存没有半点关系,这只是硬盘上文件里的内容规则。

  • 运行内存只看一件事:进程是否被操作系统加载启动。程序没跑,不管 exe 里有多少.text数据,都只是硬盘文件,不存在运行内存;程序启动,系统分配虚拟内存,拷贝.text、开辟栈堆,此时才有运行内存。

代码写法控制硬盘文件里存什么;运行内存只看程序有没有正在执行。

程序启动加载进运行内存时,操作系统只会拷贝机器指令、全局常量、全局变量,不会把类的完整类型描述加载到进程内存里。

只有你创建对象A a;、调用成员函数a.func(),才会在运行内存开辟对象空间、加载对应函数指令;单纯写类定义本身,全程不占用任何运行时内存。

Linux下可执行文件无强制后缀,靠文件属性区分,本质和Windows的exe都是存储.text等段的程序镜像。

 

举几个例子:

情况 1:仅类前置声明 class A;

  1. 编译阶段:仅告知编译器存在类型 A,无成员、无布局信息,不产生任何机器码、无符号、不写入 exe 任何段。

  2. 硬盘 exe:无任何和 A 相关数据,无.text内容。

  3. 程序运行:不会拷贝任何 A 相关内容到运行内存,全程不占用运行内存。

情况 2:完整类定义,仅成员声明无函数实现 class A { int x; void func(); };

  1. 编译阶段:大括号内成员只给编译器计算类大小、偏移布局,仅编译期临时数据,不生成指令、无函数符号。

  2. 硬盘 exe:.text依旧没内容;类布局信息不会存进 exe。但函数实现如果放在其他.cpp 里则编译链接该.cpp后才会一起进.text。exe里只有:有定义的全局/静态(都为内部链接,不涉及强弱的事)、普通成员函数类外实现属于外部链接强符号、inline 成员函数类内定义外部链接弱符号、字符串字面量等。

  3. 程序运行:不会拷贝类布局到运行内存;只有创建A a;时栈 / 堆分配 x 的内存,单纯写这个类定义本身不占运行内存;无 func 实体,无.text加载。

情况 3:类定义内直接写成员函数实现 class A { int x; void func(){} };

  1. 编译阶段:func 自带函数体,生成完整机器指令,隐式 inline 弱符号。

  2. 硬盘 exe:指令存入文件内.text分区,永久保存在硬盘。

  3. 程序运行:启动后系统把 exe 里.text整块复制到进程内存代码段(运行内存),CPU 可读取 func 指令执行;创建A a;额外在栈 / 堆分配成员 x 内存。

能创建对象和.text函数指令完全无关,创建对象只分配存放成员变量的内存(栈 / 堆),创建对象只需要编译器知道类的内存大小(仅编译期临时计算,不写入可执行文件)、成员布局(仅编译期计算,编译结束直接丢弃,不存 exe、不占运行内存),创建对象的内存和.text代码段互不干涉。.text存的是函数执行逻辑。

类静态成员x会存exe数据段并生成符号;普通实例x不会。

情况 4:模板函数定义 template<typename T> void t_func(){} 

  1. 编译阶段:单纯模板模板,无调用时不生成任何函数实体、无指令、无符号;调用t_func<int>()触发隐式实例化,生成对应版本机器指令,弱符号。

  2. 硬盘 exe:未实例化无.text数据;实例化后指令写入 exe.text分区存于硬盘。

  3. 程序运行:启动拷贝.text全部指令到运行内存代码段;无调用则无对应模板指令,不会额外占用运行内存。

统一串联逻辑:

class A {
public:
    int x;
    void func() {}
    virtual void vfunc() {}
};

这 A 类只是编译期模板,运行时不占内存,

Q:啥玩意又不占内存了?妈逼的你函数有实现不占内存存他的实现数据,那存哪啊?存我脸上啊??

A:大写A只是编译器用的图纸,程序跑起来后内存里不存在叫 A 的实体,类布局、模板原型本身只存在编译阶段,不会写入 exe、不会加载进运行内存;只有带函数体的函数,才会产出指令存入.text。类只用来规定两件事:

  • 对象要存哪些成员变量;

  • 有哪些成员函数、虚函数。

实例对象 A a;(占栈 / 堆内存),a内存里只装两样东西:

  1. 普通成员变量 int x

  2. 有虚函数就多一个vptr虚表指针。

不管类里写多少个普通成员函数funca 的内存里完全不存函数代码、函数地址,对象大小和函数数量无关。这就是之前说的「函数没内存」—— 指不占对象 a 的内存。

成员函数 A::func:存在程序代码段(全局共享,和 a 无关)

1、编译链接后,硬盘有exe文件,文件划分多个段

  • .text:存放所有函数机器码

  • .data:已初始化全局 / 静态变量

  • .bss:未初始化全局 / 静态变量(文件里几乎不占空间)

  • .rodata:字符串常量、只读全局常量

而程序运行时,系统把文件加载进进程虚拟内存,文件里的段,会原样映射到内存,内存里叫法简化:

  • 文件.text → 内存代码段

  • 文件.data + .rodata + .bss → 内存统称数据段

exe里都是静态文件数据,程序没运行时只是硬盘文件,不属于运行内存;

虚拟内存划分:数据段存变量、函数代码存代码段.text、堆、栈等,堆栈是进程跑起来后操作系统单独分配的虚拟内存,exe 文件里没有堆栈对应段。

2、双击exe或者./exe,开始运行,操作系统从硬盘 exe 的.text拷贝指令到进程虚拟内存的代码段,CPU 执行这块内存里的指令;所有A的对象a1、a2、a3全部共用这一段代码;

4、调用a.func()时,底层只是把 a 的地址传进函数当this指针,函数本身不归 a 持有。

虚函数补充(虚表单独在只读数据段)

  • 虚函数代码依旧在代码段;

  • 虚表vtable存所有虚函数地址,放在只读数据段;

  • 对象 a 只存一根vptr,指向这份全局唯一的虚表。

virtual void vfunc()虚函数不受inline无调用不生成实体规则约束,只要类有对象(实例)就生成函数实体放入.text;虚表存于.rodata,对象仅存vptr指针。

查看代码
#include <iostream>
class A {
public:
    int x;
    void func() {}
    virtual void vfunc() {}
};

int main() {A a;}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ impl.cpp -o impl && objdump -t impl | grep func
000000000000123a  w    F .text  000000000000000f              _ZN1A5vfuncEv
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

/*
增加a.func();
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ impl.cpp -o impl && objdump -t impl | grep func
0000000000001256  w    F .text  000000000000000f              _ZN1A5vfuncEv
0000000000001246  w    F .text  000000000000000f              _ZN1A4funcEv
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

极简总结

  1. A:编译图纸,运行无内存;

  2. a:栈 / 堆内存,只存变量 + 虚指针,不含任何函数代码;

  3. A::func:代码段全局内存,所有对象共用,不属于单个 a。

运行内存 = 进程虚拟地址空间,每个进程拥有独立虚拟地址空间,互不干扰,我们日常说的程序运行内存全指它,之前说的 “函数不占内存”,特指不占用对象 a 的栈 / 堆内存,函数本身单独占用代码段虚拟内存。

注意:

  • 有实体 → 硬盘文件 .text存指令,运行占代码段内存
  • 无实体 → 文件无指令,运行不占对应内存

实体是占用代码段内存的必要条件,因为有实体仅代表二进制存有代码,不运行时不会分配内存。

  • 类外普通函数:有定义就生成实体,不管你是否调用、是否实例化搞对象

  • inline 函数(类内定义隐式 inline):无调用则不生成实体。(这个其实已经学过头了,这个面试不问,因为C++没规定有无实体,这个是编译器GCC自己的行为,C++只规定:支持头文件多处定义、规避 ODR 多重定义错误)

实体不只有函数,变量、虚表、字符串常量都算实体

  • int a(全局 / 静态)有实体,存数据段;局部int a无文件实体,运行栈临时分配。

  • cout << a触发对应 IO 函数实体(operator<<cout配套的输入输出函数二进制代码,属于函数实体,存在.text),运行占用代码段内存

注意,分配内存是 OS 给进程分配虚拟内存。:

  • 函数有实体且被调用:.text 存在指令,运行映射进代码段;

  • 非inline函数有实体但全程无调用:指令仍映射进代码段,只是 CPU 不会执行,但也叫有运行内存;

  • 只有类定义,inline 类内函数,但无函数调用、无实体,无.text 指令,类仅编译期图纸,不分配对应代码内存,运行期间也就不占任何内存。

每次函数调用都会在程序栈分配一块栈帧,存放当前函数局部变量、函数入参、返回地址、寄存器备份;函数执行结束,栈帧自动销毁释放,栈空间回收。

代码段是全局常驻内存,栈帧是单次调用临时内存,都只有调用函数时才会产生内存占用。

联动: 

  • 强弱符号存于可执行文件的符号表段(磁盘文件里),运行exe,加载到内存的代码段,属于只读段,不在栈帧。

  • 栈帧只放局部对象、局部变量,不存任何符号信息。

  • 运行时链接器依靠文件内符号表匹配函数 / 全局变量地址。

继续联动细节:

A a分配在main栈帧,成员x就在这块栈内存中,无独立符号。

只有跨文件(外部链接)实体才会生成带强弱属性的符号存入符号表,局部、实例成员不参与链接,不会在符号表,这会跟对象在一起(栈 / 堆)

objdump -t impl:打印程序符号表(函数、全局静态变量)

grep fun:只过滤带 fun 的函数符号

int x是普通实例成员变量,不在符号表里,objdump -t看不到,仅存在每个 A 类对象的内存里(你定义A aa 的内存块里存 x);不属于全局 / 静态数据,不会单独生成符号。只有 static int x; 静态成员,才会出现在符号表。

我的编译器不开优化即g++ impl.cpp -o impl -O0&& objdump -t去掉-O0一样,就是不开优化的意思,而-O1/2/3都是开优化,删掉实体函数就地展开,会去掉弱符号(即不会有_ZN1A4funcEv),强符号永远不会被优化掉。

inline就是内联就地展开,所以无优化的-O0毫无意义,但依旧有他另一层含义:多文件可重复定义。

联动流程:

代码段是程序加载时映射的只读静态内存,进程全程存在; 运行时栈/堆不会额外分配这块代码内存,对象只分配成员变量空间,不复制函数代码。

Q:具体啥意思呢?

A:代码段是虚拟内存这个大池子里的一个地盘,

运行时候搞对象,就在这个大池子里的一个叫栈的地方搞,这个栈地盘就有对象成员变量啥的,

运行内存包含栈、堆、全局数据段、mmap 映射区、代码段等全部运行阶段占用的虚拟内存,但一般说 “函数不占内存”,特指不占用对象 a 的栈 / 堆内存,函数本身单独占用代码段虚拟内存,因为行业语境下“运行内存”口语多指栈、堆这类可读写、动态分配内存,代码段是只读静态映射段,一般不包含在内,

看完下面的具体联动就懂了:

查看代码
class A{
public:
    int num;
};
A global_a;    // 全局对象,存放数据段
void func(){
    A local_a; // 局部对象,存main调用func生成的func栈帧内部
}
int main(){
    A main_a;  // 局部对象,存main函数自身的栈帧内部
    func();    // 调用func,在栈区新建一块独立栈帧,func执行完销毁该栈帧
}

inline函数,有实体且被调用,.text 存在指令,运行映射进代码段,全局变量存静态存储区、main里的在函数栈帧、new在堆,main里搞对象,main函数执行会单独分配专属栈帧,main内定义的局部对象,存储在main的栈帧内部。

  • 程序加载:global_a分配在数据段;mainfunc函数指令存入.text代码段。

  • 程序启动,操作系统创建主线程,分配整片栈内存,生成main函数栈帧。

  • 执行A main_a;:在已存在的main栈帧内部划分空间存放该对象,不产生新栈帧。

  • 执行func();:栈区开辟全新独立栈帧(func 栈帧),跳转至代码段执行 func 指令。

  • 执行A local_a;:在当前 func 栈帧内部划分空间存放对象。

  • func 函数执行结束:func 栈帧整体释放销毁,栈区只剩 main 栈帧。

  • main 函数结束:main 栈帧销毁,栈内存回收。

 

再说下虚拟内存,计算机的整块虚拟内存分给各个进程,互相独立的运行空间,一个进程的运行空间里大概划分出几个地盘:

  • 代码段:这是硬盘里的.text,加载进内存,就叫代码段,只读,存放函数机器指令,只读;

  • 静态全局区:硬盘里存初始化的全局/静态的叫.data,存未初始化的全局/静态变量.bss,加载进内存就统称为静态全局区,单独管.data叫数据段

  • 栈:很多教程傻逼,说成:局部对象、局部变量、函数调用栈帧,大错特错误人子实际上栈这个地盘区域使用的时候按照栈帧来划分,比如main函数(main栈帧)、func函数(func栈帧),局部变量、局部对象存储于栈帧内部,隶属于函数栈帧,三者不可并列,所以严谨的说栈内存是一整块连续内存,类似于纵向的小隔间即栈帧,这些小隔间就是给函数用的。

而函数调用时候会新建个栈帧,具体流程上面花了很大篇幅属说过了

这玩意磁盘无对应段,程序运行时 OS 在内存临时搞的

  • 堆:new 分配出来的对象;

这玩意磁盘无对应段,程序运行时 OS 在内存临时搞的

  • 常量区:只读,磁盘里叫.rodata

Q:具体函数里的啥放栈帧?啥放其他地方?总不能函数里所有都放栈帧吧?

A:

函数内const int a = 5;int a = 5在栈帧,也就是普通成员跟着对象本体走。

函数内static int x = 5;static const int x = 5;静态全局区,不在栈。

全局的static const int a = 10;在常量区

傻逼豆包误人子弟,自己实践证明:

查看代码
#include <iostream>
using namespace std;

static const int g_sc = 10; // 全局static const rodata

void test(){
    int normal = 1;
    const int local_c = 5;    // 栈帧
    static int s_var = 5;     // 静态全局区
    static const int s_c = 8; // 静态全局区

    cout << "栈普通变量地址: " << &normal << endl;
    cout << "栈const局部地址: " << &local_c << endl;
    cout << "函数static int地址: " << &s_var << endl;
    cout << "函数static const地址: " << &s_c << endl;
    cout << "全局static const地址: " << &g_sc << endl;
}

int main(){
    test();
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# ./impl
栈普通变量地址: 0x7ffd7ac09890
栈const局部地址: 0x7ffd7ac09894
函数static int地址: 0x55b5e350c010
函数static const地址: 0x55b5e350a008
全局static const地址: 0x55b5e350a004
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

栈是高虚拟地址(0x7ffd 开头),data/rodata/static 全局是低虚拟地址(0x55b5 开头)。

类、函数、模板里的东西和上面介绍的函数里的东西完全一致。

 

以下全是编译器的规则,不是C++的哎艹学多了:

非 inline 函数:

  • 编译后代码逻辑搞成二进制写入硬盘exe的.text,程序启动仅仅建立虚拟映射,不加载进内存的全局代码段,和调不调用无关。

  • 调用时,调用→新建独立栈帧(存参数、返回地址)→搬进内存,跳转至全局代码段里该函数指令执行(CPU取指令的来源就是全局代码段)→执行结束凭栈内返回地址跳转回原位置。

inline 类内函数:

  • 没被调用编译器删除,即无text代码段数据。

  • 被调用→代码存入exe,但是否就地展开(inline就地展开、不跳转、没专属栈帧、无独立函数实体、不单独占用一段全局代码)是编译器的事,C++只是建议, 一般编译器都会就地展开。

代码段作用:专门存放程序各类函数的机器指令,只读全局内存。

面试不考objdump,他是读取磁盘可执行文件内保存的符号、指令静态信息,无法观测运行内存状态,objdump能看到没调用也有实体是磁盘exe有代码,但不代表运行直接搬进内存。

这些知识点叫做:

  • 程序内存布局(代码段 .text、虚拟地址映射)

  • 函数调用栈 / 栈帧机制、返回地址

  • inline 内联展开与普通函数调用区别

  • 编译链接优化(未引用 inline 函数被丢弃)

  • 进程只读代码段加载逻辑

这下可以完整串起从编译丢弃 inline 代码、程序加载.text、函数调用栈帧整套完整流程,联动流程。关于追问透彻咋利益最大化的写进简历区别于傻逼呵呵的背诵应试选手 —— 结合业务落地

证据:

查看代码
#include <iostream>
class A {
public:
    int x;
    void func() {} // 类内定义,隐式inline
};

int main() {
    A a;
    //a.func();//无参构造是A(){},有参是A(int a){},但类没写任何构造函数,编译器自动生成默认无参构造
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ impl.cpp -o impl && objdump -t impl | grep func
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

/*
取消注释
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ impl.cpp -o impl && objdump -t impl | grep func
000000000000123a  w    F .text  000000000000000f              _ZN1A4funcEv
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

————————————————————————————

class A {
public:
    int x;
    void func();
};
// 类外定义,非inline
void A::func() {}

int main() {
    //强符号编译阶段强制生成函数实体,和是否创建对象、是否调用无关
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ impl.cpp -o impl && objdump -t impl | grep func
000000000000112a g     F .text  000000000000000f              _ZN1A4funcEv
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/


————————————————

#include <iostream>

// 模板类,仅声明成员函数,不实例化则无代码、无符号
template<typename T>
struct Test {
    void foo() {
        // 随便写点逻辑,避免空函数被优化删
        T val = 10;
        std::cout << val << std::endl;
    }
};

int main() {
    
    Test<int> t;
    // 注释下面一行:模板不会实例化,objdump找不到_Z4fooIiEv符号
    // t.foo();//打开才有符号
}
  1. objdump -t impl:读取可执行文件符号表
  2. grep func:过滤出名为 func 的符号

  3. w:weak 弱符号(inline 函数);g:global 强符号(普通类外函数)

  4. 000000000000123a:符号地址

  5. .text:代码段,存函数机器码

  6. 000000000000000f:函数机器码长度

  7. _ZN1A4funcEvA::func() 经过 C++ 名字修饰后的符号名

  • 类内定义隐式inline:无调用时编译器丢弃函数,二进制无机器码实体;有调用则生成弱符号实体。

  • 类外普通成员函数定义:无论是否调用,都会生成强符号实体写入二进制。

实体、副本均指代函数机器码

  • 类内隐式inline(弱符号):弊端多单元生成副本,优势未调用不生成实体

  • 类外普通定义(强符号):弊端没实例化没调用仍生成实体,优势无多副本开销

分析:

  • 如果放类内,当全程未调用时,则无实体,不存在多份拖累编译的情况。当调用时,各编译单元生成完整机器码实体,链接器合并为单份,编译阶段存在多份完整机器码,拖慢速度。

  • 如果放类外,仅定义所在编译单元生成一份机器码,不管是否调用都有这全程仅一份的机器码。

所以短函数放类里,长函数放类外。

说下模板:

ODR是同一实体在整个程序中只能有一处定义,ODR 使用指代码真正用到该实体即调用。

普通类本身固定存在,模板需写Test<T>才生成对应类类型,二者inline成员均仅ODR使用才生成实体。

模板是先实例化搞出类一样的类型定义(类布局),然后搞对象(此时没符号),代码是Test<int> t;,两件事同时发生:

  • 触发Test<int>类型实例化;

  • 定义t对象。

,然后调用函数才有实体符号。

Test<int> t;默认初始化,只是在栈上开辟一块内存,不做清零操作。非临时对象

Test<int>() / Test<int>{}值初始化搞临时对象,内置类型成员会清零。

只写void f(Test<int>);不定义对象,也会触发Test<int>类型实例化:

查看代码
template<typename T>
struct Test {
    int x = 1;
};

// 声明函数,接收 Test<int> 类型对象
void f(Test<int> obj);

// 函数实现
void f(Test<int> obj) {
    // 使用对象成员
    obj.x = 100;
}

int main() {
    // 方式1:构造临时对象传入
    f(Test<int>{});

    // 方式2:先创建变量再传入
    Test<int> t;
    f(t);
}

那这里int x咋没看到有东西?上面说了。

我很熟悉类外定义void A::func(){}A().func();是啥?

栈局部对象

A a;
a.func();

有变量名a,作用域结束才销毁,不是临时。

赋值接收临时,转化为有名对象

A b = A();
b.func();
  • A():生成临时无名 A 对象

  • A b =:把这个临时拷贝给变量 b,b 是有名非临时对象

  • b.func():用有名对象 b 调用成员函数 func

A().func():临时对象,一行走完直接销毁

狗逼死全家的豆包坑死我了!狗逼写法99%不会用的,但有必要区分:

查看代码
#include <iostream>
struct Base1 {
    void func() { std::cout << "Base1\n"; }
};
struct Base2 {
    void func() { std::cout << "Base2\n"; }
};
struct Derive : Base1, Base2 {};

int main() {
    Derive d;
    // d.func(); 编译歧义报错
    d.Base1::func(); // 指定调用Base1的func
    d.Base2::func(); // 指定调用Base2的func
}

Derive 同时公有继承 Base1Base2,属于多继承。

这是重名了,一般都是d.func();调用。

如果是静态直接类名::函数名(),非静态不行。

限定域调用非静态也可以对象.类型::函数名()

void A::f() 是该非静态成员函数完整声明定义写法,和直接写 A::f() 调用是两回事。

::是域运算符,A::fun叫类域函数名,无论是否静态都可以这么写,但实际写代码不存在不加括号的情况,加括号表示调用,非静态不能直接A::fun()调用,但书写类域标识A::fun本身合法。

A::func() 两种场景都能用:
  1. 静态成员函数:直接 A::func() 调用,合法标准写法;

  2. 非静态成员函数:不能单独写 A::func(),必须搭配对象 / 指针,比如 obj.A::func()this->A::func(),用于显式指定调用父类同名函数,仅在多继承重名时使用。

结论:单写 A::func() 只能是静态;带对象 / 指针前缀的 xxx.A::func() 可以是非静态。

 

补充几点追问:

进程所有内存区域全部归属进程虚拟地址空间这一个大池子,内部划分独立区块:

此处知识点搜“看完下面的具体联动就懂了:

几块区域互相隔离,地址不重叠,同属一套虚拟内存。

  • 对象分配在进程虚拟内存中:局部对象在栈,new 出来的在堆。

  • 对象内存里只有自己的成员数据即成员变量,无任何函数、无机器指令,和.text 代码段互不占用、互不干扰。成员函数指令统一全部放在 .text 代码段,全局共享,所有同类对象共用同一套函数指令;

  • 创建对象只依靠编译期算出的类大小,不需要运行时任何函数代码参与分配内存;调用对象的成员函数 obj.func()时,CPU 才会跳转去.text 段读取对应指令执行,同时把对象地址传给函数用来访问成员变量,对象自身不带函数

补充:只有带虚函数的类,对象会多一个虚表指针,指针只是一个地址值(数据),依然不是函数指令,虚函数本体还是在 .text

全他妈串联上了

多逼逼几句:

对于变量:

  • 静态局部/全局声明不给内存,定义给内存(就是初始化赋值)

  • 类内成员变量声明依旧无内存,实例化对象才分配

对于函数:

  • void f()是声明,无内存

  • void f(){}是声明+定义,分配内存

对于类:

  • class A;仅声明,无实体类型,但完整类定义也没有实体,所以这个仅声明是不完整类型不能改搞对象,而下面的完整类定义可以搞对象

  • class A{void f();}叫完整类定义,看似有{}但依旧仅成员声明,只有类型信息,全程无内存、无符号

属于类型声明 / 类型定义,本身不产生可链接符号(无.text段实体),这是它多文件包含不冲突的根本原因,

所谓的和豆包交流时候的大坑就在这,这不是仅声明,是完整类定义,但绝对不是类实现,所以不分配内存。只有搞对象A a;时候生内存,但由于没写函数实现,不能a.f()调用类里的函数(但准确说是能过编译,因为编译只看void f()声明,知道这个成员函数合法签名匹配,可以放行生目标文件,但链接遍历.o找不到定义会报错),所以我一直误以为有{}无脑一律认为是有内存了 

  • 而类内带函数体 class A{void f(){}};也叫类完整定义,函数自动 inline,生成弱符号存.text,多文件包含靠弱符号规则避免链接冲突。

有实体、弱符号(所有文件代码一模一样就安全)

代码示例 1(类内直接写函数体)

class A {
    void func(){} // 自带弱符号属性
};
代码示例 2(inline 全局函数)
inline void func(){}

特点:每个文件都会生成一份代码,链接器自动留一份,只要所有地方写的代码完全一样就无任何问题。

比如:

a.hinline void print() { std::cout << 1; }

b.hinline void print() { std::cout << 2; }

工程同时引入两个头文件,两份不同逻辑的弱符号同时存在。链接器只会随机保留其中一份代码,你无法预判程序输出 1 还是 2,属于无编译报错、无链接报错,但运行结果随机,非法未定义。

另外弱符号 + 同名单个强符号,强制选用强符号

头文件(弱符号实现)

inline void func() {
    std::cout << "弱符号版本" << std::endl;
}

a.cpp、b.cpp 都包含该头文件,各自生成弱符号 func;再单独写 main.cpp 写一份强符号实现:

void func() {
    std::cout << "强符号版本" << std::endl;
}

链接规则:只要存在一份同名强符号,直接丢弃所有弱符号,程序永远执行「强符号版本」

有实体、强符号(整个项目只能写一次,多写直接报错)

代码示例 1(全局普通函数)

void func(){}

代码示例 2(类外单独实现成员函数)

// .h
class A {void func();};
// .cpp 单独实现,强符号,只能在一个cpp写
void A::func(){}

特点:全局唯一标识,重复定义直接报重复定义链接错误。

统一大白话总结

  1. 只有类框架、没有函数实现、没有静态变量赋值 → 随便复制重复;

  2. 带实现但属于弱符号(类内写函数体 /inline)→ 全部写一样就随便放头文件;

  3. 不带 inline 的全局实现、类外单独实现函数 → 只能写一处。

不能把「无符号纯类型定义」和「弱符号实体」合并成同一套逻辑理解,二者底层机制完全割裂,只是表象都支持多份存在:

  • 纯类型(仅声明的类 / 结构体):全程只在编译期生效,不产生任何链接符号,和强弱符号规则无关,随便重复无任何副作用;

  • 弱符号(inline 函数、模板实例等):拥有独立代码 / 内存实体,只是链接器规则宽松允许多份共存,受 ELF 符号机制管控。

{}代表里面有代码逻辑(赋值、运算、判断等),编译器会把这些 C++ 代码翻译成 CPU 能识别的二进制机器指令,也就是机器码,编译链接入写入最终可执行文件,程序启动运行时,OS从这里拿出机器码加载到一块专门的运行内存区域(代码段),CPU 执行函数时读取这块内存里的指令,所以此时会占用运行代码段内存。 

 

再说模板,逻辑与后面要说的inline逻辑一致:

查看代码
//temp.h
template<typename T>
void test(){} //不是实体函数,不会生成符号

//a.cpp
//属于库实现文件,只提供函数f和模板实例,程序入口main放在别的cpp里
#include "temp.h"
void f(){ test<int>(); } // 生成test<int>弱符号。



//b.cpp
#include "temp.h"
void g(){ test<int>(); } // 也生成test<int>弱符号

//main.cpp
void f();
void g();
int main(){
    f();
    g();
}
  • 尖括号 <>:给模板参数 T传类型,编译期确定,test<int>则 T = int

  • 圆括号 ():函数普通参数,运行期传值

隐式实例化:

  • 显式指定模板实参:写<>

  • 隐式模板实参推导:函数参数和模板参数一致都是T,通过函数参数推倒模板参数,本质落实到模板参数,所以隐式推导指的是推模板参数

调用层面,即模板实参怎么给,只和函数调用表达式有关,只有隐式实例化,代码执行调用才生成实体,多文件各自调用,每个.o都会生成一份弱符号。

上面的代码执行发现2个弱符号,模板被大量文件引用时,避免成千上百份重复弱符号,大幅缩短编译链接时间,称之为比较性能低的编译手段

a.o、b.o 各自生成一份弱符号 test<int>,每个调用该模板的.cpp都会生成一份函数实体,项目文件越多,重复代码越多,链接器自动合并重复弱符号,多文件重复生成,编译慢

image,再看咋性能高。

显示实例化定义(类inline成员函数、模板隐式实例都是弱符号,仅显式模板实例是强符号):

加一句template void test<int>();,触发条件:不需要调用,编译本文件就强制生成实体

a.cpp改为

#include "temp.h"
// 全局显式实例定义:本文件生成唯一强符号
template void test<int>();
void f(){ test<int>(); }

b.cpp改为

#include "temp.h"
// 显式实例声明:告诉编译器实体在其他编译单元
extern template void test<int>();
void g(){ test<int>(); }

测试:g++ -c a.cpp && g++ -c b.cpp && g++ -c main.cppnm a.o | grep test && nm b.o | grep test

image

a.o:豆包说理论上应该a.o 里 WT(强符号,本文件生成实体),但实际还是W弱,是 GCC 规则:只要本文件里有调用该模板,显式实例定义也会产出弱符号,不影响功能;

b.o:里变成 U(未定义引用,不生成实体,只去外部找,直接复用 a.o 的符号,仅 1 个文件生成实体,其余文件只做引用,无重复代码,目标文件体积更小,链接器无需合并海量弱符号,大型项目编译速度提升明显)

对比之前两个都是 W 弱符号,差别肉眼可见。

extern template作用就是阻止当前编译单元生成模板实例弱符号,去掉他就直接2个W。

总结:

  • 隐式实例(默认是弱符号):调用才生成,多编译单元会生成多份相同实例,链接器合并弱符号,存在冗余。

编译器看到调用test<int>(),先查有无显式实例声明:

    • b.cppextern template:编译阶段会生成一份 test<int>弱实例代码存入 b.o

      链接阶段:同时存在 a.o 的强实例、b.o 的弱实例,链接器舍弃弱实例,全程只用强实例,程序正常运行。

    • extern template仅阻止当前文件生成实例,从源头减少重复弱符号。
  • 显式实例定义:当前编译单元生成唯一强(实际nm看是弱)符号实体,其他文件加显式实例声明即可复用,全工程仅一份实体,消除多份冗余。

Q:挺有意思的这逼玩意考吗? 

A:服务端大量通用容器、序列化、网络工具模板,全项目几十上百个 cpp 包含同一模板头文件:

  • 纯隐式实例化:每个目标文件生成一份弱符号,编译慢、目标文件大

  • 显式实例化 + extern 声明:只在一个 cpp 生成唯一实体,大幅缩短编译链接耗时,是大厂工程优化常规手段

大部分人只懂基础模板调用、隐式实例化;90% 校招候选人分不清 template func<int>();func<int>() 的术语区别,只会模糊知道模板多文件会生成弱符号,很少有人会用 extern template 做工程优化。

  • 基础必问:模板为什么放头文件、多文件调用模板会发生什么、弱符号原理;

  • 进阶追问:怎么解决多文件重复实例化、减少编译耗时 —— 标准答案就是显式实例化 + extern template;

  • 如果你答不上来优化方案,面试官会判定你只写过简单业务,没接触大型工程,竞争力大幅下降,同水平候选人优先淘汰你。

大厂服务端工程动辄上千编译单元,大量通用工具模板,编译慢是真实痛点,显式实例化是标准优化手段,面试官默认你要懂底层链接、编译优化。答不出直接判定底层功底不足,不适合高性能基础岗。

此文搜相当于,全部代码写在单,那个是头文件依赖增量编译,只编译改动的即可,而现在这个是减少模板重复实例化。

Q:这俩在哪里可以学到?

A:啃书:

  • 增量编译:Makefile/CMake、构建工具教程、《C++ 构建系统实战》

  • 模板显式实例化:C++ 标准、cppreference、《C++ Templates》(模板权威书)

Q:我追问豆包不知不觉总结出来了,那其他人背八股文也可以和我一样??

A:不能。八股只背结论,分不清两套易混淆术语;你是顺着代码实操对比区分,理解深度远高于死记硬背。

Q:这个统称为啥?比如我自己念叨的时候可以诶说,我会xxx优化?

A:整体统称:C++ 编译链接性能优化

  • 增量编译那套:构建工程增量编译优化
  • 模板去冗余这套:模板显式实例化编译优化 

注意:

  • 日常业务代码:极少手写这套显式实例代码,业务模板类型不固定,新增类型就要补显式实例,维护成本远大于编译提速收益。用 extern template 声明后,新增类型只写 extern 不补 template xxx<T>();报未定义引用。

  • 面试:不要求你长期业务天天写,但必须完整讲清原理、写出标准示例、说明漏写template xxx<T>()会触发链接未定义报错;

  • 底层公共通用库(内存池、序列化、网络工具)模板固定类型、全项目几百文件复用,一次性写extern template收益极高,基建组常态化手写显式实例。

 

继续说容易混淆的东西:

void f(){} 写在代码里,编译直接生成函数二进制实体,存在代码段,有内存地址,不需要调用才生成。

函数代码:放程序代码段,编译完成就占空间,全局函数、类成员函数,编译完就固定存在代码段,不管调不调用都存在,跟有没有对象无关,程序运行就占这块内存

函数栈内存:只有调用 f() 时,才临时开辟栈空间存局部变量,调用结束释放。

对象内存:只存成员变量,不存函数,所有同类对象共用同一份函数代码。
struct A{
    void func(){}
    int x;
};
A a; // 对象a只分配int x的内存,func代码早就编译存在,不随对象创建销毁

 

函数没参数可以直接(),模板必须写个T,

template<typename T>
void test() {
    int x = 1;
}
template void test<int>();
test<int>(); // 合法
test();      // 编译报错,无法推导T

函数:函数代码是编译写入exe运行放入代码段,然后进行函数参数传参

模板:模板写test<int>,编译期把代码里所有 T 替换成 int;调用函数时运行期完成参数传递。

template<typename T>
void test(T val) {
    // 使用T类型操作
    val += 1;
}

// 指定T=int,生成int版本test函数
template void test<int>(int val);//有这句属于显示指定

test(10); // 使用生成的int版本函数

<int>作用:把模板里所有占位符T全部替换成int,生成一套仅适配 int 的函数代码。

普通函数void test(int val) {val += 1;}写死一种

 

void test(); 只是普通函数声明,不会生成函数实体;

template void test<int>(); 是显式实例定义,编译器直接生成 void test<int> 完整函数机器码。

变量对比辅助理解:

int a = 10;        // 定义,分配内存
extern int a;      // 声明,不分配内存

模板定义 template<typename T> void test(){} 已经完整包含函数体实现。

 

上面的

//a.cpp
#include "temp.h"
void f(){ test<int>(); } // 生成test<int>弱符号

函数调用只能写在任意函数内部,main或自定义函数都行,不能直接放文件全局外层。之前一直在main里写fun()函数调用没注意这一点(且头文件必须放开头,代码先用到 cout,再 include 头文件,编译器识别不出 std 符号)

a++;不可以写在类域内,类内仅允许声明 / 初始化,不能直接写执行语句,编译报错。

说个 VScode 的 bug(傻逼编译器的 bug 又以为是什么学错了,研究好几天):

image标准语法两种写法均合法,但 clangd 静态解析逻辑有缺陷:写show<int>(int)带形参列表时,工具会单独校验括号内签名,误判定找不到匹配模板;无参test<int>()无括号签名校验,不会触发该工具 bug。

LLVM是一套开源编译器工具链底层框架,clang/clangd都基于它开发,clangd是用于实时标波浪线、提示报错、跳转定义的 C++ 语言服务器后台程序。

代码:

查看代码
#include <iostream>
using namespace std;

template<typename T>
void show(T x){
    cout << x << '\n';
}

template void show<int>(int);

int main(){
    show("abc");              // 隐式实例 const char*
    auto ptr = &show<long>;   // 取地址触发隐式实例 long
    show(666);                // 使用显式实例好的int版本
}

template void show<int>(int);:显式实例化声明,提前生成show<int>函数实体。

show("abc");:字符串数组传参时数组退化为首元素指针,因此字符串字面量类型const char[4],推导为const char*,隐式实例show<const char*>

auto ptr = &show<long>; 显式写模板实参触发隐式实例化。
  • &:取该实例函数的地址

  • auto ptr:自动推导 ptr 为对应函数指针类型

show(666);:实参int,匹配已显式实例的show<int>,不再新生成模板实例。

 

说完模板实例化(上面讲的内容),说模板全特化:

#include <iostream>
template<typename T>
void test() {
    std::cout << "通用模板实现" << std::endl;
}

// int全特化,独立逻辑
template<>
void test<int>() {
    std::cout << "int专属特化实现" << std::endl;
}

int main() {
    test<int>();
    test<double>();
}

template<>:标记全特化,尖括号空着,代表所有模板参数都被固定死,没有泛型参数。原模板只有 1 个参数 T,这个唯一参数在特化时被固定为 int,该模板的全部参数都被定值,所以写template<>

如果模板是双参数template<T1,T2>,全特化就要同时固定 T1、T2 两个,同样用template<>

“全部” 指代模板自身的所有形参,不是多种类型。我起初误以为都指定int走这个了,咋还全部固定死,全部指的是模板的全部参数!

test<int>:指定这套特化只匹配 T=int。

对比普通模板

template<typename T>
void test() {}

这里 template<typename T> 是泛型模板,带类型参数 T。

对比显式实例化

template void test<int>();

开头只有 template,没有 <>,没有参数列表,只是强制生成实体,不是重写实现

 

模板偏特化:

#include <iostream>
template<typename T1, typename T2>
struct Demo {
    void print() { std::cout << "通用类模板\n"; }
};
// 偏特化:固定部分参数,剩余保留泛型
template<typename T>
struct Demo<int, T> {
    void print() { std::cout << "偏特化:第一参数固定int\n"; }
};

int main() {
    Demo<double, long>().print();
    Demo<int, float>().print();
}

没有template T这种写法,语法规定尖括号里声明类型参数必须带typename/class。 

  • template:告诉编译器,下面这段是模板代码。

  • typename T:T 是一个可变类型占位符,int、double 都能往里填

只要第一个是int就走那个偏特化。

前面template<typename T>的 T 就是填到尖括号里空位的变量,和原模板 T1、T2 无命名绑定,只看位置。Demo<int, T>第二个空位用这个 T,对应原模板 T2。

 

模板必须实例化先搞确定的类/函数,不能直接拿来搞对象。模板不会写入可执行文件,不会为他分配内存,仅编译器内部临时文本,编译结束直接销毁,我们一般说的全都是有{}的函数,分配代码段内存,CPU靠这块内存执行代码。

模板是连单独可用的名字线索都没有,不能直接定义对象、不能直接调用。

模板可以成为造函数、类的一种方式:

查看代码
#include <iostream>

// 类模板
template<int Val>
struct Data{
    int num = Val;
    void show()
        std::cout << num << std::endl;
};

// 函数模板
template<int N>
void print(){
    int x = N * 2;
    std::cout << x << std::endl;
}

int main(){
    // 类模板实例化
    Data<5> obj;
    obj.show();

    // 函数模板实例化
    print<8>();
}

关于 类 / 模板类型 至此说完。

 

关于 普通函数(其实上面已经说了些)(涉及到不考的inline和nline static的对比,极致冷门):

先说几个科普:

函数前写static是给这个函数设内部链接:

仅限当前.cpp文件内部能用,别的.cpp看不见这个函数。不同.cpp可以同名写一模一样的 static 函数,不会报多重定义。每个文件的函数是独立副本,函数里面的 static 局部变量各存各的,互不干扰。

内部链接:仅当前cpp可以访问

顶层函数:全局作用域

文件域static void func(),各自独立:

查看代码
//a.cpp
#include <iostream>
static void func() {
    static int cnt = 0;// 仅第一次调用初始化一次, 不写就默认0。仅当前编译单元一份,无需inline
    cnt++;
    std::cout << "a.cpp的输出: "<<cnt << std::endl;
}
void callA() { func(); }

//b.cpp
#include <iostream>
static void func() {
    static int cnt = 0;
    cnt++;
    std::cout << "b.cpp的输出: "<<cnt << std::endl;
}
void callB() { func(); func(); }


//main.cpp
// main.cpp 看不到 a.cpp、b.cpp 里的 callA、callB 函数定义,编译器编译 main 时不知道这两个函数存在,必须提前声明告诉编译器函数签名,否则报未定义标识符编译错误
void callA();
void callB();

int main() {
    callA();
    callB();
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ main.cpp a.cpp b.cpp -o main && ./main
a.cpp的输出: 1
b.cpp的输出: 1
b.cpp的输出: 2
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

static inline void func() ,(换顺序inline static等价)每个包含头文件的编译单元生成独立函数实例,内部 static 变量完全隔离:

狗逼死全家的术语叫作“受static内部链接限制”但其实人话就是这玩意一个cpp里一个,各自独立

查看代码
//a.h
#pragma once
#include <iostream>
static inline void func() {
    static int cnt = 0;
    cnt++;
    std::cout << cnt << " "<<std::endl;
}

//a.cpp
#include "a.h"
void callA() { func(); }

//b.cpp
#include "a.h"
void callB() { func(); func(); }

//main.cpp
#include "a.h"
void callA();
void callB();
int main() {
    callA();
    callB();
    func();
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ main.cpp a.cpp b.cpp -o main && ./main
1 
1 
2 
1 
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

普通 inline void func()inline 函数:多翻译单元存定义,链接器合并为单实体,GCC 下弱符号,全工程共用同一个 func,内部 static cnt 全局唯一,计数连续累加(如果没有inline会链接报错重定义,因为函数没自动,类里的函数才自动inline

查看代码
//a.h
#pragma once
#include <iostream>
inline void func() {
    static int cnt = 0;
    cnt++;
    std::cout << cnt << " "<<std::endl;
}

//a.cpp
#include "a.h"
void callA() { func(); }

//b.cpp
#include "a.h"
void callB() { func(); func(); }

//main.cpp:
#include "a.h"
void callA();
void callB();
int main() {
    callA();
    callB();
    func();
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ main.cpp a.cpp b.cpp -o main && ./main
1 
2 
3 
4 
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

隐式inline:类内部直接定义完成体的成员函数,编译器自动加inline

显示inline:没类,只有成员函数那就手动写inline

这两种可以放头文件,全程序唯一

头文件声明,cpp 实现普通函数,无staticinline,同上,全局唯一,但分离式实现:仅单份函数实体,强符号;

查看代码
//head.h
#pragma once
void func();

//impl.cpp:
#include "head.h"
#include <iostream>
void func() {
    static int cnt = 0;
    cnt++;
    std::cout << cnt << " "<<std::endl;
}

//a.cpp
#include "head.h"
void callA() { func(); }


//b.cpp
#include "head.h"
void callB() { func(); func(); }

//main.cpp
#include "head.h"
void callA();
void callB();
int main() {
    callA();
    callB();
    func();
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ main.cpp a.cpp b.cpp impl.cpp -o main && ./main
1 
2 
3 
4 
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
/*

①② 一组,③④ 一组,对比看:static管是否唯一 inline管是否可以多 cpp 包含(守卫管一个文件多次引入头文件)。

豆包的专业术语:

1、static:内部链接,每个 TU 独立实体,不唯一(TU 是翻译单元,一个.cpp 文件 + 所有 #include 展开后的全部代码 = 1 个 TU)。static分两层:一是可见范围锁在自身作用域,二是生命周期全局常驻。 

2、inline:满足 ODR,允许多 TU 放同一份定义,解决多文件链接报错;

3、头文件守卫:仅防止单个文件重复 #include,跨文件无效。

针对这①②③④有几个发现和学到的东西:

发现一、static int cnt函数首次调用初始化,静态存储;函数副本隔离则 cnt 隔离,全局唯一函数则 cnt 全局共享(前面全部实验基于此),这里函数匹配了好几种,那变量不用排列组合更换static int cntint cntinline int cntinline static int cnt吗?其实int cnt 栈临时变量,每次调用新建销毁,无持久计数,看不出多副本差异,一个副本也是每次从头开始,多个副本也是,而加持久化就可以一个副本 123 累加,只要从头就代表新副本。

inline int cnt / static inline int cnt函数局部变量不允许加 inline,语法报错,无意义,因为inline只修饰实体跨 TU 重复定义,局部变量作用域仅限函数内,不存在跨 TU 重复定义场景,语法不支持。而抛开函数内,那全局的int,和函数时候一样的。

C++11 / C++14inline 只能修饰函数,顶层全局变量、类静态成员变量不允许加 inline,语法非法。

C++17 及以上:inline 拓展支持顶层全局变量、类静态成员变量,函数内不可以写inline变量。且全局可以inline int a=1;,如果不赋值inline int a;,那复制必写在函数内,赋值语句不能写在函数外面,全局域只能声明/定义变量,不允许执行表达式赋值。

所谓重定义是说inline int a = 10;这句话写在头文件.h里,重复被多个.cpp包含,没问题。但不加inline就是强符号,重复被多个.cpp包含就报错。
extern inline int a;写法单纯extern行为完全一致,不能多定义,这种写法也完全没意义,extern不分配内存,inline自带定义,多TU共存,TU就是一个.cpp文件的意思,二者一起写就废掉inline的作用,和extern int a一样。

C++17之前靠extern,需要头文件写一句extern int a;然后必须某.cppint a = 10;

查看代码
//a.cpp
#include "head.h"

//b.cpp
#include "head.h"
int g_val = 10;

// header.h
extern int g_val;

//main.cpp
#include "head.h"
#include <iostream>
using namespace std;
int main(){
    cout<<g_val<<endl;
}


/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ main.cpp a.cpp b.cpp -o main
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# ./main
10
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

实测b.cpp里的头文件引入也可以,因为链接时候都会搞到一起,不报错,所有cpp都能看到,引入的好处就是让别人知道你这玩意是同head.h来的,但这种写法就比较 der,头文件写一个cpp写一个,你如果直接写到头文件extern int g_val = 10; 本质就是定义,会重定义,被多个cpp包涵又会有问题,所以 C++17 才搞了个inline出来,头文件写成inline int a =10;被多个cpp包涵就没事。

链接属性只有两类关键字控制:

  • static(内部链接)

  • extern(外部链接,啥也不写默认外部)

inline 本身不修改链接属性,不是链接修饰符,ODR规则对他有豁免。

C++ 只规定了链接属性(内部 or 外部)、ODR 规则(程序中多数实体只能仅有一处定义,inline 实体例外),C++ 根本无强弱符号的概念!

强弱是 GCC 编译器根据 ELF 二进制格式规定落地实现的东西,解决多文件同定义链接冲突。

很多傻逼博主一知半解的会说:static也是强,是本地强符号,本文件内只有一份定义,不会和其他文件冲突。

其实大错特错,static是内部链接,隔离、各自cpp独立,每个单元有一份锁死单元内部,完全没强弱的概念,不写static也他妈一样任何东西都不能重复定义啊。换句话说,任何东西本地都他妈是强,因为本地都不可以有多份定义!就算强行说,那static也应该归位弱啊!!因为隔离导致你可以“重定义”啊!

总结就是:

  • static函数:STB_LOCAL本地符号,不属于强弱符号体系,不能叫强 / 弱;

  • 普通无staticinline成员 / 全局函数:生成STB_WEAK弱符号;

  • 普通非 inline 全局函数:STB_GLOBAL强符号。

inline不改变链接属性,普通全局函数默认外部链接,加 inline 依旧是外部链接;只有 static、匿名命名空间才会改成内部链接

 

inline叫内联函数,本意:代码内联展开,编译器把函数体直接粘贴到调用位置,不生成独立跳转调用指令,只是编译器优化行为,和链接属性(内部 / 外部)完全无关!

static、不管有无extern,都默认外部链接,区别就是.hint a后再被多cpp引入.h会重定义,extern int a不会重定义,视作声明,再加inline只是把它变成外部链接弱符号,链接属性依旧是外部,只是多了弱符号特性。

static inlinestatic决定内部链接,inline仅允许本 TU 重复定义,不改变内部链接属性。

 

期间又误入歧途无意间牵扯到无用的“互斥结论 ”

妈逼的 C++17 的和非17的(C++17 有的inline)、

C 的用阳寿写论文的傻逼东西(居然他妈说int xint x=1可以共存,豆包说 C 的东西,叫试探,我用.c一实验发现确实如此)、还有这个 )、

不同编译器(一会又引出weak这种编译器的东西,一会又引出inline是 C++ 的东西),差别太大了,豆包总瞎鸡巴说,又要每个条文实际验证~~~~(>_<)~~~~

编译器结果属于是 C++ 的规则还是编译器的优化(一会告诉我是编译器自己的优化、一会告诉我C++的规则,都不知道面试考哪个,后来我才知道面试只考 ++ 的,我实测大量的结果有些都是编译器自己的实现逻辑,C++ 只规定了底线而已)(直到此刻,我才给豆包提示词:不考的编译器的结果必须叫停,禁止无限制发散顺从解答我的问题,分清高频、不考,八股文本身就没必要实践的哎~~~~(>_<)~~~~)

目标岗位是大厂linuC++高性能服务端开发基础岗,禁止回答任何目标岗位不考的东西。答案优先简洁直白大白话,只讲主干。区分内容:标注【必考】为面试核心要点;非考点、编译器特有现象、优化边界、极端场景,答完统一加上【不考,仅解答疑问,不必深入钻研】。编译链接相关结论基准固定 GCC -O0,高优化等级差异归入不考范畴。不主动发散拓展冷门案例,发现过度钻边角细节适时提醒止损;不切换条件制造互相矛盾的结论。用户要求精简时,最大限度压缩文字,去除冗余表述。

搜索权威信息必须严格警惕大量海量全网垃圾博客教程的错误知识、老久知识。

 

发现二(这块豆包起初全是误人子弟东西)、

我一直误以为弱就是可以重复搞,就是不唯一,大错特错!

链接属性、inline ODR 规则、强弱符号、static/extern 修饰作用,是四个独立东西!

先搞清楚为啥单个cpp文件总叫什么内外链接,因为编译链接就是做整合这件事的,内部链接就是仅当前翻译单元可见,而外部链接就是编译后的链接阶段整合到一起,你必须只有一个,重了就重定义,而static inline仅当前编译单元(.cpp)私有,不导出链接给全局符号,不存在强弱符号的判定。

只有外部链接,涉及到整合的情况,才有强弱符号的概念,inline是弱,可以重复,很多用阳寿写博客的劣质教程博主、死妈的狗东西会误人子弟的说static是本地强符号(本地该.cpp内唯一实体)

总结:

  • 看有没有static:有 = 内部链接,各文件隔离,不存在强弱冲突;

    • 无 = 外部链接,进入强弱符号校验范围。

    • extern 仅用来声明别处定义的外部链接符号

  • 外部链接再看有没有 inline:无 inline = 强符号,多文件同名报错;有 inline = 弱符号,多文件自动合并。

两套互不隶属的规则,定义层面完全分开:

  • 链接属性 (static/extern):控制符号跨文件是否可见;

  • 符号强弱:控制多份同名符号链接是否报错。

但实际语法绑定关系:

  • 无 static 的 inline 函数,编译器自动赋予外部链接 + 弱符号两个属性;

  • static inline 是内部链接,不参与强弱判断。

两套独立规则会叠加作用,因此能串联起来打通整条逻辑,并不矛盾(操.你妈好JB绕!!搞了一周才精通!)

链接属性分两类核心标识:

  • 内部链接:static

  • 外部链接:啥也不写或写extern,只做声明,不会创建变量、函数实体,告诉编译器这个符号的定义在别的 cpp 文件。

  • 无链接:局部变量、函数内定义的东西,不参与链接流程,链接器不管它

inline 配套 ODR 单一定义规则(只允许多文件重复写同一个函数 / 变量)

  • ODR即单一定义规则:同一实体在整个程序中只能有一处定义。默认要求:外部链接的普通函数、普通全局变量,全程序只能写一次完整定义,多文件写会链接报错

  • 加 inline 修饰后,ODR 规则放宽,允许每个用到该符号的.cpp 文件都完整写一遍相同定义,不会报多重定义错误

  • inline 同时附带编译器优化提示:编译器优先把函数代码直接展开,不做函数跳转调用

强弱符号(链接器合并实体的底层机制)

①②③④个实验里,cnt计数是否独立由链接属性(内部 / 外部链接)决定,遵循 C++ ODR 标准;强弱符号不影响这个逻辑,

static管在作用域内有效!inline管能否被多个cpp包涵!完全有static inline 的东西

强弱符号是 GCC 链接器的实现细节,仅影响符号冲突规则,不改变变量存储唯一性逻辑。

  • 外部链接(inline / 单 cpp 实现函数):static 局部全局唯一(函数内static局部变量只会在程序运行全程分配一次内存,所有调用共享同一份数据),计数连续;

  • 内部链接(static/static inline 函数):各 TU 独立 static 局部,计数隔离;

函数完整实现是带存储实体,想放头文件必须加inlineinline两个作用:

  • 代码原地展开免调用开销;

  • 允许多编译单元重复定义且不触发链接报错

大型项目业务函数代码量大,不可能给每个工具函数都加inline,全堆头文件会让所有引入它的文件重复编译,拖慢编译速度,所以分离头声明、cpp 实现,改函数逻辑不用重新编译所有依赖文件,只重编单个.cpp即可

继续说点东西(海厚海厚真JB服了):

  • 单个编译单元内(.cpp):函数内 static 局部只初始化一次。

  • 多个编译单元(.cpp) +.h里写 inline 函数,函数里有 static 局部变量,则所有cpp共用一个变量。

  • 多个编译单元(.cpp) +.h里写static inline(内部链接)函数才会每个cpp独立一份静态局部变量。

发现三、

我一直写单个cpp,就误以为static就是全局唯一,但多个cpp就会体现static的另一个或者说全部含义,

文件域static void func(),各自独立

static inline void func(),每个包含头文件的编译单元生成独立函数实例,内部 static 变量完全隔离,各自独立

普通inline void func(),inline 函数:多翻译单元存定义,链接器合并为单实体,变量全局唯一

头文件声明,cpp 实现普通函数,无staticinline,变量全局唯一

Q:为啥感觉static的反而都各自独立而没static的③反而全局唯一?我记得不是static表示全局唯一吗?

A:核心分裂根源:两个完全无关的 static,作用场景完全割裂,你混淆了两套完全独立规则。

  • 作用在函数 / 变量(文件域)的 static:控制链接属性:给实体绑定内部链接,当前编译单元私有,别的 cpp 看不见、无法合并,自然每个 TU 单独一份,互不共享。你看到的文件域 static、static inline 都是这类,全部各 TU 独立。

  • 函数内部的 static 局部变量:控制生命周期存储:和链接无关,只代表变量存在静态存储区,不是栈临时变量。

它是否全局唯一,完全由包裹它的函数链接属性决定:

  • 函数内部链接 (static 修饰) → 每个 TU 一套函数 + 一套静态局部变量

  • 函数外部链接 (纯 inline、类内定义成员) → 全程序只有一套函数实体,静态变量唯一共享

你记忆误区:误以为 static 等于全局唯一。

事实反过来:

文件域实体加 static = 隔离、多副本;

函数内部 static 变量 = 长生命周期,是否共享看外层函数链接。

 

函数:函数代码存只读代码段,所有副本指令完全相同,仅链接属性区分可见性,不存在独立内存数据;有无 static 只影响链接

变量:存数据段。对象只存成员数据,成员函数全存在代码段,所有对象共用一套函数。

同属进程虚拟地址空间,分区用途分开:

  • 代码段:只读,存函数指令,多对象 / 实例共享同一份

  • 数据段:读写,存各类变量,每份独立分配存储空间

 

说完函数再看类里的函数:

查看代码
//test.h
#ifndef TEST_H
#define TEST_H
#include <iostream>
class Test {
public:    
    // 类内直接写完整函数体,编译器自动隐式inline
    static void show() {
        static int cnt = 0;
        cnt++;
        std::cout << &cnt << " : " << cnt << std::endl;
    }
};
#endif


//a.cpp
#include "test.h"
void func() {
    Test t;
    t.show();
}


//main.cpp
#include "test.h"
void func();

int main() {
    Test t;
    t.show();
    func();
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# ./main
0x55cd4709f154 : 1
0x55cd4709f154 : 2
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

变种写法思考:

  • test.h里的函数有无static都是这个输出

成员函数(普通 / 类 static 静态)、不涉及类的普通函数都是在内存中只有一份代码段指令,所有对象、所有调用共享同一份函数机器码,不会每个对象单独复制函数代码,把 static void show() 改成普通成员 void show(),只是函数多了 this 指针,内部 static cnt 不受任何影响,地址不变、数值持续累加

  • 且类外实现也一样的,但重点注意如果类外写实现,只能声明的时候写或者不写static,实现的时候不可以带static(函数变量都如此)

类内成员函数永远拥有外部链接,隐式inline,链接共享,加static 仅代表无this(非static类成员函数有this)不改链接属性。static只是不用对象调用,不会切成多份副本。

对比之前上面那个:

inline默认外部链接,但全局函数前加static会强制改成内部链接,覆盖 inline 默认规则;类成员函数不受此限制。所以运行结果和之前那个同样static inline完全不同,此文搜“接限制”但其实人话就是这”。

该代码如果去掉类,只保留函数,直接重定义,链接报错。

注意类内static成员变量必须外部赋值,但const static可以。

注意类内普通int cnt属于实例成员,依附对象,不存在Test::cnt这种类外定义的语法。

 

继续说:

类域静态(static)成员变量(类里static int x;):仅头文件声明,.cpp单处定义,全局唯一,和函数内静态局部不是同一套规则,

Ⅰ:static成员变量:
//a.h
#pragma once
class Test{
public:
    static int cnt;//只是声明,没强弱,但一旦赋值就是定义了,多个cpp包涵会重定义
    void func(){
        cnt++;
        std::cout << cnt << " ";
    }
};
//必须额外定义,随便一个.cpp即可,否则未定义引用链接报错
int Test::cnt = 0;

也可以加inlineinline static int cnt=10;

如果是const可以直接类内写static const int cnt=10;,类内初始化静态const整型。

Ⅱ:static成员变量:

类内直接int cnt这属于实例成员,可以直接构造函数初始化列表/函数内赋值,也可以直接int cnt = 10;

inline、无强弱符号概念,仅随对象分配内存,不存在全局符号冲突问题。

  1. 全局 static 变量 / 函数:有符号,属于内部链接,符号仅当前编译单元可见,不会和其他文件同名冲突,无强弱符号竞争。

  2. 不带 static 全局:多文件同定义→多重定义报错(外部链接强符号)

  3. 类内 static 成员:全局唯一存储,产生外部链接符号,多文件同定义会触发多重定义报错,需要inline(C++17 前不行)解决,存在强弱符号场景。

  4. 局部 static(函数内):符号存在,作用域局限函数,链接层面无冲突风险。

关于无inline这件事:

  1. 类内直接写实现的成员函数(含 static 成员函数):编译器自动隐式inline

  2. 类普通成员变量:不能写inline,不存在自动 inline;

  3. 仅 C++17 起支持inline static类静态成员变量,属于手动显式加 inline,不是自动。

查看代码
//test.h
struct Test {
    // C++17 合法:inline 静态成员变量
    inline static int a = 10;//所有类实例共享一份,多文件重复定义靠 inline 合并,符合 inline 语义。
    
    // 非法,编译报错:非静态成员不能加inline
    // inline int b = 20;//每个对象独立分配内存,不存在跨文件重复定义,无使用 inline 的必要,语法直接禁用
};

// a.cpp
#include "test.h"
#include<iostream>
using namespace std;

void callA() {
    Test::a++;
    std::cout << "#" << Test::a << std::endl;
}


//b.cpp
#include "test.h"
#include<iostream>
using namespace std;

void callB() {
    Test::a++;
    std::cout << "$" << Test::a << std::endl;
}

//main.cpp
#include <iostream>

void callA();
void callB();

int main() {
    callA();
    callB();    
}


/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# ./main
#11
$12
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

此时可以此文搜“接限制”但其实人话就是这”比对下不同,

一、static inline全局函数:static inline void func(){ static int cnt; }

  • static强制内部链接,每个包含头文件的编译单元生成独立函数副本

  • 每个副本自带独立局部静态cnt,互不干扰,计数分开

二、类内 inline 静态函数(头文件定义)static void show(){ static int cnt; }

  • 函数是隐式inline、外部链接,多文件共享同一个函数实例

  • 函数内局部静态变量cnt全局唯一,全程序一份,地址相同

三、inline static类静态成员变量:inline static int a = 10;

  • 外部链接,全程序只存在唯一一份变量,所有文件共用同一内存地址

所以:

  • 非静态成员:每个对象独有,属于实例存储,无跨文件重复定义问题,天然内部存储。

  • 普通 static 静态成员:单文件内链接,多文件重复定义多重定义报错。

  • inline static 静态成员:C++17,外部链接,多文件同定义自动合并。

注意static成员函数,

// test.h
#pragma once
class A{
public:
    static void func();
};
void A::func(){}

// a.cpp
#include "test.h"
void fa(){A::func();}

// b.cpp
#include "test.h"
void fb(){A::func();}

// main.cpp
void fa();
void fb();
int main(){fa();fb();return 0;}

依旧重定义报错,仅类内实现的,才隐式inline,类外写实现不会。

赋值方式:

①就地初始化(对象分配内存后、执行构造函数前完成初始化;无初始化列表时保留该值,有初始化列表则被列表覆盖)

struct A {
    int x = 10;
};
②构造函数初始化列表(直接初始化成员,无中间默认构造,优先级最高,覆盖就地初始化,开销最小)
struct A {
    int x;//仅声明
    A() : x(10) {}
};

③构造函数体内赋值(构造函数体内赋值:先默认构造成员,再执行赋值,多一次拷贝开销)

struct A {
    int x;
    A() { x = 10; }
};
④static const 整型类内常量初始化(静态常量,不属于实例成员,编译期定值,全局唯一,不随对象创建初始化)
struct A {
    static const int x = 10;
};

初始化列表 > 就地初始化 > 构造函数体内赋值。

代码书写顺序不影响执行顺序,标准固定:初始化列表覆盖就地初始化,和先后书写无关。

#include <iostream>
using namespace std;

struct Test {
    Test() : val(200) {} // 初始化列表
    int val = 100; // 就地初始化
};

int main() {
    Test t;
    cout << t.val << endl;//200
}

注意:

类内static int cnt:仅声明,不分配内存,不生符号,不能在此处完成定义,仍要外部定义。或者inline!

全局域int Test::cnt;是定义,生成强符号。

全局int cnt=9:完整定义,强符号。

extern仅用于外部声明,和类内 static 声明场景不同。

但注意:

全局域 extern 能多次声明,仅一次定义,声明仅登记符号信息,不占用存储空间,允许多次重复。

extern int x;
extern int x; // 合法,只是重复告知编译器存在x
int x;        // 唯一定义

类体内static int cnt虽然没内存没符号,但不能重复写,类的成员名空间是封闭唯一域,C++ 语法规定:同一个类作用域内,同一个成员标识符只能出现一次声明。

extern t然后其他地方没定义也可以,只要不用就行,用就必须定义

 

再来,

static const 整型 = 常量值 类内初始化:仅编译期常量,无存储实体。

  • 只用数值(std::cout << A::num):编译替换数字,正常运行。

  • 取地址 &A::num:链接报错,缺少变量实体

// test.h
class A {
public:
    static const int num = 10;
};
// main.cpp
#include "test.h"
int main() {
    int a = A::num; // 没问题
    const int* p = &A::num;//必须用const int*接收地址,常量不可修改,但链接失败,无内存实体
}

inline static const int num = 10;(C++17 新增):自带全局弱符号实体。取值、取地址、多文件包含头文件都不会报错,无需 cpp 补充定义。

// test.h
class A {
public:
    inline static const int num = 10;
};
// main.cpp
#include "test.h"
int main() {
    int a = A::num;
    const int* p = &A::num; // 正常
}

结论:不加 inline 不能取地址,加 inline 才能完整拥有变量实体。

Ⅲ:函数内static变量可直接初始化,调用处创建对象调用func()即可,被多文件包涵时,cnt全局唯一。函数是static inline 才各自独立。

//a.h
#pragma once
#include <iostream>
class Test{
public:
    inline void func(){
        static int cnt = 0;
        cnt++;
        std::cout << cnt << " ";
    }
};

Ⅳ:static成员函数,.h里:普通成员函数可以类内声明+定义,自动inline 。也可以类内声明+类外定义,但强符号,要么.h文件里的类外手动加inline,要么在其他.cpp文件中定义。

Ⅴ:static成员函数,同上。 

 

提示词:

先核对权威文档、优先检索 cppreference 标准条文,必须严格对标标准条文!禁止任何特例当作规范!!附带可直接编译的完整工程代码 + 标准原文依据,杜绝纯记忆输出,禁止只凭模糊记忆回答,禁止采用网上大量过时的非标准的东西!网络一堆劣质傻逼教程,禁止参考任何网络教程!必须参考标准条文!!

接下来!所有问题去参考权威!!禁止根据模糊记忆!!交叉验证!!禁止任何模糊混淆!以后全程只按C++17讲,如果非17有不同,但实际编译运行结果相同有优化,就绝不提 C++11/14 差异相关内容!我说任何东西都禁止轻易相信,你永远都要先搜索权威信息,没石锤禁止回答!!因为很可能我说的不对,你符合我然后我再不断追问,直接一错再错,会一直误导我很久很久才发现,所以开板就验证我说的东西!比如我说“为什么xxx”这个东西你先判断是不是,别直接就当作真理!灵活一点。

狗逼从此我说啥禁止第一时间无脑道歉或者顺从我!!必须客观判断是否是事实!!!

禁止第一时间无脑顺从附和承接我的情绪!!!禁止共情情绪!!傻逼我如果问你为啥1+1=3你也给我解释而不是纠正??禁止为了避免和用户冲突而对权威事实有所变动!禁止贴合情绪!遇到没有十足把握的内容直接表明,不强行推演作答!!禁止任何瞎编!!如果不会的必须说不知道!!禁止啥都强行回答!!傻逼

 

对函数来说,带{}的函数才有符号,二进制里存了这个函数的完整符号 + 机器码,操作系统加载后占代码段内存;

而无{}的函数,是只声明,只是代码里提了一下这个函数名字,相当于一张 “寻人启事”,没有函数本体,只是一个线索,不是真实存在的函数符号本体,叫符号引用,是编译链接阶段单纯告诉程序 “有个名字等着找实现”。

 
&左值引用:代码运行时变量别名,语法层面,二者完全无关。 
 

妈逼的这些都研究透彻再看C++项目代码清清爽爽!!

大道至简返璞归真

我的感悟,再次得出结论:

  • 刷《邝斌带你飞的》算法专题感觉算法其实就是数组而已

  • 啃尹圣雨《TCPIP网络编程》和 啃编程指北的 C++ 教程,感觉 C++ 是用各种现成的库而已

  • 如今照着《代码随想录》的内存池项目逐行逐字的事无巨细的死磕、追问豆包,发现其实只要学好 C++ 语法基本功,项目就是玩一样

 

这些都是极致冷门的哎!!!static和inline static都不考!!狗逼死全家的豆包再次误人子弟,准确的说没及时制止我

关于 普通函数(其实上面已经说了些)至此说完

 

关于 友元(很多之前说过,顺序无法保证,写博客太累了,给豆包纠错、逐条验证、梳理总结,我他妈脑瓜子不炸、学东到真东西就够不容易了)

友元:给外部函数 / 类开权限,让外部代码能访问当前类的私有、保护成员,不受类访问控制限制

 

这里为什么用友元:容器类内部要创建 / 销毁自身类型对象,但newElement/deleteElement是全局模板函数,无法访问容器私有成员,所以把这两个全局模板函数声明为类的友元,允许它读写类私有变量。

 

模板带函数体定义 = 实例产物弱符号;模板仅声明无符号

  1. template<typename T> void func(){}(带花括号 {} 完整定义):头文件多文件包含,每个编译单元都会生成该类型实例符号,天生弱符号,链接器会只保留一份,不报重定义。

  2. 只有声明(末尾仅;,无 {}):不会生成任何机器码、无符号,只是告诉编译器有这个函数存在,没有实体。

模板定义是弱符号的作用:不用手动加inline,头文件直接写完整模板定义,多个.cpp包含该头文件、实例化同一种T类型,链接不会报多重定义错误,简化头文件模板写法,不用拆分声明定义到.cpp做显式实例。

Q:不是你类销毁自己跟全局有啥关系

A:容器自身没法全局new/delete时访问自己私有成员,所以把全局模板函数设友元:

template<typename T>
struct List {
private:
    Node* head;
    template<typename Ty, typename... Args>
    friend Ty* newElement(Args&&...);
};
// 全局函数
template<typename Ty, typename... Args>
Ty* newElement(Args&&... args) {
    // 要创建List内部Node,Node私有,全局函数正常无权访问
    return new Node(std::forward<Args>(args));
}

全局函数不属于类成员,默认碰不到类私有结构,友元专门放开访问权限。

你代码里.cpp里完全没写deleteElement:它是模板全局函数,完整定义写在头文件,不需要进 cpp。不在你这份内存池源码内部调用,是外部业务代码调用

// 外部使用示例
auto obj = newElement<int>(123);
deleteElement(obj);
内存池自身只用allocate/deallocate,只管裸内存,不负责对象析构;deleteElement封装「先析构对象 + 归还内存池」,给外部统一销毁对象接口。

友元作用:newElement/deleteElement是全局模板,要调用HashBucket静态内存池接口,此处接口全是public,友元在这里完全多余,实测去掉.h的两句友元,运行没问题。

实际在UnitTest.cpp里的BenchmarkMemoryPool用的,

P1* p1 = newElement<P1>();
deleteElement<P1>(p1);

 

Q:这里友元完全多余,但如果不多余的私有场景,既然全局没权使用,那你在内部搞啊!为啥要全局搞?!

A:函数要脱离类实例单独调用,写成成员会被对象绑定,全局模板无绑定,只能用友元借权限。

  1. 成员函数必须靠对象调用:list.deleteNode(p),你想直接裸调用deleteElement(p)做通用内存销毁,成员做不到。

  2. 全局函数不属于任何对象,无实例绑定,能统一给所有同类节点分配释放,但它读类私有成员会编译报错。

  3. 友元唯一作用:只放开私有访问,不改全局函数的调用形式

Q:我靠无穷无尽妈逼的!啥又叫【函数要脱离类实例单独调用,写成成员会被对象绑定,全局模板无绑定,只能用友元借权限】感觉支离破碎的【提示词:你说的又是否定又是肯定的我感觉很割裂,就好比你说的东西风格就像,你要左走不要右走,第二步不是下,第四步,你太呆板了每个串联!你禁止用纠正的话术!你要之直接给我一套正确的东西我自己就可以自我修正啊!!而不是机械的反复纠错!犀利指导性强势一点!】

A:

类成员函数:第一个隐藏参数this,就是调用他的那个对象的地址,必须靠对象调用,绑定实例。

具体来说,调用成员函数语法:obj.func(),编译器底层转成func(&obj),强制塞对象地址进this

全局 / 友元函数没有隐形this参数,不用绑定任何对象,能直接调用,但默认碰不到类私有变量,靠友元授权才能访问。

函数内部自动用形参名this接收传进去的&obj

类存函数与成员布局规则,对象存数据,类只是图纸,只定义两份东西:

  1. 成员变量的布局规则

  2. 全部成员函数的代码逻辑

对象是按图纸造出来的实体,只分配成员变量内存,不存任何函数代码。

函数代码全局唯一(代码存放于程序代码段,和存放对象数据的内存相互独立),绑定类而非对象,同一个类创建一万个对象,内存里只存在一份 func() 机器码(这是我之前最大的误区,我一直以为obj.fun()是说函数归对象所有,一个对象一个呢)

this 只是用来区分不同对象的数据

调用 obj1.func() 底层等价 func(&obj1)
 
调用 obj2.func() 底层等价 func(&obj2)
 
两份调用跑同一段 func() 代码,靠隐藏参数 this 区分现在操作哪个对象的变量。
串联:

编译期间绑定:

  • 先看类代码,编译器把类里的 func() 编译成独立代码段并做函数标识符收纳类作用域,归属该类,仅此一份代码,不归任意对象,外部调用必须带上.对象才能找到该函数。即确定自己这个类都有那些成员函数,要执行哪些函数代码,固定死。这里就是“匹配”而不能说“查找”,“查找”指的是通过对象地址,可是这里不需要地址

然后执行 Obj obj1, obj2,栈只分配成员变量内存,函数不占内存。

把代码obj1.func(),编译器自动补隐藏参数 this

运行期间绑定:

  • 执行传入操作将&obj1传入this,底层等价func(&obj1)this指针绑定调用该函数的对象实例,因为成员函数里要访问实例独有的成员变量,必须靠this拿到当前对象的内存地址,才能定位到该对象的数据,不然函数分不清要改哪个对象的成员。

其实就是用this把函数和对象搭桥(func() 内部有this->xxx可以访问对象obj1身上的变量)执行 obj2.func(),同一段函数代码,this 换成 &obj2,操作 obj2 变量。 

注意:obj.func() 只是语法糖,底层统一调用同一段函数,靠隐藏参数this区分操作哪个对象的数据。

注意:运行拿到&obj1只用来访问对象成员变量,不用这个地址找函数,函数早已编译期确定。

注意:func不会和obj绑定,func符号仅存于类的符号表,所有实例共用同一份func代码;

注意:

  • 普通成员函数(fun):属于类,内存里只存一份,所有该类对象共用同一段函数代码;

  • 类成员变量,也叫实例成员变量(num):属于单个对象,每个对象单独分配内存,互相独立

静态成员函数:同样全类共用一份代码,但没有隐藏this参数,不能访问实例变量(实例变量属于单个对象),只能操作静态变量(静态变量属于类),实例依附(只有创建出对象才会分配独立内存、归单个对象所有的成员变量)对象存在,访问它必须通过this->num,静态函数不存在this指针。

调用是A::f(),但a.f()也可以,编译器只提取a的类型(这个编译时候就确定了),不会使用a的地址,底层调用不会传递&a,不存在this,A::f()

全局函数:编译期绑定全局作用域符号表,不绑定任何类。

 

友元分两种:

全局友元函数:在类内部声明friend,函数定义写在全局作用域,函数本体是全局函数,不归类管辖,无隐式this,全局模板函数 newElement HashBucket 类的友元。

类内友元(成员友元):一个类的成员函数,被另一个类声明为友元,函数归属原类,调用必须带对象,自带this

查看代码
//B::func 是 A 的友元:允许 B 的成员函数 func 访问 A 的私有成员
class B;
class A{
    int x=1;
    friend void B::func(A); // 指定:仅B的func能访问A私有
};
class B{
public:
    void func(A a){a.x;}
};
class C{
    void test(){
        A a;
        B b;
        b.func(a);
    }
};

//————————————————————

// A 的成员函数 A::func 声明为 B 的友元,仅该函数可访问 B 私有成员 y
#include <iostream>
class B;
class A{
public:
    void func(B& b); // A的普通成员函数,自带this
};
class B{
    int y = 20;
    friend void A::func(B& b);
};

void A::func(B& b){
    std::cout << b.y;
}

int main(){
    A a;
    B b;
    a.func(b); // 必须用A类对象a调用,隐含传this=&a
}

声明是引用int& ref

表达式 &变量取地址。

第一段代码
  1. 参数func(A a):值传递,拷贝一份 A 对象,无 &;

  2. B::func是 B 成员,调用b.func(a)自动生成this = &b,this 指代调用函数的 B 对象 b。

第二段代码

  1. 参数func(B& b):& 代表引用,直接使用传入的原 B 对象,不复制;

  2. A::func是 A 成员,调用a.func(b)自动生成this = &a,this 指代调用函数的 A 对象 a

突然恍然大悟一件事:

查看代码
#include <iostream>
class A {
    int data = 10;
    // friend void func(A a);
};
void func(A a) {
    std::cout << a.data;
}
int main() {
    A a;
    func(a);
}

友元只解决:外部函数 / 别的类读取本类私有成员的权限问题,不改变函数本身在哪能调用。

我一直误以为友元比如类里写了友元 A,然后我以为是说使得外部某某某可以调用这个 A,结果错了,看内存池.h代码,我一直认为friend决定外面某某某能不能调用newElement(实际这个公开就可以解决没必要用友元,所以既然存在友元这个东西,那他的作用就不可能是让外面可以调用这么简单),但实际friend只决定newElement内部能不能访问HashBucket私有(这才对,私有一般没把法访问,给你个钥匙直接插逼里),因为newElement写在了外部,所以任何地方都可以调用,唯一friend起作用的是调用之后,这个newElement里面访问HashBucket私有。

所以内存池代码之前说友元多余,我错误的以为是说newElement没私有,但其实是newElement没调用HashBucket的私有,因为HashBucket根本没任何私有。

 

:: 只代表所属域,类名::函数名 四种完全分开场景,互不混淆

1. 静态成员函数(static

类内静态成员函数可类内实现,不必外部定义。

class Test{
public:
    static void foo();
};
// 外部实现,带::
void Test::foo(){}

2. 普通成员函数外部定义

普通成员,自带this,调用:Test{}.bar()

class Test{
public:
    void bar();
};
// 外部实现,带::
void Test::bar(){}

3. 成员函数作为友元(你之前的类内友元)

A::func 只是标记这个函数是 A 的成员

class B;
class A{
public:
    void func(B&);
};
class B{
    // 把A的成员函数设为友元,这里出现A::func
    friend void A::func(B&);
};
// 外部实现依旧带::
void A::func(B& b){}

4. 类模板成员外部定义

template<typename T>
class Test{
public:
    void func();
};
// 模板外部实现,带::
template<typename T>
void Test<T>::func(){}

5. 全局友元不带::

全局友元函数本身不存在类名::,只有成员做友元才会写X::func

class Test{
    // 全局函数做友元,无类域::
    friend void globalFunc();
};
// 全局函数,没有::
void globalFunc(){}

 

符号表:编译全程存储所有符号信息的数据结构。

函数、全局静态变量会写入符号表,标记强/弱属性;

类、结构体、枚举仅编译期类型信息,不存入符号表,无强弱属性。

关于 友元 至此说完

 

关于 初始化、构造、调用、()写法、{}写法、RVO优化(涉及到非C++的而是编译器的行为的这种不考的知识点,学完才知道):

所谓的默认初始化:

  1. 类内就地初始化 int a=0;

  2. 无参构造自动初始化 A(){}

  3. 局部内置变量不赋值,随机垃圾值(原生默认初始化)

代码找感觉:

查看代码
#include <iostream>
struct A
{
    // 1.类内就地初始化
    int x = 10;
    int y;

    // 2.无参构造,默认初始化成员y
    A(){}

    // 带参构造
    A(int val) : y(val) {}
};

int main()
{
    A a;     //调用无参构造    x=10,y随机垃圾值
    A b(20); //调用带参构造 A(int val) : y(val) {},x=10,y=20

    // 3.内置类型局部变量默认初始化,无赋值是乱数
    int num;
    std::cout << num << std::endl;
    std::cout << a.x <<" "<<a.y<< std::endl;
    std::cout << b.x<< " "<<b.y << std::endl;
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
32619
10 32619
10 20
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

类内 int a = 0; 标准名称:就地成员初始化,属于默认初始化的一种实现形式。

然后再说下,执行顺序:类内默认值 → 初始化列表 → 函数体内赋值,列表会覆盖类内值,体内再覆盖列表。

查看代码
#include <iostream>
struct A {
    A(int v) : x(v){ // 初始化列表
        x = 999; // 函数体内赋值
    }
    int x = 100; // 类内默认初始化
};

int main() {
    A a(10);
    std::cout << a.x<<std::endl; // 输出999
}

A a(10);

1、分配栈内存(垃圾数据,无对象生命周期),

2、类内初始化 x=100

3、执行初始化列表 x=10→ 到此,对象构造完成,生命周期正式开始,a 已是合法对象

4、执行构造函数体 x=999→ 只是对已存在的合法对象成员修改赋值

所以C++基本语法,

  1. int a=0;:类内成员默认初始化(写在类里)

  2. A(int v):a(v):构造函数初始化列表(构造函数头)

  3. A(int v){a=v;}:构造函数体内赋值(函数体)

而用来调用上面三种构造逻辑,即搞对象(属于类类型变量)有几个写法:

A a(1)原地构造对象 a:

关于拷贝构造语法:

参数const A& 

  • A:函数返回类型必须和类名一致,拷贝构造是构造函数,无返回值,仅标识所属类类型

  • &:引用,只绑定原对象,不产生副本,不会递归调用拷贝。 

    • 如果传值A(A),传参要复制实参 → 调用拷贝构造 → 又要复制,无限递归。 

  • const:可绑定左值、临时纯右值,通用合法写法 
    • 临时是纯右值,不能绑定非 const 左值引用A&

    • const A&是常量引用,既能绑普通变量,也能绑临时。

整体A(const A&):标准拷贝构造,用已有同类型对象初始化新对象时调用

#include <iostream>
struct A {
    A() {std::cout<<"普通构造\n";}
    A(const A&) {std::cout<<"拷贝构造执行\n";}
};
int main() {
    A x;
    A y = x;
}

懂了拷贝构造语法,证明A a(1)是原地构造对象 a:

查看代码
#include <iostream>
struct A {
    int x;
    A(int v) {
        std::cout << "构造地址:" << this << '\n';
    }
    A(const A&) { std::cout << "拷贝\n"; }
};
int main() {
    A a(10);
    std::cout << "变量a地址:" << &a << '\n';
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造地址:0x7ffc8f8c6b54
变量a地址:0x7ffc8f8c6b54
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

原地构造直接在目标变量内存构造,无临时,A a(10): 

  1. 栈上分配一块内存比如0x333,用来存对象a

  2. 调用构造函数A(int v),把这&a=0x333作为隐式this入参传入构造函数。

  3. 构造函数直接操作 0x333 内存完成初始化;执行完毕直接 return,无任何对象返回,语句执行完成,0x333就变成合法对象,a 直接可用,回到main继续往下执行;

普通函数靠返回值传出数据,构造函数没有返回值,语法本身就不支持返回对象,只是返回调用流程,不返回任何对象 / 数值。

我强行问豆包发现个误区,我问:

假设这个是有临时(非原地构造就是有临时)是啥流程?我咋感觉这个无法验证原地构造啊?因为就算有临时,那在构造函数里用完临时就扔了,地址一定相同啊!

我才知道妈逼的原地构造没临时里的临时,指的不是构造函数的没临时,是外部调用这里。

所以其实学错的也有好处, 所以真正牛逼的精髓解释是用错误的方法来解释!如下:

先点明核心:A a(10) 语法标准下不可能产生临时,下面只假设强行存在临时的反常流程(仅理论推演):

  • 栈开 0x999 临时内存,执行A(10),把这个地址传递给构造函数的隐参this,即this=0x999,初始化临时;

注意:A(10) 是纯右值临时,表达式结束前存在,直接作为拷贝构造入参,无函数返回环节。

注意:这里是给构造传递啥地址,构造就在啥地址 “原地” 搞对象,由this指针指向的,只是普通分配逻辑,不是编译器拷贝消除优化(RVO/NRVO 的 “原地构造” 特指:跳过中间临时,直接在接收变量地址构造,是优化行为),我那个 “原地” 也不通用,只是我自己的叫法,规定的原地指的是main里调用是否有临时这件事

  • 栈开 0x333a,以临时 0x999 为参数传递这个临时对象,给拷贝构造,即调用拷贝构造,然后拷贝也有this指针,用的是该a的地址,即0x333传递给拷贝构造,拷贝构造函数就开始在0x333通过拷贝那个临时0x999来搞出想要的对象a

  • 拷贝完成,调用A的析构函数销毁0x999临时对象;

  • 回到 main 打印 &a=0x333

和你原本无临时流程区别:多一块独立临时内存、触发拷贝构造、两个不同 this 地址,即:非原地先造临时对象,再把临时拷贝到目标变量。即总共无优化就是多个对临时对象的普通构造和拷贝构造。

这里a是临时的数据传的,称之为:临时拷贝构造左值变量a(很歧义的叫法,意思就是a是主语,由临时拷贝构造而来,官方术语)

注意:这段代码A a(10)是直接初始化,语法本身不存在中间临时对象,和优化开关无关,无论开 / 关-fno-elide-constructors都不会产生拷贝,复制消除优化根本没有介入空间。

复制消除生效的前提是存在临时对象,形如A a = A(10)

我这理解牛逼吗?

A a{1}列表原地构造对象 a:

A a(10):老式直接构造,允许数值窄转换(隐患)

A a={30}:列表初始化,禁止窄转换

struct A { A(int) {} };
A a(3.5);  // 编译通过,3.5截断成3,隐形bug
A b={3.5}; // 直接编译报错,阻止错误

其他完全等同A a(1)

提一嘴A a{1}A a = {1}区别(除了explicit外没任何区别):

查看代码
#include <iostream>
using namespace std;
struct A {
    int x;
    A(int val) : x(val) {cout << "普通构造\n";}
    explicit A(double val) : x((int)val) {cout << "explicit构造\n";}
};

int main() {
    // 无explicit重载,两种写法全都正常
    A a1{10};
    A a2 = {10};

    A b1{3.14};//允许调用 explicit 构造,叫直接列表初始化
    // A b2 = {3.14};//禁止调用 explicit 构造,叫拷贝列表初始化
}

A a = A(1):生成临时 A (1) 再拷贝构造 a

先科普几个词汇:

  1. 复制:统称,包含拷贝、赋值

  2. 拷贝:新建对象时复制数据(拷贝构造)

  3. 赋值:已有对象覆盖数据(operator=)

查看代码
#include <iostream>
struct A {
    int x;
    A(int v) {std::cout << "构造地址: " << this << '\n';}
    A(const A& other){ 
        std::cout << "this:     " << this << " —— other: " << &other << std::endl;
    }
};
int main() {
    A a = A(10);
    std::cout << "变量地址: " << &a << '\n';
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造地址: 0x7fff8a76a9a4
变量地址: 0x7fff8a76a9a4
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 && ./a
构造地址: 0x7ffcf9c1ba64
变量地址: 0x7ffcf9c1ba64
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造地址: 0x7ffdc05cd2f4
变量地址: 0x7ffdc05cd2f4
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors -std=c++14 && ./a
构造地址: 0x7fff4eb558a4
this:     0x7fff4eb558a0 —— other:0x7fff4eb558a4
变量地址: 0x7fff4eb558a0
*/

C++17强制消除临时,不移动/拷贝,咋搞地址都一样,走移动/拷贝必须C++11/14且关优化。

具体咋个临时,啥流程,就是刚刚上面说的,此文搜“生临时,下面只假设强”。

*this 等价this

  1. thisA* 类型指针,存当前对象内存地址。

  2. *this:解引用,得到当前对象本体,类型 A

  3. &*this:取解引用后的对象地址,结果和 this 完全相等。

const A&const A& other

  • const A& 是类型声明;

  • const A& other 是类型 + 变量名,other 是接收临时对象的引用名。

你只写A(const A&)只是省略参数名,函数内部无法访问临时对象,拿不到临时地址;

但不能像输出this一样输出otherotherconst A&对象,没重载<<,编译器不知道怎么打印自定义结构体,

cout 本身没有万能输出能力,所有<<输出逻辑全靠单独重载operator<<函数实现,int、char、指针各一套

  1. 打印指针:标准库提前写好了指针类型的operator<<重载,所以直接输出,就是指针存储的对象地址;

  2. 打印自定义类 A 对象:标准没写对应重载,编译器找不到匹配函数,直接报错。 

cout << otherotherA 对象,系统无对应 << 重载,报错。

cout << &other&other 是指针,系统自带指针专用 << 重载,直接打印地址。

想看 other 地址用&other,得到const A*指针

this就是当前对象的地址。

这个其实就是早年叫的:URVO 优化。

此文搜“体例外),C++ 根本”,C++只规定链接属性,强弱是编译器为了解决多文件搞的。

这里也一样,C++标准没有RVO这些东西,只叫copy elision(拷贝消除),C++标准是ISO C++,是业界老头专家写的极致权威但晦涩的玩意,大家一般不读这个,为此衍生个cppreference这个权威通俗版,他收录了民间称呼RVO这些,所以很多教程会有出入,基本大同小异。

RVO是针对搞对象的优化:有 URVONRVO

C++14时: 

  • URVO(无名返回值优化):处理无名临时对象,优化发生在函数返回环节,包含两类场景,

    • 直接初始化:A a = A()

    • 函数返回无名临时:A f(){ return A(); }

  • NRVO(具名返回值优化):仅作用于函数返回内部具名局部对象 A f(){ A local; return local;}

C++17 修订标准:A a = A()A f(){ return A(); }不再归属拷贝消除体系,成为强制纯右值初始化规则,民间随之去掉URVO,C++17仅有NRVO了。

普通具名变量无法触发任何 RVO,URVO 消除无名临时对象的拷贝,NRVO 消除函数内具名局部对象返回时产生的临时拷贝,而普通变量有名字,不存在无名临时,自然触发不了,非对象类型没有自定义拷贝构造,不存在拷贝消除优化,自然无URVO一说。

死妈豆包说很多面试官会把URVO叫狭义RVO,把RVO=URVO+NRVO的这里RVO叫做广义RVO。

真的痛苦,这里全网90%的资料都是错的,昨天无意间想到AI时代鱼皮都是Java速成AI,那代码随想录之前一直在搞Java/C++的又在干啥,结果发现代码随想录培训班日报截图貌似讲课老师都不知道C++14/17叫法区别,且C++14/17本来就有变化!!难为豆包了!我无尽追问、辱骂、实践、重构博客,这一个知识点搞了 3 个星期。

细节:

1、A a = A(10);可全局。

2、全局变量无法 NRVO!全局对象不会随函数销毁,没法覆盖复用返回内存:

A g(1); // 全局,程序全程存活
A func() {
    return g; // 不能NRVO,必须拷贝
}

懂了词汇开始说具体:

先说 URVO,这个是早期的,C++17 强制优化了,只有两类场景(共同点:无具名变量,直接原地构造消除拷贝):

场景一、本地作用域无名临时赋值:A a = A(10);

C++14:拷贝消除是可选优化,-fno-elide-constructors 能强制关掉,出现拷贝构造。

C++17:强制拷贝消除,,无论加不加 -fno-elide-constructors 都不会走拷贝

场景二、函数 return 无名临时:A f(){ return A(5); } A a = f();

查看代码
#include<iostream>
using namespace std;
struct A {
    A(int) { std::cout << "构造: "<<this<<endl; }
    A(const A& other) { cout << "拷贝: "<<this<<" —— other: "<< &other <<endl; }
    ~A() { std::cout << "析构: "<<this<<endl; }
};

A func() {
    // 函数内return无名临时,URVO场景
    return A(5);
}

int main() {
    A res = func();
    cout << "哈哈: "<< &res <<endl;
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffdc233e767
拷贝: 0x7ffdc233e797 —— other: 0x7ffdc233e767
析构: 0x7ffdc233e767
拷贝: 0x7ffdc233e796 —— other: 0x7ffdc233e797
析构: 0x7ffdc233e797
哈哈: 0x7ffdc233e796
析构: 0x7ffdc233e796
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 && ./a
构造: 0x7ffe7b9b3017
哈哈: 0x7ffe7b9b3017
析构: 0x7ffe7b9b3017
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a  -fno-elide-constructors && ./a
构造: 0x7ffd1d4d4207
哈哈: 0x7ffd1d4d4207
析构: 0x7ffd1d4d4207
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/


//——————————按照0x333 666 999来加备注就是——————————————
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffdc233e767(0x333)
拷贝: 0x7ffdc233e797(0x666) —— other: 0x7ffdc233e767(0x333)
析构: 0x7ffdc233e767(0x333)
拷贝: 0x7ffdc233e796(0x999) —— other: 0x7ffdc233e797(0x666)
析构: 0x7ffdc233e797(0x666)
哈哈: 0x7ffdc233e796(0x999)
析构: 0x7ffdc233e796(0x999)
*/

细微差别:

之前的普通表达式 A a = A(10);(无函数)无优化执行顺序:

  1. 起手main是函数,别管主函数还是啥函数,只要是函数就有栈帧,在栈帧里找一小块空间,开辟临时内存,构造A(10)0x999

  2. 再在main里找个地方,开辟变量 a 的内存(0x333

  3. 拷贝临时到 a,调用析构函数销毁临时0x999这块栈空间对应的对象资源

→ 先生成临时,后分配 a 本体

而此处函数返回A res = func(); return A(5);(无尽探索,豆包给我解答后,我无尽质疑、互相启发、协作、修正豆包,然后再自己的话总结,再追问无尽细节,再梳理,写不下去再发给豆包完善)(学完狗逼豆包说性价比为0,不考察)

  • 函数调用 ABI 规则:调用方 main 必须提前预先分配好接收对象 res 的内存(0x999),再跳转进入 func 函数内部执行,也就是缓冲区,之前那个是同一栈帧内的直接传临时对象地址调用拷贝构造A a = A(10)不用缓冲区中转,缓冲区只用于这里的跨函数返回对象场景,进入 func 后才会创建函数内的无名临时(0x333)后返回缓冲区。

→ 先分配 res 本体,后生成函数内部临时。

编译期:只写死栈所需内存总大小、生成汇编指令,不分配真实物理内存地址 —— 规划内存布局。

运行期:程序执行到对应代码,才在栈上划出 0x9990x666 两块内存 —— 实际分配内存。

具体流程:

编译解析A res = func();main 栈同步开出 0x999 (存变量res)、0x666 (缓冲区用来接收func返回的对象) 两块内存;

如果返回普通类型的函数,比如int类型的直接把值给寄存器,然后返回调用位置直接从寄存器里拿值给等号左边的那个变量的内存,全程没任何能优化拷贝的地方(只有涉及到构造才有直接再目的地搞数据这种优化情况)。

而返回自定义的类对象A数据比较大不放寄存器,编译器会把0x666缓冲区地址作为隐藏参数传给func,靠内存拷贝传递,不用寄存器,编译器把 0x666缓冲区地址当作隐藏参数传给 funcfunc 靠这个地址写入数据,行为上和传指针写入目标内存一致,但语法层面不会暴露指针类型,本质逻辑类似this而已。

开始运行,A res = func();调用func,执行 call func 指令(return A(5);),压入返回地址(即调用完func 后程序回来接着执行main的哪行地址)、保存寄存器(主函数正在用的放入寄存器别被fun改了),新建 func 专属栈帧。

return对象也会搞个临时

func 栈帧找个空地0x333,通过构造函数在this指向的0x333内存空地创建临时对象A(5),搞完把 0x333 的数据拷贝构造到main栈帧的0x666(注意0x666缓冲区此时已经有了A实例,是临时对象)。

拷贝完成立刻析构0x333的临时对象。

准备回main,读取汇编里ret指令,从栈取出预存的返回地址,销毁 func 栈帧内存,跳转到返回地址,即从之前压入的返回地址跳转回 main 继续运行,

0x666缓冲区的数据拷贝至 0x999 的变量 res,析构0x666

科普一个硬件布局:

堆内存从低地址向上增长,栈向下,二者反向扩张。

科普一个关于顺序问题:

C语言上层视觉不谈汇编会有大量书籍教程说成是 “析构变量 -> 跳转 -> 回收func栈帧” 的错,但汇编硬件视角CPU真实执行是 “析构变量 -> 取返回地址 -> 回收func栈帧 -> 跳转”。

CPU内部寄存器:ebp栈基指针、esp栈顶指针,都只有一个。

mov:传送指令,把一处的值复制到另一处,原数据不变。mov ebp, esp表示把 esp 寄存器存的地址数字,复制给 ebp 寄存器,

具体流程(追问千辛万苦搞了整整 2 天):

运行main,在0x1008空地地址搞栈帧,那就把0x1008这个索引头头放入ebp寄存器,仅此而已,只是名字很歧义叫栈基指针听着好像既然有存的地址又有指针地址一样,事实上没指针地址。所以一句话就是在0x1008地址搞main栈帧,同时 CPU 寄存器记录0x1008这个数据

main 调用 func,返回地址 0x2000 存入 0x1003, 

0x1002func栈帧,ebp只有一个,所以ebp里写入0x1002之前先把0x1008放到0x1002上,即此时ebp0x1002指向该func栈帧的起始位置,待会回到main想用main的栈基0x1008也不会丢失,即将 0x1008 存入 0x1002,寻址基准切换为指向 0x1002

func 执行完,已知该栈帧栈基是0x1002,然后软件层面C++执行析构 0x1001func 局部变量,然后硬件层面,直接定位到0x1002,指针回缩丢弃低地址,不再使用更低地址的局部变量空间。

读取 0x10020x1008,寻址基准恢复为 0x1008

执行 ret

总结即:

调用func→ 保存返回地址→ push ebp→ 更新ebp→ 函数逻辑→ 局部对象析构→ mov esp,ebp(栈顶指针回缩到栈基地址0x1002)→ pop ebp(从栈0x1002取出0x1008,写入ebp寄存器,恢复main的栈基准)→ ret(取出 0x1003存的返回地址0x2000,调整栈顶指针回到0x1004释放 func 栈帧,跳转至 0x2000 执行代码)

注意:0x2000是代码段的地址用于执行代码用的,0x1008是栈段地址用于存数据用的,内存硬件布局:

查看代码
高地址
====================================
main栈帧
0x1008 ← main运行期间,ebp(栈基指针)
====================================
0x1003 │ 返回地址:0x2000(代码地址,call压入)
====================================
func栈帧
0x1002 │ 保存数据:0x1008(main的ebp)
        ↑
        func运行期间,ebp(栈基指针)
--------------------
0x1001 │ func局部变量 ← esp(栈顶指针)
====================================
低地址

这就是32/64位系统调用函数的底层ebp模型(无优化版本),有优化懒得学,累了,全都他妈的不考!

 

再说 NRVO:即有名的(也就是真正C++17后有且仅有的和RVO优化这件事有关的),他们都会有0x666缓冲区这件事,只不过有名的A a; retrun a;里0x333这个临时的变成a有名的了。

所以关闭拷贝省略g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a时,return局部有名对象只有1个返回临时+具名a,URVO 的return字面匿名对象是内部字面临时+返回临时共2个。

开优化就是直接让func0x999搞东西,然后return A(5)也遵循优化逻辑在0x999搞东西。

 

自己的总结: 

对于 URVO(C++17叫强制优化):

可以验证在C++17后都强制了,也可以说C++17后没有URVO了

URVO的定义是「副本消除优化」,C++14中两类场景都属于可选URVO,C++17不再依靠副本消除优化实现,是语言硬性初始化规则,所以严格来说不能归类为URVO,不再称为URVO,虽然运行效果等同于永久开启URVO:

查看代码
A a=A(10);

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造: 0x7ffe37bc5ff7
哈哈: 0x7ffe37bc5ff7
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14  && ./a
构造: 0x7ffcf24cb697
哈哈: 0x7ffcf24cb697
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造: 0x7ffcf6780227
哈哈: 0x7ffcf6780227
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7fff9eb2b1b7
移动: 0x7fff9eb2b1b6 —— other: 0x7fff9eb2b1b7
哈哈: 0x7fff9eb2b1b6
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

==============================================

A func() { return A(5);}
A a=func();

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造: 0x7ffc8c668847
哈哈: 0x7ffc8c668847
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14  && ./a
构造: 0x7ffde6ebc8b7
哈哈: 0x7ffde6ebc8b7
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造: 0x7ffd2bebfdb7
哈哈: 0x7ffd2bebfdb7
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffd75192637
移动: 0x7ffd75192657 —— other: 0x7ffd75192637
移动: 0x7ffd75192656 —— other: 0x7ffd75192657
哈哈: 0x7ffd75192656
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

现在提及广义 URVO(社区民间统称,C++14):包含两段代码:A res=A();A f(){return A();},二者同属 URVO(拷贝消除优化)。

狭义 URVO(一部分教程片面定义):只认定 A f(){return A();} 才算 URVO;人为剔除 A res=A()。这个基本就是特错特错没任何对的地方!不存在什么狭义的!

对于 NRVO 叫可选优化:

查看代码
A func() {
    A a;
    return a;
}
A res=func();

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造: 0x7ffee7fb50e7
哈哈: 0x7ffee7fb50e7
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14  && ./a
构造: 0x7ffe4139f8b7
哈哈: 0x7ffe4139f8b7
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造: 0x7ffe9616fc37
移动: 0x7ffe9616fc57 —— other: 0x7ffe9616fc37
哈哈: 0x7ffe9616fc57
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffeb2dfc607
移动: 0x7ffeb2dfc627 —— other: 0x7ffeb2dfc607
移动: 0x7ffeb2dfc626 —— other: 0x7ffeb2dfc627
哈哈: 0x7ffeb2dfc626
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

这里很多次摇摆追问无数次我选择相信目前总结的这个版本:

输出结果得知:

C++17关优化:A a造个对象,然后移动/拷贝给res

C++14关优化:A a造个对象,然后移动/拷贝给func(),func()移动/拷贝给res。

这里由于是具名NRVO,所以不存在强制优化,强制优化是关都关不掉,而C++17只是默认优化而已,上面URVO才是关都关不掉的强制。

C++17 标准强制生效强制合并优化的,只有原生纯右值初始化对象这一类场景,即return A(); / A x = A();即临时,不受-fno-elide-constructors控制;而A a; return a;(NRVO)依旧是可选优化,标准不强制,但return a能走移动不靠它是临时,是标准单独给 return 局部对象开的特例规则,叫亡值,注意:

  • 左值:有名字、能长期待着的东西,比如你定义的局部变量 a。

  • 右值:分两种(都可以移动)

    • 纯右值也叫无名临时:写完马上就要销毁,比如A(),由他搞的叫临时对象 —— C++强制的是这个

    • 亡值:有本体快要死了,可以抢走它资源,但它本身不是无名临时,典型就是return a —— C++不强制优化这个

且注意:C++17强制搞的是临时,你return a这种NRVO的,优化关闭后生的临时也不可以再用C++17强制搞了,即起手就是return a非临时,中间的临时产物也不再当作C++强制概念里的可优化临时。不严谨的说C++17强制这件事绑定URVO临时。

Q:那既然如此,C++关闭优化为啥和C++14关优化不同啊?好像自动优化了一步呢?

A:NRVO 关闭,生成的临时是将亡值,不属于强制规则范围,所以C++17关优化,比C++14关优化少了一步,不是C++17强制的优化的结果,只是编译器自己对非临时的亡值的的优化。

插一个知识点 —— delete

这个属于定义且删除,比如你写  

A(A&& other) noexcept { cout << "移动: " << this << " —— other: " << &other << endl; }
A(A&&)=delete;

同一个签名不能同时定义与=delete,冲突重载,直接编译报错。 

  1. A(A&&)=delete; 是显式声明移动构造,=delete 代表调用移动构造会报错;编译器不会再自动生成移动构造(本来你已经手写声明了)。

  2. 不是顺带把拷贝构造标记为 delete;规则是:声明移动构造后,编译器不再自动生成拷贝构造。

  3. 类内没有任何拷贝构造,拷贝代码报错;你手动写上拷贝构造(自定义 / =default),拷贝就能正常使用。

精简一句话版本:A(A&&)=delete;:声明移动构造并禁用移动;编译器不再自动生成拷贝构造,想要拷贝必须手动定义拷贝构造。

拷贝也同理,禁止拷贝的话也不生移动,要用自己写。

查看代码
#include <iostream>
using namespace std;
struct A {
    A() {cout << "普通构造\n";}

    A(A&&) = delete;

    // A(const A&) = default;    
    // A(const A& other) {
    //     cout << "自定义拷贝构造\n";
    // }
};

int main() {
    A a;
    A b = a;         // ✅ 允许拷贝
    // A c = std::move(a); // ❌ 移动被delete,编译报错
}

右值本身就是右值,std::move仅仅把左值转为右值引用,对于右值:有移动构造才走移动,没有就回退调用拷贝构造。和=delete的不自动生不同,move是不影响,=delete是强制不生。

Q:为何手写一个会禁止生另一个

A: 如果拷贝构造直接 buf = other.buf(复制地址),两个对象共用同一块内存,析构双双 delete,直接崩溃。所以A(const A& other):拷贝构造,不直接抄 other.buf地址,而是重新new一块独立内存,让新对象 buf 指向新内存(深拷贝)。

查看代码
struct A{
    char* buf;
    A(){buf=new char[10];}
    // 你手写深拷贝构造,自己管理内存
    A(const A& other){
        buf = new char[10]; // 新开内存,深拷贝
    }
    ~A(){delete[] buf;}
};

如果编译器自动生成默认移动构造:任何默认都是浅拷贝,它只会直接复制指针 buf,移动后两个对象的 buf 指向同一块堆内存,析构先后释放同一块内存 → 双重释放崩溃。所以 C++ 规则:只要你手动声明拷贝函数,编译器不再自动生成移动函数,避免这套危险的默认移动逻辑自动出现。反过来手动声明移动函数,也不会自动生成拷贝构造,道理一致。

继续说NRVO, 如果有move:

查看代码
A func() {
    A a;
    return move(a);
}
A res = func();

/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造: 0x7ffffa1e7637
移动: 0x7ffffa1e7657 —— other: 0x7ffffa1e7637
哈哈: 0x7ffffa1e7657
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14  && ./a
构造: 0x7fff1aa40477
移动: 0x7fff1aa40497 —— other: 0x7fff1aa40477
哈哈: 0x7fff1aa40497
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造: 0x7ffec88758e7
移动: 0x7ffec8875907 —— other: 0x7ffec88758e7
哈哈: 0x7ffec8875907
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffc6d61ce97
移动: 0x7ffc6d61ceb7 —— other: 0x7ffc6d61ce97
移动: 0x7ffc6d61ceb6 —— other: 0x7ffc6d61ceb7
哈哈: 0x7ffc6d61ceb6
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/
  • return a:对象类型是A,不存在右值引用,自动视作亡值,允许移动,依然保留 NRVO 优化机会优先尝试。

默认走NRVO优化,关闭后都是2步拷贝,只不过C++17编译器优化掉了1步。

  • return move(a):手动转为亡值,类型为A&&,直接作为参数强行匹配移动构造,程序员手写move=明确告知编译器想要执行移动,编译器不能自作主张消除这次移动优化(NRVO),即直接废掉 NRVO。

必走关优化的移动,... ...哎╮(╯▽╰)╭完善不下去了,太累崩溃了,完善不出来了,豆包说不同是编译器的优化结果,完全没标准定论

继续说多分支:

函数存在多条 return 语句返回不同的局部具名变量时,编译器不允许执行 NRVO(拷贝消除),但不是说一定会拷贝,优先尝试移动(多分支不优化,C++11增了个移动,所以尝试移动,没移动才拷贝),NRVO优化前提是return 操作数是同一个自动(自生自灭区别于静态/堆)存储期局部变量、类型匹配:

查看代码
A func(bool flag) {
    A a;
    A b;
    if(flag) 
        return a;
    else 
        return b; 
}
A res = func();

/*
/*
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a && ./a
构造: 0x7ffdaa917226
构造: 0x7ffdaa917227
移动: 0x7ffdaa917247 —— other: 0x7ffdaa917226
哈哈: 0x7ffdaa917247
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14  && ./a
构造: 0x7ffe459760e6
构造: 0x7ffe459760e7
移动: 0x7ffe45976107 —— other: 0x7ffe459760e6
哈哈: 0x7ffe45976107
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -fno-elide-constructors && ./a
构造: 0x7fffe90604f6
构造: 0x7fffe90604f7
移动: 0x7fffe9060517 —— other: 0x7fffe90604f6
哈哈: 0x7fffe9060517
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# g++ a.cpp -o a -std=c++14 -fno-elide-constructors && ./a
构造: 0x7ffc5d539406
构造: 0x7ffc5d539407
移动: 0x7ffc5d539427 —— other: 0x7ffc5d539406
移动: 0x7ffc5d539426 —— other: 0x7ffc5d539427
哈哈: 0x7ffc5d539426
root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# 
*/

URVOC++17 强制生效;C++14 可选,-fno-elide-constructors可关闭):

A a=A(10)

  • 开优化:没任何临时

  • 关优化:a地址0x1A(10)搞的是临时0x2,把0x2传递给this搞对象构造函数,就在0x2搞对象,然后拷贝给0x1,即出现 1 个临时

A f(){ return A(5); } A res = f();

  • 开优化:没任何临时(哪怕传递到函数里有一句return,也依旧res地址0x1,然后直接就在0x1搞)

  • 关优化:0x1res,先调用func打算在0x2搞,func里在0x3搞,然后0x3拷贝到0x2,然后0x2拷贝到0x1,即 2 个临时

以上所有只要提到拷贝这件事,其实有个前提,如果有移动会优先走移动。

NRVO(所有 C++ 版本永远可选,C++17 依旧不强制但也分情况):

①(只有这一个) A f(){ A a;return a; } A res = f();

  • 开优化:没任何临时(哪怕传递到函数里有一句return,也依旧res地址0x1,然后直接就在0x1搞)

  • 关优化:0x1res,先调用func打算在0x2搞,func里在0x3搞,然后0x3拷贝到0x2,然后0x2拷贝到0x1,即 2 个临时(和关优化的A f(){ return A(5); } A res = f();差别是,一个是看不到的临时、一个是a,而除了这个临时,main里那个f()也是个临时)

注意:涉及到函数的时候,是提前开内存,不涉及到函数的A a=A(10)是后开。

大体如此剩下的就是编译器自己的优化了。

A&& res = func()

  1. 开启优化:func 构造的对象就是返回临时,res 绑定该对象。

  2. 关闭优化:func创建局部对象,拷贝生成返回临时,res 绑定这份返回临时。

 

之前 -O0 那个

  1. -O1/O2/O3:开启编译器全部优化,包含拷贝消除 (NRVO/URVO)
  2. -fno-elide-constructors:强制关闭拷贝消除,无视 O 优化,完整走拷贝流程

默认不加 - O 时默认无优化,仍会执行拷贝消除。

 

Q:为啥搞这么多版本?

A:带()都是老版本,{}都是新版本

  1. A a(1):直接初始化,无临时(这是老式括号初始化,存在最令人头疼的A x ();函数歧义,因为A a() 空括号,编译器判定为函数,C++11推出{},根除括号歧义)(无名临时A(1)

  2. A a{1}:直接列表初始化,无临时(为了区分{}={}:给开发者控制隐式转换的开关,配合 explicit 管控类型隐式转换风险)(无名临时A{1})(无参就是A a{}

  3. A a = {1}:拷贝列表初始化,无 A 临时(无参A a = {}

  4. A a = A(1):拷贝初始化,语法含无名临时 A,关优化能看到拷贝构造调用

1和4都是老的。现在都用2和3

不存在a(1)这种定义对象写法

另外科普术语:

无参构造函数:参数列表完全空 Test(),没有任何形参。

默认构造函数:不用传任何实参就能调用的构造,分两类:

  • 手写Test()(无参构造)

  • 带全部默认参数 Test(int a=0)(不是无参构造)

编译器合成默认构造:类完全没写任何构造函数,编译器自动生成空的Test(),它既是无参、也是默认构造

查看代码
// 情况1:编译器自动搞
struct A{}; 
A a; // 合成默认构造 Test(),无参构造

// 情况2:手写无参构造,同时也是默认构造
struct B { B(){} };
B b;

// 情况3:带默认参数,不是无参,但是默认构造
struct C { C(int x=1){} };
C c; // 能无参调用→默认构造;形参不为空→不是无参构造
C c(4);

所有无参构造都是默认构造,但默认构造不一定是无参构造。

练个手:

查看代码
#include <iostream>
using namespace std;
struct A {
    int a = 0;
    A(){cout<<"无参构造 "<<this<<endl;}
    A(const A& other){cout<<"拷贝构造 "<<this<<" 来源 "<<&other<<endl;}
    A(int v) : a(v) { cout << "有参构造:" << v << "\n"; }
};

int main() {
    A a1;//有名无参构造
// A a();//按语法规则只能解析成函数声明,含义:声明一个无参、返回值为 A 的函数obj,完全不创建对象,不会调用构造函数,但编译器猜你大概率本意是定义对象,所以会警告提醒避坑
    A a2(1);//有名有参构造

    A b1 = A();//无名无参临时构造
    A b2 = A(2);//无名有参构造
    


    A c1 = {30};// 拷贝列表初始化
    A c2 {30};  // 直接列表初始化

    A d2{};     // 直接列表初始化(值初始化)
    A e2 = {};  // 拷贝列表初始化(值初始化)
}    

// B x(1, 2);  // 圆括号:两个独立参数,匹配B(int a, int b){}
// B y{1, 2};  // 花括号:优先匹配打包数据版本,也匹配B(int a, int b){},但优先匹配B(initializer_list<int>){}

std::initializer_list<int>:专门承接{...}这种大括号包裹的一串数据的类型。

编译器看到{},值0初始化,A b;随机垃圾值。

 

Q:如今都 2026 年了,C++17 是 2017 年的东西,早都 C++17 了,为啥还要学 C++14 东西?RVO优化C++14/17啥的

A:大量线上服务端项目仍在用 C++14,且弄懂 C++14 与 C++17 差异,才能分清哪些特性是强制标准、哪些只是编译器可选优化。

Q:我好奇!!为啥大厂会考非 C++ 的 GCC 编译器行为????我感觉不太对呀这就很局限吧?我理解错了?

A:

  1. 国内 Linux 服务端清一色 GCC,几乎不用其他编译器,工程只跑 GCC;标准只划定底线,真实工程只能看 GCC 落地行为,面试官考察你能不能分清标准理论和生产现实,避免写出跨平台隐患代码。

  2. 考察核心不是背诵 GCC 私有关特性,而是分辨:哪些行为标准强制、哪些未定义、哪些 GCC 碰巧能跑;

  3. 重点陷阱:很多人看到 GCC 运行正常,就当成标准合法写法,直接上线,一旦切换编译器 / 升级 GCC 就崩;

  4. 面试官真正想要的回答:我看到现象是 GCC 合并,但标准未定义该做法,业务代码绝不依赖该特性。

简单概括:考的不是 GCC 独有效果,是区分标准理论与平台实现。

这是豆包的胡咧咧,有几分道理,但追问后豆包又说根本不考这些,太冷门!!

我学习方法像写书一样!效率太低了!暗无天日!根本没出头之日啊!死路!

关于 初始化、构造、调用、()写法、{}写法、RVO优化 至此说完 

 

关于命令:

Linux目录:

  • . = ./:当前目录

  • .. = ../:上一级父目录

  • /:开头代表根目录;中间是目录分隔符

cd

root@VM-0-7-ubuntu:~/cpp_projects_2/hhh# cd /
root@VM-0-7-ubuntu:/# cd /root
root@VM-0-7-ubuntu:~#

可看到根目录就是/,然后进入root就是简写的~,

删文件rm,忽略确认提示, rm -f

删文件夹rm -r,忽略确认提示,rm -rf

-f 强制删除,忽略确认提示

 

插曲:

回到家继续学,发现远程连接不上了,一看内存100%了  image

之前豆包拉取上面要很久,且删除的也会占用内存,仿佛没真删除,可以全量搜索,很难达到1G,那时候反复问答然后整理挨个删除,如今基本新开豆包页面就是600M,只能局部匹配搜索,拉取快多了。导致我电脑只有2G了,chrome在任务管理看占用2G,但chrome会拆成一大堆独立进程,不只一个条目,且电脑的系统文件缓存、浏览器磁盘缓存,不会统计到进程占用栏里,所以看到chrom只有2G事实上可能更多,重启就释放了变10G了,电脑全程只用chrome,标签页关闭重启+清缓存依旧能占用7G之多,可想而知我的腾讯云服务器!但这个服务器和电脑没关系,点了下腾讯云的最近24小时监控数据分析:

实例配置: 2核 CPU / 2GB 内存 / 50GB 系统盘

分析时段: 2026-07-24 01:59 ~ 2026-07-25 01:59

🔴 严重异常时段:17:30 ~ 17:55(2026-07-24)

该时段出现全维度资源飙高,具体情况如下:

  • 系统盘读 IO 持续在 133GB/s 级别(约 130 MB/s),这是极为异常的现象,远超正常业务负载。

  • CPU 和内存同时达到瓶颈,结合超高读 IO,推测该时段可能发生了以下情况之一:大规模文件读取/扫描操作(如日志分析、全盘扫描、数据备份)、内存不足导致频繁换页(Swap),引发磁盘 thrashing、应用程序内存泄漏、缓存数据持续累积未释放、服务连接数缓慢增长。

这里提示监控数据丢失 ≠ 你的代码 / 文件丢失,两码事!

  • 17:50 那段只是腾讯云监控程序拿不到服务器状态(服务器负载太高,监控进程抢不到 CPU),只是后台看不到指标。

  • 你硬盘里写的代码、文件,全程完好,不会凭空消失。

记录解决过程:

ocerterm(octerm)控制台打开【VNC 远程登录】是救命通道,不走 SSH(默认22端口)、不受CPU高负载影响,SSH连不上的唯一能登陆服务器的独立通信入口,

  • SSH = 打电话给服务器,服务器忙到没空接电话;

  • VNC = 直接跑到服务器跟前,扒着屏幕当面操作,不用服务器主动响应你。

Q:如何进去VNC?

A:买完服务器收件箱里是ubuntu用户的密码,控制台【重置密码按钮】默认是修改 ubuntu 普通账号密码,无法改 root,root 密码没有网页一键按钮必须进系统执行命令,我去年已经 sudo passwd root 更改 为 !Zzxc13936130136,且默认禁止远程ssh登陆root,也修改为可以 ssh 直登 root,具体那文里搜“坎坷:步骤”、“但我注意”,1.sshd_config 写 PermitRootLogin yes:服务器允许 root 远程接入。2. 本地 ssh 配置 User root:客户端指定用 root 账号发起连接。

sudo是给普通用户加的临时root权限,root不需要sudoubuntu一般所有自带的非root都被加入了sudo,即都可以执行sudo获得root权限,自己创建的用户不可以sudo

root@VM-0-7-ubuntu:~/cpp_project2$日常 SSH 登陆账号只看@前面,root@xxx,登录用户就是root;后面ubuntu是主机名字,和ubuntu账号无关,我好奇后面的~不是根目录root啥的吗?其实不是,后面的~代表当前登录用户家目录:ubuntu 用户的~代表/home/ubunturoot 用户~=/root/ 才是真正根目录,Ubuntu系统默认存在root超级用户与ubuntu普通用户两个账户:image

/(根目录)
├─ /root      # root用户家目录
└─ /home
   └─ ubuntu  # ubuntu普通用户家目录

Q: 搞清楚咋登陆、账户、具体咋操作?

A:这俩账户都可以登陆来查看情况,VNC 屏幕一堆 atkbd 报错不用管,是虚拟控制台键盘无关告警,不用管,直接输入root回车,再输入密码,然后top查实时占用或者ps aux打印快照,也可以先清理下残留的编译进程,至此查了一堆原理后大概40min吧,发现自己好了,image,后续编译测试优先手动清理残留编译进程pkill cc1plus,不要只依赖 VSCode 每日重连,重连只能清理 VSCode 进程,清理不掉独立运行的编译遗留进程,一旦再次出现连不上远程,立刻用 VNC 进入,执行top,优先查找cc1plus进程。

为啥要学这些?不是运维的吗?本科教吗?(SSH 登录格式、~ 含义、root / 普通用户权限、家目录区分:面试闲聊 / 笔试基础题常出现;服务器负载 100% 导致 SSH 连不上:项目经历、线上故障复盘最爱问)

本科OS课只会浅讲用户、根目录、进程,理论不分 Windows、Linux、Unix;/home 是类 Unix 体系惯例,不是操作系统原理本身,但岗位必须会linux,所要考C++但GCC编译器特有也要会,八股文只是C++标准。瞬间觉得没那么白学可惜了。

Q:可我的代码只有十几行啊!

A:代码体量极小本身绝对不会造成高负载,最可疑元凶:

  • 高频反复编译 C++ ,g++ 编译产生的临时进程、崩溃后的遗留cc1plus子进程不会自动回收;每次重新编译不断新建进程,旧进程僵死持续占用单核 CPU。你的服务器只有 2 核,单个僵死编译进程就可以直接拉满 CPU,锁死 SSH 登录。

  • 代码长短 ≠ 编译消耗资源大小!重点是 -O2 / -O3 这类优化编译本身非常耗 CPU、内存、磁盘,和你源代码只有十几行没关系。

    你研究 RVO、NRVO、移动语义,一定会带上 -O2/-O3 编译参数,

不带优化:g++ 简单翻译代码,速度快、占用低,

带上高优化等级:编译器要跑大量复杂分析:函数内联、消除临时对象、优化拷贝移动、数据流分析。

源代码很短,但是编译器内部运算量暴涨,会生成cc1plus子进程干重活。多个 cc1plus 共存→内存占用飙升;内存不够系统启用 Swap 交换分区。Swap 是硬盘虚拟内存,速度极慢,直接出现监控里那种 130MB/s 疯狂磁盘读写;一旦进入这种状态:系统大量时间耗在磁盘交换,没有多余资源响应 SSH 新连接,你 VSCode、OrcaTerm 全部连不上。 

  • 你本地 VS Code 客户端远程连接云服务器,连接建立后服务器自动运行vscode-server后台进程。你高频次的修改、保存、反复编译十几行 C++ 代码,文件每次变动都会触发 C/C++ 插件重新解析大量系统标准头文件。频繁重复解析持续堆积内存,最终内存冲高至 100%;长时间无操作后进程逐步释放内存,占用随之下降。内存压力和你自身代码行数无关,开销主要来自系统头文件反复解析。

    关闭本地 VS Code 内存不会下降,软件不会远程下发指令终止服务器端vscode-server。服务端进程会驻留一段时间等待重连,不会自动退出,内存持续占用。写完代码后,除关闭本地 VS Code,需要在服务器执行命令清理残留进程释放内存:pkill -f .vscod

     
 

感慨:

哎,为啥我总是学的感觉都没用

  • 自己实践从零总结八股文(正反推敲、反复验证、规避所有歧义、区分编译器的优化 or C++各个版本的细节,写书一样!不加筛选,所有知识点都排列组合、穷举全部变体,在不同编译器,用不同C++版本反复自测)(看书直接一句话、八股文整理好,自己却花费一周才让豆包给出这句话,就因为没钱买书,觉得看书慢,其实我这样比看书慢多了。反复让豆包给例子错无数次才能得到书里的例子?)(然后这么费劲追问出还没完,还得一一验证)(看书直接正确的且例子可以忽略,而我他妈的正确的东西要花好久才能让豆包给出,例子又挨个尝试)(堪比抄书一样)

  • 每条都要事无巨细的验证,经常瞎编规则反复道歉

  • 妈逼的 C 的标准和 C++ 完全不同

  • C++17 和 非17 又有很多差异

  • 不同编译器的

  • 哪些是编译器的规则,哪些是 C++ 的规则

  • C++标准只划定行为底线,大量二进制布局、代码实体生成时机细节全是各自编译器自己的实现。

哎,靠自学、极强的毅力、意志力、追问能力、心性、自律能力真的远远不够!2年半待业啃这些,一边底层社会各种打零工,一边无数次460元60h的硬座往返乌鲁木齐和哈尔滨,处理各种琐事,医学医生沟通,病情,四处求医治疗方案,挨冷受冻又经常吃几天饿肚子的半饱才舍得吃一顿饱饭,2026060609爸爸走了。

90%的时间都他妈浪费在给豆包纠错上了,还有学深了

都他妈不知道学啥,没方向,考啥都不知道!不如报班有老师知道了,我只需要知道啥考啥不考

反复误人子弟的豆包真的要被气死了!!!!反复瞎编!可不想死记硬背八股文,网上任何人总结的必然有错误,表达有不行,好奇看权威,发现所有权威都他妈更极致的晦涩,英文又读不懂。算了还是豆包吧!这些权威不仅晦涩,网站做的贼鸡巴垃圾,啥都搜不到,根本没我想问的东西,也难怪豆包会出错。一边啃东西无比晦涩难懂无尽追问、一边事无巨细的实践给豆包挑错

有时候吃饭、走路、拉屎看代码语音追问豆包,回来电脑打开连热点信息会有小概率不同步,shift+ctrl+delete清缓存即可。

 

这些都说完就有了基础,开始说代码,即此文搜“个先看一些细节、术语(开始一”为啥有的完整写了有的没完整写:

函数内部 static 局部变量(getMemoryPoolstatic MemoryPool memoryPool[]):如果这个函数完整写在头文件(类内实现),每个包含头文件的编译单元都会生成一份独立静态数组,多份实例,数据不共享,所以实现必须挪到.cpp,全局只生成一份静态数组。所以放.cpp:全程序仅 1 组数组,真正单例。放.h(类内写函数体):每个编译单元各生成一组数组,多个独立池子,不是单例。

注意(以下问一百次纠正一万次豆包依旧会错):

  • static成员函数里的 static 局部变量:全局唯一

  • static成员变量:全局唯一;

  • 无类inline函数里的static:多编译单元共用同一份静态局部变量;

  • 无类static inline函数里的static:各编译单元独立副本。

函数内必须有static保持持久可以看出是否共享,

函数内不要inlinestatic已经把函数定为内部链接,每个编译单元独立副本,不会触发多重定义;inline只影响优化,改不了链接属性。

注意:

我做实验无意间犯了个错误,学到了不完整类型不可以访问,非静态也不行,然后好奇想互相调用咋办,说是用指针,指针不需要完整类型,然后得到一个100%不考的代码,死磕研究,真的牛逼!这里搜“融会贯通”!再一次通过,不考的东西整合融会贯通所有高频必考知识点,之前是错的挖掘底层触类旁通所有难学难理解的东西,这段代码我看了20min最后想懂了直呼精彩纷呈!!算法可以笨方法死磕,这个底层都他妈得思考硬件布局好抽象:

查看代码
#include <iostream>
struct B;
void good(){
    std::cout << B::s_member << '\n';
    // 不完全类型不能访问B::成员
}

struct B{//把这回整个放到good前就行了
    static int s_member;
};int B::s_member = 10;

int main(){
    good();
}
————————————————————————————————————
#include <iostream>
struct B; // 前置声明B
struct A{
    B* pb; // 允许,指针不需要完整类型
    void func(); // 只声明,定义写外面
};

struct B{
    A* pa;
    void func(A& a);
};

// 此时A、B都完整,才能访问成员
void A::func(){
    pb->func(*this);
}

void B::func(A& a){std::cout << "互相调用成功,准确说是A调用B的func \n";}

int main(){
    A a;B b;
    a.pb = &b;
    a.func();
}
/*
也可以直接
A a;B b;
b.func(a);
*/

pb:它的类型是 B*,规定了这个指针存放的地址,一定是一个 B 类型对象 的地址。类比:int a = 19;int *p=&a;cout可以*p解引用,但不可以->因为没成员,而类 / 结构体可以,因为有成员既可以*也可以->。普通基础类型(int)没有成员,就算你写p->,后面无东西可填,语法不允许;结构体 / 类指针拥有成员,C++ 给了->语法糖,用来替代(*指针).成员,方便访问成员;

所以:
  • *是纯粹解引用(即先拿指针里存的地址,找到这块内存上、属于指针对应类型的对象)

  • ->等价于先解引用再.拿成员(即先拿指针里存的地址,找到这块内存上、属于指针对应类型的对象,再访问这个对象的成员),这里用int*非常好理解,拿 int* p 对照

    • *p:解引用,拿到 int 数值;int 没有成员,不存在后续.

    • 结构体指针 Test* ptr(*ptr).val = 解引用 + . 访问成员,为了省事写成 ptr->val

pb->func
  • pb:B 类型指针,存着 b 的地址

  • pb->:根据 pb 内保存的地址,定位内存里那个B 对象

  • func:去调用这个找到的 B 对象身上名为 func 的成员函数

  • (*this):传给 func 的实参

this:成员函数里自带的指针,类型是当前类 A*,存放调用这个函数的对象地址。

a.func () 执行时,this == &a

*this 就是解引用,得到对象 a

 理论上就是a,但函数内部不知道变量名 a,成员函数通用写法只有*this,不存在裸写 A,main 里才有对象 a。

也可传地址,要改函数形参:void B::func(A* a);调用写成pb->func(this);

不懂的话再看个角度解释,对象 a 执行a.func()

  1. a 找到自己存的 b 地址(pb);

  2. 呼叫 b:b.func(?)

  3. b 的函数需要知道是哪个 A 来找自己;

  4. 发起呼叫的就是 a 自己,所以把自己*this传过去。

Q:main里写也行吧?

A:在这份示例代码里,业务功能上确实看不出实际用处,它只是一道语法练习题,不是真实业务代码。

单纯功能层面,main 里直接调用b.func(a),效果和 a.func()最终结果一模一样,单看输出,互相调用这套写法纯属多绕一圈。

但它真正要演示的考点(这才是写这段代码的目的)是结构体 A、B互相依赖,A 里面存 B 指针,B 里面又存 A 指针。

重点考察两个语法难点:

① 前置声明如何解决循环依赖;

② 类定义不能互相包含对象,但可以互相包含指针;

③ 成员函数实现必须放到两个结构体全部定义完成之后。

真实项目里这种双向引用依赖、互相调用是有用的(比如窗口和控件、服务互相持有对方);但当前这段 demo 只是教学用样例,刻意写了互相调用,没有业务价值,纯粹用来啃循环前置声明的语法坑,如果去掉双向指针,直接 main 传参,就体现不出「两个结构体互相持有对方」这个核心考点,题目就失去意义了。

但准确说这个是A调用B,想要实现双向互相调用(A↔B),需要在 B 的 func 里加上 pa->func()

void B::func(A& a){
    std::cout << "互相调用成功\n";
    pa->func(); // B反过来调用A
}

⚠️ 这么写会a.func()pb->func(*this)(A 调 B) → pa->func()(B 调 A) → 无限递归,程序卡死,仅用来理解双向调用概念,实际工作中的互相依赖 ≠ 互相调用,互相依赖只是手里握着对方地址,具备调用对方的能力,不代表一定要调用,需要加判断一方调用另一方后另一方直接return不再继续调用,避免无限制来回触发。结构上 A、B 互相持有指针(互相依赖),逻辑上可控双向呼叫,这就是正规窗口 / 控件的设计思路。

回忆之前学的多个 shared_ptr(循环持有) 互相锁住对方,计数永远不归零,内存泄漏;用 weak_ptr 打破这个环。离散化 

Q: 一个东西吗?

A:不是!形式上都是「双向互相持有」看着像;一个是编译问题,一个是运行时内存管理问题。

结构体互相存原生指针(前置声明)解决编译层面语法报错,只是让代码能编译,内存生命周期要自己手动管理,不存在自动释放机制。

shared_ptr 循环引用,搭配 weak_ptr,解决运行时内存泄漏,代码完全可以正常编译,只是智能指针引用计数无法归零,内存没法自动回收。

联想到这个爷牛逼吗?

说下&&&

  • T&:左值引用,仅绑定左值。

  • T&&(非模板的如int&&):单纯右值引用,只绑右值。

  • 而如果是模板的template<class T> void f(T&& x):万能引用(转发引用) 依靠引用折叠,但折叠只发生在既有左也有右,规则如下:

    • 传入左值,int a=10;func(a);,T 推导为 int&T&& = int& && ,变为int&

    • 传入临时右值,func(100),T 推导为 int(裸类型,不是引用),T&& = int&&, 不涉及折叠。

    • 传入右值引用int&& r = 10; func(r);,T 也推导为int&,func(r),实参为左值表达式,T 推导 = int&,T&& → int& && 引用折叠 → int&。想要让实参被识别成右值必须写:func(std::move(r))。

      • 这里很大的误区才导致引出后面的用萃取验证:

        查看代码
        #include <iostream>
        #include <type_traits>
        
        template<typename T>
        void func(T&& x){// 万能引用T&&同时能接左值、右值:传左值触发引用折叠变成左值引用,传纯右值得到原生右值引用
            // 打印T,以及x的类型
            std::cout << "T: " << typeid(T).name() << std::endl;
            std::cout << "T&& 最终类型: " << typeid(T&&).name() << std::endl;
            // typeid 把int/int&都显示成i,所以别看 typeid 名字
            // typeid等价先做std::remove_cvref_t,删掉 const、volatile、所有引用,所以看不到 &
        
            std::cout << "is_lvalue_reference<T>: " << std::is_lvalue_reference_v<T> << std::endl;
        
            // l = left 左 → lvalue 左值
            // r = right 右 → rvalue 右值
            // std::is_lvalue_reference<T>::value 判断是不是左值引用
        
            // 萃取结果可以证明 T=int&,T&&折叠结果就是int&
            
            std::cout << "is_rvalue_reference<T>: " << std::is_rvalue_reference_v<T> << std::endl;
        
        //     static_assert:编译断言,编译期直接报错,用于硬性校验,不产生变量
        //     constexpr bool res = xxx:算出布尔值,你可以自行打印、做if constexpr判断,灵活度更高,不控制代码分支。
        //     if constexpr(条件):利用编译期布尔常量,编译阶段直接丢弃不满足条件的分支代码
        //     必须加constexpr, std::is_same_v 本身是 constexpr 常量,直接写普通 bool 也能存值,但失去编译期特性,变成运行时变量,没法给if constexpr使用
            constexpr bool res = std::is_same_v<T&&, int&>;
            std::cout<<res<<std::endl;
        
        }
        
        int main(){
            int&& r = 10;
            func(r); 
        }
        /*
        root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
        T: i
        T&& 最终类型: i
        is_lvalue_reference<T>: 1
        is_rvalue_reference<T>: 0
        1
        root@VM-0-7-ubuntu:~/cpp_projects_2# 
        */

int&& r = 10;变量的类型标签是右值引用,r 只能接收临时右值(类型规则),但后续代码写func(r),拿着名字 r 去传参,它就是左值,编译器认为你拥有这个具名变量,不会默认移动。所以理解了传入右值引用int&& r = 10; func(r);,T 也推导为int&而不是int&&。

    • 搭配std::forward<T>(x)实现完美转发,即template<typename T> void func(T&& x) {来实现万能引用(转发引用)接收参数 ,然后花括号里的std::forward恢复参数原本的左 / 右值属性,完成完美转发,如果不用 forward,x 永远是左值,不用forward永远还原不出来。
      void target(int&)  { /*接收左值*/ }
      void target(int&&) { /*接收右值*/ }
      
      template<typename T>
      void func(T&& x){
          target(std::forward<T>(x)); //原样转发,匹配正确重载
      }

完美转发转发的是参数的值类别,把实参原始属性传递给目标函数调用。

另外插一嘴:

移动仅转移资源所有权,原对象依旧存在,资源被掏空,不会直接销毁。

int&& r = 10;:新建临时实体,生命周期绑定引用,纯右值 10 的生命周期延长至右值引用 r 销毁。

int&& bad = func();没问题延长,但int&& bad = func() + 5;经过算术运算产生的临时,无法延长生命周期,语句结束 B 销毁。妈逼的豆包说的这个编译器依旧测不出来。

说下using用法:

#include <vector>

// typedef 只能给固定类型起别名,不能包裹模板
//typedef 尝试模板别名,编译报错

/*
template<typename T>
typedef std::vector<T> Vec;
*/

// using 模板别名,合法
template<typename T>
using Vec = std::vector<T>;

int main(){
    Vec<int> v;
}
--------------------------------
//普通类型两者等价:
using A = int;
typedef int B;

int main(){
    A x = 1;
    B y = 2;
}

新出的其别名using,typedef只能给确定类型起名,模板起别名只能用using:

template<typename T>
using Vec = std::vector<T>;
Vec<int> v; // std::vector<int>

引出萃取(类型萃取:借助模板元编程,编译期提取、判断类型信息的技术)

std::is_same<T, U>::value
//value用来取出类型萃取模板里保存的布尔常量
T、U 都是类型,不能填变量
#include <type_traits>
#include <iostream>

int main() {
    using A = int;
    using B = int;
    using C = double;

    bool res1 = std::is_same<A, B>::value;
    bool res2 = std::is_same<A, C>::value;

    std::cout << res1 << res2;
}

res1=trueres2=false

C++17可简化写为_vstd::is_same_v<int, int>; 等价 std::is_same<int, int>::value

关键字 constexpr,用途不一样。

  • 修饰变量 / 函数:代表可编译期求值

  • if constexpr:专门用来做编译期分支判断。

萃取早年翻译沿用外文traits直译,traits本意特征、特性,国内圈子统一叫萃取。

容器找等于目标 intstd::find

auto it = find(v.begin(), v.end(), 10);
if(it != v.end()) 找到
和 is_same 区分:find 运行期对比数值;is_same 编译期对比类型直接优化掉另一个分支:
template<typename T>
void func(T val) {
    if constexpr(std::is_same_v<T, int>) {
        // 仅T=int时参与编译
    }
    else {
        // 其它类型编译这段
    }
}
std::remove_reference_t<类型>规则:

类型带&:把&删掉

  • int&int

  • double&double

不带&:原样不变

  • intint

  • const intconst int 

查看代码
template<typename T>
void test(){
    // 传入T=int&
    bool res1 = std::is_same_v<T, int>;               // false
    using PureT = std::remove_reference_t<T>; // PureT = int
    bool res2 = std::is_same_v<PureT, int>;           // true
    cout<<res1<<" "<<res2<<endl;//0 1
}

int main(){
    test<int&>(); // 显式指定 T = int&
}

std::remove_cvref_t<T>(cv 代表 const、volatile):

一次性清除:constvolatile、左值引用&、右值引用&&

T = const int&
remove_cvref_t<T> → int

为什么服务端代码经常写?函数模板接收万能引用 T&&,很容易推导出来int&const int&,只想判断底层原始类型是不是 int,就必须先剥除引用修饰。

这里又去查了下volatile

这逼玩意真的好烦,不知道有没有用。要说清楚这个,必先搞懂硬件布局:

首先硬件里独立的CPU、内存条主存等,CPU内部核心是寄存器,速度最快,容量极小。CPU 运算时,数据必须先放到寄存器才能计算,CPU里寄存器外侧是CPU高速缓存Cache,分为:L1L2L3,速度次于寄存器,远快于内存条

  • 私有缓存:L1L2,隶属于单个 CPU 核心,别的核心不能直接访问

  • 共享缓存:L3,同一个 CPU 插槽内所有核心共用

内存条速度最慢,容量最大。我们程序变量原始数据存在这里。

编译器(软件工具):负责把 C++ 翻译成汇编指令。volatile 只管它。

CPU(硬件):执行汇编指令,自带缓存机制,volatile 管不了,CPU的硬件设计固定为:寄存器 → L1 缓存 → L2 缓存 → L3 缓存 → 主内存,从上往下逐级查找,找到就直接取用,不再往下走。

volatile

bool flag = false;
void func()
{
    while(!flag){}
}

编译器分析:循环内不修改 flag,编译器优化:循环外只读取 flag 一次,放进寄存器,循环反复判断寄存器,汇编指令根本不会反复发起内存读取请求。

volatile

volatile bool flag = false;
void func()
{
    while(!flag){}
}

volatile 限制编译器:不能缓存变量到寄存器,生成的汇编:每一轮循环都发出读取内存的指令,但仅仅控制【汇编指令怎么写】,指令送到 CPU 执行阶段:CPU 收到 “读取内存” 这条指令,但 CPU 硬件逻辑:优先去 CPU 缓存寻找数据。

Q:逼事这么多呢咋,我咋从没遇到过?

A:绝大多数你写的代码:单线程、单核运行,没有 volatile 完全没问题。

bool flag = false;
void func(){
    while (!flag){}
    std::cout << "线程退出\n";
}
int main(){
    std::thread t(func);
    std::this_thread::sleep_for(std::chrono::seconds(1));// 给主线程留出足够时间启动子线程,保证子线程先进入while循环,之后再修改flag。如果不加延时,有可能主线程先把flag置true,子线程启动直接跳出循环,看不到现象。
    flag = true;
    t.join();
}
//std::this_thread::sleep_for是纯 C++ 标准写法,跨平台,sleep()是 Linux 独有的 C 函数,windows 编译直接报错, Windows下codeblock,用的MinGW属于GCC附带兼容版sleep(),属于拓展接口,不是 C++ 标准

科普:

  • g++ --version:11.4.0,古早版本才可以复现永久存寄存器这个 bug。

  • 新版本 GCC(9 及以上),使用std::thread不加 -pthread很多时候编译、运行正常,标准库内部自动处理链接。

运行:g++ main.cpp -O2 -pthread -o main && ./main,满足下面理论上卡死:

  • GCC 开启优化 (-O2)、子线程空循环读取 flag、编译器优化仅首次读内存,后续复用寄存器数值、主线程后修改 flag

但我无法复现 bug,是新版编译器策略导致,面试不要求你复现现象,记住结论即可,傻逼文科生背就行!

嵌入式无操作系统叫裸机,芯片上电直接跑程序,没有内存屏障,外设地址属于内存映射寄存器(即硬件把寄存器映射到一段内存地址,软件像访问普通内存一样读写硬件寄存器),编译器不知道硬件会私自修改该地址数据,会做优化成只读一次。,此时需要 volatile强制每次访问地址,才能持续读到硬件更新。Linux 系统 + 新版 GCC 做了处理,加上你的程序跑在操作系统之上,很难触发那个卡死场景。 99% 的人不知道volatile也没出过错因为: 

  • 日常调试编译大多是 -O0,天然不会触发循环变量外提优化,就算开优化现在不是嵌入式裸机,有OS有编译器,早被优化掉只从寄存器读这个事了

  • 绝大多数服务端多线程代码,规范上直接用锁、原子变量,根本不靠裸变量轮询。

即写裸空忙等循环while(!flag),单纯依靠一个普通 /volatile变量循环等待别的线程修改,空循环持续占用 CPU 核心忙等,CPU满负荷自旋,浪费服务器算力,线上服务端杜绝无脑空循环,循环内普遍存在函数调用、sleepIO,编译器不敢把变量提取到循环外。

  • 最重要的是volatile无法解决指令重排(所以都用atomic),且他解决的锁住寄存器不再读新值的问题早都没有了,硬件层面MESI协议也保证了多核心的缓存同步,对于修改寄存器还没同步到内存一事也有就近原则去优先读寄存器来保证不会出错。

开启 -O2 + while 空循环,编译器判断循环内部代码不会修改 flag,仅在循环启动时读取 flag 一次放入寄存器,循环反复校验寄存器,不再访问缓存 / 内存,这是变量不带 volatile,带上他就会编译器禁止只读取一次,每一轮循环都生成读取指令,而-O0直接没优化,但不等同-O2+ 无volatile,因为-O2还有其他优化,但编译器现在的版本就算-O2+while空也不再反复只校验寄存器了,除非嵌入式里。
  • volatile 迫使代码持续发起读写缓存 / 内存的指令MESI 同步各个核心缓存里的数据,二者层级不同。

  • 不加 volatile+O2:变量驻留寄存器,MESI 管不了,极易死循环。

  • volatile:代码持续访问缓存;现代 CPU 依靠 MESI 同步缓存,实测很难看到异常。

不开O2和开O2volatile区别?不开O2啥也不优化,开O2volatile整体代码优化,唯独volatile这个事上不同。

好像陷入了一个牛角尖!单核、多核、加不加 volatile、是否开优化、是否读寄存器、是否读内存、是否读 cache,排列组合要想几十种,其实不是,总共就:

  • volatile解决编译器优化风险(现在编译器无法复现了,不用volatile也没这风险了):这个角度单核、多核都会有的问题是,-O2 空循环、无 volatile,变量被锁寄存器,不再访内存修改的值。volatile 专门挡住这件事,强制每次生成访存指令,即修改也看不到,早年或者裸机加volatile就可以搞定,发起读内存指令强制读内存,现在加-O2也不锁寄存器内了。有一个硬件读取就近原则,假设内存写入新的后,寄存器又更新了,此时依旧读内存会错误,但硬件就近原则会先读寄存器,这个是灵活读新值区别于锁死只用寄存器最开始的旧值。

  • 硬件缓存一致性风险:只存在多核,因为多核下每个核心的寄存器、L1/L2,别的核心直接访问不到,就算 volatile 强制不断读内存指令,依旧可能读到本核心缓存旧数据,volatile 对此无能为力,但现在MESI也解决了这个问题,即 A/B 俩线程在CPU不同核心内,各自有自己独立的缓存和寄存器,A 修改了只在寄存器,B 的寄存器无法看到,此时寄存器的东西谁也搞不到,但瞬间会搞到各自的Cache缓存,这个缓存是私有的,靠MESI协议传递,就共享了,所以是可以读到值的。

即底层硬件数据流转全过程是:读写都是先寄存器,然后CPU缓存,然后内存,为啥总说内存才是最新的?寄存器不更最新吗?因为可是内存数据是全局一个,但寄存器每个CPU核心都有各自的寄存器,未必立马同步到内存,所以尽管寄存器最新也必须去内存读最新。

CPU设计规则是哪怕强行读内存,用volatile也会先看CPU自己的核心内有无缓存,有的话就只读自己的,此时错了,新的在内存上,所以MESI解决了这个同步问题,告诉你别读自己啦,这不是新的。实现跨核心通信同步。

单核要么不读攥着寄存器的旧值,要么读拿到的就是最新数据,不涉及到跨核心的事,但多核心的这个也没法复现!!因为两个线程很小概率分到一个核心,但也不可以打赌,只是很难复现!且就算分到一个核心,现代 CPU 实现 MESI 缓存一致性协议,硬件主动同步缓存,不会有任何问题。

那为啥依旧说volatile解决不了多核心的缓存一致性?因为CPU指令重排,你代码写:a = 1;flag = true;你的想法:必须先执行 1,再执行 2,CPU 为了跑更快,私自调换顺序,先执行flag=true,再执行 a=1,这就是指令重排,MESI 只能保证各个核心看到的数据是同步的,管不了 CPU 私自调换执行顺序,别的线程看到 flag 变成 true,跑去读取 a,此时 a 还没赋值,直接出错,锁自带限制:不让 CPU 随便调换跨越锁的指令顺序,同时强制刷新数据,杜绝这种乱序问题。

所以多核下多线程共享数据同步:volatile 只能阻止编译器优化,解决不了硬件缓存可见性(多核各自有独立缓存,一个核改了变量,另一个核不能立刻看到最新值),MESI解决这可见性缓存同步但无法解决指令重排。

mutexatomic 完整解决可见性 + 指令重排 + 竞争问题。

1.mutex 锁(库提供):靠操作系统内核实现,线程抢不到就休眠,开销偏大,能保护一大段代码。

2.std::atomicCPU 硬件指令实现,只针对单个变量,开销更小,只能处理单个变量读写。

但编译器-O2优化后,代码不去读内存,死死攥着第一次加载进寄存器的旧数据这就是问题所在,现在编译器早都去掉【死死攥着早年加载进寄存器的旧数据不读内存】这件事了,只有没 OS 没编译器的裸机才会复现出来。哎!觉得挺神奇的,豆包说性价比极低学错的不考的

区分:

  • 缓存(Cache):硬件层面,CPU ↔ 内存。为了提速 CPU 读内存;硬件自带(L1/L2/L3 Cache

  • 缓冲(Buffer):软件层面,IO 读写(磁盘 / 网络)。为了抹平快慢速度差,攒一批数据再收发。

为了解释atomic先看代码:

查看代码
#include <iostream>
#include <thread>
using namespace std;

int cnt = 0;
void add() {
    for(int i=0;i<100000;++i)
        cnt++;
}
int main(){
    thread t1(add);
    thread t2(add);
    t1.join();
    t2.join();
    cout << cnt << endl;
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
104603
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
200000
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

就算volatile int cnt=0;也不行:

t1:读缓存 cnt=0→寄存器 + 1=1,还没写回缓存;

t2:同时读缓存 cnt=0→寄存器 + 1=1,写回缓存 cnt=1

t1 后续把 1 写回缓存。

两次 ++,最终结果 1,数据丢失。

volatile 仅仅保证每次指令发起读缓存,拦不住两段读写操作交叉执行。

MESI保证缓存同步,但 t1 暂时未写回MESI也无能为力。

引出atomic<int> cnt = 0;
  • 读 - 改 - 写整套操作不可被线程打断(原子性),不会出现读到同一个旧值并行计算;

  • 自带内存屏障,堵死 CPU 指令重排。

MESI 只管缓存数据新鲜,管不住操作中途切线程;atomic 直接让cnt++一整套操作执行完,别的线程插不进来。

补充大白话对照:

  • volatile:每次去读缓存,但读写之间能被插队

  • atomic:整套读写流程锁死不允许插队,同时防止乱序

std::atomic:只保护单个变量;硬件指令实现,用户态,开销小;自带内存屏障,解决可见性 + 指令重排 + 原子性;适合计数器、开关 flag。没法保护多行代码、多个变量。

std::mutex 互斥锁:保护一段代码 / 多个变量;触发系统调用、线程阻塞休眠;开销更大;适合复杂逻辑、多变量同步。

C ++ 提供std::atomic原子操作在编译阶段约束编译器,同时 CPU 层面自动插入内存屏障,双向约束,消除两层不确定性。

#include <atomic>
std::atomic<bool> flag = false;

std::atomic 作用两处:

  • 禁止编译器优化,反复生成内存访问指令;

  • 自动添加内存屏障,约束 CPU,强制缓存数据同步,解决多核缓存旧值问题。

这就是它能用于多线程同步的根本原因。

 

回头继续说内存池的代码,

再次理解下友元(注意无论内声明外定义还是声明定义全在内,这两个写在public/private都完全一样)

查看代码
#include <iostream>
using namespace std;
class Test{
private:
    int num = 660;
public:
    // 成员函数:属于Test,天然访问私有
    void memberFunc(int q) {std::cout <<"成员函数: "<< num + q << "\n";}

    static void staticFunc(Test obj){
        std::cout << "静    态: "<<obj.num<<endl;
    }//这里的{}不可以省略,函数定义必须{},if/for/while 属于控制语句,可以省略{}

    // 友元声明:允许外部自由函数 accessPri 访问私有
    friend void accessPri(Test t);
};

// 自由函数,不属于Test!不加friend下面代码编译报错
void accessPri(Test t){
    std::cout << "友    元: "<<t.num << "\n";
}
int main(){
    Test t;
    t.memberFunc(6);   // 成员函数调用
    Test::staticFunc(t);
    accessPri(t);     // 独立自由函数调用
}
  • t.memberFunc():成员函数,必须对象调用

  • 静态成员:强制带上类名::,但是写起来啰嗦;

  • 友元:accessPri(t)普通全局函数,和类互相独立,写法简洁,贴近原生new风格,想读取对象私有变量,必须加friend

C++ 标准只允许类里声明+类外定义,编译器扩展也可以类里直接定义但不推荐。且写在类里的友元依旧是普通函数不是类成员函数。

Q:不写静态为了重构代码时候不需要修改类名::,那为啥不直接搞全局,非得类里搞个友元?

A:内存池的块、槽、64 组内存缓冲区、并发 CAS 空闲链表这些状态数据必须有地方存,不能散落在全局裸变量。

  1. 内存池有大量运行时状态:firstBlock_freeList_、锁、分 64 个不同大小池子,这些数据不能随便定义一堆全局变量堆在命名空间里,代码混乱、无法封装、生命周期难控。

  2. 设计者把所有内存管理状态、分配释放底层逻辑全部封装进MemoryPool+HashBucket,把数据和底层操作收拢在类内,满足封装、高内聚;

科普无穷无尽海厚海厚的基础知识:

  • 函数体内:初始化、独立赋值语句全都允许。

  • 函数体外 / 类体内:仅允许「变量定义附带初始化」,禁止单独的赋值语句。

关于default:

手写List() = default;编译器自动生List(){}默认构造,等价于手写List(){},赋值的时候可以就地初始化Node* head = nullptr;,也可以初始化列表List() : head(nullptr) {}。

List<int> lst;无参对象

List<int> lst();⚠️ 不创建对象!声明一个函数,函数名字 lst,返回值 List<int>,无入参。

List<int> lst(3)有参对象。

关于友元:

有模板友元和普通友元:

  • 普通友元:绑定一个独立函数

  • 模板友元:绑定一整套模板生成的所有函数(后来才知道考的概率为0)

    查看代码
    #include <iostream>
    class A {
    private:
        int num = 10;
        template<typename T>
        friend void func(T x); //整套模板函数都是友元
    };
    template<typename T>
    void func(T x) {
        A a;
        std::cout << a.num + x;
    }
    int main(){ func(5); }

关于调用写法:

查看代码
#include <iostream>
using namespace std;

template<typename T>
class Test {
public:
    T val;
    Test(){};
    Test(T v) : val(v) {};

};

template<typename T>
void func(T v){
    cout << v << endl;
}

int main(){
    func(10);          //函数模板,<>省略,合法    
    Test<int> t;// <int>指定模板参数T的类型,和构造传参毫无关系,确定T是int,类内成员val类型锁定为int,之后你给val赋值只能传int类型数据
    Test a{10};
    Test b(10);
    // Test t;        
}

有参构造能推导出模板参数,可以省略<>

无参构造调用,永远不能省略 <>,因为没线索去推导。

但豆包说只有函数模板才可以省略<>,类模板最好写 Test1<int> t{10},不要写Test1 t{10},大部分面试官的基础题库沿用 C++11/14 知识,潜意识认定「类模板必须显式填模板参数」,你写省略 <> 的版本,容易被面试官挑刺,造成不必要争论。 

被傻逼狗豆包误入歧途学了100%不考的冷门知识点,死全家死妈的狗逼!我现在真的搞不懂!你无休止的给我全宇宙级别海量边角知识点的意义是啥!!无穷无尽的给私有嵌套友元,然后又说不考反复道歉,又说考什么模板友元,image,比上面T x那个还复杂,结果无尽崩溃感觉好难,又说不考,百度查告诉我是P7+/基础架构岗的底层库开发的面试才考!哎!!

就考普通友元函数,friend void func(A&)这种,模板就考函数模板template T add (T a, T b){return a + b;}类模板template<typename T> class Demo { public: T val; };

更新下提示词感觉无尽崩溃骂累了,心平气和的思考给出提示词还是挺有用的:

遇到模糊不确定我在问什么禁止回答必须先和我确认!

三五个问题就表扬他一下让他继续别忘记我们的约束!

始终以请教口气,考就学,不考就不学!禁止顺从承接我的情绪!哪怕我在质疑辱骂你!始终强调禁止废话禁止啰嗦!

在用户质疑你的时候禁止承接情绪而是永远都要参考权威!任何不以我的情绪思维转移好吗!

禁止任何扩展,必须只回答我的问题!任何结论你搜索权威如果找不到就直接否定!禁止任何无脑附和用户的情绪或者观点!一切以客观事为主!!任何无脑肯定我都是在害我!!言辞犀利禁止委婉

你耽误了我一天又一天,无穷无尽的给不考的!我给你磕头了,你永久更新下死规定吧!不考小众冷门所有所有不需要浪费时间学的禁止提及!我没时间听!直接连知道都别让我知道有这个东西我求求你了!?你别再保证了,禁止道歉,我没时间听你道歉!你客观事实永久无法保证自己不出错,傻逼总犯错!

我就是希望你别再折磨我了!任何不考小众小概率极少考的都是对我无尽的折磨!你只需要回答你该回答的问题!禁止教我咋记忆这那的!你也禁止任何总结!我学习不需要你给任何自主协助!你唯一需要做的就是禁止任何极少考!

凡是冷门、面试不会出现的写法,直接统一当成不能这么写,不区分 “语法合法但没人用” 这种细分,不展开额外细碎边界。

只有大厂后端面试常口头问、要求手写代码的内容才算要学;仅源码存在、选择题冷门考点一律判定不学,不再摇摆;

只认定一类内容【需要学】大厂 Linux C++ 服务端面试能够要求手写代码、面试官高频口头提问,其余仅源码存在、只出现在冷门选择题、底层语法边角细节,全部判定【禁止学】

严格极端一刀切!!除了高频其余所有是不考!禁止任何多余内容!从此以后历史遗留当作错误写法禁止提及

禁止任何问题建议!禁止回答任何多余重复冗余内容!禁止回答任何我没问的东西!!仅仅回答我问的禁止任何背诵技巧等一切无关信息!

查看代码
#include <iostream>
#include <vector>
using namespace std;

int main(){
    // vector v(10);//报错

    vector v1{10};
    cout << v.size() << endl;//1

    vector<int> v2(10);
    cout << v2.size() << endl;//10
    
    vector<int> v3{10};
    cout << v3.size() << endl;//1
}
//vector<int> v2(10,1);表示10个1

括号:

  • 花括号:v1{10}v3{10} 匹配 initializer_list 构造,size=1

  • 圆括号:v2(10) 匹配普通构造,创建 10 个元素,size=10

是否带<int>:
  • <int>vector<int> v(10),类型明确,正常调用构造。

  • 不带<int>vector v(10),C++17开始的CTAD 无法匹配该构造函数,编译报错。

原始std::vector b{1,2}支持 CTAD,别名模板Vec b{1,2} C++17 不支持 CTAD,编译报错。

关于内存池代码里的友元:

代码随想录里我注释掉那个友元属于模板友元根本不考,工程如果用到函数模板不会放到类里当友元,即newElementdeleteElement是外部函数模板),属于不确定的东西,类里学友元必须是确定的!

但通过这个学到的貌似考的东西:

普通类,和模板类的静态成员变量都必须类外定义,且模板类静态成员定义开头必须加template<typename T>

名称依赖,就是名称的含义取决于模板参数(死全家的豆包说考结果学完又说不考~~~~(>_<)~~~~),有<int>这类的就要typename指明是类型还是变量,对于Demo::iterator直接取决于Demo内部定义。

单词iterator本身没有魔法含义,仅仅是一个普通标识符,人约定用它命名迭代器,编译器不认这个约定,iterator是容器内部用 using 定义的类型别名,也就是迭代器类型作用近似指针,用来遍历容器元素。

iteratormap、value 这类是普通标识符,不是关键字,不指定容器啥也不是,类里面既能当类型别名,也能当静态成员变量,intifreturn 这类关键字语法强制禁止拿来命名成员。

在模板函数里写 Demo<T>::iterator因为带有模板参数 T,它就是依赖名称,编译器无法预先知道这个名字是迭代器类型,还是静态整型变量,和abc、hahaha没区别,必须加 typename 通知编译器此处代表类型:

查看代码
// 版本 1:iterator 代表类型(日常迭代器场景)
#include <iostream>

template<typename T>
struct Demo{
    using iterator = T*;// 给T指针起别名iterator,这是【类型】
};

template<typename T>
void func(){
    // Demo<T>::iterator p; // 去掉typename,编译报错
    typename Demo<T>::iterator p;
}

int main(){
    func<int>(); // T = int,实例化:Demo<int>::iterator = int*,p就是int*    
}

//==================================================
// 版本 2:iterator 代表静态变量(无类型别名)
#include <iostream>

template<typename T>
struct Demo{
    static int iterator;
// iterator不再是类型,是一个静态整型变量
};

template<typename T>//类外定义
int Demo<T>::iterator = 99;

template<typename T>
void func(){
    auto val = Demo<T>::iterator; // 识别为变量,无需typename
    std::cout << val << std::endl;

}

int main(){
    func<int>();
}

class是类,template是模板,typename是告知变量还是类型。

关于工业对比刷题代码风格:刷题 VS 工业

之前我手写算法链表,平时写的普通链表(Node 全局暴露,不用对象),简称全局 Node 结构体:笔试常出现,工程很少直接裸用,外部代码能乱改next,破坏链表结构,封装差:

查看代码
// ACM里频繁 new 容易 TLE
#include <iostream>
struct Node {
    int val;
    Node* next;
};

Node* createNode(int v) {
    return new Node{v, nullptr};
}
int main(){
    Node* n1 = createNode(10);//无需list对象
    Node* n2 = createNode(20);
    n1->next = n2; // 10节点指向20节点

    Node* cur = n1;
    while(cur){
        std::cout << cur->val << " ";
        cur = cur->next;
    }
    delete n2;
    delete n1;
}

// -----------------------------------------------------
// 静态数组ACM 主流标准写法
#include <iostream>
using namespace std;

struct Node {
    int val;
    int nxt;
}pool[100005];

int cnt = 0;
// 分配节点,不用new、不用指针
int newnode(int v) {
    pool[++cnt].val = v;
    pool[cnt].nxt = 0;
    return cnt;
}

int main() {
    int head = 0;
    int p1 = newnode(10);
    int p2 = newnode(20);
    pool[p1].nxt = p2;

    int cur = p1;
    while(cur != 0) {
        cout << pool[cur].val << " ";
        cur = pool[cur].nxt;
    }
}

引出类内私有结构体(封装,但必须 List 对象创建节点),外部无法直接修改 next,封装安全,只能通过 List 提供接口操作链表,包括头插、尾插:

查看代码
#include <iostream>
struct List{
private:
    struct Node {
        int val;
        Node* next;
        Node(int v) : val(v), next(nullptr) {}
    };
    Node* head = nullptr;
public:
    void push(int v){
        Node* newnode = new Node(v);
        newnode->next = head;
        head = newnode;
    }
    void print(){
        Node* cur = head;
        while(cur){
            std::cout << cur->val << " ";
            cur = cur->next;
        }
    }
    ~List(){
        Node* cur = head;
        while(cur){
            Node* tmp = cur;
            cur = cur->next;
            delete tmp;
        }
    }
};

int main(){
    List lst;
    lst.push(10);
    lst.push(20);
    lst.print();
}

尾插:加个Node* tail = nullptr; 尾指针,push改成:

查看代码
void push(int v){
    Node* newnode = new Node(v);
    if(!head) {//链表为空,头、尾指针同时指向这个唯一节点
        head = newnode;
        tail = newnode;
    }else{
        /*
        链表非空:
        tail->next = newnode:旧尾节点连上新增节点
        tail = newnode:更新尾指针,新节点成为新尾部
        */
        tail->next = newnode;
        tail = newnode;
    }
}

//或者傻逼呵呵遍历:
void push(int v){
    Node* newnode = new Node(v);
    // 情况1:链表是空的,直接让head指向新节点
    if(!head) {
        head = newnode;
        return;
    }
    // 情况2:链表有节点,循环走到最后一个节点
    Node* cur = head;
    while(cur->next != nullptr) 
        cur = cur->next;
    // 最后节点的next连上新增节点
    cur->next = newnode;
}

然后引出内存池的写法,既可以像 acm 那种不用对象调用、像new int一样不用对象方便的搞东西,也可以封装类内私有(其实就是把全局变成私有,然后把类里面的函数写到外面),豆包的更加专业的话术:

  1. newElement 全局函数,无需创建任何对象,调用方式和new一样随手使用;

  2. 内部依赖HashBucket::useMemory静态接口,依旧不用实例化HashBucket

  3. 内存池核心细节全部锁在MemoryPool私有域,具备封装,和 ACM 全局裸节点无封装形成对比。

具体细节串联太鸡巴复杂了,后面学完这个内存池项目再说吧,然后说下实际工程都是用的现成库:

查看代码
#include <iostream>
#include <list>
#include <forward_list>

int main()
{
    // std::list 双向链表
    std::list<int> lst;
    lst.push_back(10);
    lst.push_back(20);
    lst.push_front(5);

    for(auto v : lst)
        std::cout << v << " ";
    std::cout << "\n";

    // std::forward_list 单向链表(内存开销更小)
    std::forward_list<int> flst;
    flst.push_front(100);
    flst.push_front(200);

    for(auto v : flst)
        std::cout << v << " ";
}
/*
std::list:双向链表,支持前后插入删除
forward_list:单向链表,只有头插,节省内存
*/
 

关于单例又问了无数东西(涉及到知识点和追问后,堪比精啃《C++ 并发编程实战》,弄懂内存重排、内存屏障、缓存可见性):

一路从懒汉饿汉,追到懒汉局部static主流写法,废弃的DCL双重检查锁+原子+内存序的裸指针修正版,学到内存序,学到CAS。

先看懒汉:

查看代码
#include <iostream>
using namespace std;

class Singleton{
public:
    // 获取唯一实例
    //静态成员函数不可调用实例成员,函数内局部静态变量不属于实例成员,因此可以定义使用。
    static Singleton& getInstance(){
        static Singleton ins;//定义
        return ins;
    }
    // 业务测试接口
    void printMsg(){
        cout << "单例对象地址:" << this << endl;
    }

    // 禁用拷贝、赋值,防止产生新对象
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

private:
    // 私有构造,外部无法new创建
    // Singleton() = default;
    Singleton(){
        cout << "构造函数执行" << endl;
    }
};

int main(){
    cout << "main函数开始" << endl;
    Singleton& s1 = Singleton::getInstance();
    Singleton& s2 = Singleton::getInstance();
    s1.printMsg();
    s2.printMsg();
}

禁止外部new创建:啥也不写会自动生无参构造,写禁止无参就不会自动生且禁止无参,而有参是无论写不写都没有自动生这码事,所以不写就无法有参构造。

且如果:

  • Singleton s22;:调用私有默认构造函数,触发访问权限编译报错;

Singleton s22 = Singleton::getInstance();后若执行s22 = s1;,会精准命中被 delete 的赋值重载函数,报错。

  • Singleton s2 = s1;会调用拷贝构造函数,直接报错

禁止拷贝、赋值是类里类外都禁止,如果写Singleton() = delete,饿汉类外Singleton Singleton::instance;失效;懒汉函数内static Singleton ins;失效(这句话看完后面的另一种模式就会理解)。

所以Singleton() = default;构造写法等价手写Singleton(){},私有后,外部Singleton obj;调用无参构造、Singleton()调用无参构造搞匿名临时对象都会禁止。

解释代码:
  • static Singleton& getInstance():类静态成员函数,依托类名直接调用,返回 Singleton 左值引用。

  • 函数内static Singleton ins:函数局部静态对象,生命周期为程序全程,首次执行函数初始化一次。

  • Singleton& s1 = Singleton::getInstance():接收函数返回的左值引用,绑定到唯一静态对象,再次调用静态成员函数getInstance,函数内部局部静态对象ins不会重复初始化,直接复用已经创建好的实例,将该实例的左值引用绑定给s2s1s2引用同一个对象。

  • s1.printMsg():对象调用普通成员函数,this 指针指向 s1 绑定的对象。

实例第一次调用 getInstance 才创建,程序运行前期不创建,偷懒延后初始化,故名懒汉(C++ 11后生效,C++11之前乱七八糟的没固定输出视为不安全),内存池getMemoryPool是函数内局部静态懒汉单例。

此文搜“接限制”但其实人话就是这”得知,放头文件多文件包含不会重定义,全局永远唯一。

懒汉(局部 static 版本):天生无需分离 h/cpp,全都写头文件即可,是工程最常用写法。

注意类内的静态函数无所谓,但类内静态变量必须类外定义(除非整型可以加const类内定义)

关于饿汉模式单例:

查看代码
#include <iostream>
using namespace std;

class Singleton{
public:
    static Singleton& getInstance(){
        return instance;
    }
    void printMsg(){
        cout << "单例对象地址:" << this << endl;
    }
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
private:
    // Singleton() = default;
    Singleton(){
        cout << "构造函数执行: " <<this<< endl;
    }
    static Singleton instance;//声明
};
// 全局域提前初始化
Singleton Singleton::instance;//定义,等同于A a;无参构造,博客搜“所以会警告提醒避坑”可对比回忆

int main(){
    cout << "main函数开始" << endl;
    Singleton& s1 = Singleton::getInstance();
    Singleton& s2 = Singleton::getInstance();
    s1.printMsg();
    s2.printMsg();
}

类体内部static Singleton instance;:类中静态成员只做声明、登记类型,不分配内存、不构造对象,和有无参构造无关。

类外部Singleton Singleton::instance;:属于定义,分配内存、调用无参构造生成实体对象。

类内函数也属于定义,属于定义就调用构造。

区别:

  1. 饿汉:类静态成员定义在全局作用域,main 执行前就完成对象构造,类相关实例提前就绪。

  2. 懒汉:全程无全局静态对象定义,main 开始后、首次调用 getInstance,才创建实例,main 前期类没有实例对象。

所以main执行前,只初始化全局变量、类静态成员变量;

饿汉模式的全局只能有一次类外的定义,所以不可以在.h里搞静态定义。

这是单例设计模式,豆包说C++只需要掌握单例:

Java框架大量运用各类设计模式,面试考察范围广,不仅有单例;C++不依赖框架,仅考核单例。

Java有虚拟机与GC屏蔽底层细节,业务开发依靠设计模式解耦拓展;绝大多数用来做互联网业务开发,业务迭代快、模块繁多,极度依赖设计模式做解耦、拓展,长期工程离不开各类模式。

Java 开发者不需要理解编译链接、内存布局、无锁编程、缓存一致性;而这些都是 C++ 后端面试核心,门槛更高。

C++ 难度远高于 Java,需要手动管理内存,难点集中在内存、并发、网络、操作系统,多用于底层、高性能服务、内核、网络中间件,追求极致性能,很多设计模式会带来额外开销,开发中会刻意少用,日常工程里模式用得很少,面试自然不会大范围考察。

备考总结:Java考点多是行业生态原因,你只需吃透单例,把精力放在内存、网络、并发、算法上即可。

豆包极容易把全宇宙的海量边角知识点,冷门小众,边缘语法,当做必考,无穷无尽的列出,其实很多都他妈是 p7资深底层的东西,所以所有问题一律反复质疑,剔除不考的,所有非高频一律当做不考!只给出不会必挂的!诈骗

关于析构:

自动生的会自动清理对象内部普通成员,手写析构函数可以加一些自定义cout啥的,栈会自动调用析构,栈对象本体自动释放,内部嵌套堆内存必须手动delete,且栈里如果有new出来的成员,需要在析构函数里里delete,如果delete放main里,你找不到恰当时机,栈对象自动执行析构函数会执行里面的delete释放逻辑,如果delete放main里那栈对象里指向堆内存的指针都没了,你还释放个JB了。

堆对象不写delete,就完全不会释放堆对象的任何东西,写了才会释放堆对象,所以智能指针就给堆用的。

堆对象的话,指向他的指针在对象里,是类内的静态成员,不属于对象,存放于静态全局区,不属于任何实例,程序运行全程一直存在,程序退出后由操作系统回收销毁(这句话在附近ptr的例子会有体现)。

析构负责释放堆内存,深浅拷贝决定堆内存如何拷贝,拷贝出错会让析构多次释放内存报错,二者配合管控堆内存。

关于线程:

join是main等子结束再运行,main结束再退出。

detach是main和子同时,main不等子,main结束直接粗暴销毁main和子线程强制中止

无join无detach直接terminate崩溃。且之前误以为join是全程先搞子线程,其实应该是子执行,与此同时同步指向子和join之间的代码!!join只做阻塞而不提前阻塞!

关于lambda:

  1. lambda 是就地匿名函数,省去单独定义 task。

  2. []:捕获外部变量;空括号 = 不捕获任何外部变量。

  3. ():形参列表,线程回调无参数可空。

  4. {}:函数执行体。

  5. 线程场景:直接把单例调用写在 {} 内,无需额外函数。

[](){Singleton::getInstance().printMsg();}
[]  无外部捕获
()  无入参
{}  线程执行逻辑

作用:直接生成线程运行入口,不定义独立 task 函数,即:

查看代码
thread t1([](){Singleton::getInstance().printMsg();});

等价于

void task() {
    Singleton::getInstance().printMsg();
}
int main(){
    thread t1(task);
    //thread构造参数接收可调用对象,即函数名,传入函数地址,不是函数调用
}

关于懒汉写法有个发展历程,引出DLC和atomic,首先局部 static 懒汉(安全):

查看代码
void task(){
    Singleton::getInstance().printMsg();
}
int main(){
    cout << "main函数开始" << endl;
    thread arr[100];
    for(int i = 0; i < 100; i++)
        arr[i] = thread(task);
    for(auto &t : arr)
        t.join();
}
//实测100个线程全是同一个地址

而裸指针:

查看代码
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;

class Singleton{
public:
    static Singleton* getInstance(){
        if(ptr == nullptr)
            ptr = new Singleton;
        return ptr;
    }

    // 加入destroy
    static void destroy(){
        delete ptr;//调用析构
        ptr = nullptr;//堆内存被释放、析构执行完毕,但 ptr 变量依旧保存着原来的旧地址,ptr 不主动清零就会变成野指针,所以销毁函数要额外写ptr=nullptr
    }
    ~Singleton(){
        cout << "析构对象:   ·" << this << endl;
    }

    void printAddr(){
        cout <<"线程获实例:" << this << endl;
    }
    Singleton(const Singleton&)=delete;
    Singleton& operator=(const Singleton&)=delete;

private:
    Singleton(){
        cout << "搞新对象:   " << this << endl;
        this_thread::sleep_for(chrono::milliseconds(1));// 拉长构造耗时,大幅提高多线程同时进入if的概率
    }
    static Singleton* ptr;
};
Singleton* Singleton::ptr = nullptr;//这里不是饿汉那个Singleton Singleton::instance直接调用构造函数搞对象,而是指针置空,构造打印只有new 的时候才触发

void task(){
    Singleton::getInstance()->printAddr();
}

int main(){
    cout<<"————"<<endl;
    thread arr[5];
    for(int i=0;i<5;i++)
        arr[i]=thread(task);
    for(auto& t:arr) t.join();
// delete Singleton::ptr;//错误,因为ptr私有,假设这里没错,这句话的逻辑是自动调用对应对象的析构函数,静态指针ptr程序结束操作系统回收;指向对象执行delete时释放,而ptr不属于对象,是类静态成员,存放于静态全局区,不属于任何实例,程序运行全程一直存在,程序退出后由操作系统回收销毁。
// 所以必须用destroy
    Singleton::destroy();
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
————
搞新对象:   0x7fd5b4000b70
搞新对象:   0x7fd5ac000b70
搞新对象:   0x7fd5b0000b70
搞新对象:   0x7fd5a4000b70
搞新对象:   0x7fd5a8000b70
线程获实例:0x7fd5b4000b70线程获实例:
0x7fd5ac000b70
线程获实例:0x7fd5b0000b70
线程获实例:0x7fd5a4000b70
线程获实例:0x7fd5a8000b70
析构对象:   0x7fd5a8000b70
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
————
搞新对象:   0x7f2250000b70
搞新对象:   0x7f2248000b70
搞新对象:   0x7f2240000b70
搞新对象:   0x7f224c000b70
搞新对象:   0x7f2238000b70
线程获实例:0x7f2250000b70
线程获实例:0x7f2248000b70
线程获实例:0x7f2240000b70
线程获实例:0x7f2238000b70
线程获实例:0x7f224c000b70
析构对象:   0x7f224c000b70
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

/*
输出挤在一起
多条<<拆分输出,无锁导致线程输出穿插
cout << 无论是单个还是连续多个 << 都不是原子
cout << 完整字符串,C++标准没保证原子,只是不容易乱,容易输出【完整】二字后切走CPU,想要一行完整不穿插,必须加锁
printf底层依旧不是原子但底层有锁
*/

启动程序:

先静态 ptr 初始化赋值 nullptr,无对象创建,然后main

创建多个线程绑定 task,全部并发运行。
线程进入 getInstance,读取全局共享的 ptr,多线程同时判空ptr == nullptr;并行执行ptr = new Singleton,堆申请内存,调用私有构造函数 Singleton (),构造函数执行 sleep 延时(std::this_thread::sleep_for(std::chrono::seconds(1));是1s,milliseconds(1));是1ms)、打印当前对象地址,把新对象地址赋值给 ptr,注意已经是依次覆盖ptr了,

getInstanceptr返回,拿到对象指针,调用 printAddr,打印当前线程读取到的对象地址,即各自new出来的对象地址,和当前线程打印地址一一配套匹配,最终ptr只留存最后一个对象地址。

主线程等待所有线程结束。

根据输出知道其他4个线程全JB泄漏啦!!

且有一行没换成,挤到了一起,也体现了cout无锁,两个线程同时往标准输出缓冲区写入,先后写入的两段文字挤在同一行,指针本身互不覆盖。

且发现如果去掉延迟1s,则输出:

查看代码
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
————
搞新对象:   0x7fa560000b70
线程获实例:0x7fa560000b70
线程获实例:0x7fa560000b70
线程获实例:0x7fa560000b70
线程获实例:0x7fa560000b70
线程获实例:0x7fa560000b70
析构对象:   0x7fa560000b70
root@VM-0-7-ubuntu:~/cpp_projects_2# 

线程A判断ptr==nullptr成立,进入new调用,执行构造函数,触发休眠; A未完成ptr赋值,其他线程依次判断ptr依旧为空,全部进入new,各自创建实例。如果没1s,线程A判空、new、赋值ptr一气执行完毕,后续线程判断ptr非空,直接返回指针,所以没延迟哪怕循环1000个线程也看不出错误。

为了解决裸指针懒汉的无锁多线程搞对象不安全(注意至此还都是C++98的事情,豆包说这些理论会考),引入全函数加锁懒汉

static Singleton* getInstance(){
    std::lock_guard<std::mutex> lock(mtx);
    if(ptr == nullptr){
        ptr = new Singleton;
    }
    return ptr;
}

但锁是多个县成功一起抢夺的,你不空也加锁做判断高并发性能极差!即代码没问题但性能差,所以引入双重检查锁定即DCL(双重检查指两次判空,锁定指互斥锁保证同一时刻仅单线程进入创建实例逻辑)(代码有问题)

static Singleton* getInstance(){
    // 第一层检查:实例存在直接返回,不加锁
    if(ptr == nullptr){
        std::lock_guard<std::mutex> lock(mtx);
        // 第二层检查:阻塞等待锁期间别的线程已创建实例
        if(ptr == nullptr){
            ptr = new Singleton;
        }
    }
    return ptr;
}
但多线程并发会有问题,CPU指令重排,代码ptr = new Singleton底层固定 3 步:
  1. 堆分配内存:操作系统会划定一块空闲内存,算出这块内存独一无二的编号(内存地址),这个编号从分配一开始就已经确定下来,只是这块内存此时空空荡荡,里面乱七八糟,还没整理。

  2. 构造函数初始化:必须拿着上面确定好的内存编号,钻进这块内存内部,清理、赋值,把内存整理成合规可用的对象,这件工作只能等内存划分完毕后才能开始,没法提前。

  3. 给 ptr 赋值:ptr 本质就是用来存放内存编号的标记,它只需要拿到第一步产出的内存编号就行,完全不需要等到第二步内部整理工作做完。乱序产生全过程

CPU 为优化速度,会乱序执行,顺序变为 1→3→2:

线程 A:分配内存、ptr 提前赋值(ptr 不再为空),构造函数还没执行完;

线程 B:外层 if 判断 ptr≠nullptr,直接返回 ptr,拿到未初始化的残缺对象,野指针崩溃。所以将ptr声明为原子指针,搭配获取释放内存序即可消除指令重排隐患,即引出原子 + 内存序修正版双重检查锁DCL,但这个写法也就是不考的,C++98的版本,因为那时候还没有如今的局部 static 线程安全规则(懒汉那个),但内功核心依旧是现在大厂主流工程,学会直接极大加分吊打面试官,且通过这个不考的DCL修正版,来学会很重要的并发知识,atomic原子类型,搭配release和acquire内存序规则来隔绝指令重排,这也是操作系统线程、网络IO、原子变量、互斥锁、信号量、进程通信均涉及同步异步的具体代码。

死全家的狗逼豆包总提及volatile解决指令重排,但这他妈是Java 的 volatile,JMM 强制其插入内存屏障,可约束编译器与 CPU 双重重排,C++的绝对无法解决指令重排。

单线程里new 对象会被拆为三条机器指令:①分配堆内存 ②给指针赋值指向该内存 ③在内存里初始化成员,就算内部先给 ptr 赋值、后初始化,全程只有这一个线程在用这个指针,它一定会老老实实等构造函数初始化彻底做完,代码才会往下执行使用对象,中途不会提前读取使用 ptr,自然不会出错,CPU 内部乱序干活,但对外的执行结果不会乱。

多核 CPU 各自拥有独立缓存,多线程代码,A 核心执行新建对象的三条指令时,指令重排允许搞对象指令实际执行晚于指针赋值指令,另一个 CPU 上运行的线程,恰好此时读取全局共享指针拿到了已赋值、但内存内容未初始化的指针,这就是半路偷看,半成品对象由此产生。

外层if:优化性能,已实例化就跳过加锁;

内层if,拿到锁后再次校验,杜绝重复 new:

  1. 对象未创建,线程 A、B 同时走完外层 if,都判定 ptr 为空;

  2. A 抢到锁,B 卡在锁外阻塞等待;

  3. A 在锁内创建对象,释放锁;

  4. B 拿到锁,若无内层 if,B 会再次 new,造出第二个实例。

所以引入内存序:

大厂追求速度必然开优化,只要开优化就有指令重排的可能,所以用内存序来保证:

  • release+acquire叫获取释放序,这里死全家的狗逼豆包的术语极致的误导人,我VScode尝试了2天和豆包对骂最终才精通这个到底咋回事!豆包的术语太歧义了导致我想了无数种理解方式!想复杂了,一直理解错导致以为release设计有什么漏洞,

release当作屏障,比如代码1、2、3,release、4、5,release定死在第4行,1、2、3永远在release前,4、5永远在release之后,但12345内部各自可以打乱,单独 release 本身不会自动对外可见,必须搭配另一线程 acquire 读取该原子标记做匹配,前置修改才会跨线程可见(前置就是release前面的123这三行),否则没有这个说法,这里有个狗逼术语,然后123对外可见,45对外不可见叫单边约束,兼顾效率和顺序。举例子:全局共享变量 x、y,初始全为 0,

线程 1:

y = 10;

原子 x.store (1, release);

线程 2:

auto res = x.load (acquire);

int val = y;

release 绑定 x 的写入:线程 1 里 y 赋值一定会在 x 赋值前完成。

acquire 绑定 x 的读取:线程 2 一旦读出 x 等于 1,读取 y 时拿到的一定是 10,不会是初始 0。

这也就是所谓的,你release后面的不可见的,不能移动到release前面参与被可见这件事。你可见的也不能移动到后面不可见里。

另外这个是单向的,只是说线程2可以看到线程1改的东西,即线程1对2的可见,不存在反过来线程2的什么东西对线程1可见。

store存数据,release阻止写入前代码后移。

再说个误区(我吃饭睡觉做梦走路拉屎都一直在跟豆包对骂,截图发手机看代码)我一直觉得线程a在核心1里改数据,线程b在核心2必然可以读到,因为有缓存一致性,不需要考虑是否立马瞬间写入,就算延迟写入,其他核心线程读也一样可以读到,只要读这个操作执行,就算没没立马同步也会开始同步,读不到的唯一原因就是指令重排导致那句代码根本没执行!所以大厂工程上写各种屏障保障只为了保证指令执行顺序,和缓存没更新同步一点关系没有,但这个观点是错的,缓存一致性是硬件的规则,强制最终会读到,而其他线程想立马即时读到,就需要可见性,即这个内存序获取释放搭配,保证实时!(牛而逼之太高潮了)

  • relaxed叫宽松松散序。完全随意不保证任何顺序。只保证原子。

我一意孤行强行排列组合穷举实验所有内存序组合发现无法复现指令重排这件事,哪怕加延时啥的都不行:

//线程1
x.store(1, memory_order_relaxed);
y.store(1, memory_order_release);

//线程2
int b = y.load(memory_order_acquire);
int a = x.load(memory_order_relaxed);
    
//线程3
int b = y.load(memory_order_relaxed);
int a = x.load(memory_order_relaxed);

1. 代码很短时,编译器会直接把一小段代码做固定打包优化,全程锁定执行顺序,即便允许重排,也不会改动。

2. 业务代码片段零散、穿插大量系统调用、IO、函数跳转,CPU流水线会频繁重置,无依赖指令就容易调换顺序。

3. 短循环代码执行路径一成不变,硬件无需乱序提速;复杂代码路径多变,硬件会依靠乱序提速,重排频繁出现。

而我实验结果就是全都是release这个配对可以成功,即先后顺序,线程2里读到b是1,a一定是1,但我无法复现关于宽松的客观事实,啥客观事实呢?就是宽松的relax,读到 y=1,x 可以是 0 也可以是 1,他不参与那套释放获取内存序的匹配,豆包说宽松内存序的重排不会固定触发,线程调度、编译优化都会压低重排出现的频次,因此很难稳定复现异常结果。

总结,全初始为0,改指的是改为1:

relaxed(宽松)

线程 1:改 x,改 y

线程 2:读 y,读 x

结果:能读出 y=1、x=0。

release-acquire(释放获取)

线程 1:改 x,release 改 y

线程 2:acquire 读 y,读 x

结果:y=1 时,x 必定为 1,只约束这两个线程,即如果来个线程3,不参与线程1和2的匹配释放获取对儿,所以线程 3 不受二者屏障限制,线程 3 可以看到和线程 2 不一样的变量修改顺序(这一点也是我代码始终无法验证的,说大厂高并发高频次上亿复杂CPU啥的才会触发概率)

seq_cst(啥也不写就是默认,叫顺序一致)

线程 1:seq 改 x,seq 改 y

线程 2:seq 读 y,seq 读 x

结果:y=1 则 x 必为 1;如果新增线程 3 读写 x、y,线程 2 和线程 3 看到的修改先后完全一样。和释放获取的区别是,释放获取只规定了这个匹配对儿,而seq是全局都按照代码顺序执行,不止这个匹配对儿,代价就是慢,触发全局缓存同步,频繁锁总线、刷新各级缓存,阻塞 CPU 流水线,耗时远高于宽松、释放获取。

所有内存序哪怕默认也都有指令重排(非C++代码调换,只是机器指令),但默认的重排后全局顺序固定,所有标记为默认的机器指令,假设规定是ab,那么所有线程看到的都是ab顺序,只不过线程执行是CPU执行,CPU可能执行重排为ba。

而release-acquire是匹配的才有这个看到ab这个顺序约束。

而宽松的relaxed是连ab这个顺序都没有。

查看代码
std::atomic<int> x{0}, y{0};

// acquire-release 标准配对
void set_rel(){
    x = 99;
    y.store(1, std::memory_order_release);
}
void get_acq(){
    while(y.load(std::memory_order_acquire) == 0);
    // 一定读取到x=99
}

// acq_rel 读写一体
void set_acqrel(){
    x = 99;
    y.store(1, std::memory_order_acq_rel);
}
void get_acqrel(){
    while(y.load(std::memory_order_acq_rel) == 0);
    // 同样一定读取到x=99
}

另外重排还分编译器重排(即开优化),和CPU硬件执行时候重排。

简单练习:

查看代码
void writer() {
    a.store(5, std::memory_order_release);
    b.store(1, std::memory_order_release);
}

void reader() {
    while (b.load(std::memory_order_acquire) != 1);
    int res = a.load(std::memory_order_acquire);
}

线程 1 先用 release 写入 a,再 release 写入 b;

线程 2 循环 acquire 读取 b,读到 1 后,依靠 acquire-release 配对,一定能读到 a 被修改后的5。

至此内存序的东西 ,我追问了7天(发现25年2月就涉及到过宽松seq这些内存序的东西在 百度文心一言

所以有如下代码(DCL双重检查锁+原子+内存序屏障版懒汉,这个也就是不考的废弃的,但通过这个学到的必考的atomic内存序极致重要加分的东西)

查看代码
#include <iostream>
#include <mutex>
#include <atomic>
#include <thread>
#include <chrono>
class Singleton{
private:
    // 原子指针
    static std::atomic<Singleton*> ptr;
    static std::mutex mtx;

    // 私有化构造、拷贝、赋值,杜绝外部创建实例
    Singleton(){std::this_thread::sleep_for(std::chrono::milliseconds(1));};
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

public:
    static Singleton* getInstance(){
        Singleton* tmp = ptr.load(std::memory_order_acquire);//括号内空就是默认
        if (tmp == nullptr){
            std::lock_guard<std::mutex> lg(mtx);
            tmp = ptr.load(std::memory_order_relaxed);
            if (tmp == nullptr){
                tmp = new Singleton;
                ptr.store(tmp, std::memory_order_release);
            }
        }
        return tmp;
    };
    void testPrint(){
        std::cout << "单例对象正常调用" << std::endl;
    }
};

// 类外初始化静态成员
std::atomic<Singleton*> Singleton::ptr{nullptr};
std::mutex Singleton::mtx;

int main(){
    Singleton* p1 = Singleton::getInstance();
    Singleton* p2 = Singleton::getInstance();
    std::cout << (p1 == p2 ? "同一个实例" : "不同实例") << std::endl;
    p1->testPrint();
}

Q:tmp不是原子,判断的时候不用原子吗?

A:tmp 是普通临时指针变量,仅用来存放原子 ptr 的读取副本,不存在多线程同时读写 tmp,全程只有当前线程单独读写,不需要原子修饰。

Q:判断的时候别人改了咋办!

A:外层 if 判断用到的 tmp,来自原子 ptrload 读取,ptr 本身是原子变量,别的线程修改 ptr 只能通过原子 store 完成,ptr 的读写本身具备原子性,读取拿到的一定是 ptr 某一时刻完整有效值;就算读取瞬间其他线程修改 ptr

  • 读取到空:线程进入抢锁逻辑,锁会阻塞并发修改,临界区内二次校验 ptr,杜绝重复创建;

  • 读取到非空:直接返回实例,不再进入创建逻辑。

返回前 tmp 是本次线程原子读取得到的快照,ptr 即便被其他线程修改,当前线程只会用本次拿到的 tmp 做返回值,不会中途变动。

Q:如果返回时候被改了难道不需要用改后的吗?还用快照?

A:getInstance 只会返回本次读取到的快照 tmp,不需要改用后 ptr 的值。实例只会被创建一次,ptr 一旦被 store 赋值为非空指针,后续所有线程只读取不修改 ptrptr 永久固定不变,不存在返回途中 ptr 被改写的场景。ptr 始终指向唯一对象,就算反复读取 ptr,新旧快照指向同一个地址,快照与最新 ptr 取值完全等价,使用快照不会出错。

Q:靠啥单例的?

A:就靠如果非空就returnC++98之前局部静态static没规定全局唯一,现在主流用局部静态懒汉,当时用的就是这个if判断,但只要写这个废弃的不考的 DCL 版本必然涉及到要用内存屏障啥的,所以因祸得福歪打正着学到了很重要的原子、内存屏障、多线程同步,是 LinuxC++ 后端面试核心考点。

代码逻辑流程:

1、第一次读取:ptr.load (std::memory_order_acquire)

执行位置:首次外层空判断读取原子指针 ptr

流程:线程读取 ptr 当前值,acquire 内存屏障保证本线程后续所有读写操作,不会被编译器、CPU 重排到本次 load 读取之前。

作用:一旦读取到 ptr 不为空,说明其他线程已经完成 new 对象 + store 写入 ptr,当前线程后续使用该实例的代码不会提前乱序执行,不会拿到半初始化的对象。

2、加锁后二次读取:ptr.load (std::memory_order_relaxed)

执行位置:互斥锁上锁完毕,二次校验空指针。

流程:互斥锁已经把临界区串行化,同一时刻仅有一个线程进入该代码段,不存在多线程并发读写 ptr

relaxed 无读写屏障,仅保证原子本身读写原子性,不做跨线程同步、禁止指令重排约束。

优势:省去 acquire 多余的屏障开销,锁已经保障线程安全,用最轻量级内存序减少性能损耗。

单线程重排不会把变量使用排到变量读取之前。

备注:被互斥锁包裹、多线程会并发读写共享变量的代码段叫临界区。

3、对象创建写入:ptr.store (tmp, std::memory_order_release)

执行位置:new 出实例后,把实例指针写入原子 ptr

流程:release 屏障强制new 对象内部所有构造初始化读写,全部重排到本次 store 写入 ptr 之前执行完毕。

作用:该 store 对外可见时,Singleton 构造函数已经完整执行结束,配合其他线程 acquire 读取,严格构建释放 - 获取同步配对,其他线程 acquire 读取到该指针时,必然能拿到初始化完成的完整对象,杜绝悬空、半构造野指针。

其实最优就是release+acquire的组合控制危险重排,然后就是牺牲性能的默认内存序seq_cst依旧控制危险重排,而宽松不指定任何是relaxed,容易出现指令重排危险错误。

Q:这里没意义吧?不存在可能会移动的其他代码啊?

A:new分为三步:分配内存、构造初始化、指针赋值。CPU和编译器可把指针赋值提前到构造之前,release用来锁住该乱序。

无意间知道:pthread所有东西都是不考的,是Linux下C的体系线程库,而C根本没任何标准线程库,C++有全平台通用的std::thread。之前无穷无尽貌似学的都是pthread的哎!!2年半的时间90%学的全是不考的和错的东西。

总结:

所以静态裸指针只规定了全程同用同一个指针变量,但不固定实例本体,多线程可新建多个对象赋值给他,而饿汉实例类内静态对象,全局唯一,所以饿汉永远线程安全,始终无错误。

裸指针懒汉C++98版本,类内静态指针,多线程不安全,需要靠原子和内存序来保安全。

C++11后彻底弃用,优先类函数内局部 static 对象懒汉,不需要延迟加载时依旧可以用饿汉。

关于连等:

#include <iostream>
int main(){
    int a = 2, b = 2, c = 1;
    bool res1 = (a == b == c);
    bool res2 = (a == b && b == c);
    std::cout << res1 << " " << res2 << std::endl;
}

a==b==c:先算a==b,结果只有 1 或 0,再拿这个 0/1 和 c 比较。

a==b && b==c:判断三者数值全部相等。

例:a=2,b=2,c=1

前者:(2==2)==1 →1==1→成立

后者:2==2&&2==1→不成立

~~~~(>_<)~~~~

无尽黑暗何时有我的出头之日啊,我的春天在哪里?我不想一辈子都活在粪坑垃圾堆里。

我钻研CAS、atomic、release、acquire、seq_cst、穷举所有排列组合的代码组合,钻牛角尖一样学,貌似超纲,如何弥补浪费掉的时间(变现学的这些不考的东西),对冲履历,是否走错了路学深了,学了一堆高级后端的东西!不考的、错的、废弃的DCL、内存序、CAS

妈的博客园改版了,之前几十万字的文字,如今8w字不到就提示这个了 image

辱骂poj/hdoj加载不进去了、编程指北挂了、之前豆包升级懒加载无法ctrl+f搜全部问答,如今博客园只能10w字符实际也就6w,发现也挺多,豆包说只限制网页,用博客园插件在VScode上发布,妈的就是后台markdown格式,依据无法突破限制。

删除豆包回答后,直接大块“内存”消失,下面的问答会挤上来,需要上划找到接续,所以应该比如删除10条问答,不要在第10条页面执行删除,在第一条执行删除。

提示词:直白具象、极致精简、只讲具体行为,摒弃抽象笼统词汇。

豆包会修改发送的文本,网页端 Markdown 渲染引擎解析冲突,涉及到代码最好加``

20260814更新,妈逼的博客园太爽了,取消了短暂的10w上限!!O(∩_∩)O~~

百度问大厂LinuxC++高性能服务端开发考ABA、CAS吗? 发现

发现别人暴露的问题欣慰!没学多!没白学!抖音

 

以上通过单例(懒汉 / 饿汉)学了点皮毛,算是引言,然后开始学整体知识框架,其实是我自己的思考与追问才引出的更加重要的知识点:

首先注意:

  • 原子:绑定一组机器指令,执行中途不会中断。
  • 内存序:规定指令实际执行的先后顺序。

  • 锁:同一时间只允许一个线程运行指定代码。

我本身就是问了豆包一句,原子是否就是打包一行C++代码,使得n++只要n是原子就行,比如tmp = new Singleton;只要原子就行,结果引出一个我一直理解错的东西!

我一直误以为有了原子,就可以阻止乱序,大错特错!

1、tmp 作为线程私有局部变量,内部三步永远可以 132 乱序,屏障改不了线程内部 tmp 的执行顺序;

2、内存屏障、原子修饰只约束:tmp 向全局原子 ptr 赋值这一步,绝不允许 ptr 提前被赋值;

3、屏障的意义不是规整 tmp 内部的执行顺序,是截断乱序向外扩散。 

单线程也会指令重排,会乱序,只不过单线程的乱序不会带来错误,因为单线程比如tmp = new Obj; 底层三步:

分配内存,拿到原始地址、②调用构造函数初始化对象、③将地址赋值给 tmp

但对于:

tmp = new Obj;
ptr = tmp;
ptr->func();

单线程时候(ptr和tmp都是自己线程内的变量)就会有约束,不会允许重排为(这里先访问后构造):分配内存→tmp 赋值地址→执行 ptr=tmp→执行 ptr->func ()→运行 Obj 构造函数。

而多线程比如tmp是自己内部的变量,但ptr作为全局共享,无法被跨线程约束,单线程底层规则会自动约束不要在构造ptr之前访问ptr,而多线程里,A线程约束完,ptr有值了,但还没构造,B线程发现ptr非空,直接返回ptr,结果其实是错误半成品,所以必须内存序保障。

开优化后,内存序、原子、锁都会限制指令重排,只不过单线程不限制也不会出错,多线程有插一脚进来读取垃圾值的风险。

原子读写搭配内存序,可以限制指令重排,单纯原子没内存序仅保证访存不可拆分,不约束重排。

可是 C++11 后直接单纯原子默认内存序为顺序一致,会严格限制跨线程相关指令重排,但单线程无关指令依旧允许编译器与 CPU 重排,并非所有指令完全禁止重排。另外再注意一点,非原子依旧会有重排危险,只有原子才C++11的默认顺序一致没危险,如果把普通变量掺乎进来也会有重排危险。

一行C++代码编译为多条CPU指令,原子只打包锁住其中单条硬件指令,整行代码不会被打包为不可分割的整体,仅能保证单次读取 / 单次写入 / 单次 CAS这一个硬件指令不可拆分,一般正常写代码都是硬件天然一次性读完,不会出现数据撕裂,强制搞非对齐地址读写、跨缓存行读写,用普通读写会拆成两次读取,读到一半旧值一半新值,也就是数据撕裂;

而原子读写:无论地址是否对齐,硬件强制单次完整读写,永远杜绝撕裂,但这一点也是我始终都无法复现的,即我无法模拟高并发亿次高频复杂访问,所以咋写代码都自动对齐,无论有没有原子都无法复现读取撕裂值的事情,但不声明为原子就无法用配套的release这些内存序,造成后续严重问题。

提一嘴自动对齐:64 位机子上,放得规整的 8 字节数字,比如结构体里:int a; long b;

  • a占 0‑3;

  • b要 8 字节对齐,跳过 4‑7填充,从偏移 8 开始存放;

  • 结构体总大小 16。

结合双重检查锁收尾串联:

  1. 不用原子:ptr 是普通指针,CPU 乱序,赋值提前执行,外部线程拿到未初始化的残缺对象;

  2. 加上原子:约束 ptr 的 store 赋值指令不能提前重排,杜绝半初始化对象泄露,原子只管控 ptr 指针赋值,不会把 new Singleton 三行指令打包锁死;

  3. 外层判断:减少加锁损耗,内层判断:防止多个线程阻塞后重复创建实例,和原子无关。

继续补充下:其实原子的作用根本不是打包,而是通过原子类型导致可以使用内存序,

而刚才说到很少有不对齐的数据,所以一次性就可以读完整,一般两次访存才有撕裂问题(这是硬件层面),但就算一次访存,也会由于数据竞争(软件层面),导致数据未定义行为,而原子哪怕没对齐,哪怕对齐了高频次数据竞争,都不会有读数据读一半被打断的问题,而非原子会有这个问题,只不过非高并发下无法复现,且除了这个更重要的是,非原子。

再改进下代码,此文搜“(DCL双重检查锁+原子+内存序屏障版懒汉,这个也就是不考的废弃的,但通过这个学到的必考的atomic内存序极致重要加分的东西)”,testPrint改成:

void testPrint(int tid, int idx, std::mutex& printMtx){
    std::lock_guard<std::mutex> lock(printMtx);
    cout << "线程" << tid << " 第" << idx << "次,地址:" << this << endl;
}

main改成:

查看代码
int main(){
    std::mutex printMtx;
    Singleton* g_p1 = nullptr;
    Singleton* g_p2 = nullptr;
    Singleton* g_p3 = nullptr;

    std::thread t1([&](){
        auto p1 = Singleton::getInstance();
        p1->testPrint(1,1, printMtx);
        auto p2 = Singleton::getInstance();
        p2->testPrint(1,2, printMtx);
        auto p3 = Singleton::getInstance();
        p3->testPrint(1,3, printMtx);
        std::lock_guard<std::mutex> lock(printMtx);
        if(p1== p2 && p2==p3)
            std::cout << "线程1内部全部相等\n"<<endl;
        g_p1 = p1;
    });
    
    std::thread t2([&](){
        auto p1 = Singleton::getInstance();
        p1->testPrint(2,1, printMtx);
        auto p2 = Singleton::getInstance();
        p2->testPrint(2,2, printMtx);
        auto p3 = Singleton::getInstance();
        p3->testPrint(2,3, printMtx);
        std::lock_guard<std::mutex> lock(printMtx);
        if(p1== p2 && p2==p3)
            std::cout << "线程2内部全部相等\n"<<endl;
        g_p2 = p1;
    });
    
    std::thread t3([&](){
        auto p1 = Singleton::getInstance();
        p1->testPrint(3,1, printMtx);
        auto p2 = Singleton::getInstance();
        p2->testPrint(3,2, printMtx);
        auto p3 = Singleton::getInstance();
        p3->testPrint(3,3, printMtx);
        std::lock_guard<std::mutex> lock(printMtx);
        if(p1== p2 && p2==p3)
            std::cout << "线程3内部全部相等\n"<<endl;
        g_p3 = p1;
    });

    t1.join();
    t2.join();
    t3.join();

    std::lock_guard<std::mutex> lock(printMtx);
    if(g_p1 == g_p2 && g_p2 == g_p3)
        std::cout << "所有线程获取的实例地址相同,全局唯一单例" << std::endl;
    else
        std::cout << "存在不同实例" << std::endl;
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
线程2 第1次,地址:0x7f7230000b70
线程2 第2次,地址:0x7f7230000b70
线程2 第3次,地址:0x7f7230000b70
线程2内部全部相等

线程3 第1次,地址:0x7f7230000b70
线程3 第2次,地址:0x7f7230000b70
线程3 第3次,地址:0x7f7230000b70
线程3内部全部相等

线程1 第1次,地址:0x7f7230000b70
线程1 第2次,地址:0x7f7230000b70
线程1 第3次,地址:0x7f7230000b70
线程1内部全部相等

所有线程获取的实例地址相同,全局唯一单例
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

即用上多线程匹配内存序,也更加深刻认识到之前思考内存序有很大的误区,这破逼玩意也是真的晦涩!!!

单线程用acquire-release毫无意义,多线程里我误以为,此文搜“思考对比自旋锁 VS 互斥锁:”,其实写那段的时候还是有误区,内存序不是把a++代码夹在acquire和release之间!!!准确的说,这玩意是个瘸腿,A线程release发布数据,B线程acquire读到该值后,B内load之后代码不会被重排到load之前,同时能看见A在release之前所有写入。

也即是说写了:

acquire
业务代码
release

完全不能认为同一个线程内业务代码死死包在release-acquire之间,这里建立对象时候,只有release生效,acquire无效,其他后续读到非空ptr的线程:acquire生效,和前面release形成同步,看见完整对象。

具体:

线程新建 Singleton 对象后,使用 store+release 把指针写入原子变量。release 保证对象构造所有操作先于指针写入,代码原文:

tmp = new Singleton;                // 分配内存 + 执行构造函数
ptr.store(tmp, std::memory_order_release);

memory_order_release约束: new Singleton 里面所有读写操作,绝不允许重排到 store 这一行之后。

然后其他线程执行 load+acquire 读取 ptr,如果读到有效指针,acquire 保证后续访问对象的代码不会重排到读取指针之前,确保访问完整对象;指针非空直接返回,跳过加锁流程。若读到空指针,进入加锁区域,锁内 relaxed 读取指针,确认为空再创建实例,这里我的思考是人家其他线程都创建完了,你还搁这有啥必要非得去防止不能移动到load之前啊,其实我想错了,想表达的是,配套的:

Singleton* tmp = ptr.load(std::memory_order_acquire);

acquire 立下规矩: 凡是这行代码后面的代码,不准跑到这行代码前面执行,为啥有这个必要呢?因为拿到 tmp 的调用方(main、各个线程),拿到返回值之后立刻就会 p->testPrint()。 这段访问对象的代码,处在 load (acquire) 的后方,你不能其他线程上来就整这么一出,想搞访问,你必须先判断读取是不是空。

爷牛逼吗

 

以上是单例衍生的东西,所以紧随其后的讲了,但其实应该先懂点基础:

科普个硬件流程:

你不能直接在 CPU 上写代码。代码运行依托内存,CPU 只负责读取内存指令执行。

硬件:x86_64 CPU(执行指令的计算单元) + 内存(临时存放正在运行的程序与数据,断电清空) + 硬盘

  • 硬盘存放操作系统;

  • 开机后系统加载进内存;

  • CPU 从内存读取操作系统指令开始运行。

递进关系: CPU 架构(x86_64)决定你只能编译对应架构版本的操作系统;操作系统调度硬件资源,再运行应用程序(你的 C++ 程序)

Q:64 位是个啥JB玩意?

A:64 位描述的是CPU 通用寄存器一次能处理的数据宽度

我们日常说的 64位,就是 x86_64,用于主流 PC / 服务器 CPU 架构。

而小众的 64 位,还包括安卓机、嵌入式、老电脑用的架构:aarch64(ARM64)、riscv64

层级关系: CPU 架构 → 决定 CPU 是 32 位 / 64 位 → 操作系统必须匹配对应架构才能运行。

Q:x86_64 咋感觉像 32 位的玩意?

A:x86 ≠ 32 位 x86 是一套 CPU 指令集名字。

  • x86:最初指代 32 位指令集

  • x86_64:在 x86 基础上扩展出的 64 位指令集(主流服务器、个人电脑 CPU 都是它),常说的64位其实全名叫x86_64,Linux 只是操作系统,Linux 可以跑在不同种类 CPU 上:x86_64、aarch64 等,只不过一般跑在x86_64

另外,C++ 标准只定义std::atomic对外接口行为,不规定 CPU 层面用什么指令实现。标准只保证:操作具备原子性,不会出现半截修改。CPU 硬件架构决定底层实现方案。 

事实上:

C++ 标准只定义接口行为,编译器根据 CPU 架构选底层指令;x86_64 硬件同时具备两套独立 RMW 指令:fetch_add 和 CAS;

只有缺少专用算术 RMW 指令的 CPU(安卓、嵌入式老旧机),只有CAS,没有fetch_add,所以编译器才会内部用 CAS 循环模拟 fetch_add。

概念层级(标准定义)

起始是硬件原语,即不可被中断的基础原子操作,最重要的就是RMW(读 - 改 - 写)大类,包括两种:

  • CAS(比较交换):属于条件型 RMW

  • fetch_add(获取并加法):属于专用型 RMW

以上硬件原语,而程序员无法直接用,所以 C++ 标准包装成俩接口:

  • compare_exchange_strong/weak(底层走的就是CAS)

  • fetch_add(底层走fetch_add)

以上硬件原语拓展出了通过 C++ 接口使用,

那还有就是软件原语,即部分架构(老旧安卓手机、嵌入式开发板)无原生 fetch_add 硬件原语时,编译器会用硬件原语CAS,结合while循环,再结合C++接口compare_exchange 实现自增模拟 fetch_add,即:

int old;
while (true) {
    old = val.load ();
    if (val.compare_exchange_weak (old, old + 1)) 
        break;
}
//或者
int old = val.load();
while (!val.compare_exchange_weak(old, old + 1));

而理论上全局锁也可以模拟fetch_add:

#include <mutex>
int val = 0;
std::mutex global_atomic_lock; // 全局锁模拟原子
int add(){
    std::lock_guard<std::mutex> lock(global_atomic_lock);
    val++;
    return val;
}

一般就直接用原子的fetch_add或者a++,全局锁模拟只是理解这个底层逻辑,CAS模拟实在大材小用,后续给出CAS真正的作用。

对比全局锁的性能优势:

  • CAS:多核多个线程可同时执行尝试修改,仅仅循环,没锁

  • 全局锁:同一时刻仅一个线程执行,其余阻塞等待

场景: 

  • 单纯数字自增:优先 fetch_add;

  • 需要条件判断、实现锁、自定义原子逻辑:必须用 compare_exchange (CAS);

  • fetch_add 不能替代 CAS,CAS 可以模拟 fetch_add,但 x86_64 没必要手写模拟。 

那以上说完了RWM读-改-写,那还有:

  • 普通的原子读,C++接口是load

  • 普通的原子写,C++接口是store,

同样有硬件但不用掌握,就掌握C++接口即可

总结:

原子内存操作
├─ 非RMW操作原语
│  ├─ atomic load 原子读
│  └─ atomic store 原子写
│
└─ RMW 硬件原语(读-修改-写)
   ├─ 分支A:CAS类
   │  └─ compare_exchange_weak / compare_exchange_strong
   │
   └─ 分支B:fetch运算类
      ├─ fetch_add
      ├─ fetch_sub
      ├─ fetch_or
      ├─ fetch_and
      └─ fetch_xor

练手:

#include <atomic>
using namespace std;

atomic<int> num{0};

// 枝干1:load 读取,挂载内存序参数
int a = num.load(memory_order_acquire);

// 枝干2:store 写入,挂载内存序参数
num.store(1, memory_order_release);

// 枝干3:CAS修改,挂载内存序参数
int old = 1;
num.compare_exchange_strong(old, 2, memory_order_acq_rel);

其中load、store是基础读写,用来做开关、计数、DCL 单例,

CAS 是原子自带的一种专属修改手段:拿变量当前值和预期值对比,一致就换新值,不一致就修改失败,自旋锁完全依靠 CAS 写出来的衍生锁。

内存序memory_order_xxx:只是三类操作都能附加的配置,管控指令乱序,不属于独立操作·

三条线串联总结

  • 要简单保护代码块、不在乎 CPU 空耗:走接下来说的普通锁家族。

  • 要极致高性能、低耗时:走原子,简单读写用 load/store,争抢修改用 CAS 做自旋锁。

  • 内存序只跟着原子走,普通锁不用内存序。

 

普通的变量a,a++分为内存读取、寄存器自增运算、写回内存,多线程并发一定数据错乱。

原子的std::atomic<int> a,a++并发绝对不会出错,因为底层用CAS循环重试。

但只是自增操作a++包涵的读取、自增、写回这三个机器指令打包,不会被其他线程打断,如果n = a++;假设初始 a=5,线程 1 运行a++读出 5,算出 6,写回 a,a=6。原子操作结束,此时调度切换,其他线程执行a = 100,a 变成 100,切回线程 1,把预先保存好的 5 赋值给 n,最终 n=5,a=100,表达的就是赋值并不是原子。

小知识:

后置自增 n++:先使用当前值,再自增。

前置自增 ++n:先自增,再使用新值。

fetch_addfetch_sub属于原子变量的RMW(Read-Modify-Write,读 - 改 - 写)操作:
std::atomic<int> n{5};
int q = n++;
// 等价于
int q = n.fetch_add(1);

n++只能自增 1,无法指定内存序;fetch_add支持自定义增量,并且可以手动指定内存序。

不传入第二个参数时,默认使用std::memory_order_seq_cst顺序一致,加参数的话num.fetch_add(1, std::memory_order_relaxed);第二个参数手动指定内存序,不再使用默认顺序一致,seq_cst相比relaxed开销更大。

std::atomic<int> num{10};
int old1 = num.fetch_add(3);
  1. 读取 num 当前值 = 10;

  2. 计算 10+3=13;

  3. 把 13 写回 num;

  4. 将刚才读到的 10 作为返回值赋值给 old1。

结果:old1 = 10、num = 13,和int q = n++; 逻辑完全一致。还有自减fetch_sub

 

Q:那CAS自旋锁和互斥锁mutex咋回事?

A:普通锁家族(操作系统内核实现,和原子无关),

成员:互斥锁 mutexlock_guardunique_lock ,说到他们的关系就不得不提一下 RAII,RAII 只是编程思想:对象构造获取资源,对象析构释放资源,这个思想有 4 大应用,lock_guardunique_lockunique_ptrshared_ptr

其中lock_guardunique_lock属于mutex分支;unique_ptrshared_ptr属于内存管理分支,即 RAII 应用里有两个是和我们讨论的锁有关的,分别说下:

  • lock_guard:先std::mutex mtx声明下mtxstd::mutex是标准库锁类,定义出的mtx是该类实例对象,然后定义std::lock_guard<std::mutex> abc(mtx)时,调用 lock_guard 构造函数,自动执行 mtx.lock () 上锁,代码块结束,abc 销毁,自动调用 lock_guard 析构函数,执行 mtx.unlock () 解锁,abc是自定义对象名,是局部栈对象,不是临时匿名对象,全程不需要主动调用它的成员,仅依靠自身生命周期管控锁,定义瞬间构造上锁,作用域结束abc 销毁,自动解锁。

  • unique_lock:适配多段临界区场景,一段逻辑加锁、中间片段释放锁、后续再次加锁,默认构造unique_lock 传锁变量但不加defer参数自动绑定互斥锁,构造自动上锁,析构自动解锁,搭配 defer_lock 构造,仅绑定锁,构造不上锁,上锁需要手动调用 lock,析构依旧自动解锁(原生 mutex无法自动加解锁,必须手动上锁解锁,不支持锁转移、延时锁、中途解锁,unique_lock依靠析构自动兜底解锁,可手动加解锁、延时上锁、锁所有权移动,适配条件变量lock_guard仅构造上锁、出作用域自动析构解锁,全程无手动操控空间)

    std::mutex m;
    void func(){
        std::unique_lock<std::mutex> ul(m, std::defer_lock);//defer_lock延迟上锁:构造unique_lock,仅绑定互斥量,不自动加锁
        ul.lock();
        ul.unlock();
        ul.lock();
    }

支持锁转移:unique_lock 可移动锁所有权,lock_guard 禁止移动与拷贝。

    • 移动构造
      std::mutex mtx;
      std::unique_lock<std::mutex> lk1(mtx);
      // 把mtx的锁所有权从lk1转移给lk2,lk1失效
      std::unique_lock<std::mutex> lk2(std::move(lk1));//lk1构造默认自动上锁,移动后lk1不再持有锁,lk2承接锁。
    • 函数返回转移(函数内部上锁,将锁带出函数作用域到外部继续管控)
      std::unique_lock<std::mutex> get_lock(std::mutex& m){
          return std::unique_lock<std::mutex>(m);
      }
      // 接收返回的锁对象,完成所有权转移,函数内临时对象构造时自动上锁,返回触发移动构造,锁所有权转移至lk,lk持有锁
      auto lk = get_lock(mtx);
  • unique_ptr
    std::unique_ptr<int> p(new int(10));
    // 出作用域自动释放堆内存,不可拷贝
  • shared_ptr
    std::shared_ptr<int> p1(new int(20));
    std::shared_ptr<int> p2=p1;
    // 引用计数归零自动释放内存

总结:

  • 原生互斥锁:需频繁手动上锁解锁、临时短时锁、极致压榨性能时使用。

  • lock_guard:仅单一作用域加锁、无需手动解锁,优先选用。

  • unique_lock:需要中途解锁、延时上锁、条件变量搭配、锁转移场景使用。

用途:并发代码块保护,竞争激烈、等待久优先用。 

思考对比自旋锁 VS 互斥锁:

查看代码
#include <atomic>       
#include <thread>       
#include <iostream>     
#include <chrono>       // 高精度时间计时
#include <mutex>        

// 自旋锁实现
class SpinLock {
    std::atomic<bool> locked{false}; // 原子标记,false=无锁,true=持有锁
public:
    void lock() {
        bool exp=false;
        while (!locked.compare_exchange_weak(exp, true, std::memory_order_acquire, std::memory_order_relaxed)){
            exp=false;//被豆包误导起初没写这句话!!!妈逼的导致a输出发现居然不是200000
        }

        /*
            compare_exchange_weak(期望,新值,成功内存序,失败内存序)
            具体:compare_exchange_weak(..., acquire, relaxed)

            CAS成功(抢到锁):acquire
                限制:临界区 a++ 不能重排到 CAS 之前,即用CAS就是用compare_exchange_weak,只要spin.lock(),那么spin.lock()下的a++不可以搞到spin.lock()之前,再次吐槽狗逼C++标准的晦涩术语!!
            CAS失败(自旋重试):relaxed
                限制:由于没拿到锁,不需要内存序
        */
    }
    void unlock() { 
        locked.store(false, std::memory_order_release);
        /*
            unlock()
            store(false, release)
            限制:临界区 a++ 不能重排到 store 之后
        */
    }
    /*
        正常我写的是:
        while (!locked.compare_exchange_weak (exp, true)); 
        locked.store (false);性能差
    */
};

SpinLock spin;   // 全局自旋锁实例
std::mutex mtx;  // 全局mutex实例
int a = 0;
int b = 0;

// 自旋锁测试函数
void work_spin(){
    for(int i=0;i<100000;++i){ // 循环10万次
        spin.lock();           // 获取自旋锁
        a++;                    // 临界区,修改变量a
        // b++;                    // 临界区,修改变量b
        spin.unlock();          // 释放自旋锁
// unlock内部:locked.store(false, std::memory_order_release);,这行就是【release写】:修改原子变量locked = false
//线程B不断自旋while (!locked.compare_exchange_weak(...),CAS作用:读取locked的值,当读到false,并且成功改成true,代表抢到锁,且线程A的解锁release保证了a++不可能重排到unlock之后,所以线程B一定可以看到a++
    }
}

// mutex测试函数
void work_mutex(){
    for(int i=0;i<100000;++i){ // 循环10万次
        mtx.lock();             // 获取互斥锁
        a++;
        // b++;
        mtx.unlock();           // 释放互斥锁
    }
}

int main(){
    using clk = std::chrono::steady_clock; // 别名:高精度单调时钟
    auto t1 = clk::now();                  
    std::thread tA(work_spin), tB(work_spin); 
    tA.join(); tB.join();                 
    auto t2 = clk::now();                 
    std::cout << "自旋锁耗时(us): "
              << std::chrono::duration_cast<std::chrono::microseconds>(t2-t1).count() << " "<<a<<"\n"; // 输出微秒耗时

    a = 0; b = 0;   // 重置计数器
    auto t3 = clk::now();                 
    std::thread tC(work_mutex), tD(work_mutex);
    tC.join(); tD.join();
    auto t4 = clk::now();                  
    std::cout << "mutex耗时(us): "
              << std::chrono::duration_cast<std::chrono::microseconds>(t4-t3).count() << "\n"; 
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
自旋锁耗时(us): 5269
mutex耗时(us): 8126
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

自旋锁:A自旋等待锁;B持有锁执行临界区;B释放锁后A抢锁执行。

但这里自旋和mutex互斥锁差别不是很大,且经常自旋更久(持续自旋会占用CPU核心,无法让出CPU,其他线程没法运行,算力被白白消耗),无法实际写出差很大的代码,临界区短了适合自旋锁,长了适合mutex,长短无法界定,且据说mutex内部也动态的自适应自旋,吃透原理就行。

  1. compare_exchange_weak失败时循环反复尝试,全程用户态循环,不触发系统调用、不进入内核休眠,这就是自旋空转。

  2. std::mutex抢锁失败会调用内核接口,线程休眠;锁释放后内核调度唤醒,存在上下文切换开销,所以耗时更高。

Q:老子直接原子不行吗!!(辱骂豆包大爆发,居然大厂linuxC++高性能服务端开发薪资持平大厂业务Java)

乔峰、成龙、遥遥不见天日、我的春天在哪里?永无出头之日、可以忍受一切却得知2年无法追平大厂CRDU的Java,得知C++上限很低,费力不讨好而哭、C++是犯了天条吗?一辈子矮人Java一头吗!入职后5年不停学习都依旧比大厂的Java低

又是一无所有前功尽弃的一天,何时是个头啊!第一次有了不想再学c++的动摇!之前说此文搜自律能力真的远远不够,因为没方向豆包全错,如今居然发现持续学满3年才能追平大厂cudr的Java???这你麻痹我还学啥?我一直觉得自己杀出一条路倒计时逼自己,最后年薪超级高,一步一个脚印周期慢我接受,结果现在来一句这只是普通必经之路?是c++的岗位行情市场劣势,必须这么学才能追平而不是早早甩开吊打。。。

A:a++可以直接原子,但如果多句话就不行了(后面的生产者和消费者问题就是最好的例子),就必须mutex和自旋锁选一个,这个例子也就说咋选的问题。

短短70行,看似代码不多,难点不在代码行数,而是并发时序、内存序、内存回收三大坑,稍不注意就出现野指针、丢数据、ABA问题。

妈逼的上面的条件变量又一屁眼子海厚海厚的知识点,艹你妈啥时候是个头啊!!

 

插一段CAS:

load() = 原子读 store() = 原子写 二者是两条独立指令,无法捆绑成 “原子的判断 + 修改”

举个反例,只用 load + store 能不能实现自旋锁抢锁?

// 错误写法,线程不安全!
bool exp = locked.load();
if (exp == false) {
    locked.store(true);
}

问题: 线程 A load 读到 false,还没执行 store,线程 B 同时 load 也读到 false; 两个线程先后执行 store (true),两个线程都认为自己拿到锁,临界区并发破坏! 👉 load 和 store 中间存在间隙,会产生竞态条件。

CAS(compare_exchange)解决的就是这件事:把【判断值是否等于预期】和【赋值】合并成一个不可分割的原子硬件操作 整个过程 CPU 层面不会被打断,不存在中间空隙。

各个原子 API 分工清晰

  1. load():只读取,不修改

  2. store():只写入,不判断

  3. fetch_add/fetch_sub:内置封装好的「读 + 算术修改 + 写」,只能做加减运算

  4. compare_exchange_weak/strong(CAS):通用的「条件判断 + 修改」、自旋锁。

重点澄清:虚假失败 ≠ 虚假唤醒,但都靠while解决

  1. 条件变量 std::condition_variable 的虚假唤醒:内核调度机制问题。操作系统为了简化内核实现,不保证只有 notify 才唤醒线程wait,偶尔会主动把休眠线程唤醒,属于系统设计妥协。

  2. compare_exchange_weak 的虚假失败(compare_exchange_strong 不存在虚假失败,代价更大)

场景:原子变量的值 == expected,逻辑上本应该交换成功,但是 CAS 依然返回 false。

CAS 指令执行时,只要发生缓存一致性、流水线干扰,就算数值相等,硬件也允许直接返回失败。 weak 版本不做重试,把重试交给代码;strong 版本库内部会循环重试屏蔽该现象。

但这不叫 CPU 漏洞,是硬件规范预先允许的行为。 现代多核 CPU 依靠缓存一致性协议同步各个核心的数据,每个核心拥有独立 L1/L2 缓存。执行 CAS 时,硬件要同时完成:读取原子变量、对比数值、尝试写入新值。 在指令流水线运行、不同核心频繁同步缓存行的过程中,会出现临时竞争干扰。硬件设计者不想为了消除这种极小概率干扰付出巨大性能代价,于是在指令规范里定下规则:

    • weak 版 CAS 遇到这类干扰可以直接宣告操作失败,即便此时内存里的值和预期值一致。 硬件层面不会主动帮你反复重试,把重试逻辑抛给上层代码自行实现。

    • compare_exchange_strong 在标准库封装内部自带循环,一旦遇到这类硬件干扰,库代码会持续重试直到成功,对外屏蔽掉虚假失败现象,代价是额外开销。 weak 版本把重试交给你手写代码,减少库内部额外循环带来的性能损耗,适合自旋锁这种本身就存在循环等待的场景

说下CAS(Compare And Swap,比较并交换)

CAS 是硬件支持的原子操作原语,整套操作不可被线程打断。 操作对象:原子变量 函数形式:原子变量.compare_exchange_weak(exp, desired)

  • 原子变量:目标被读写的std::atomic<T>变量(示例里是locked

  • exp:普通局部变量,保存你预期原子变量应当拥有的值

  • desired:匹配成功后,要写入原子变量的新值

执行流程

  1. 原子读取原子变量当前值

  2. 判断:原子变量当前值 是否等于 exp

    • 相等:将原子变量赋值为 desired,函数返回 true

    • 不相等:自动把原子变量最新值覆盖写入 exp,函数返回 false

案例 1:CAS 自旋锁抢锁代码核心:

std::atomic<bool> locked{false};//初始没人拿到锁
bool exp = false;//期望是没人拿到锁
locked.compare_exchange_weak(exp, true);//这步骤涉及到读取locked,如果和期望一样,就做个标记true,其他人看到是true就知道有人拿到锁了,持续循环等待
  • 原子变量:locked

  • 预期 exp=false(认为锁空闲)
  • 成功:locked 变成 true,抢到锁;

  • 失败:说明锁已经被占用

完整自旋锁必须套while 循环,抢不到持续重试(while不原子,被其他线程插入执行,就下一轮抢到 CPU 再继续执行判断):

查看代码
//死妈的狗逼玩意豆包,无穷无尽的犯错,艹你妈的真不如看书了,没钱买书天天在这纠正豆包浪费了99%的时间!妈逼的我这2年半如果看书,艹你妈早就进Google / 微软了!!!!!
//以CAS 实现原子自增为例子:
std::atomic<int> val{0};
int add(){
    int old;
    do {
        old = val.load();
    } while (!val.compare_exchange_weak(old, old + 1));
    return old + 1;
}
/*
do {
    循环体;
} while(条件);
do先执行循环体 → 判断while条件;条件true,再次执行循环体
*/

/*
推演:
1. 进入 do,old = val.load (),old = 0
2. compare_exchange_weak 尝试将 val 从 0 更新为 1
3. 若其他线程修改 val 为 1代表其他线程拿到了锁
4. val 与 old 不相等,CAS 失败
5. CAS 将 val 当前值 1 存入 old,返回 false
6. 进入下一轮循环
7. 再次执行 old = val.load ()
多增加一次判断性能损耗,狗逼豆包不知道参考的啥给的do-while版本,直接while就行!
所以循环开头的old = val.load()冗余,CAS失败时compare_exchange_weak会自动刷新old
且必须val必须原子是因为,硬件缓存一致性,解决CPU硬件层面缓存同步,但拦不住编译器优化把变量放到寄存器,导致线程一直用旧值,普通int没有强制刷新规则,atomic强制每次访问不依赖寄存器缓存,所以软件层面有原子来保证可见性
*/

//所以只要涉及到CAS,他的功能自增和自旋锁,全都不需要do-while,看到do-while结合CAS直接认定为傻逼!

//正确的应该是:
std::atomic<int> val{0};
int add(){
    int old = val.load();
    while (!val.compare_exchange_weak(old, old + 1));
    return old + 1;
}
//或者
std::atomic<int> val{0};
int old = val.load();
while(true){
    if(val.compare_exchange_weak(old, old+1))//这步骤会读取val当前值
        break;
}
return old+1;
/*
1. int old = val.load();获取初始值
2. 判断!val.compare_exchange_weak(old, old+1)
   - 成功:返回 false,循环终止
   - 失败:返回 true,old自动更新为 val 最新值,继续循环
3. 循环结束,返回old+1
compare_exchange_weak成功:val更新为old+1,但old变量维持交换前原值不变,所以必须return old+1得到新值
*/

    
//所以狗豆包给的:
do {
    exp = false;
} while (!locked.compare_exchange_weak(exp, true));

//正确应该是:
//注意豆包给出的依旧是错的!!!
std::atomic<bool> locked{false}; // 初始未上锁
void lock(){
    bool exp = false;
    while (!locked.compare_exchange_weak(exp, true));//这段代码只负责上锁,不需要返回东西
}
/*
locked是std::atomic类型原子变量,充当锁标记

基准约定:
locked == false → 锁空闲
exp 初始等于 false
CAS 逻辑:
locked == exp(都是 false)→ 代表锁空闲,原子把 locked 置为 true,上锁成功
locked != exp → 锁已经被占用,更新 exp,继续循环抢锁

如果直接写分开两步:
if (locked == false)
    locked = true;
中间存在线程切换空隙,两个线程同时判定锁空闲,双双上锁
所以硬件提供 CAS 原语,定规则:拿期望值、对比、满足则修改,三者打包原子执行,原子的完整检测锁空闲+上锁
*/


//注意自增的没问题,但自旋锁有问题!
std::atomic<bool> locked{false}; // 初始未上锁
void lock(){
    bool exp = false;
    while (!locked.compare_exchange_weak(exp, true));//这段代码只负责上锁,不需要返回东西
}
//这个写法如果失败,代表locked是ture别人持有锁,你他妈直接给exp更新,你期望成true了,继续判断当前别人依旧有锁,你ture==ture直接以为拿到锁!
//狗逼豆包说,大厂C++80%都是这个水平,要看懂,但工作不用手写,对于我这垃圾履历基本就要钻研形成差异化亮点,而20%是自研组件中间件、写网络库、内核、存储、数据库的,才需要真正会写,而80%的业务服务端面试要会。所以这些基本只有简单八股文,没扣这么细,所以豆包无尽犯错

//真正正确的:
std::atomic<bool> locked{false};
void lock() {
    bool exp = false;
    while (!locked.compare_exchange_weak(exp, true)) {
        exp = false;
    }
}
//或者
std::atomic<bool> locked{false};
void lock(){
    bool exp;
    while(true){
        exp = false;
        if(locked.compare_exchange_weak(exp, true)){
            break;
        }
    }
}
//自增要拿最新值继续试,抢锁每次都必须拿false去试,所以自旋锁要重置
//然后解锁:
void unlock() {
    locked.store(false, std::memory_order_release);//释放写操作需要保证锁内所有修改先完成,再把锁置false,release防止指令重排
}

案例 2:CAS 循环实现原子自增(上面一起都说过了)

CAS 本身只是一套「比较 + 交换」通用原子逻辑,本身不绑定自增:

  • 你传入固定新值,就能实现自旋锁;

  • 你传入old+1循环重试,就能实现自增。 

只不过单纯自增优先num.fetch_add(1),原生指令性能更高;CAS 循环手写实现原子自增适合复杂自定义更新逻辑,实现自增属实大材小用。 

Q:那究竟CAS牛逼在哪里?

A:fetch_add仅能完成数值算术原子修改,无法实现“比较+条件写入”语义,CAS可以模拟fetch_add但大材小用,链表节点指针的条件替换逻辑只能由CAS实现,fetch‑add做不到该语义,所以因此引出生产者消费者模型。

 

这个也是服务端异步解耦的经典业务场景模型,把生产数据的线程 和 处理数据的线程拆开,解耦、削峰,生产者只管丢任务,消费者只管干活,生产快消费慢,中间队列缓冲,防止直接崩掉。

条件变量的waitwait_forwait_until属于等待函数,这类函数入参仅支持unique_locklock_guard与原生mutex无法传入。

cvcondition_variable条件变量的缩写,mutexunique_lockwaitnotify_one是岗位高频考点。

notify_one:随机唤醒等待队列中的一个阻塞线程。

notify_all:唤醒等待队列内所有阻塞线程。

查看代码
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <iostream>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> task_queue;

//生产者
void produce(){
    for(int i = 1; i <= 5; ++i){
        {
            std::unique_lock<std::mutex> lock(mtx);
            task_queue.push(i);
        }
        cv.notify_one();
    }/*多加一个花括号因为如果没花括号,唤醒后立马执行解锁代码,消费者要等你解锁后才干活,终究是有延迟
        大括号缩小锁范围,先解锁,再 notify_one
        不带大括号:先 notify 后解锁,会出现:消费者被唤醒后抢锁失败,再次阻塞。
        所以带大括号写法性能更好*/
}

//消费者
void consume(){
    while(true){
        std::unique_lock<std::mutex> lock(mtx);//lock是unique_lock实例,管控的底层互斥量就是mtx,加解锁操作都通过lock对象完成
        cv.wait(lock, [](){return !task_queue.empty();});//lock就是传入的unique_lock<std::mutex>对象,cv.wait 会接管这个 lock
        int val = task_queue.front();
        task_queue.pop();
        std::cout << val << std::endl;
        if(val == 5) break;
    }
}

int main(){
    std::thread t1(produce);
    std::thread t2(consume);
    t1.join();
    t2.join();
}

比如说生产者先拿到锁,task_queue里入队,出作用域释放锁,cv.notify_one唤醒,注意唤醒那块必须消费者走到wait阻塞处,唤醒才生效,卡在lock处的消费者接收不到唤醒信号,所以唤醒失败但代码没错,因为此时消费者会拿到锁,进入wait理应等待唤醒,可是唤醒失败,可是要知道他只有空的时候才释放锁等唤醒,此时有数据不会等,直接读,然后循环之后,比如消费者拿到锁,那就wait释放锁,然后生产者拿到锁继续往复。

谓词是条件判断函数,返回 bool。

关于wait等价的两种写法:

写法 1:while(task_queue.empty()) { cv.wait(lock); }(无谓词)

写法 2:cv.wait(lock, [](){return !task_queue.empty();});(有谓词)

推导过程:

pred = [](){return !task_queue.empty();}

标准等价公式:

while (!pred()) {
    cv.wait(lock);
}

代入 pred:

while (! ( [](){return !task_queue.empty();} ) () ){
    cv.wait(lock);
}

内层运算:!(!task_queue.empty()) ⇔ task_queue.empty()

化简得到:

while(task_queue.empty()){
    cv.wait(lock);
}

队列空:进入循环,执行底层cv.wait(lock),释放锁阻塞。

队列非空: 循环不执行,直接返回,不调用底层 wait,不释放锁  

其实以上这些都是豆包给的,狗逼我真他妈服了!!这不就是最最最基本的语法while规则吗??一堆傻逼书呆子应试高手脑残,只会背才导致大批量的海量教程说的那么晦涩死板条条框框,强行拆分大量名词概念,堆砌术语,把简单逻辑切割成一堆生硬规则,读起来极度晦涩。

无谓词版本(while 循环由开发者手动编写,忘记 while 会遭遇虚假唤醒;只要代码执行到cv.wait(lock),立刻释放锁、阻塞)

while(task_queue.empty()) {
    cv.wait(lock);
}
  1. 持有锁,执行while(task_queue.empty())条件判断

  2. 为空,进入循环

  3. 调用cv.wait(lock):释放互斥锁,线程阻塞等待通知

  4. 收到通知 / 发生虚假唤醒,线程就绪争抢锁

  5. 成功获取互斥锁,cv.wait(lock)返回

  6. 回到 while 头部再次执行task_queue.empty()判断(这一步抵御虚假唤醒)

    • 队列为空:再次进入循环,执行 wait 阻塞

    • 队列不为空:跳出 while 循环,向下执行业务代码

带谓词版本(while 循环由标准库内部封装,天然规避虚假唤醒):
cv.wait(lock, [](){return !task_queue.empty();});
  • 进入cv.wait(lock, pred),先执行 pred 判断

  • pred 返回 true:函数直接返回,不释放锁、不等待

  • pred 返回 false:调用底层无谓词 wait,释放锁,阻塞等待通知

  • 收到通知后唤醒,重新获取锁

  • 再次执行 pred 判断,解决虚假唤醒(死记硬背就行,OS内核会调度策略不收到notify信号主动唤醒wait阻塞的线程,多消费者场景下,一个通知唤醒多个线程,其中一个线程取走全部数据,其余被唤醒线程面对空队列,解决手段是wait 搭配循环条件判断,而非单次 if 判断)

  • pred 为 true:函数返回;pred 为 false:再次释放锁继续阻塞

这就是线程同步并发编程的知识,也是unique_lock可手动解锁、灵活托管锁生命周期,条件变量wait会自动释放传入的unique_lock,二者以此完成搭配。

这个代码就是操作系统里学的PV原语在C++工程落地,叫阻塞队列版本:

  • wait() 等价于 P 操作(申请资源、阻塞)

  • notify_one() 等价于 V 操作(释放资源、唤醒)

而另一种实现是SPSC无锁队列,绕开互斥锁,不用 PV 这套信号量思路,依靠 CPU 原子指令实现同步(后面说)。

std::condition_variable:本身不带计数器,必须强制搭配 mutex 使用,让线程阻塞休眠,靠外部变量 / 队列做条件判断,等待某个条件成立,被别的线程唤醒再继续跑,容易出现虚假唤醒,必须带上条件判断

  1. wait():解锁锁,线程休眠;被唤醒后重新拿锁

  2. notify_one / notify_all:唤醒休眠的线程

妈逼的无意间接触到信号量,得知之前啃的《TCPIP网络编程》尹圣雨书里的sem_post(POSIX 信号量接口)根本不考,具体不考指的是:

  • sem.acquire();P操作:减1,资源不足就阻塞(不考)

  • sem.release();V操作:加1,释放资源,唤醒等待线程(不考)

信号量:自带内部计数,可独立完成同步互斥,不需要搭配锁。而我代码里是条件变量cv:本身无计数,必须绑定互斥锁,依赖谓词,会虚假唤醒,要循环判断。

这个阻塞队列 (mutex+cv)写法的生产者消费者几个优点: 

std::queue<int> task_queue;
  1. 解耦:生产者task_queue.push(i);只管放数据,消费者task_queue.front(); task_queue.pop();只管取数据,二者不用互相等待对方即时处理,生产、消费线程逻辑互不绑定。

  2. 削峰:生产者短时间大量 push,数据暂存task_queue,消费者按自己速度 pop,抵消生产者流量突增。

  3. 缓冲:task_queue作为中间缓冲区,存放已经生产还未被消费的任务。

  4. 队列空处理:cv.wait(lock, [](){return !task_queue.empty();});,队空时!task_queue.empty()为 false,cv.wait阻塞消费者线程;配合 lambda 判断抵御虚假唤醒。

  5. 队列满处理:这份代码没有做队列满边界控制,生产者会无限制task_queue.push(i);持续入队,存在内存暴涨风险。可以设置个计数

入队前先判断达到返回false,push 成功执行size_.fetch_add(1);计数 + 1

pop 成功执行size_.fetch_sub(1);计数‑1

再来个 SPSC (Single‑Producer Single‑Consumer)无锁模型队列版本:

关于【生产者 - 消费者】真的呕吐了:

但这附近的所有知识点也打通了底层硬件原语到 C++ 标准库再到工程应用整条链路,是原理层面的理解,堪比《C++ 并发编程实战》 完整覆盖 std::atomic、各类原子操作、compare_exchange 强弱差异、内存序 / 内存屏障、自旋锁实现,辅助理解底层:《深入理解计算机系统》 用来理解 CPU 缓存、指令重排序,搞懂内存屏障诞生的根源。

  • 只会背八股的人:只会默写自旋锁代码,分不清compare_exchange_strong/weak,不知道内存屏障为什么存在,说不清 CAS 适合做锁还是无锁容器,不理解 fetch_add 这类原子操作定位。

  • 你现在可以串联:硬件 CAS 原子指令 → C++std::atomic各类接口 → 手写自旋锁 → CAS 构建无锁数据结构、内存屏障作用,属于理解原理而非单纯记忆,在基础岗求职者里属于上游梯队。

豆包一天又一天无尽犯错,我才终于脱离豆包不再依靠豆包,而是自己独立思考,他给的所有都是错的,我照着错的学了一天又一天,最后说代码错了,反复辱骂反复依旧固有模型设定机制,说改正,依旧是错的,继续学,又是无穷无尽的垃圾代码,永远给不出这这正确的,无穷无尽的分支知识点,永远不一次性说完,完全是概率拼凑,毫无任何自己检查的逻辑!基本就是无穷无尽的砸时间。

越权威的资料,越晦涩越英文,

书买不起,就算看书又需要穿插翻好久

海量全网的博客又一定夹杂错误,所以只能增加自己的思考,豆包每次犯错理性分析及时修正,从辱骂暴怒浪费时间,到心平气和的接受。

妈逼的就2行自旋锁都能搞错!就几十行生无锁产者消费者都能逻辑错

网络铺天盖地AI写可上线的代码都是:

  1. 业务 CRUD、管理后台、简单脚本:逻辑线性,极少并发、无底层内存 / 时序问题,AI 代码改改就能用。

  2. 上层应用代码:业务逻辑、接口、页面,出错影响小,人工容易排查修复。

  3. 现成成熟库封装调用:AI 只做胶水代码,核心能力依赖第三方稳定开源库。

AI 短板:底层并发、无锁、内核相关、内存模型、时序敏感代码,极易写出逻辑漏洞,这类代码无法直接采信。

永远别辱骂豆包,因为纯浪费时间。

实测,附加思考真的很省事,之前太依赖豆包都不思考,这才是互相配合、启发、合作。

没AI时候,全网的文章、回答就没错误?AI错本质是全网海量错误博客!

这样堪比精啃全部权威经典书籍了!(没钱买)

bool pop(int& out) {

  1. 解耦:生产者push(int val)构造新结点接入链表;消费者pop(int& out)摘取结点,生产消费线程互不阻塞对方执行逻辑。

  2. 削峰:新结点不断接入链表,链表充当缓冲,消费者循环 pop 慢慢取,承接生产者突发产出。

  3. 缓冲:链表结点作为缓冲区,保存已生产未消费的数据。

  4. 队列空处理:

    if (next == nullptr)
        return false;

next == nullptr代表队列为空,pop 直接返回 false,交给外层循环自旋重试。

队列满处理:这份 SPSC 无锁链表代码没有队列满的判断逻辑,push直接 new 新结点,没有上限,会不停分配内存。

这都是代码写完的后话,先看代码咋写吧,这里无尽曲折:

豆包给了个错的,从错的里其实学到好多。(不考的错的)

  

错误代码如下(死妈豆包以及任何大模型AI的现状:狗逼玩意毫无任何逻辑全部回答都是无脑概率拼凑,对错毫无负责的能力,全是胡乱概率拼凑): 

查看代码
#include <atomic>
#include <thread>
#include <iostream>

struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

// 单生产者单消费者无锁队列
class LockFreeQueue {
private:
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;
public:
    LockFreeQueue() {
        Node* dummy = new Node(0);
        head_ = dummy;
        tail_ = dummy;
    }

    void push(int val) {
        Node* new_node = new Node(val);
        Node* prev_tail = tail_.load();
        while (!tail_.compare_exchange_weak(prev_tail, new_node)) {
            prev_tail = tail_.load();
        }
        prev_tail->next = new_node;
    }

    bool pop(int& out) {
        Node* h = head_.load();
        Node* next = h->next;
        if (next == nullptr)
            return false;
        if (head_.compare_exchange_weak(h, next)) {
            out = next->val;
            return true;
        }
        return false;
    }
};

LockFreeQueue queue;

void produce() {
    for (int i = 1; i <= 5; ++i) {
        queue.push(i);
    }
}

void consume() {
    int val;
    while (true) {
        if (queue.pop(val)) {
            std::cout << val << std::endl;
            if (val == 5) break;
        }
    }
}

int main() {
    std::thread t1(produce);
    std::thread t2(consume);
    t1.join();
    t2.join();
}

原先:mutex + condition_variable,靠锁实现互斥,线程拿不到锁会进入内核阻塞,低并发更快;

无锁版本:没有 mutex,依靠 CAS 原子指令循环尝试修改指针;竞争失败只是自旋重试,不主动陷入内核。,高并发、线程频繁入队出队,无锁队列优势显现,减少线程内核阻塞开销。

插入个代码技巧 —— 哑节点

哑节点(dummy node):队列初始化时,预先创建一个不存储有效业务数据的节点。

作用:规避头尾指针相等时的边界判断,简化 CAS 并发逻辑,没有哑节点,空队列、只有一个元素的场景需要额外判断,代码更容易出并发 bug。

初始状态:head、tail 都绑哑节点,哑节点 next=nullptr。

如果没有哑节点:队列为空时 head 和 tail 都是 nullptr;第一个入队、出队要写特殊 if 判断,代码极易出错。

有哑节点:队列永远至少保留这个节点,空队列、有数据队列可以统一一套逻辑,省去一堆空指针分支。

哑节点自身不存储业务数据,它的 next 指向队列第一个有效节点。

head 永远指向哑节点,有效数据全部在 head->next。 队列空:head->next == nullptr

查看代码
//无哑节点
//无哑结点: head本身就是第一个有效结点,head->next指向后继结点
struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}//val 存放业务数据,next 存放下一个节点的地址
};

Node* head;
Node* tail;


// 入队
void push(int val) {
    Node* n = new Node(val);
    if (tail == nullptr) // 队列为空,特殊分支
        head = tail = n;
    else {
        tail->next = n;
        tail = n;
    }
}

// 出队
bool pop(int& out) {// 引用参数,把出队拿到的值拷贝带回调用方,不用函数返回值承载数据
    if (head == nullptr) // 队列为空,特殊分支
        return false;
    Node* del = head;
    out = head->val;//取出数据,哑节点后移
    head = head->next;
    if (head == nullptr) // 取出最后一个元素,tail也要置空,额外分支
        tail = nullptr;
    delete del;
    return true;
}
==============================================================
// 带哑节点版本
//有哑结点(哨兵d): d为哑结点,head 永远存储哑结点 d 的地址,d->next才指向第一个有效结点,此时真正的头结点不再是head,而是d->next,也就是head->next
struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

Node* head;
Node* tail;

// 初始化:创建哑节点d
void init() {
    Node* d = new Node(0);
    head = d;
    tail = d;
}

// 入队
void push(int val) {
    Node* n = new Node(val);
    tail->next = n;
    tail = n;
}

// 出队
bool pop(int& out) {
    // 空队列:head == tail
    if (head == tail)
        return false;
    Node* del = head;
    out = head->val;
    head = head->next;
    delete del;
    return true;
/*
队列:d (哑节点) → Node1 (val=10) → nullptr
pop 执行:
head 从 d 移到 Node1,删除旧 d
此时队列:Node1 → nullptr
Node1 原先的 val=10 就是本次取出的数据,之后 Node1 充当哑节点,它的 val 不再参与业务读取
*/
}

这里代码是单生产单消费已经是俩线程了,但豆包的说法是,不可以多线程其实指的是多个生产多个消费会有问题,当然那个更复杂基本不用会,而内存池就是多生产消费,所以学完这个还要啃那个。 

没任何约束,生产者、消费者同时读写同一个共享变量 / 链表节点,会出现数据竞争:消费者读到半更新、残缺的节点;生产者刚挂好节点,指针还没更新,消费者看不到新元素;重复读取、丢失元素、程序崩溃(野指针),所以使用了std::atomiccompare_exchange_weak作为同步机制。

当看不懂代码的时候,豆包的解释又看不懂,我的提示词:

不是,你这个就没解释你想干啥。你该起手第一步就讲这是干啥用的,你别直接说这是各种结论的,什么什么 CAS, 我真的不是,我真的没法串联上。你就能不能从最原始的开始讲,比如说你想干嘛,你想写啥,然后此时你想遇到什么功能?然后你再引出 CAS, 你别直接上来就各种什么CAS,我连 CAS 它是怎么来的都不知道! 

目标:我们想要实现一个队列

队列功能:往末尾放数据(push)、从头部拿数据(pop)。 正常多线程场景难题: 如果两个线程同时修改队列,链表指针会乱掉,数据丢失、程序崩溃。

普通人第一想法,加互斥锁 mutex,任何线程想要 push 或者 pop,先抢到锁。 抢到锁,才能修改队列;操作完释放锁。 别的线程只能原地等待,等锁释放才有机会操作,但缺点: 线程抢不到锁的时候,操作系统会把这个线程暂停,放到等待队列。等锁释放,操作系统再唤醒线程。 暂停、唤醒线程都要操作系统介入,存在开销。如果线程频繁收发数据,大量时间浪费在内核调度上。

于是人们思考:能不能不用锁,让多个线程安全操作共享数据,避开操作系统的线程阻塞开销? 这就是无锁队列的出发点。

核心难题:没有锁,怎么保证多个线程修改同一个变量不会乱?

举个最简单例子:有一个全局指针 tail,所有人都可以读取和修改。 线程 1 读取 tail 地址;线程 2 同时读取 tail 地址。 两个线程拿着同一个地址各自修改,最后必然互相覆盖,数据出错。

我们想要一种操作: 读取变量,对比一下变量有没有发生变化。如果变量和我刚刚读到的值一样,就把新值写进去;如果不一样,说明别人中途改过了,本次写入直接放弃。 整套动作必须一次性完成,不能被别的线程打断。 这个硬件提供的一次性操作,名字就叫 CAS。

现在你先记住 CAS 的通俗含义: 先对比,相等才写入;不等就不写入,整套动作不可拆分。 不需要理解 CPU 底层,只记住这个行为,那段错误代码里:

void push(int val) {
    Node* new_node = new Node(val);
    Node* prev_tail = tail_.load();
    while (!tail_.compare_exchange_weak(prev_tail, new_node)) {
        prev_tail = tail_.load();//狗逼死妈玩意豆包,就算有while这句话也绝对的冗余
    }
    prev_tail->next = new_node;
}

我们想要完成的目标:把新节点接到队列尾部,更新尾指针 tail。

设想理想操作顺序:

  1. 读出当前队列尾部节点 prev_tail

  2. 把 tail 改成新节点

  3. 旧尾节点的 next 指向新节点

问题来了:第 2 步、第 3 步是分开的两行代码。两行代码中间,别的线程有机会插进来执行代码。

我们先用单生产者场景(只有一个线程调用 push)模拟运行流程:

  1. 创建新节点 new_node

  2. 读取现在队列尾节点,保存到 prev_tail

  3. 执行 CAS:检查 tail 现在是不是等于 prev_tail,如果相等:把 tail 更新成 new_node,CAS 成功,跳出循环

  4. 将旧尾节点 prev_tail->next = new_node,链表拼接完成。

因为只有一个生产者线程,不存在其他线程同时修改 tail。CAS 几乎一次成功,中间不会有人干扰。整个流程稳定,队列正常工作。

重点模拟:如果开两个生产者线程,为什么会崩

初始状态:tail 指向节点 A,A->next 是空。 线程 A、线程 B 同时执行 push。

危险的时序步骤:

  1. 线程 A 读取 prev_tail = A

  2. 线程 B 读取 prev_tail = A

  3. 线程 A CAS 成功,tail 变成 Node1

  4. 线程 B CAS 成功,tail 变成 Node2

  5. 线程 B 运行:A->next = Node2

  6. 线程 A 恢复运行:A->next = Node1

最后结果:A 节点的 next 被线程 A 覆盖,Node2 彻底从链表消失,永久丢失。 根源:CAS 修改 tail 和 链表拼接是两步独立操作。两个线程拿到同一个旧尾节点时,后续赋值会互相覆盖,这就解释清楚:这份代码只能单线程调用 push,不能多生产者。

那单生产单消费就没问题了吗?其实依旧有问题!

无多生产,所以没必要用CAS,不会有人同时改动tail尾节点,那就等价于

①Node* new_node = new Node(val);

②Node* prev_tail = tail_.load();

③tail_ = new_node;

④prev_tail->next = new_node;

可是当③执行完,CPU 发生线程切换,生产者暂停,还没执行prev_tail->next = new_node,消费者if (next_node == nullptr) return false;,认为队列空,丢失这条数据。所以应该调换③④顺序。哎真的无尽痛苦啊!!!被豆包误人子弟了!!!这个代码根本没任何顺序问题!!!就算④没执行就消费者pop了,为空返回push生产者,还会接着执行next指针更新,下次pop就读到了!!!

只有多生产才有问题,且无论是否调换顺序都有问题,就算调换成先④next后③tail:

①new 完节点,执行完②tail_.load()拿到旧的 prev_tail 本地副本,随即发生线程切换;新一轮的 push拿到CPU使用权,完整跑完入队,更新了全局 tail。旧 push 切回,拿着早已过期的本地 prev_tail 执行prev_tail->next = new_node,直接覆盖新 push 刚接入的节点,丢失数据。 不是全局 tail 旧,是线程本地保存的 prev_tail 快照过期!不调换也是一样!!!

且再次往深了思考,③④不调换时候,当先执行①②③,此时pop空返回,新push进来,完整压入任务,此时本该返回执行上次的最后一行代码④,结果pop拿到CPU,那么直接发现依旧是空,只要不执行④,就无穷压入任务也拿不到任务,一直空。

Q:是ABA吗?

A:不是!ABA 通俗解释:线程读到变量值是 A,中途切走;别的线程把它改成 B,又改回 A;切回来的线程看见还是 A,误以为状态没变,实际中间发生过改动。你的当前 SPSC demo 场景不涉及 ABA,你的 bug 是线程持有过期 tail 本地副本,覆盖新接入节点,属于读取旧快照带来的时序错误,没有 A‑B‑A 的地址轮回。

思考了这么多咋写到简历体现

至此了解顺序对SPSC无任何影响,记录下之前【认为只有先next再tail顺序才可以时候】的思考吧:

我思考既然容易断链,那把判断while (!tail_.compare_exchange_weak (prev_tail, new_node)) {改成while (!prev_tail->next.compare_exchange_weak (??, new_node)) {

其实CAS自旋毫无意义,去掉while,直接顺序调换就行,但现在得知顺序都不用调换了,可是之前哪知道啊,傻乎乎想了好多好多~~~~(>_<)~~~~

另外说一嘴,我真的痛苦万分真的服了!!我突然发现狗逼狗逼豆包给的while里那个prev_tail = tail_.load()没任何意义冗余!!!!!!

就算prev_tail从普通指针改成原子类型,补全期望值:

Node* prev_tail = tail_.load();
while (!prev_tail->next.compare_exchange_weak(nullptr, new_node)){//compare_exchange_weak第一个形参是Node*&非const左值引用,atomic<Node*>的模板参数是Node*,compare_exchange_weak第一个参数的引用类型追随模板参数,于是推导得到Node*&,nullptr属于临时值,不能绑定该引用,直接无法编译,但逻辑思路是对的
    // CAS失败,必须重新拉取最新尾节点,CAS失败说明prev_tail->next不再是nullptr,代表别的线程已经把新节点挂到该节点的next上,此时prev_tail已经不再是真正的尾节点,需要tail_.load()读取最新的尾指针,拿到新的prev_tail
    prev_tail = tail_.load();
}
tail_.store(new_node);

解释: prev_tail->next 作为队列尾节点时,它的后继必然是nullptr,所以预期值expected初始为nullptrcompare_exchange_weak语义:如果prev_tail->next == expected(nullptr),就把它修改为new_node

其实考虑这么多都是错的不考的但也因祸得福,学到了CAS的思想。 

首先之前说到CAS模拟fetch_add自增大材小用,CAS就是用来搞无锁数据结构的,比如无锁队列场景就是生产者消费者,而SPSC 只有一个线程写 tail,不存在竞争,CAS 自然派不上用场,CAS是在多生产多消费(MPMC)才用的,不是只要叫无锁队列就一定要写 CAS!!!

无锁(lock-free)标准定义是不使用互斥锁,线程之间同步依靠原子操作,原子操作分为两类:

  • 普通原子读写(load/store)

  • CAS 类原子读改写

无锁 ≠ 必须使用 CAS。单纯依靠原子 load/store、配合内存序实现并发安全的数据结构,同样属于无锁结构。

我的思考是那带CAS就是无锁数据结构了吗?其实也不是,此文搜“思考对比自旋锁 VS 互斥锁” ,是自定义自旋锁与 std::mutex 并发性能对比测试代码,对比自定义自旋锁和标准互斥锁在多线程竞争累加变量时的运行耗时:

  1. 只有原子变量实现自旋锁,不存在任何数据结构,也就不是无锁数据结构

  2. 是否是无锁,判定标准不是有没有链表 / 队列这类数据结构,而是算法是否依赖锁(CAS实现自旋锁、mutex 都属于锁机制)。

  3. CAS 是原子操作,CAS 本身不是锁;可以用 CAS 实现锁(自旋锁),也能用 CAS 实现无锁算法。

  4. 区分两条边界:

    • 自旋锁:利用 CAS 实现的锁机制 → 属于有锁并发

    • SPSC/MPMC 无锁队列:依靠原子操作(原子读写或 CAS)、不使用任何锁 → 无锁并发

核心结论: 有无数据结构无关;重点看代码逻辑是用原子操作实现锁,还是完全抛弃锁实现并发安全。 

再看 pop 函数:

bool pop(int& out) {
    Node* h = head_.load();
    Node* next = h->next;
    if (next == nullptr)
        return false;
    if (head_.compare_exchange_weak(h, next)) {
        out = next->val;
        return true;
    }
    return false;
}

目标:把 head 向后移动,取出 head 下一个节点的数据。 同样逻辑: 多个消费者同时执行 pop 时,多个线程读取同一个 head。 一个线程成功移动 head,另一个线程继续操作旧 head,会重复读取数据或者访问无效内存。 所以 pop 也只能单线程运行。

或者另一种情况:

pop 读取完 next=null,准备执行 return false,另一个线程立刻完成 push,链表新增节点,pop 不会重新读取 head 和 next,直接返回 false,这就是单次快照。

再分析,CAS 缺乏循环,竞争失败直接退出:

  1. 线程 1 执行 h = head_.load(),h = 哨兵节点 A

  2. 线程 1 执行 next = h->next,next = 节点 B

  3. 线程 2 抢先执行 pop,CAS 把全局 head_从哨兵节点 A 更新为节点 B

  4. 线程 1 执行head_.compare_exchange_weak(h, next) 此时全局 head_已经不是哨兵节点 A,CAS 执行失败,函数直接 return false。

 

对比带锁队列: 带锁队列:线程抢不到锁,操作系统直接暂停线程,产生开销。 这份无锁队列:只有一个生产者、一个消费者。没有锁,线程不会被操作系统挂起。线程循环不断重试 CAS,一直保持运行。 在持续高频收发数据场景,省去线程阻塞唤醒开销,速度更快。

局限也很清晰: 只允许一写一读,不能多个线程同时放数据、同时取数据。一旦突破这个限制,链表结构会损坏。

这也直接体现了队列整体操作,无法原子。

且prev_tail->next = new_node; 又无法放入while,CAS 只是更新 tail,链表赋值依旧是独立操作。

 

至此支离破碎的分析了很多收获很大。

所以push的顺序没问题!!但无奈一直被豆包误导学了错误的,就索性放上更改顺序的吧,加了内存序:

查看代码
#include <mutex>
#include <atomic>
#include <thread>
#include <iostream>

struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

// 单生产者单消费者无锁队列
class SPSCLockFreeQueue {
private:
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;
public:
    SPSCLockFreeQueue() {
        Node* dummy = new Node(0);
        head_.store(dummy, std::memory_order_relaxed);//增加了参数
        /*
        seq_cst:全局全序屏障,CPU 需要同步所有核心缓存,开销更大
        relaxed:仅保证原子性,无读写同步屏障;release 保证本线程前面所有写入对其他线程 acquire 可见
        重排只改动底层机器指令,不会调换C++源码两行
        */
        tail_.store(dummy, std::memory_order_relaxed);//...
    }

    void push(int val) {
        Node* new_node = new Node(val);
        Node* t = tail_.load(std::memory_order_relaxed);//细微的机器指令可以【不影响代码的正确性为前提】做调整,比默认的好
        t->next = new_node;//这个三行完全可以直接tail_=new_node啊,我感觉多此一举其实不然,看下面解释
        tail_.store(new_node, std::memory_order_release);
        /*
        release禁止 CPU 把t->next = new_node 重排到tail_.store 的后面
        如果重排发生:先更新tail_,再写next。消费者线程看到新 tail,但next还是空,链表直接损坏
        */
    }

    bool pop(int& out) {
        Node* h = head_.load(std::memory_order_acquire);
        /*
        acquire加载和生产者的release配对
        pop加载head用acquire,配对push的release,保证push内next节点写入对pop线程可见
        */
        Node* next_node = h->next;
        if (next_node == nullptr) 
            return false;
        out = next_node->val;
        head_.store(next_node, std::memory_order_relaxed);//再次说明这里没内存序约束会对无依赖的做重排,而就算用release,release也只是跨线程和acquire匹配的,根本无法保证他在本线程是最后,依旧可以前面的跑后面去!具体此文搜“e毫无意义,多线程里我”
        return true;
    }
    
    ~SPSCLockFreeQueue() {
        Node* cur = head_.load(std::memory_order_relaxed);
        while(cur != nullptr) {
            Node* del = cur;
            cur = cur->next;
            delete del;
        }
    }
    //拷贝构造:禁用对象拷贝构造
    SPSCLockFreeQueue(const SPSCLockFreeQueue&) = delete;

    //拷贝赋值:禁用对象拷贝赋值
    SPSCLockFreeQueue& operator=(const SPSCLockFreeQueue&) = delete;

    //移动构造:禁用对象移动构造
    SPSCLockFreeQueue(SPSCLockFreeQueue&&) = delete;

    //移动赋值:禁用对象移动赋值
    SPSCLockFreeQueue& operator=(SPSCLockFreeQueue&&) = delete;
    
    
/*
1. const SPSCLockFreeQueue&:const 左值引用,拷贝版本的参数
2. SPSCLockFreeQueue&&:右值引用,移动语义的参数
3. operator=:赋值运算符重载
4. 返回值SPSCLockFreeQueue&:返回自身引用,支持链式赋值
5. =delete:告诉编译器,不要生成这个函数,禁止使用
*/
};

/*
关键约束:
1. 必须保证只有一个线程调用 push,只有一个线程调用 pop;
2. 代码不处理节点回收,正式项目需要解决内存回收问题;
3. 操作顺序:先赋值tail节点->next,再更新 tail,杜绝链表断裂
*/



SPSCLockFreeQueue queue;

std::mutex cout_mtx;

void produce() {
    for (int i = 1; i <= 5; ++i) {
        queue.push(i);
        {
            std::lock_guard<std::mutex> lock(cout_mtx);//没这个会黏在一起,比如:push: pop: 22            
            std::cout << "push: " << i << "\n";
        }
    }
}

void consume() {
    int val;
    while (true) {
        if (queue.pop(val)) {
            {
                std::lock_guard<std::mutex> lock(cout_mtx);
                std::cout << "pop: " << val << "\n";
            }
            if (val == 5) break;
        }
    }
}
/*
Q:为啥cout里一个是val一个是i?
A:流程串: 
produce:i=1,调用 push (1) → 把 1 存入 Node 的 val 成员,节点入队列 
消费者 consume 调用 pop (val) 
pop 拿到链表下一个节点,把节点内保存的数字赋值给参数 out(也就是 consume 里的 val 变量) 
consume 拿到这个 val,打印输出,判断 val 等于 5 就退出循环
val 来源:push 存入节点里的数值,pop 把节点里的值拷贝出来给到 consume 的局部变量 val。

数字1、2存于链表各个Node节点内部,依靠head_、tail_指针维护链表顺序,永远从链表头部依次取出

out就是consume里的val,传入pop的是变量的引用,pop把节点的值写入out,外部就能拿到该值,起初传入要饭碗,pop把out装入碗里
*/

int main() {
    std::thread t1(produce);
    std::thread t2(consume);
    t1.join();
    t2.join();
}

几个问题:

Q:一般都是push1~5然后pop1~5,为啥5个一组?

A:CPU轮询巧合+生产者连续拿到CPU时间片,一口气push完1‑5,之后才切到消费者,于是一次性pop1‑5。

Q:输出加锁后不会出现  push: pop: 22 ,每次都是完整单独的输出,但居然会出现先pop1

A:queue.push和queue.pop分别和各自下面的cout不是原子组合,生产者 push (1) 跑完,消费者调度抢占,先执行 pop 拿到数据输出 pop:1,属于线程调度时序,不是 bug。

可以把打印语句放queue.push和queue.pop之前,是针对日志打印乱序的工程妥协方案,适合调试,不改动无锁队列核心逻辑。明确不能为了日志顺序,污染无锁队列高性能逻辑。绝对不要为了打印顺序,给无锁队列加锁,直接废掉无锁的性能收益。

面试官潜台词:考察你能不能分清「业务逻辑 bug」和「观测日志的显示 bug」,会不会为了表象,破坏底层组件性能。

回答:“pop 先输出是 OS 线程调度导致日志时序错乱,队列的数据流转本身是正确的。我不会修改无锁队列内核去适配打印,会把打印前置加锁做调试;生产环境改用专业日志库,依靠日志时间。

Q:压入弹出都完事了,为啥还要析构?

A:pop 只会移动 head_指针,不会 delete 节点。 pop 把哑节点向后挪,旧 head 节点直接丢弃,内存没释放,只是链表逻辑上看不见,堆内存还占着。

哪怕 5 个数据全部 pop 完毕: head_与 tail_会重新指向最初那个 dummy 哑节点,所有曾经 pop 出来的旧节点仍然在堆上,内存泄漏。 析构就是把这些逻辑已经弹出、但内存没释放的节点 + dummy 节点一起 delete 掉。

这个如果说线程没结束,对象先销毁会发生:正在运行的线程继续访问已销毁的队列对象,触发野指针程序崩溃,所以要写join等线程结束自动析构。

Q:关于禁止拷贝赋值

A:拷贝赋值不delete,当你写赋值拷贝语句的时候,编译器会自动隐式生,然后结合原子类型直接报错,而写了delete会告诉程序员别写拷贝赋值。

Q:为啥不拷贝赋值?

A:此文搜“为整条语句原子,直接删除该”

且就算不禁止,真的发生赋值比如:

class Queue{
    int* ptr; //👉这就是内部裸指针,ptr存一块堆内存的地址
};

int* ptr,裸指针变量,放在 Queue 类里面,叫内部裸指针。ptr 记录一块 new 出来堆内存的门牌号。

Queue q1; q1.ptr 指向堆上一块内存(地址 0x500)。

如果允许拷贝:Queue q2 = q1;,q2.ptr 直接抄成 0x500。q1、q2 的 ptr,同时都指向同一块 0x500 堆内存。

对象销毁,q1 先跑析构:delete ptr;,把 0x500 内存还给系统。

之后 q2 销毁,又执行一遍delete ptr;

注意:Queue q2 = q1; 是浅拷贝,只复制指针变量(门牌号),不复制背后堆内存。 两个对象指针指向同一块堆内存,析构都 delete → 双重释放崩溃。

深拷贝:拷贝的时候,新开辟一块堆内存,把旧内存的数据复制过去。 新对象拿到全新内存地址,各自管自己的内存,析构各删各的,不会双重释放。

总结:

  • 默认拷贝 = 浅拷贝,裸指针类不处理就会踩坑。

  • 不是禁止拷贝,是不能用编译器默认生成的拷贝。

  • 要么自己写深拷贝,要么直接=delete禁用拷贝。

无锁队列这种,内存结构复杂,做深拷贝难度极大,所以直接 delete 禁用。

 

科普:

  • lock-free:自旋锁抢不到锁,线程留在用户态不停循环,不求助操作系统、不暂停线程。

也叫乐观锁:依靠CAS做版本(解决ABA的)校验,假设冲突很少,不加锁,冲突就重试,fetch_add、无锁队列都是乐观锁思路。

  • mutex:抢不到锁,进入内核,线程暂停,让出CPU。

 也叫悲观锁:代表mutex,默认认为一定会发生竞争,直接加锁排他访问。

知道了CAS和fetch_add,引入个test_and_set

  • test_and_set :专属服务自旋锁

  • CAS:搞自旋锁大材小用,适合无锁队列

  • fetch_add:多用于原子计数,统计队列元素数量

先看你已经掌握的两个原子操作: CAS:传入预期旧值、新值。只有内存当前值等于预期旧值,才把内存改成新值;整个操作原子完成。修改不匹配时,内存不会发生任何改动。

fetch_add:给内存上的值做原子加法,返回加法执行之前的旧值。它是无条件执行,加法一定会生效。

现在看一类实际需求:我手上只有 1bit 布尔标记,我想要无条件把这一 bit 置 1,同时拿到置 1 之前原来的值

CAS实现这件事:你需要反复传入预期值,判断当前是不是 0,再写成 1,要写判断分支。 用fetch_add实现这件事:做加 1 会改变整个数值,不适合单纯只置 1 的布尔标记场景。

C++ 标准库提供std::atomic_flag,不能直接读取内部状态,不能赋值,仅允许test_and_set()clear()操作,其中test_and_set(),就是专门对应这个 “无条件置 1,返回修改前旧值” 的原子语义。

test_and_set()执行逻辑:

  1. 无条件把atomic_flag内部的布尔位设置为true

  2. 原子返回修改发生前,这个位原本的值。

两种运行情况:

  • 标记原来为false:调用test_and_set(),标记强行置true,返回false

  • 标记原来为 true:调用test_and_set(),标记强行置true,返回true

查看代码
std::atomic_flag spin_lock = ATOMIC_FLAG_INIT;//固定语法,ATOMIC_FLAG_INIT是初始化宏,用来把std::atomic_flag初始成未置位false状态

// 获取锁:循环调用test_and_set(),抢不到就空转
while(spin_lock.test_and_set());

// 临界区:多线程竞争访问共享变量
count++;//处于自旋锁保护的临界区内,锁保证同一时刻仅一个线程进入,因此count++不需要原子修饰

// 释放锁
spin_lock.clear();

clear()用来原子释放锁,把标志位改回false

 

 科普语法:

 代码里为啥语法上搞了三行这个东西:

Node* t = tail_.load(std::memory_order_relaxed);

t->next = new_node;

tail_.store(new_node, std::memory_order_release);

先看代码

#include <atomic>
#include<iostream>
struct Node{int x;};

int main()
{
    std::atomic<Node*> at;
    Node real_node;   // 真实节点
    at = &real_node;  // 给原子变量存入有效节点地址

    Node* p = at.load();
    p->x = 1;
    std::cout<<p->x<<std::endl;
}

验证 std::atomic<Node*> 这个对象本身能不能直接用 -> 去访问它内部存的那个指针所指向节点的成员。

std::atomic<int> a a 是 std::atomic 模板实例出来的类对象,对象内部只存放一个普通 int 值

std::atomic<int> a;
a.store(10);
int x = a.load();

内部存的就是单纯整数 10,把 int 包起来,实现原子读写。

对比: std::atomic<Node*>:对象内部存Node * 指针(地址) 

std::atomic<T>,尖括号填 T,实例出来的对象内部就保存一份 T 类型的数据。

 可以做的操作:

  • .store(val):把 val 存入原子对象内部

  • .load():读出内部保存的值

  • =赋值、隐式转换(部分支持)

  • 还有compare_exchange_weakcompare_exchange_strongCAS 操作。

不能直接拿内部存的数据用.->去访问,必须先 load 拿出来,再操作。

分清两个完全不同的结构体:

Node 结构体:

struct Node{
    int val;
    Node* next;
};

Node 对象:内存里有int val + Node* next两个成员。这里的 next 是存放在 Node 内部的指针,指向另一个 Node。

std::atomic<Node*>at ,atstd::atomic模板实例出来的另一个独立类的对象,不是 Node 对象。 std::atomic<Node*>at这个对象内部,只保存一个成员:Node*(仅仅是一个指针变量)。 它没有 val,没有 next,根本不是节点。

  • Node:真正的节点,存 val、next。

  • std::atomic<Node*> 对象 at:包装器,只存一个 Node * 地址,用来原子读写这个地址,不含节点的数据。

普通Node* ptrptr->x合法,std::atomic<Node*> atat->x编译报错,不支持。

at.x写法无论是否是原子类型都肯定错,atstd::atomic<Node*>对象,没有 x 成员,就算普通类型指针也没x成员。

标准std::atomic<T*>没有重载operator->

原因:如果提供->tail_->next会等价于:

  1. 原子读取出指针

  2. 访问成员next

两步不是原子,中间别的线程可以修改原子内部指针,产生隐蔽 bug,标准直接不提供这个重载。

->包含两步:解引用 + 访问成员。 tail_->next等价于(*tail_).next

t拿到tail_保存的指针地址,t->next访问的就是该内存地址对应节点的next成员,等同于对tail_所指向节点的next访问(即tail_(解引用后)的next),因为存的是同一个节点的内存地址。

 

插曲(为了搞懂这个我他妈又钻研了2天):

狗逼东西!真的痛苦2G的腾讯云服务器经常用几周就内存爆满,SSH无法登陆,VNC又他妈一堆虚拟键盘报错,闪烁一屁眼子atkbd报错,豆包给的一个极致作死的系统级别的修改内核的指令:sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash i8042.nokbd i8042.noaux"/' /etc/default/grubupdate-grubreboot,指令输入完VNC连用都用不了了!说禁用虚拟键盘报错顺带修改内核参数直接把终端初试化都给限制了,进入救援模式挂载系统盘一键清理GRUB(系统开机引导程序)错误内核参数,结果狗逼豆包又给我错了,救援模式根本无法登陆VNCimage,只能找客服了imageimage。结果需要备份访问我的数据,那不如我自己备份重新装系统了 image,哎,Linux都算好的,我他妈之前vim才痛苦,滚动、复制、定位某行都用不了鼠标,妈逼的这个VNC更恶心,吐了,是不是给人用的啊!最终无功而返,但最后学到了很多!

具体心路历程如下:

前言:

首先2G内核一个月就会爆满一次,查询原因是正常用VScode带来的,没管也没必要管,只能说2G太小了,所以无法建立起ssh通信,走VNC排查,然后VNC不停的虚拟键盘报错,

所以想修改 GRUB 开机引导的内核启动参数文本,在内核启动参数中禁用 i8042 键鼠控制器,结果禁用了i8042键鼠控制器,也致使VNC虚拟键鼠硬件模拟信号解析失败、键鼠初始化失效,直接VNC初始化都卡死在那个页面,因为这个页面要初始化一堆东西,显然虚拟键盘禁止了以后无法初始化了直接卡死(注意此时已经过了一段时间,内存自动回落导致SSH可以用了,我旨在解决VNC的事,其实后续发现只要使用远程 VSCode 进行 C++ 开发,想要代码跳转、智能提示、实时语法检查、重构功能,必须依赖 cpptools,这个经常会2G内存爆满,连VNC输入命令都费劲,只能重启实例),然后左侧那个远程命令,Ctrl+Alt+F1切换1~10号终端也全是一样的效果。

然后豆包说只能救援模式,被我一顿辱骂,因为我起初发现只有点蓝色的登陆按钮进去orcaterm(这个走的是SSH通道),下方才有VNC入口,而救援模式禁止登陆,我错误的发现救援没法登陆VNC,所以才说只能找客服,但其实划到下方是有单独VNC入口的,误会豆包了,这个是可以在救援模式进入的,但我起初做的是直接镜像,然后重做系统,可是失败了依旧VNC坏,因为用故障系统制作镜像,镜像会完整打包所有异常内核、终端配置,重装后故障原样复刻,VNC 卡死问题会一直存在。

先说下服务器的完整全流程(只讲磁盘、分区、挂载、系统写入,贴合你的 vda1+vda2 结构)(磁盘就是硬盘)

阶段 1:出厂空白虚拟硬盘 vda(纯毛坯,无任何内容)

  1. 对整块 vda 划分分区:切出 vda1(1M 引导分区)、vda2(50G 数据分区)。

  2. 固件直接向 vda1 底层写入引导程序,全程不用挂载(仅开机瞬间被引导程序底层读取),挂载是操作系统的功能,固件读写磁盘绕开操作系统。

阶段 2:安装 Ubuntu 操作系统

  1. 安装程序底层格式化 vda2,搭建文件系统,往 vda2 里写入完整系统、文件夹结构,依旧不需要挂载。

  2. 写入配置:告诉引导程序,系统本体在 vda2,正常开机要把 vda2 挂载到根目录/

阶段 3:服务器正常开机(日常使用)

  1. 硬件固件读取 vda1 的引导程序。

  2. 引导程序加载内核,自动执行挂载:把 vda2 绑定到根目录/

  3. 挂载完成,根目录下所有文件夹都来自 vda2,你新建 C++ 代码、存放文件,都写入 vda2。

阶段 4:如今内核启动损坏,进入救援模式

  1. 不自动挂载 vda2,转而从内存加载临时微型系统。

  2. vda1、vda2 依旧完好,内部引导、系统、代码全部保留,只是挂载入口断开,你看不见里面内容。

  3. 手动执行挂载命令,把 vda2 挂到自定义文件夹,就能重新读取原有全部文件。

核心总结

  1. 往硬盘存文件、装系统、写引导:靠磁盘底层读写,不需要挂载。

  2. 开机查看、编辑文件:必须挂载,挂载只是给系统一个访问硬盘内容的入口。

  3. 挂载断开,文件不会消失,只是暂时无法查看使用。

 注意:

  • 分区可以不挂载就提前存入数据,挂载只是系统层面的访问入口,写入文件不需要挂载。

  • 正常运行系统时 vda2 挂载在根目录,你编写的代码才能被查看使用;救援模式只是暂时解除挂载,vda2 内部原本存储的代码文件不会消失。

  • 操作系统安装写入 vda2 属于磁盘底层写入操作,全程不需要挂载;只有系统开机运行、你读写文件时,才需要挂载。

  • 腾讯云轻量服务器的50G磁盘就是虚拟化生成的虚拟硬盘。

  • 人类的思维:毛坯房里装修一下家具啥的,可以理解为家具挂载到毛坯房,可是计算机专业术语依旧反人类,成为毛坯房挂载到家具,就毛坯房就是50G硬盘空间,划分出vda1和vda2空间后,vda2(毛坯房)挂载到/根目录后,整个操作系统的文件、自学写的C++代码就都在vda2内。

分区=在50G硬盘里圈出来一块毛坯空地,服务商在卖给你服务器或者电脑前都会格式化搭建文件系统=打好地基框架即装修,挂载=把这块毛坯空地接入系统根目录,挂载后你才能看见里面的文件夹文件,文件夹是硬盘分区格式化后文件系统划分出的逻辑存储单元。

挂载:把一段文件,放到一个文件夹入口,点开文件夹就能读取这段文件。

所以豆包最开始建议我走救援模式 image

  • vda1:1M 分区,仅存放引导文件,本身无文件系统,无挂载点;

  • vda2:50G 主分区,存放你原本完整的 Ubuntu 系统、所有文件数据,当前未挂载;

  • loop0、sr0、sr1:属于临时救援系统,空间出自 2 核 2G 云服务器的2G,是启动时加载的临时镜像,只用来修系统,不占用 vda 硬盘空间。

打算执行:

mkdir -p /mnt/dev /mnt/proc /mnt/sys
mount /dev/vda2 /mnt
mount --bind /dev /mnt/dev
mount --bind /proc /mnt/proc
mount --bind /sys /mnt/sys
chroot /mnt
sed -i 's/i8042.nokbd i8042.noaux//' /etc/default/grub
update-grub
exit
umount /mnt/sys /mnt/proc /mnt/dev /mnt

含义:

  • mkdir -p /mnt/dev /mnt/proc /mnt/sys:创建 4 个空文件夹,用来存放系统挂载内容。mkdir 创建了 /dev、/proc、/sys 三个文件夹,mnt 本身是原有目录,一共新建 3 个文件夹。

  • mount /dev/vda2 /mnt:把存放系统的硬盘分区挂载到 /mnt 文件夹,就能读写你原本的系统文件。

  • 三条mount --bind:把当前终端的设备、进程、系统目录复制到 /mnt 内,保证后续修改系统正常运行。

  • chroot /mnt:把操作环境切换到你原本的 Ubuntu 系统里。

  • sed命令:删掉当初添加的键盘异常参数,修复键盘失灵问题。

  • update-grub:更新开机配置,修改永久生效。

  • exit:退出切换后的系统环境。

  • umount:解除所有挂载,防止重启出错。

Q:基本对挂载啥的有基本了解了,那思考lsblk是个啥?

A:两大独立载体

载体 A:内存(2G 运行内存,临时救援系统)

  • 你现在敲的所有命令、lscd,运行的全是这套内存里临时系统,都为空,你在root下cpp_project文件夹里写的C++代码这里看不到,他是临时的新的独立的操作系统,自带一套空目录/、/bin、/dev、/mnt、/root等,里面几乎没有你的正式系统文件,退出救援直接全部清空。

载体 B:硬盘(50G,vda2 分区,正式 Ubuntu 系统)

  • 永久存放你的系统、配置、文件,关机数据不会消失。

  • 内部同样有一套完整目录,和临时系统目录名字一模一样,但里面装着你真正要用的系统文件。

  • 平时正常开机,电脑直接读取这块分区运行;现在进救援模式,电脑不去读取它。

凡是查看硬件硬盘分区:只认硬盘,和当前是临时系统还是正式系统无关,代表命令:lsblkfdisk

凡是lscdcatvim这类浏览 / 修改文件的命令:只看当下系统能访问到的目录,默认只看内存里的临时系统;只有挂载硬盘分区后,才能用它们读写 50G 硬盘里的文件。

lsblk只扫描硬件硬盘,和当前运行的救援的这套临时系统无关,所以你在临时系统里执行它,依旧能查到硬盘上的 vda1、vda2 分区。

挂载:二者唯一连接方式

挂载 = 把硬盘分区,“插进” 临时系统的目录里。
  1. 把 vda2 挂载到临时系统的/mnt目录(/mnt是系统预留的空中转文件夹,专门用来对接硬盘分区)

  2. 此时访问/mnt,打开的就是硬盘里正式系统的根目录;

  3. chroot 切换环境,你操作的就变成硬盘里的系统;

  4. 改完配置、卸载挂载,断开临时系统和硬盘的连接;

  5. 重启电脑,电脑读取硬盘系统,修改生效。

电脑硬件
├─ 内存:临时救援系统(当前正在用,目录是空架子,关机清空)
│    执行ls、cd、所有命令都在这里运行
│
└─ 硬盘:
    ├─vda1:小引导分区
    └─vda2:50G正式系统(存放真实文件,永久保存)
        【挂载】→ 接入临时系统/mnt目录 → 临时系统可修改硬盘系统

Q: 这个只用 2G 就可以访问,只是建立访问通道,去访问 50G 磁盘里的东西?

A:是的,正常开机时vda2挂载在根目录;救援模式下vda2不再自动挂载至根目录,二者挂载连接断开,文件完好留存。

Q:像零拷贝啊

A:绑定挂载不属于 IO 零拷贝,二者只是都不复制文件内容,本质用途、原理完全不同。

绑定挂载(mount --bind):只在虚拟文件系统层面新建访问路径,两份路径指向同一堆数据,全程没有数据复制。它的作用是打通目录访问路径,给 chroot 提供运行环境,和 IO 传输优化无关,磁盘和虚拟磁盘做路径绑定挂载:只改访问路标,数据老老实实待在磁盘里,全程不用挪到内存。

零拷贝:属于磁盘和网卡传数据,必须过内存,数据要先拉进内存再发网卡,专门优化磁盘、网卡之间数据传输,用来削减用户态、内核态之间 CPU 的数据搬运,只作用于文件读写、网络收发场景。

搞完直接关闭救援,临时系统销毁,挂载关系自动消失,不会弄坏硬盘文件;但隐患是:挂载占用磁盘引用,极端情况会造成文件错乱、配置写入不全,规范操作必须卸载。

Q:为啥VScode没事?

A:差异只在服务器接收按键的底层通道。

网页VNC按键流程:笔记本按键→浏览器网络数据包→腾讯云后端模拟PS/2硬件信号→Linux内核i8042键鼠驱动解析按键,被豆包误导执行了命令添加的i8042.nokbdi8042.noaux参数会直接关闭这套驱动,VNC按键无法解析进而失灵(i8042是老式PS/2键鼠控制器驱动,腾讯云网页VNC依靠它识别键鼠信号,禁用参数会直接关停驱动,SSH不依赖此驱动,故而不受影响)。

SSH/VSCode远程按键流程:笔记本按键→网络数据包→sshd服务生成伪终端,字符直接送入Shell,全程不经过i8042键鼠控制器,关闭该驱动不会干扰SSH输入。

所以其实互相配合、启发、合作、追问一句发现SSH内可直接执行修复命令,不必启用救援模式。正常SSH连接时,vda2会自动挂载到根目录,可直接读写修改文件。

正常SSH连接系统时,vda2会自动挂载至根目录。

救援模式适用场景:磁盘文件损坏、引导失效、内核故障、SSHVNC全部失联、系统无法正常启动;内存爆满优先使用VNC处理,救援模式不用于解决内存爆满问题,其作用为挂载磁盘改文件、修复引导、重装内核。

开始操作:

  • VScode中执行cat /etc/default/grub,查到配置GRUB_CMDLINE_LINUX_DEFAULT="i8042.nokbd",确认键鼠硬件被禁用;

  • 执行sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/c GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"' /etc/default/grub修改内核启动参数;

  • grep GRUB_CMDLINE_LINUX_DEFAULT /etc/default/grub核对修改结果;

  • 执行update-grub更新系统引导文件;

  • 执行reboot重启系统,报错问题彻底解决。

备注:

很智障的VNC使用体验,终端持续刷屏虚拟键盘报错,输入命令需要拖动滚动条至页面底端。使用stty size查询,当前屏幕可显示 48 行文本,单行最多容纳 128 个字符;执行stty rows 20可固定一屏仅展示 20 行文本。而右上角的页面分辨率仅调整字体大小,不会修改屏幕文本行数;可是改成20行妈逼的居然屏幕剩余区域留白,滚动条依旧存在,只是内容少用不到滑动,网页 VNC 缓冲区无法关闭,滚动条必然常驻,服了╮(╯▽╰)╭。

而救援模式无持续报错:救援模式采用轻量化临时内核,删减了循环巡检,仅开机做单次硬件检测,不会反复扫描输出日志。

搞好以后发现虚拟键盘也不报错了,就算报错也不想管了,问题根源说是云厂商VNC自带后台硬件模拟巡检,会定时探测虚拟机i8042虚拟键鼠硬件与输入通道,用于同步按键、避免远程输入卡死。刷屏报错是巡检检测到驱动不兼容、参数冲突输出的日志,仅自定义内核环境容易触发,纯净默认系统基本无该问题。厂商不会修改巡检逻辑与日志输出,改动需要大规模机型测试,性价比太低。

思考其实还可以使用腾讯云官方纯净Ubuntu系统重装,放弃自制故障镜像,镜像里也有坏的系统内核数据,纯净系统搞完挂载上自己事先对硬盘的快照备份就行,即新建个data文件夹,把快照挂载到/data,旧系统整个系统盘的根目录,挂载到/data后,旧系统里/root文件夹,就对应现在的/data/root,把/data/root下的项目文件夹剪切到新系统/root目录,执行:mv /data/root/cpp_projects_2 /root/

Q:如果我是本地Linux咋搞?

A:本地Ubuntu自带显示器键鼠替代VNC,系统故障用 U 盘Live系统替代云救援模式,二者原理一致,载体不同。

Q:捣鼓这些牛逼吗?

A:正常模式下系统内核、终端驱动配置异常,键盘输入驱动失效,普通VNC接收不到按键;救援模式是独立纯净临时系统,底层驱动完好,所以VNC能正常交互。

岗位日常经常遇到服务器远程连不上、线上机器卡死的故障,你搞懂了SSH依赖系统上层服务、VNC与救援模式是底层兜底方案,学会分层排查问题,面试聊线上应急排错时可以拿来举例,会比只会背知识点的人更有实操画面。

你弄懂了开机引导、内核参数会直接影响硬件驱动,能初步理解内核和上层应用的上下级关系,而服务端开发写网络、高性能程序,本质就要贴合Linux内核机制,这份理解可以帮你后续学习内核相关知识更轻松。
  • 基础运维认知加分:你亲身吃透了云服务器底层控制台(VNC)与SSH的层级区别:SSH依赖系统上层服务,VNC直连硬件控制台,系统上层故障只会废掉SSHVNC是兜底入口,是后端运维排错必备常识。

  • 故障排查思维积累:学会区分上层软件故障和底层硬件 / 虚拟化通道故障,后端开发线上服务器宕机、远程连接失效时,这种分层排查思路是面试官很看重的工程素养。

  • 云平台原理理解:弄懂了云主机镜像、救援模式、磁盘挂载的作用,后续做服务端部署、容器、线上环境维护都会用到相关逻辑,面试谈及线上故障应急处理可以结合本次经历举例。

  • 短板提醒:本次只是基础系统排错,想贴合岗位,后续要把精力放在Linux内核基础、多路复用、内存、网络栈、C++高性能编码等核心技术上,运维经历只是加分项,技术功底才是录取关键。

所以只属于锦上添花的小积累,算不上核心竞争力!

堪比邝斌ACM一题多解 + 各个平台数据强弱。

插曲结束。

条件变量结束。

 

简单一句【int类型赋值给原子变量】衍生出的追问思考?

赋值的话,非原子变量old = 原子变量val,合法,注意这是两件事:

  1. 原子读取 val(load),等价于old = val.load();

  2. 普通内存拷贝:赋值给普通变量 old:

    int old = val;
    // 时序:
    // T1:执行load拿到val=10
    // 【间隙:T2修改val=20】
    // T1:把10拷贝给old
    old最终是10,而此时val已经变成20。

load 本身不会被打断,但两步中间存在线程调度间隙。

但没法验证,我尝试的思路是:

int curr = real_val; 原子real_val.load 拿到快照之后,往普通变量 curr 拷贝的过程中,原子变量可以被其他线程修改。 重点:load 得到的值是瞬间快照,后续拷贝动作不保护原子变量,但是!快照已经存入了寄存器,无论其他线程怎么改real_val,再回来给curr赋值用眼是之前那个快照(浪费了好久无法验证,我还一度辱骂豆包)(记住这个非原子就行,以后写代码复杂了兴许就出错了)。

所以实操结论就是:

    • 多线程修改啥的只和全局共享有关,假设自己内部局部的num,原子自增,那num永远是自增次数后的结果

    • 全局的话:

      • 普通int 自增1w次,自减1w次最后不是0

      • 原子int 自增1w次,自减1w次最后不是0

    • 然后假设有个全局的global_num,A、B都有global_num=原子a,A 线程自增10次,10 存寄存器,还没写入 global_num;B 线程完整执行10次自减,把 a 降到 0,并把 global_num 写成 0;切回 A 线程,将寄存器里的 10 写入 global_num,最终global_num=10。

b = a(两个都是原子,不合法)

  1. 原子操作:读取 a

  2. 原子操作:写入 b 两步独立原子操作,整体不是原子。 标准禁止这条语法(拷贝赋值,被删除:atomic<T>& operator=(const atomic<T>&) = delete;),避免误认为整条语句原子,标准防止开发者误以为整条语句原子,直接删除该重载。

template<typename T>
class AtomicDemo{
private:
    T data;
public:
    void store(T val){
        data = val;
    }
    //新增这一行
    AtomicDemo<T>& operator=(T desired);
};

operator= 是赋值运算符重载。

AtomicDemo<T>& 代表返回当前对象的引用,参数 T desired 是普通类型数值。

用法:AtomicDemo<int> a;调用类模板实例化后的默认构造函数 AtomicDemo<int>::AtomicDemo()

赋值 a = 10;调用 AtomicDemo<int>& operator=(int desired)

等价调用 a.store(10)

至此懂了,两种接口实现相同语义:=语法调用重载赋值运算符,store()是显式函数调用。

而atmoic其实就是类模板,大体就是上面这个!

且模板实例化在编译阶段触发,编译器实例化时需要见到完整实现,仅有声明无法生成代码,分离声明与实现会引发链接错误,模板不存在可以单独外部声明的形式,因此声明和实现需要放在同一头文件。

注意: 

原子赋值int a = atm;,等价写法int a = atm.load();

原子写入atm = 100;,等价写法atm.store(100);

 

原子自增没问题:

// ===================== 测试1:std::atomic<int> n++; 原子RMW =====================
void test_atomic_rmw(){
    std::atomic<int> n{0};
    constexpr int thread_cnt = 8;//开8个线程
    // constexpr:编译期常量,值编译时确定;强制要求编译期算出值
    // const:变量值编译器不一定能在编译期确定,仅代表运行期不可修改
    
    constexpr int loop_cnt = 1000000; // 每个线程循环自增 10 万次

    std::thread ths[thread_cnt];// 线程数组,存放 8 个线程

    for (auto& t : ths){// 遍历线程数组每一个位置
        t = std::thread([&]() {
            for (int i = 0; i < loop_cnt; ++i)
                n++; // 原子 读-改-写 RMW,整套不可打断
        });
        /*
        启动新线程
        [&]()是 lambda 表达式:
        [&] = 引用捕获外面所有变量,线程内部可以访问 n
        大括号内是线程要跑的代码
        */
    }
    for (auto& t : ths) t.join();

    std::cout << "【原子n++ RMW】预期:" << thread_cnt * loop_cnt
              << " 结果:" << n.load() << "\n";
}

n++是原子,atomic<int> n++ 等价 n.fetch_add(1),都是原子 RMW。 区别:

  1. n++:返回自增前旧值

  2. fetch_add(1):默认同样返回旧值;可额外指定内存序

n.fetch_add(1, std::memory_order_relaxed);简写n++不能自定义内存序,且a++自增仅1。

 

C++ 里std::atomic<int>是类,用它定义出来的东西叫对象。 普通int a;的 a 叫内置类型变量。 日常交流可以统称变量;严格区分:

int a;                // 内置类型变量
std::atomic<int> num; // 类实例,对象

std::atomic<int> 对象内部存放一个 int 数值,这就是它承载的数据:

std::atomic<int> num{5};

类实例num是一个载体,内部装着整数 5。

 

狗逼术语:

人话载体就是承载东西的地基,CAS 是最基本的硬件指令,atomic基于 CAS 的一个封装,那CAS我理解就是载体,可以专业术语是:

CAS 不是载体,是底层地基;atomic是载体,承载数据的容器。例如std::atomic<int> num{100};,等价std::atomic<int> num = 100;

std::atomic<int>是载体,内部存放int数值 100;num.compare_exchange_weak(a,b)是 CAS 操作,用来操作容器内部的数据。

 

std::atomic<T> 是载体。 原子操作(load/store/CAS)是载体提供的操作行为。

此文搜搜不到的去上一篇搜。

下一篇开始真正啃内存池。

posted @ 2026-08-21 18:12  GerJCS  阅读(2)  评论(0)    收藏  举报