0%

C/C++编程—内存管理和智能指针

C++中最常使用的资源就是内存,其他常见的还包括文件句柄、互斥锁(mutex lock)、数据库连接、以及网络socket等。在计算机系统中,这些系统资源是有限的,所以要进行有效的管理。本文主要记录C++中的内存布局、内存管理、资源管理的机制RAII(Resource Acquisition Is Initialization)以及智能指针。

C语言编写的程序经过编译、链接后,会形成一个格式统一的可执行文件,可执行文件只有放在计算机内存中才能够运行。程序的几个阶段最终会转化为内存中的几个区域,通常表示为“内存四区”——栈区、堆区、数据区和代码区(内存地址从高到低)。对于内存布局也有其他类型的描述,本质上是对数据区和代码区的子项按其他标准进行分类。

一个可执行文件分为映像运行两种状态。在编译链接后形成的映像中,只包含代码段(Code)只读数据段(RO data)读写数据段(RW data)。在程序运行前的加载过程中,将动态生成未初始化数据段(BSS),在程序运行时将动态生成堆(Heap)栈(Stack)区域。

1. 内存布局

1.1 静态区域(全局区域)

代码段

代码段由程序中执行的机器代码组成。在C语言中,程序语言进行编译后,形成机器代码。在程序执行过程中,CPU的程序计数器指向代码段的每一条机器代码,并由处理器依次运行。

只读数据段(RO data,即常量区)

只读数据区存储的是程序中使用的一些不会被更改的数据,如字符串常量。程序运行结束后由系统进行释放。

读写数据段(RW data)

存放已初始化的全局变量和静态变量(在程序生命周期中地址不变),这些变量占用存储器的空间,在程序执行时要位于可读写区域且被初始化。

未初始化数据段(BSS-Block Started by Symbol)

未初始化数据是在程序声明,但是没有初始化的变量,这些变量在程序运行之前不需要占用存储器的空间。BSS段的变量只有名称和大小,没有值。

1.2 动态区域

堆(Heap)

  • 堆内存只在程序运行时出现,一般由程序员分配和释放(C语言中使用malloc/free,C++中使用malloc/free或new/delete),区别于数据结构中的堆。
  • 操作系统中有一个记录内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请的空间的堆结点,然后将该结点从链表中移除,并将该结点的内存分配给程序,多余的部分重新放回空闲链表中。
  • 在windows下,堆是由低地址向高地址扩展的结构,是不连续的内存区域。

栈(Stack)

  • 栈内存只在程序运行时出现,由系统编译器自动分配和释放,存放函数的参数值、内部的局部变量以及返回值等。
  • 只要栈的剩余空间大于所申请的空间,系统将为程序提供内存。在windows下,栈是由高地址向低地址扩展的结构,是一块连续的内存区域。

2. 常见的内存错误

C/C++强大的原因之一在于能够掌握对内存的处理,什么时候使用内存,用多少,什么时候释放这些都在程序员的掌握之中。但是,不恰当的内存操作会引起难以定位的灾难性问题。

  • 没有初始化堆栈中的数据
    • 初始化是指对数据对象或变量赋予初始值,初始化可以避免使用变量的脏值
    • 静态变量和全局变量会被默认初始化,int初始化为0
  • 缓冲区溢出
    • 通常指的是向缓冲区写入了超过缓冲区所能保存的最大数据量的数据
    • 库函数中一些函数可能造成缓冲区溢出,应该尽量避免使用
  • 将指针当作数据对象,如数组名是指向该数组第一个元素的指针常量,不是整个数组对象
  • 指针运算出现错误,指针运算是以指向对象大小为单位进行的
    1
    2
    3
    4
    int arr[] = {1,2,3};
    int* ptr1 = arr; // ptr1+1会移动sizeof(int)个字节
    char* ptr2 = (char*)ptr1; // ptr2+1会移动sizeof(char)个字节
    int* ptr3 = &arr; // ptr3+1会移动sizeof(arr)=3*sizeof(int)个字节
  • 引用已经释放的内存
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    char* getStr()
    {
    char str[] = "hello";
    return str; //函数运行完成,str指向的内存已经被释放
    }

    int * a = (int*)malloc(10);
    // do sth
    free(a); // 此处指针指向的内存已经被释放,不能再引用,但是指针变量还存在
    a=NULL; // 建议显式的将其置为NULL
  • 对NULL解引用,对于指针类型的参数,我们需要经常对其进行检查,避免对NULL解引用
    1
    2
    3
    4
    5
    6
    7
    8
    int calc(int* a)
    {
    if(NULL!=a) //C++中可以传引用,从而避免重复性检查代码
    {
    // do sth
    }
    return 0;
    }
  • 没有释放内存,在编程中,我们常说的内存管理是针对动态内存中的堆,内存的使用都必须遵循一个步骤:申请内存->使用内存->释放内存。内存只有申请后才能使用,使用完成后必须要释放,否则会造成内存泄漏。在C++中,我们使用malloc/new开辟的内存资源,通过free/delete进行释放。

NOTE: new/delete和malloc/free的区别

  • malloc/free是C/C++的标准库函数,包含在头文件stdlib.h中。new/delete是C++的运算符,需要编译器的支持。
  • malloc在分配内存时必须显式的指定内存的大小,new无需指定大小,编译器会根据类型信息自动计算内存的大小。
  • new返回的指针带有类型信息,而malloc返回的指针是void*类型,需要做类型转换。
  • C++允许重载new/delete运算符,malloc/free不允许重载。
  • 由于malloc/free是库函数不是运算符,不在编译器控制权限制内,不能够调用构造函数和析构函数。

3. 智能指针

智能指针是RAII的实现范例,主要用于管理在堆上分配的内存。在C++中,我们创建一个指向某个对象的普通指针,在使用完这个指针之后需要进行删除,否则会造成一个悬挂指针,导致内存泄漏。智能指针将普通的指针封装为一个栈对象,当栈兑现过的生命周期结束以后,会在析构函数中释放掉申请的内存,防止内存泄漏。

智能指针和普通指针的区别在于智能指针实际上是对普通指针加了一层封装机制,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。

NOTE: C++11的智能指针的构造函数都有explicit关键词修饰,表明它不能被隐式的进行类型转换

C++11中有三个智能指针:unique_ptrshared_ptrweak_ptr。C98中有一个智能指针auto_ptr,已被unique_ptr取代。

3.1 unique_ptr

unique_ptr是一个独享所有权的智能指针,其功能是保证同一时间内只有一个智能指针指向该对象。相较于auto_ptrunique_prt禁用了拷贝,从而避免了潜在的内存崩溃问题。

3.2 shared_ptr

shared_ptr实现了共享式拥有概念,即多个智能指针指向同一个对象,该对象和其资源会在最后一个引用被销毁时进行释放。shared_ptr使用了计数机制来表明资源被几个指针共享,调用release()可以释放当前指针的所有权。可以通过use_count()函数来查看资源被引用的个数,当新增一个引用时,引用计数加1,当过期时引用计数减1,当引用计数为0时,智能指针会自动释放引用的内存资源。

NOTE:智能指针依然存在这内存泄漏的可能性,当两个对象互相使用一个shared_ptr成员变量指向对方,会造成循环引用,使得引用计数失效,从而导致内存泄漏。

3.3 weak_ptr

为了解决shared_ptr循环引用导致的内存泄漏问题,C++引入了weak_ptrweak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,类似于一个普通指针,但不指向引用计数的共享内存。其功能时检测到所管理的对象是否已经释放,从而避免非法访问。
weak_ptr相当于shared_ptr的辅助指针,所以主要的智能指针只有shared_ptrunique_ptr

4. 资源管理和RAII机制

4.1 C++资源管理

我们在使用系统资源时,都必须遵循一个步骤:申请资源->使用资源->释放资源。资源只有申请后才能使用,使用完成后必须要释放,如果不释放就会造成资源泄露。

1
2
3
4
5
6
7
FILE* file = fopen(fn,'r'); // 申请文件句柄资源
// 使用资源
if(!f()) return; // f()失败,返回
// ...
if(!g()) return; // g()失败,返回
// ...
fclose(file); // 释放资源

在上述代码中,存在着因某些操作失败而提前返回的现象,这时就会跳过资源释放的操作,造成资源泄露。对于简单的代码可以在不同的位置重复书写释放资源的代码,如果项目中有异常处理或者需要管理的资源有多个,重复书写资源释放的代码会造成代码冗余且后期难以维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FILE* file = fopen(fn,'r'); // 申请资源
// 使用资源
try
{
if(!f()) {fclose(file); return;} // f()失败,返回
// ...
if(!g()) {fclose(file); return;} // g()失败,返回
// ...
}
catch(...)
{
fclose(file);
throw;
}

fclose(file); // 释放资源

4.2 RAII机制

在C++中,定义在栈上的局部对象的创建和销毁是由系统自动完成的。我们在某个作用域中定义和使用局部对象,当控制流程超出作用域的控制范围时,系统会自动调用析构函数来销毁该对象。

RAII是C++语言中一种资源管理的常用规范,其基本思路是用类来封装资源,在类的构造函数中获取资源,在类的析构函数中释放资源。使用的时候,把资源管理类实例化为一个对象,当类超出作用域的时候,就会调用类的析构函数对资源进行释放。

1
2
3
4
5
6
7
8
9
10
class FileHandle
{
public:
FileHandle(char const* fn, char const* t){ f = fopen(fn,t);}
~FileHandle(){fclose(f);}
private:
FileHandle(FileHandle const&); //复制构造函数
FileHandle& operate= (FileHandle const&);//赋值运算符
FILE* f;
}

FileHandle类的构造函数调用fopen()获取资源;FileHandle类的析构函数调用fclose()释放资源。FileHandle对象代表的是资源,不具有拷贝语义,因此需要将复制构造函数和赋值运算符声明为私有成员,这样可以避免在进行资源对象作为参数传递时发生值的复制,造成访问冲突。

参考文章 & 资源链接