详析new/delete内存分配与释放的内部工作流程

1. 示例类

提供一个精简版的复数类和一个精简版的字符串类用来演示。
示例类中函数均取自C++标准库。

复数类:

#include <iostream>
#include <thread>
#include <map>
using namespace std;
//复数类
class Complex
{
public:
    /*
    提供默认构造函数,采用初始化列表方式,构造函数执行有两个执行阶段
    1. 初始化阶段 2.函数体内部赋值阶段(按执行时间排序)
    在给成员变量进行赋值时最好采用初始化列表的方式,而不是采用构造函数体内部赋值方式
    Complex(double re = 0, double im = 0) 
    {
        m_re = re; m_im = im; //这样的方式当然也可行,但是浪费了一次初始化值的机会。
    };
    如果采用构造函数体内部方式相当于错过了初始化阶段。
    */
    Complex(double re = 0, double im = 0) :m_re(re), m_im(im) {};
    Complex& operator+=(const Complex& r) 
    {
        m_re += r.m_re;
        m_im += r.m_im;
        return *this;
    }
    /*
    * 对于函数体内部不需要更改数据的函数,最好在后面加上const。
    * 例如const Complex c这种使用场景,如果函数real()、imag()不加const
    * 编译器在看到const Complex c时一定认为c是不会改变数据的,但是调用real()/imag()函数时没有const
    * 编译器又以为要修改数据,这个是不好的做法。
    */
    double real() const { return m_re; }
    double imag() const { return m_im; }
//对于所有的数据,最好采用private保护起来,如果外部需要访问数据提供外部接口即可。
private:
    double m_re;
    double m_im;
};

/*
对于重载的操作符,可以采用成员函数的方式也可以采用全局函数的方式。
此处采用全局函数的考量是因为如果采用成员函数,那么只能计算Complex类之间相加。
但实际上复数可以和实数加,实数并不属于Complex类,所以如果设计为成员函数operator+,那么功能会被有所限制
*/
Complex operator+(const Complex& x, const Complex& y)//复数+复数
{
    return Complex(x.real() + y.real(), x.imag() + y.imag());
}
Complex operator+(const Complex& x, const double y)//复数+实数
{
    return Complex(x.real() + y, x.imag());
}
Complex operator+(const double x, const Complex& y)//实数+复数
{
    return Complex(x + y.real(), y.imag());
}
ostream& operator<<(ostream& os, const Complex& l)
{
    os << '(' << l.real() << "," << l.imag() << ")";
    return os;
}

字符串类:

#include <cstring>
#include <iostream>
using namespace std;

class String
{
public:                                 
   String(const char* cstr=0);                     
   String(const String& str);                    
   String& operator=(const String& str);         
   ~String();                                    
   char* get_c_str() const { return m_data; }
private:
   char* m_data;
};

//inline只是对编译器的一个建议,希望编译器可以将这个函数设置为内联函数,实际上会不会设置取决于编译器。
inline String::String(const char* cstr)
{
   if (cstr) {
      m_data = new char[strlen(cstr)+1];
      strcpy(m_data, cstr);
   }
   else {   
      m_data = new char[1];
      *m_data = '\0';
   }
}
inline String::~String()
{
   delete[] m_data;
}
inline String& String::operator=(const String& str)
{
   if (this == &str)
      return *this;

   delete[] m_data;
   m_data = new char[ strlen(str.m_data) + 1 ];
   strcpy(m_data, str.m_data);
   return *this;
}
inline String::String(const String& str)
{
   m_data = new char[ strlen(str.m_data) + 1 ];
   strcpy(m_data, str.m_data);
}
ostream& operator<<(ostream& os, const String& str)
{
   os << str.get_c_str();
   return os;
}

2. new/delete

定义一个对象时,一般会将内存分配在栈区、堆区,用来控制对象存在的生命周期。
还可以使用static关键字或者将对象定义为全局变量来控制生命周期。

  • static关键字:变量生命周期在作用域结束之后仍然存在,直到整个程序结束。
  • 全局对象:变量声明周期在整个程序结束之后才结束,作用域是整个程序。其生命周期比main()更早。

2.1 new内存泄漏

下面这块代码就会造成内存泄漏。指针变量p离开作用域后,指针p的生命结束,作用域外再也看不到p,也没有机会delete p,此时其所指向的堆区空间无法使用delete进行释放,这块内存将会泄漏。

{
	Complex* p = new Complex;
}

正确实例:

{
	Complex* p = new Complex;
	...
	delete p;
}

2.2 new

使用new关键字时,会先分配内存,然后调用类的构造函数。

Complex* p = new Complex(1,2);

编译器会将上行代码转化为:

void *mem = operator new( sizeof(Complex) ); //分配内存
Complex* pc = static_cast<Complex*>(mem);//类型转换
pc->Complex::Complex(1,2);//调用Complex类的构造函数

operator new()是C++内部一个名字特殊的函数,其内部调用的就是malloc(n)函数,来分配内存的。

Complex::Complex(1,2)函数的参数列表中实际上隐藏着this指针,谁调用Complex::Complex()函数谁就是this指针,所以实际调用方式为Complex::Complex(pc,1,2);,此时pc就是this指针。

2.3 delete

使用delete关键字时,会先释放内存,然后调用类的析构函数。

String* ps = new String("123");
...
delete ps;

编译器会将上面代码转化为:

String::~String(ps);//析构函数
operator delete(ps);//释放内存

operator delete()是C++内部一个名字特殊的函数,其内部调用的就是free(ps)函数,来释放内存的。

对于String类来说,其大小本身只是一个指针m_data大小,指向一个字符串。

2.4 内存分配过程分析

使用Complex* p = new Complex(1,2);创建一个复数,会分配一个内存空间,大小为8个字节(两个double,按原来double为4个字节时分析)。

在调试模式下,并不是真正得到8个字节,得到的字节数如下图左侧所示,每一格为4个字节,灰色的上下两部分为Debug的头和尾,砖红色的为cookie,用来标记这块内存的分配范围一级是否已经分配出去。在VC下,每次编译器所分配的内存区块都会是16的倍数,所以new一个复数实际上分配的内存大小为:8+(32+4)+(4*2)+12=64个字节。

  • 8为Complex大小;
  • (32+4)为Debug头尾大小;
  • (4*2)为cookie大小
  • 12(图中pad部分)为补充16倍数添加的大小。

所以一个复数在Debug模式下实际上会分配64个字节大小的内存区块。

release模式下,分配的内存区块如下图右侧所示。当然,对于分配的内存区块来说,一定是16倍数的,只是release模式下内存大小刚好已经是16的倍数了,所以不再需要pad部分。

为什么分配的内存区块一定要是16的倍数呢?
操作系统利于16进制的最后一个16进制位来标记这块内存是给出去了还是还回来了。16的倍数转换为16进制后,最后一个16进制位就一定是0,才可以用来作为标记。
cookie值为0x00000041,如果没有给出去值为0x00000040,给出后值为0x00000041。

Complex类
同理,String* ps = new String("123");也是如此。

String类
上面只是讨论了分配一个复数类或一个字符串类的内存模型,那么当分配多个的情况呢,如下所示。例如分配一个复数数组的内存空间。

我们都知道分配内存时,使用Complex* p = new Complex[3]来分配3个复数对象的内存空间,释放内存时需要使用delete[] p来释放这块内存,为什么需要加上[]呢。

除去Debug头尾、补充16倍数的pad块、cookie块,已经此时内存模型比上面多了4个字节,是来记录数组个数的。3个复数,这四个字节里就存放的是3。(VC编译器是这样做的,其他编译器不保证)。

所以在Debug模式下,实际分配的内存大小为(83)+(32+4)+(42)+4+8=80个字节。

  • (8*3)为数据大小,1一个复数有两个double为8个字节,一个3个复数;
  • (32+4)为Debug头尾大小;
  • (4*2)为上下cookie大小;
  • 4为存放数组个数的大小;
  • 8为pad块大小。

release模式下,实际分配内存大小除去Debug头尾后为(83)+(42)+4=36个字节。
Complex复数数组

2.5 array new内存泄漏

通过上面的例子,已经知道了动态分配数组的内存模型。上面也谈到需要使用delete[] p来释放数组,那么假如忘记加[]会发生什么情况呢。此处使用String类来做演示,如图所示。

实际上没有添加[]造成了内存泄漏,但泄漏的并不是String类的内存空间,因为已经有cookie来标记了操作系统给出去的整个内存区块范围,所以仍然能够正常回收。但是因为少加了[],会少调用两次析构函数,而析构函数中会去释放指针指向的字符串空间,所以此时实际上是指向的字符串空间没有被释放造成的内存泄漏,即图中?!标记的地方。
在这里插入图片描述
总结:

Complex类与String类的区别是一个类内部没有指针;另外一个类内部有指针,并且指针指向其他的内存空间。

所以,当使用Complex* p = new Complex[3]分配一个复数数组时,因为Complex类内并没有指针指向另外的内存,所以其实不使用delete[] p也不会造成内存泄漏,但是为了养成一个良好的编程习惯,array new还是一定要搭配array delete来使用。


版权声明:本文为qq_45164331原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。