一、程序预编译-编译-链接-运行的过程
1.1 基础知识(操作系统相关)
1.1.1 可执行文件格式及数据
1)Windows和Linux的可执行文件格式
以C++为例,Windows下的可执行文件格式是.exe
,格式为PE;Linux下的可执行文件是ELF格式。
2)数据和指令的区别
无论用什么语言编写的程序,最终都是一堆二进制数据,这些数据有两种类型:指令和数据。指令是CPU执行的,数据是CPU读写的。
其中函数调用、循环、条件判断等最终都会被编译成一系列的指令存在文本段,而变量、常量等则会被编译成数据存储在**数据段。
3)文本段和数据段
- 文本段:存放程序的指令,是只读的,不允许写入。(代码区)
- 静态成员函数、普通成员函数
- 数据段:存放程序的数据,是可读写的。数据段又可以分为数据段和BSS段
- 数据段存放初始化的全局变量
- BSS段存放未初始化的全局变量。
这些段组成了可执行文件,在程序运行时会被加载到进程的虚拟内存中,然后通过页表映射到物理内存中。
4)虚拟地址空间
前面提到段在程序运行时会被加载到虚拟内存中,当程序运行时,操作系统会为每个进程分配一个虚拟地址空间(Linux下是4G),其中0-3G是用户空间,3-4G是内核空间。
其中用户空间组成为(从低位到高位):
- 预留空间:
128M
- 文本段.text:存放程序的指令
- 数据段.data:存放程序的已初始化的数据
- BSS段.bss:存放未初始化的全局变量
- 堆heap
- 栈stack
虚拟空间由用户空间和内核空间组成,因此32位系统下虚拟内存的最大空间为3G+1G,64位系统是128T+128T:
访问栈区的数据比访问堆区的数据快:
- 栈区数据直接由寄存器访问;堆区需要由指针访问,先将指针加载到寄存器,然后再访问数据
- 栈区可以利用连续空间的特性缓存数据;堆区数据是离散的,无法充分利用CPU缓存
1.1.2 CPU、寄存器、内存的关系
计算机的三个核心组成部分:CPU、内存、I/O。
- CPU:是计算机的大脑,属于高速设备,用来频繁地读取、执行指令(数据)。
- 内存:属于中速设备,用来存储程序的指令和数据。
- I/O:属于低速设备,用来输入输出数据。
而寄存器是CPU内部的一块高速缓存,用来存储CPU执行指令时的临时数据,如程序计数器、指令寄存器、通用寄存器等。
一般来说,程序运行中会先从内存中读取指令到寄存器中,然后CPU执行这些指令,最后将结果通过寄存器写回到内存中。也就是说,寄存器是CPU和内存之间的桥梁。
1.2 生成可执行文件的过程
以Linux下的C程序为例,生成可执行文件的过程主要分为四个阶段:
- 预编译(Preprocessing)->
.i
文件 - 编译(Compilation)->
.s
文件 - 汇编(Assembly)->
.o
文件 - 链接(Linking)->可执行文件
当我们有一个.c
文件时,我们可以通过gcc
命令将其编译成可执行文件:
1
2
3
4
5
6// hello.c
int main() {
printf("Hello World!\n");
return 0;
}1
2
3 gcc hello.c # 编译
./a.out # 执行
hello world!
而生成可执行文件过程就是gcc
这个语句实现的,这句语句就实现了上述说到的4个步骤,接下来我们将剖析一下这四个步骤的具体实现。
1.2.1 预编译
预编译的主要工作是将头文件以及宏定义替换成其真正的内容,得到一个.i
文件。所以预编译后得到的文件将比原文件大很多。
在Linux中我们可以通过gcc -E
命令来分解编译过程,使其停留在预编译阶段,并通过-o
指定文件名:
1 | gcc -E hello.c -o hello.i |
结果预编译后文件的大小变大,因为预编译后的文件中包含了很多头文件的内容:
时间 | 文件名 | 大小 | 代码行数 |
---|---|---|---|
预处理前 | hello.c | 50 | 3 |
预处理后 | hello.i | 1.1K | 3 |
C++中宏定义与函数的区别
- 宏定义在预编译阶段就完成了替换,相当于直接插入了代码,不存在函数调用,也没有返回值、不做类型检查、不加分号
- 函数是在编译阶段才会被调用,有返回值、有类型检查、需要在最后加分号。
1.2.2 编译
编译的主要工作是将预编译后的文件转换成汇编代码,得到一个.s
文件。
在Linux中我们可以通过gcc -S
命令来分解编译过程,使其停留在编译阶段,并通过-o
指定文件名:
1 | gcc -S hello.i -o hello.s |
最终得到的文件是一个汇编代码文件,内容如下: 1
2
3
4
5
6
7
8
9
10
11
12 .file "hello.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
......
1.2.3 汇编
汇编的主要工作是将汇编代码转换成机器码Machine
Code(二进制),得到一个.o
文件。
1 | gcc -c hello.s -o hello.o |
前面我们提到,可执行文件是由文本段和数据段组成的,其实每个.o
文件就是一个目标文件,其中包含了文本段和数据段的内容。因此单独一个.o
文件是无法运行的分布如下:
1.2.4 链接
假设我们代码不止一个文件,那么我们需要先将这些文件都全部生成为汇编代码.o
,然后再将这些汇编代码链接成一个可执行文件a.out
。
1 | gcc hello.o hello2.o -o a.out |
在链接的过程中,会将多个.o
文件中的文本段和数据段合并成一个可执行文件,也就是将所有.text
合并成一个.text
,将所有.data
合并成一个.data
。最终的布局如下:
1.2.5 运行
运行时会先将可执行文件a.out
加载到虚拟内存中,然后通过页表映射到物理内存中,最终通过CPU执行这些指令。
1.3 静态链接和动态链接
静态链接和动态链接的主要区别在于链接的时机不同
- 静态链接是在编译时进行的
- 动态链接是在运行时进行的。
其库存储的位置不同
- 静态链接库是编译时链接集成到可执行文件中
- 动态链接库是作为独立的共享库存储的,运行时再加载到内存中。
1.3.1 静态链接
静态链接是指在链接阶段将程序中所有的静态库(如Linux下的.a
文件、Windows下的.lib
文件)都链接到可执行文件中,生成一个独立的可执行文件。
1)优点
静态链接的方式包含了程序运行需要的所有代码和数据,因此可以独立运行,不需要依赖其他文件。
2)缺点
每个使用该库的程序都会有一份该库的拷贝,因此会占用更多的磁盘空间。
同时,如果库文件更新了,那么所有使用该库的程序都需要重新编译,维护成本高。
1.3.2 动态链接
动态链接是指在链接阶段只将程序中的引用链接到可执行文件中,实际链接库是在运行时进行的,操作系统执行时会根据需要加载共享库(如Linux下的.so
文件、Windows下的.dll
文件)。
1)优点
由于多个程序运行时只需要一个共享库,因此节省了磁盘空间。
如果库文件更新了,只需要更新一份库文件,所有使用该库的程序都会自动更新。
2)缺点
程序运行时需要加载共享库,因此启动速度慢。
如果共享库出现问题(丢失或损坏),那么所有使用该库的程序都会受到影响。
参考:C/C++编译链接
二、内存管理
2.1 C++内存分区
C++程序在运行时会将内存分为5个区域:
- 栈区:存放局部变量等,由系统自动分配和释放。
- 堆区:存放动态分配的内存,由程序员分配和释放。
- 全局/静态区:存放全局变量和静态变量,程序启动时分配,程序结束时释放。
- 常量区:存放常量,如字符串常量。
- 代码区:存放函数体的二进制代码。所有成员函数、全局函数都放在代码区。所以函数不占用对象的内存
其实还有个自由存储区,也会被认为是堆区的一部分,用于存放new和delete的内存。
2.2 内存池
2.3 内存泄漏
内存泄漏在前面的博客中已经提到过:C++基础知识学习
内存泄漏主要是由new或malloc分配的内存没有被释放,长时间运行后如果内存满了会导致内存溢出、造成程序崩溃。建议通过析构函数、智能指针、RAII等方式来避免内存泄漏。
2.4 this指针
同样在前面的博客中也提到过:C++基础知识学习
主要注意的是它只能在类的成员函数中使用,且静态成员函数中不能使用。
2.5 类的内存分配
这部分包括类内的对齐在后面的内存对齐中会详细介绍。
一些特殊数据类型的大小:
- 指针:32位下是4字节,64位下是8字节
- union:取最大的成员的大小
三、内存对齐
内存对齐是编译器为了提高内存访问效率而采取的一种措施,能减少内存访问次数。
以32位系统为例,寄存器只能从能被4整除的地址(4bytes=32bits)中读取数据,因此如果数据不是4的整数倍,那么就需要两次内存访问才能读取完整的数据。
至于对齐字节是多少,这跟硬件的颗粒度有关,比如32位系统的寄存器是32bits,64位系统的寄存器是64bits。
3.1 内存对齐的好处
- 方便平台移植
- 某些硬件平台不能访问任意地址的内存,只能访问某些特定地址的内存,因此需要对齐来兼容这些硬件平台。
- 提高内存访问效率
- 未对齐的数据在内存中需要访问两次,而对齐的数据只需要访问一次,因此对齐能提高内存访问效率。(对于数据结构特别是栈,最好是在自然边界上对齐)
3.2 C++中结构体及类的内存对齐
1)结构体的内存对齐
C++中结构体的内存对齐是由最宽基本类型决定的,即结构体中的每个成员都要对齐到最宽基本类型(结构体中变量类型最大值)的整数倍。
point:union的话会以union中最大的成员的大小为对齐基准,union的大小也是最大成员的大小
对齐原则:
- 确定对齐基准字节数:找到结构体中最宽的基本类型,如最宽的是
double
,那么对齐基准字节数就是8
。后面的数据都要按照8
的整数倍对齐,不足的补0。 - 每个成员对齐规则:每个成员的距离整个结构体初始位置的offset必须是min(该成员大小,对齐基准字节数)的整数倍。
- 整体对齐原则:最后结构体的大小
sizeof(structA)
必须是对齐基准字节数的整数倍。
- 确定对齐基准字节数:找到结构体中最宽的基本类型,如最宽的是
举例说明: 1
2
3
4
5
6struct structA {
//最大的是int,因此对齐基准字节数是4,最终大小为12
char a; //a - - -
int b; //b b b b
short c; //c c - -
};1
sizeof(structA);//12
优化后将int
放在最前面或者最后面: 1
2
3
4
5
6struct structA {
//最大的是int,因此对齐基准字节数是4,最终大小为8,放在末尾同理
int b; //b b b b
char a; //a - - -
short c; //c c - -
};
2)类的内存对齐
- 如果是类中的成员变量,也是按照上述规则进行对齐
- 同时如果类中有虚函数,那么类中会有一个虚函数表指针,这个指针大小为4也要进行对齐。
- 类中的成员函数不占实例化对象内的空间,因为成员函数是共享的。
- 所以一个类中如果只有普通函数,没有任何变量,那么这个类的大小是1。(因为类的大小最小是1)
- 类中的static静态变量也不占类的大小,因为静态变量是共享的。
1 | class A { |
1 | class A { |
1 | class A { |
3)C++内存对齐最终还会再操作系统再次检查对齐
比如我们在一个结构体中设置两个char
型数据,那么根据C++的对齐规则,该结构体的大小是2
,但是操作系统会再次检查对齐,如果操作系统要求对齐是4
(32位),那么最终该结构体的大小是4
(通过检查两个类对象的地址差发现的)。
1 | struct test2 { |
当用sizeof
和通过地址差来检查对齐时,会发现sizeof(test2)
是2
,但是两个类对象的地址差是4
。
1 | int main(){ |
四、内存池设计
4.1 C++默认的内存管理函数
4.1.1 系统分配内存空间
系统在接收到内存分配请求时,会有以下的操作:
- 查找内存空闲表
- 按照一定算法分配不小于申请需求的内存块
- 切割成合适的大小返回给用户
- 更新内存表
- 如果涉及多线程,由于多线程共享一块内存空间,所以还会统一进行锁竞争
那么就会存在内存碎片和效率性能的问题:
1)内存碎片
- 由于堆上内存的分配和释放是程序员控制的、不连续的,所以会产生内存碎片,导致内存利用率低。
- 内存碎片又分为外部碎片和内部碎片
- 外部碎片是指已经被释放的内存,但是由于大小不合适无法分配给新的内存请求。
- 内部碎片是指已分配的空间如C++中的
new
分配的内存,由于对齐等原因导致的浪费。
2)效率性能问题
- 在多线程环境下,并发加锁会导致性能下降。
- 就像前面提到的系统分配内存的方式需要经过查找、分配、更新等操作,这些操作都会消耗时间,因此会影响效率。
所以需要引入内存池来优化性能以及减少内存碎片问题。
内存池是一种预先分配一定数量的内存,然后按需分配给用户,用户使用完后再归还给内存池,这样就可以减少内存碎片、减少频繁地向系统申请和释放资源来提高效率。
4.1.2 malloc内存分配机制
malloc分配内存有两种方式:brk
和mmap
- malloc不是系统调用,由于使用了池化技术会从提前申请的内存中分配,所以不会陷入内核态
brk
和mmap
是系统调用,会陷入内核态
1)C++内存回收
C/C++是编译型语言,没有内存回收机制,程序员需要自己释放不需要的内存。
C++中的内存管理方式主要有运算操作符new和函数malloc两种,其中new也是基于malloc实现的。所以我们这里主要讨论malloc。
在C++程序中进行内存回收的方式有全局变量自动回收、delete、free回收、RAII通过析构函数自动回收等方式
对于堆上的资源,由于上面提到的效率和内存碎片问题,C++不会直接向堆申请资源,而是借助malloc函数来向系统申请资源。
2)malloc内存分配机制
malloc属于标准库用于接口操作层,而用户调用malloc接口属于在用户层实现的,就像上面提到的,出于性能考虑,malloc是通过内存池的方式来管理内存的。
malloc的实现机制简单来说是,当我们申请一块如8B内存时,会实际上向系统申请一块更大的如1M内存,然后后面申请内存时都是从这1M内存中分配,当这1M内存不够时,再向系统申请1M内存。同时malloc内部还尝试通过空闲链表的设计方式来减少内存碎片。
其中最主要的分配内存思想是分配时会搜索空闲链表,找到第一个大于等于所需空间的空闲区块来进行分配,并根据不同的编译器具有不同的分配方式(一般不同编译器平台用的都是不同的内存分配算法):
- 如windows下的采用的是微软的一套方案
- Linux下gcc用的glibc采用的内存分配器是ptmalloc
malloc的优点:减少内存碎片
malloc 只分配几种固定大小的内存块,可以减少外部碎片。
malloc的缺点:多线程下性能问题
- malloc的分配区有互斥锁来保证线程安全,但是进程中多个线程是共享一个malloc的分配区的,加锁的时间代价高。
- 由于需要查找合适的空闲区块,因此还有查找耗时的问题。
4.2 经典内存池的设计
4.2.1 设计思路
最基础的内存池设计类似于malloc的设计,主要包括内存分配和内存释放两个操作。操作是由空闲链表来实现的。
首先是提前向OS申请一块大内存,然后将这块内存均分成小块(后面优化可以分割成不同大小的内存块)
用链表表头
freeNodeHead
来存储空闲内存块,当用户申请内存时,就从freeNodeHead
中取出一块内存块分配给用户分割后的内存块通过链表组成内存节点
每次分配内存时从空闲链表中取出一个头节点,时间复杂度为O(1)
每次释放内存时将内存节点插入到空闲链表的头部,时间复杂度为O(1)
当空间不够用时,再向OS申请一块大内存
4.2.2 内存池的结构体设计
1)变量
- 申请的内存块头指针:
pBlockHeader
- 内存池可以申请多块内存
- 上述每个内存块的空闲链表头指针:
pFreeNodeHead
- 每个内存块可以被分割成更小的内存
- 每个内存块的空闲链表都是独立的
节点间的关系如下:
2)结构体
- 内存块结构体:
MemoryBlock
- 包括内存块的所有空闲节点和空闲链表的头指针
1
2
3
4
5struct MemoryBlock
{
MemBlock *pNext;
FreeNode data[NumofObjects];
};
- 包括内存块的所有空闲节点和空闲链表的头指针
- 空闲节点结构体:
FreeNode
- 包括空闲节点的数据和下一个空闲节点的指针
1
2
3
4struct FreeNode {
FreeNode* pNext;
char data[ObjectSize];
};
- 包括空闲节点的数据和下一个空闲节点的指针
4.3 tcmlloc内存池
tcmalloc 是 Google 开发的内存分配器,在多线程场景下性能表现比malloc更好。
主要表现在tcmalloc会为每个单独的线程申请一块独享内存空间,在线程级实现了缓存,使得用户在申请内存时大多情况下是无锁内存分配。
4.3.1 tcmalloc的架构
tcmalloc的架构在应用层由上至下主要包括:多个Thread-cache线程缓存、一个Central-cache中央缓存和一个Page-cache页缓存三部分。
- Thread-cache线程缓存:每个线程都有一个线程缓存,用于存储小块内存,线程在申请内存时优先从线程缓存中申请,如果线程缓存中没有内存,那么再从中央缓存中申请。
- 由于线程缓存是独享的,因此线程缓存是无锁的。
- Central-cache中央缓存:用于存储大块内存,线程缓存中没有内存时会从中央缓存中申请。
- 中央缓存是共享的,因此需要加锁。
- 但是tcmalloc设计中,用哈希桶来减少锁竞争,每个哈希桶对应一个锁,因此锁竞争的概率会降低。
- Page-cache页缓存:用于存储大块内存,中央缓存中没有内存时会从页缓存中申请。
- 页缓存是共享的,且为串行方式,每次仅有一个线程可以访问,因此页缓存是有锁的。
4.3.2 tcmalloc的性能优化
1)效率优化
查找时间优化
为了减少查找合适内存块的时间,tcmalloc采用了哈希桶的方式来设计内存块的分割:
前面讲到分割一块内存块是线性的,而且首次分割是等值的。而tcmalloc通过空间换时间的方式,将内存块分割成不同大小的内存块,然后将这些内存块放到哈希桶中,这样在分配内存时就可以直接按照对应大小找到合适的内存块,而不需要线性查找。
这里大小不同是按照2的幂次来分割的,如16
、32
、64
等。
多线程竞争优化
- 线程缓存独享的无锁机制
- 中央缓存采用哈希桶来减少锁竞争(多线程访问同一个哈希桶的概率降低)
- 加锁使用更高效的自旋锁(自旋锁减少上下文切换、持锁时间短)
2)减少内存碎片
central cache
释放回一个span,则依次寻找page cache
中span所管理的页号的前后页号的页有没有空闲,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。
4.3.3 tcmalloc的缺点
所需内存较大的服务时,独享Thread-cache
保守的小内存空间会失去意义,这种情况下当请求量上来,锁冲突严重,CPU使用率将指数暴增。
实际生成环境我们可以选择用jemalloc。