文章目录
- 前言
- 操作系统内存
- 地址空间
- 基本数据类型
- sizeof运算符
- 指针运算
- 内存分配与回收
- 堆与栈
- malloc/new与free/delete
- 基本类型的指针操作
- 类定义中的内存使用
前言
C++凭借其指针变量可以直接操作内存而得到了非常高的效率和程序性能,在一种编程语言里独树一帜。当然,现在很多更高级的语言对底层的内存操作进行了封装,让程序员们不用再考虑内存的分配和回收,可以将自己的精力主要放在业务逻辑的实现上。
但是我觉得,一个好的程序员必须了解程序在内存中的基本逻辑,而了解和使用C++是一个比较有效的方式。我可以不用C++去写代码,但是我可以通过C++的代码来了解程序代码在内存中的基本逻辑,对进一步了解操作系统有很好的帮助,每一个程序员都应该有这方面的基本了解。
我把我对内存和操作系统,还有C++中的一些基本函数的一些小经验分享一下。
操作系统内存
现在计算机不管是服务器,桌面还是嵌入式设备,在体系结构上都是遵循了冯诺依曼提出的基本结构:
从这个体系结构图中可以看出,存储器是整个体系结构中最核心的部分,在一般的服务器和工作站中,这个存储器就是内存。
地址空间
整个内存的存储空间可以被称之为“地址空间”。一般以十六进制来表示,比如一个8GB的内存,总共有8 * 1024 * 1024 * 1024 B(字节,一个字节8个bit-比特)的存储空间,假设每个字节使用一个地址的话,其地址空间就是从0x0 0000 0000到0x1 FFFF FFFF的十六进制之间。
现在的操作系统访问内存就是通过这样的一个地址去访问内存,对内存进行读写的。比如老的32位操作系统,表示处理器在执行指令时,一次可以处理一个32位长的数据,也就是4个字节。假设这个指令时去读取一个内存的数据,这个内存地址最长就只能是32位,或者说4个字节,其最大的地址为 2 3 2 = 4 G B 2^32 = 4GB 232=4GB。也就是说在32位操作系统上,只能支持4GB的内存,如果要扩大的话,需要有一些其他的手段,比如改造指令集等。
当然,现在流行的是64位系统,内存的空间已经到了 2 6 4 2^64 264的空间了,这个已经是一个海量的数字,现在暂时是用不上了。
基本数据类型
在我们日常的程序开发中,会碰到很多数据类型,也有自定义的数据类型,我觉得C/C++的基本数据类型很好的能说明内存的使用逻辑。
C++中我们一般会用到下面几种类型:
-
char,字符型。一般来说,占用一个字节的内存。
-
int,整数型。一般来说,占用4个字节的内存。还有比如int64这种的,就会占用64
-
float,浮点型。一般来说,占用4个字节的内存。
-
自定义数据类型,比如定义一个结构体:
typedef struct mydata{
char x;
int y;
float z;
}mydata;
这样的一个结构体,占用的内存空间就是三者之和。当然,有些操作系统中会有一些内存对其的操作,可能会比三者之和要大,这个就要看具体操作系统或者编译环境怎么处理了。
-
指针类型。重点来了:指针类型是存储地址用的,这个地址存的是某种数据类型的地址,占用的内存空间和这种数据类型占用的内存空间没有什么关系,而只和操作系统的寻址空间,也就是地址空间有关系。比如在64位操作系统中,一个指向char类型的指针也会占用8字节的内存空间,因为它必须是一个64位的地址,而不是一个字节。
sizeof运算符
在C/C++中,有一个sizeof的运算符,就是用于计算数据类型的变量所占用的内存数量的,单位为字节。可以用一下代码在32位和64位的操作系统中测试一下上面的描述:
char c;
int i;
float f;
mydata data;
char *cPtr;
int *iPtr;
float *fPtr;
mydata *dataPtr;
printf("%d\n", sizeof(c));
printf("%d\n", sizeof(i));
printf("%d\n", sizeof(f));
printf("%d\n", sizeof(data));
printf("%d\n", sizeof(cPtr));
printf("%d\n", sizeof(iPtr));
printf("%d\n", sizeof(fPtr));
printf("%d\n", sizeof(dataPtr));
输出结果:
sizeof©: 1
sizeof(i): 4
sizeof(f): 4
sizeof(data): 12,这就是上面说的,做了一个内存对其,把char的内存也补齐到了4个字节。
sizeof(cPtr): 8
sizeof(iPtr): 8
sizeof(fPtr): 8
sizeof(dataPtr): 8
从上面可以看出,所有的指针都是占用了8个字节(64位环境)
指针运算
指针的运算是一个基本知识了,也就是指针的+和-相关的操作符已经被重载了。
前面提到了,指针保存的是地址信息,所以对指针变量的加和减都是对地址信息的加和减,指针的加1,就是指针指向的地址增加或减少其对应类型的占用内存空间的大小。我觉得这个就是给指针指定类型的作用之一了。
举个简单的例子,某个指针的值时0x000001,如果这个指针的类型是char,那么指针加1,就会变成0x000002,以此类推。
char* x = new char[10];
printf("%x\n", x);
x = x+1;
printf("%x\n", x);
int* y = new int[10];
printf("%x\n", y);
y = y+1;
printf("%x\n", y);
输出结果是:
48db63c0
48db63c1
48db0940
48db0944
个人觉得在实际使用过程中,有几点注意的事项:
- 在void*指针做参数指向的连续空间时,做指针类型变换是,必须注意其移动的步距。
- 32位或者64位系统或者编译器可能造成某种类型的步距发生变化,使用sizeof先进行一下计算会比较好。
- 注意内存补齐情况的发生。
- 这里另外提一点,在体系结构中,有big-endian和little-endian的区别,也就是说是从高地址往低地址排,还是低地址往高地址排。这个和操作系统和体系结构有关系,一般来说不需要管,如果做不同平台之间的代码迁移,可能就需要考虑这个问题了。
因为一旦超过分配的区域进行访问,就会发生内存越界,整个进程崩掉。因为在操作系统中,对每个进程能访问的地址空间是做了严格限制的,一旦越界就会导致进程崩溃(为了安全起见,当然也可以通过一些手段获取到其他进程的内存地址,进行一些非法的操作)
内存分配与回收
所有的程序都需要被操作系统加载到内存中才能执行,所以程序在内存中是有一个存储分布情况的,C代码的程序分布如下:
程序员接触的比较多的区域就是堆区和栈区这两个部分了。
平时用到的函数指针,我理解指向的地址空间就是位于代码区这一块的地址。
堆与栈
一般来说,由编译器来决定什么时候分配,什么时候收回的这些内存都放在栈区内存中。比如说局部变量,包括了局部的一般数据类型,和指针类型指向的内存地址。因为这部分内存地址是在栈中被分配的。
那么由程序员来决定什么时候分配,什么时候回收的内存就会在堆区内存中了。以下几个标准的操作就会在堆区中分配内存了,如果这些内存没有被程序手动指定释放,操作系统是不会回收这些内存的,这些内存就会一直没滞留在堆区中。如果一直持续下去,就会导致程序崩溃,这种情况就可以称之为内存泄露。
malloc/new与free/delete
这两套函数很类似,网上有很多文章都提到了两者的区别,我这里只写一下我自己使用过程中的感受。
-
malloc是纯C语言时代就存在的函数,我一般用于大片的连续内存的分配。new是为了配合C++的对象分配而提出来的关键字,我一般用于某个对象的创建与内存分配(因为这样的话,会调用类对象的构造函数)。
-
还有一个区别是说malloc分配的内存在堆上,new是在自由存储区中(也是为了new单独划出的一块内存区域)。但是我感觉写程序的时候没有太大必要分清这个区别,只要知道这个都是由程序员自己分配,自己去销毁的内存区域即可。
-
new分配的对象内存,一般用delete来释放,因为这样可以调用到类对象定义的析构函数。
-
new关键字也可以用于基本类型的内存分配,比如分配一个100个字节长的unsigned char的数组长度,就可以使用:
unsigned char ptr = new unsigned char[100];
-
delete的使用,delete有两种形式:
-
delete,直接释放指针指向的内存空间或者对象。
-
delete[],如果指针是指向一个对象数组的话,就需要使用这种形式,下面看一下代码。
比如有这样一个类和代码:
class A { private: char *m_cBuffer; public: A(){ m_cBuffer = new char[1024 * 1024 * 1024]; } ~A() { delete [] m_cBuffer; } }; A *a = new A[3]; delete a;
如果是delete的话,只会调用A[0]的析构函数,释放A[0]的内存,因为从语法上来说,数组的名字就代表这个数组第一个元素的地址,所以,delete a就表示释放A[0]的地址指向的内存空间。
正确的姿势应该是:
delete [] a;
看下结果:
如果不带中括号,在任务管理器中显示的内存占用:
-
如果带了中括号,这3个G的内存就会被全部释放掉。
基本类型的指针操作
-
把一个整形拆成4个字节输出:
其中&为取地址操作符,获取当前变量的地址。
*为取值操作符,获取这个地址对应的值(这个就和指针类型强相关了,同一个地址,类型不同,值不同,底下的代码也反应了这一点,就是说到底取几个字节出来翻译的问题)
int i=1000; char * c = (char *)&i; printf("%d\n", *c); printf("%d\n", *(c+1)); printf("%d\n", *(c+2)); printf("%d\n", *(c+3));
输出结果(big-endian和little-endian会不同):
-24,整型中第一个字节的值
3,整型中第二个字节的值
0,整型中第三个字节的值
0,整型中第四个字节的值
-
不同的指针经过赋值后指向同样的内存地址。
unsigned char* ptr1 = new unsigned char[sizeof(unsigned char) * 1024 * 1024 * 1024]; unsigned char* ptr2 = new unsigned char[sizeof(unsigned char) * 1024 * 1024 * 1024]; ptr1 = ptr2; delete ptr1; delete ptr2;
在这个例子里,ptr1分配并指向了内存块1,ptr2分配并指向了内存块2。经过ptr1 = ptr2的赋值后,ptr1也指向了内存块2。
delete ptr1实际上释放的是内存块2,而不是内存块1。
delete ptr2也是去释放内存块2,这时就会出现内存越界,程序直接崩溃。
同时,内存块1一直遗留在堆中,无法被释放。
类定义中的内存使用
个人觉得C++类涉及到内存的有下面几种情况:
-
类对象作为局部变量,作为栈的使用方式出现。比如:
void MainWindow::on_pushButton_clicked() { A x; QMessageBox::about(this, "a", "wait"); }
各位可以自行试一下,在点击弹出框按钮之前,占用的内存在1GB,点击之后,该函数即会执行完成,作为栈里面的变量和内存就都会被释放(实际上是调用了该类的析构函数,如果没有编写析构函数去释放类中指针的内存空间的话,这部分内存是不会被释放的,因为在我的这个例子中,A的成员指针分配的空间时在堆上面的)。
所以,在C++中,析构函数是非常重要的(我认为C++编程的原则之一就是类的构造函数分配成员内存,在析构函数中统一释放该类用到的内存)。
-
类对象以指针的方式出现,此时该类对象本身的内存地址就在堆上(不是成员指针指向的内存地址),函数结束后是无法自动释放该对象的地址及调用其析构函数进行成员内存的释放的,必须使用delete关键字进行处理。
void MainWindow::on_pushButton_clicked() { A* x = new A(); QMessageBox::about(this, "a", "wait"); delete x; }
-
类对象的指针赋值
实际上就是两个指针指向同一个类对象的地址,记住只能delete一次,然后两个指针同时赋值为null。
A* x = new A(); A* y = x; QMessageBox::about(this, "a", "wait"); delete x; // 或者delete y,因为是指向同一块地址 x = nullptr; y = nullptr;
-
类对象直接赋值
这个比较复杂一点,类对象之间的赋值,在没有对=这个操作符进行重载的时候,会对成员一个一个进行默认的赋值操作。参考一下下面的代码:
void MainWindow::on_pushButton_clicked() { A x; A y = x; QMessageBox::about(this, "a", "wait"); }
上面这段代码会直接崩溃,出现内存越界访问。
原因是:在A y = x这一句代码中,会把对象x的每个成员变量赋值给y这个对象的每个成员变量,赋值之后x和y这两个对象的成员变量cPtr就都指向了同一块地址空间。那么在函数执行完成之后,两个对象的析构函数都会被执行。第一次就已经把这块地址释放掉了,第二次就会造成内存访问越界了。
解决这个问题的办法就是使用C++中的重载操作符函数:
A& A::operator =(const A& rl) { this->cPtr = new char[1024 * 1024 * 1024]; memcpy(this->cPtr, rl.cPtr, 1024 * 1024 * 1024); return *this; }
这样,A y = x这句代码实际上就会执行上面的这个函数,把y这个对象中的成员变量分配一个新的内存地址,把x对象中的成员内存中的内容给复制过去,函数退出的时候,就会分别释放不同的内存地址了。
一般来说,如果类的成员中有初始化时分配内存地址的,最好是重载一下这个操作符,避免内存泄露或者越界。