目录
C++内存结构
作用域与生存周期
堆与栈
内存对齐
智能指针
编译与链接
内存泄漏
补充问题
include “ ”与<>
大端与小端
C++内存结构
C++程序内存分区
- 代码区
- 文件中所有的函数代码、常量以及字符串常量
- 只读,保护程序不会被其他进程恶意修改
- 全局/静态存储区
- 存储全局变量和静态变量。这些变量在程序开始时分配对应内存空间,程序结束的时候则释放
- 该区域还可以被进一步细分,初始化数据段(已经初始化的数据)、未初始化数据段
- 堆
- 动态分配的内存空间,类似于new 和 malloc分配的内存空间
- 堆的大小是动态变化,程序员负责分配和释放
- 栈
- 栈的内存空间是编译器来自动管理,函数调用时分配空间,函数返回时释放内存区域
- 存储函数调用的局部变量以及函数调用的返回地址
- 常量存储区
- 用于存储常量,不允许更改,程序结束的时候自动释放(例如初始化的字符串)
作用域与生存周期
总结
- 全局变量
- 作用域:全局作用域。全局变量在一个源文件中定义,可以作用其他所有源文件。不包含该定义的源文件,需要借助extern关键字再次声明该全局变量
- 生命周期:程序运行的整个声明周期都存在,程序结束则自动销毁,由系统对资源进行回收
- 注意:全局变量定义不要在头文件中定义,当其他文件包含该头文件时,该文件的全局变量就会被定义多次,编译时会导致重复定义出错。
- 静态全局变量
- 作用域:文件作用域。只作用于定义它的文件中,不同的源文件即使定义了相同的静态全局变量,两者也是不同的变量(全局变量是可以实现一次定义,全文件使用)
- 生命周期:存在于程序的整个生命周期,与全局变量相同
- 局部变量
- 作用域:局部作用域。与函数的生命周期相对应,并非程序运行期间一直存在
- 生命周期:生命周期只存在于函数调用的时候,函数调用结束后会自动销毁
- 静态局部变量
- 作用域:局部作用域。仅在声明静态局部变量的函数中可见
- 生命周期:自从初始化后直到程序运行结束都一直存在
静态局部变量实验
#include <iostream>
void staticVariableExample() {
static int count = 0; // 静态局部变量
count++;
std::cout << "Count: " << count << std::endl;
}
int main() {
for (int i = 0; i < 5; ++i) {
staticVariableExample();
}
return 0;
}
堆与栈
函数调用与栈
当程序调用函数的时候,按照从 右往左的顺序,依次将 函数调用的参数压入栈中,并在栈中压入返回地址与当前的栈帧,然后跳转到调用函数内部。
栈空间分配测试(gdb)
- stact level 栈帧层 + 当前栈帧的起始地址、
- saved rip :调用该函数的指定地址信息
- called by frame at 地址:调用该函数的栈帧所在的地址,该地址是一个栈帧起始地址(是被哪一个栈调用的,即上一层的地址)
- arglist at 地址,args:参数列表的地址以及当前函数参数的数值是多少
- saved registers:保存的寄存器列表
- rbp at 地址, rip at 地址:基址指针和指令指针的地址
//测试源码
#include <iostream>
void recursiveFunction(int a, int b) {
int c = a + b;
std::cout << "a: " << a << ", b: " << b << ", c: " << c << std::endl;
if (a > 0) {
recursiveFunction(a - 1, b + 1);
}
}
int main() {
recursiveFunction(3, 1);
return 0;
}
栈溢出(程序运行中,栈的空间被耗尽,导致程序崩溃或者异常)
- 原因分析
- 深度递归:函数递归调用过深,每次递归的时候都会消耗响应的栈空间,最终导致栈溢出
- 局部变量过大:栈空间内如果存储了较大的局部变量,同样会导致栈空间不足
- 无限循环调用
#include <iostream>
void recursiveFunction() {
int largeArray[10000]; // 大型局部变量
std::cout << "Recursion" << std::endl;
recursiveFunction(); // 无终止条件的递归调用
}
int main() {
recursiveFunction();
return 0;
}
堆
- 含义:进程运行时,需要动态申请内存空间存放数据时使用的空间,堆的内存空间由操作系统来管理(资源的释放由操作系统自动管理)
- malloc 和 new从堆中申请内存,使用free 和 delete释放空间
堆与栈的对比
- 分配方式:栈编译器自动分配,堆由程序员手动分配
- 生命周期:栈随函数调用来决定,堆则灵活,是由程序员控制
- 内存大小:栈的内存空间通常固定且较小,堆的空间相对较大
- 线程安全:栈的线程安全,堆的线程不安全(因为同一个进程下的内存,多线程都可以访问)
- 场景:栈适用于局部变量、函数参数以及返回地址;堆则适用于动态数据结构以及大的数据块
内存对齐
含义
- 编译器将程序中的每个数据的地址,都放在机器字长的整数被的地址所指向的内存中(数据按照计算机系统大小的倍数进行对齐)
- 计算机内存的地址空间按照比特划分,但是实际情况上并不是严格按照顺序进行排放
内存对齐原因分析
- 提高性能
- 减少内存访问次数,从而提高性能
- 硬件限制
- 确保程序可以在不同硬件平台的正确性和兼容性
- CPU访问按照机器字长为单位,不以字节为单位,机器字长又是由总线宽度决定
- 简化硬件设计
- 对齐的数据访问,减少了访问内存次数,从而提高内存访问效率
内存对齐规则
- 基本对齐
- 数据类型地址必须是该类型大小的倍数
- 结构体对齐(重点)
- 第一个成员在结构体变量偏移量的0位置
- 结构体中的每个成员必须是该成员大小的整数倍
- VS系统中,要与8比较,取较小值
- 结构体的总大小必须是该结构体中最大成员大小的倍数,从而保证数组中每个结构体实例的对齐
- 例子分析
- char a :占用1个字节,因为下一个类型是int,所以该处需要填充3字节,从而保证int的对齐
- int b:占用4个字节
- short c :占用2字节,所以向下填充两个字节,最终大小要保证是最大字节的倍数,即4的倍数,所以需要填充2个字节
- 所以最终计算的结果是12字节
智能指针
智能指针管理动态分配的内存和其他资源,可以实现避免内存泄漏,其本身遵循RAII原则,实现自动创建内核和销毁内存。
unique_ptr
- 独占对象所有权:该指针指向的对象,只可以被该指针所占有,不能够拷贝构造和复制,但是可以移动构造和移动复制构造(即一个uinque_ptr 的对象赋值给另一个uinque_ptr 对象)
- 作用:轻量,因为只指向一个资源,开销小;适用于资源独占的场景,利于在socket编程中以及互斥锁
#include <memory>
#include <iostream>
struct Foo {
Foo() { std::cout << "Foo constructed\n"; }
~Foo() { std::cout << "Foo destroyed\n"; }
};
int main() {
std::unique_ptr<Foo> ptr1(new Foo());
// std::unique_ptr<Foo> ptr2 = ptr1; // 错误,不能复制
// std::unique_ptr<Foo> ptr2(ptr1);..错误
std::unique_ptr<Foo> ptr3 = std::move(ptr1); // 转移所有权
return 0;
}
shared_ptr
- 资源共享:多个shared_ptr指针可以指向同一个对象,指针内部会维护一个引用计数
- 使用
- use_count():查看资源使用者个数
- release():释放资源所有权,每一次释放会将计数减1,直到引用计数减到0,才对资源进行释放
- shared_ptr不是线程安全的,其内部的引用计数是原子性的
#include <memory>
#include <iostream>
struct Foo {
Foo() { std::cout << "Foo constructed\n"; }
~Foo() { std::cout << "Foo destroyed\n"; }
};
int main() {
std::shared_ptr<Foo> ptr1(new Foo());
std::shared_ptr<Foo> ptr2 = ptr1; // 共享
return 0;
}
weak_ptr
- 作用:指向share_ptr指针所指向的对象,目的是解决shared_ptr所带来的循环引用(两个shared_ptr指针互相指)问题
- 使用
- weak_ptr 转换为shared_ptr的时候,可以访问shared_ptr所指向的资源,但是不会影响share_ptr指针的计数
- shared_ptr被weak_ptr指向时的释放:一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使有weak_ptr指向对象,对象也还是会被释放。
- 使用weak_ptr访问对象的时候,需要调用lock去检查weak_ptr所指向的对象是否存在:如果存在则会返回一个shared_ptr的指针
shared_ptr 循环引用问题
总结:两个对象相互引用对方的shared_ptr的时候,双方的引用计数都不会变成0,所以导致对象都不会被销毁,最终会导致内存泄漏
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> ptrB;
~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
std::shared_ptr<A> ptrA;
~B() { std::cout << "B destroyed" << std::endl; }
};
int main() {
{
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->ptrB = b;
b->ptrA = a;
} // a和b出了作用域,但不会被销毁
std::cout << "End of main" << std::endl;
return 0;
}
weak_ptr打破循环引用思路
- a持有一个指向b的shared_ptr指针,这样就增加了b的引用计数
- b持有一个指向a的weak_ptr指针,但是这样不会增加a的引用计数
- 销毁过程
- a和b两者超出作用域后,其shared_ptr引用计数会递减
- 当引用计数降为0的时候,a被销毁
- a销毁后,其内部成员ptrB(指向b的shared_ptr)也会被销毁,所以此时b的引用计数也就会降至0,所以b也就被销毁了
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> ptrB;
~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
std::weak_ptr<A> ptrA; // 使用weak_ptr打破循环引用
~B() { std::cout << "B destroyed" << std::endl; }
};
int main() {
{
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->ptrB = b;
b->ptrA = a; // 使用weak_ptr
} // a和b出了作用域,会被正确销毁
std::cout << "End of main" << std::endl;
return 0;
}
编译与链接
编译的处理过程(代码转换为二进制指令==编译器读取源文件,源文件翻译成ELF,ELF文件经过操作系统进行加载执行)
- 预处理:处理源代码中的预处理指令。取出注释以及头文件、条件编译指令、宏的替换等
- 编译:CPP源文件翻译成.s的汇编代码。
- 汇编:汇编代码.s 翻译成机器指令.o文件,一个cpp文件只可以生成一个.o文件
- 链接:将生成的.o文件打包成一个整体,从而生成一个可被操作系统加载执行的ELF程序文件。
静态链接
- 静态链接是将代码运行所需要的库函数在编译的时候,全部放到可执行文件中。生成的可执行文件就包含的所依赖的库代码,所以在运行的时候,不需要外部库的支持。
- 过程
- 编译源文件,生成目标文件
- 链接器将目标文件和库文件合并成一个可执行文件
- 编译链接实验步骤说明
- g++ -c library.cpp -o library.o :仅编译,将cpp文件输出称为library.o文件
- ar rcs libstatic.a library.o:创建静态库 libstatic.a,并将library.o文件添加进去
- g++ main.cpp -L. -lstatic -o static_example:main文件连接上创建的静态库,创建出可执行文件
root@hcss-ecs-b4a9:/home/test/test_o# tree
.
├── library.cpp
├── library.o
├── libstatic.a
├── main.cpp
└── static_example
g++ -c library.cpp -o library.o
ar rcs libstatic.a library.o
g++ main.cpp -L. -lstatic -o static_example
./static_example
//library.cpp
#include <iostream>
void libraryFunction() {
std::cout << "Library function called!" << std::endl;
}
//main.cpp
#include <iostream>
extern void libraryFunction();
int main() {
std::cout << "Hello, static linking!" << std::endl;
libraryFunction();
return 0;
}
动态链接
- 运行时将程序与库函数链接。只要在文件运行的时候,再加载对应的动态库
- 过程分析
- 编译源文件生成目标文件(.o)
- 编译器生成的可执行文件中包含对动态库引用
- 程序运行的时候,动态加载器加载所需要的动态库
- 编译链接步骤说明
- g++ -fPIC -c library.cpp -o library.o
- -fPIC:因为动态库加载的时候可能会被映射到内存的不同位置,所以需要生成位置无关代码
- -c :仅编译不链接
- g++ -shared -o libdynamic.so library.o(创建动态库)
- -shared:生成动态库
- 指定.so的名字,并输入目标文件
- g++ main.cpp -L. -ldynamic -o dynamic_example((编译链接main,同时生成可执行文件)
- -L:指定链接时搜索库文件的目录,在此处表示库文件在当前目录
- -ldynamic:动态库的名字" l+ dynamic)
- export LD_LIBRARY_PATH=.
- 将动态库的目录添加到LD..这个环境变量中
//library.cpp
#include <iostream>
void libraryFunction() {
std::cout << "Library function called!" << std::endl;
}
//main.cc
#include <iostream>
extern void libraryFunction();
int main() {
std::cout << "Hello, dynamic linking!" << std::endl;
libraryFunction();
return 0;
}
动静态链接对比
特征 | 静态链接 | 动态链接 |
文件大小 | 大 | 小 |
运行速度 | 快 | 慢 |
依赖性 | 不依赖外部库 | 依赖外部库 |
更新 | 困难(重新编译和链接) | 容易(直接更新静态库即可) |
内存 | 大(每个程序都有独立副本) | 少(多程序共享库) |
版本 | 确保一致(编译时确定) | 可能不一致(运行时确定) |
使用 | 方便 | 复杂 |
内存泄漏
内存泄漏即是在堆中动态申请的内存,程序使用结束时没有及时释放,生命周期已结束,但是该变量在堆中的空间没有释放,从而导致堆中可使用的内存越来越少,最终导致系统变慢或者内存不足而崩溃。
防止内存泄漏
- 内部封装: 内存分配封装到类中,构造时创造,析构时释放
- 智能指针:本身就是一个防止内存泄漏的工具,内部封装了自动释放机制
- 良好编码习惯、使用内存泄漏检测工具
Valgrind 工具检测内存泄漏
//测试代码
#include <stdio.h>
#include <stdlib.h>
void memory_leak() {
int *leak = (int *)malloc(100 * sizeof(int)); // 分配内存,但没有释放
if (leak == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(1);
}
}
int main() {
for (int i = 0; i < 100; ++i) {
memory_leak();
}
printf("Memory leak example finished\n");
return 0;
}
补充问题
include “ ”与<>
#include(关键字用于标识源代码编译时所需引用头文件,编译器自动查找头文件中信息)
- #include<>:用于包括标准库头文件。编译器在预先指定的搜索目录中进行搜索,/usr/include目录。
- #include" " :用于包括标准库头文件。先在当前源文件目录中查找。如果没有,再到当前已经添加的系统目录中(编译时-I 指定的目录)查找,最后在 /usr/include目录查找
__has_include:
- C++17的特性,用于检查是否已经包含了某个文件
#include <iostream>
int main()
{
#if __has_include(<cstdio>)
printf("c program");
#endif
#if __has_include("iostream")
std::cout << "c++ program" << std::endl;
#endif
return 0;
}
大端与小端
大小端含义
- 大端字节序:最高有效字节存储在最低地址
- 小端字节序:最低有效字节存储在最高地址