0.内存模型

以32位操作系统为例,1GB为操作系统的内核空间,用户无法更改,这部分不用管它;剩下的3GB位用户的内存空间,这是供用户程序使用的。

从上至下,依次为:

栈区(Stack):

由编译器自动分配释放 ,存放函数的参数值,局部变量的值等,内存的分配是连续的;

使用栈空间存储函数的返回地址、参数、局部变量、返回值,从高地址向低地址增长。在创建进程时会有一个最大栈大小。如果定义的变量或者函数超出了这个最大栈大小,就会发生栈溢出现象

共享库的内存映射区(Memory Mapping Segment):

这个区也可以称做共享内存,主要有两个功能:

一是在程序链接阶段,会进行库链接,而链接的库则会保存在这里。链接又分为静态链接和动态链接,这里属于另外一个知识点,暂时不展开,后续单独开一个文章来写。只需知道这里保存者许多库文件即可。

二是给进程间通信提供一种方式,那就是共享内存,此时通过内存映射来实现。这里也是属于进程一块的知识,暂时不展开。

基于此,这块的功能大概就是上述两个。

堆区(Heap):

这块内存,就是由用户或者说程序员来主动开辟并释放的。

C语言中通过调用malloc/free,C++通过new/delete;这两个有点区别,一个属于函数,在stdlib.h库中,一个属于运算符;同时new还要调用构造函数,delete调用析构函数。

.bss 段:

这部分内存存储的是:

未初始化的 全局变量 和 静态变量(局部+全局)

所有被初始化为 0 的 全局变量 和 静态变量

.data 段:

这部分内存存储的是:

已初始化的 全局变量 和 静态变量

总结:其实 .bss 和 .data段应该合称为数据段,它包括了未初始化的变量,已初始化的变量,文字常量rodata。C++语言将这些合称为数据段。如果从汇编角度去细分,就可以分成三个部分。

区别初始化和非初始化是为了空间效率。未初始化变量不占据实际内存空间(bss变量只在段表中记录大小,在符号表中记录符号。当文件加载运行时,才分配空间以及初始化),BBS段在程序执行之前会被系统自动清0,所以未初始化的全局变量和静态变量在程序执行之前已经为0。(也正是因为反正在bss上的变量肯定都是0,所以不需要为他们分配空间)

.text 段

这部分存储的是:

只读存储区;文本区;

只读存储区存储字符串常量;

文本区存储程序的机器代码

1.堆和栈的区别

堆和栈是计算机内存中用于管理变量和数据的两种不同的存储区域。

  1. 内存分配方式:栈采用静态内存分配,由编译器自动管理。而堆采用动态内存分配,需要手动进行申请与释放。

  2. 空间大小:栈空间通常较小,具有固定的大小,并且通过函数调用层级来管理变量的生命周期。而堆空间相对较大,没有固定大小限制,可以灵活地进行内存分配。

  3. 内存管理方式:栈由编译器自动进行变量的分配和释放,在函数结束时会自动回收局部变量所占用的栈空间。而堆则需要手动进行内存的申请(如malloc、new等)和释放(如free、delete等),否则会出现内存泄漏问题。

  4. 数据访问速度:栈上的数据访问速度较快,因为它使用了先进后出(LIFO)的原则,并且位于CPU高速缓存中。而在堆上分配的数据访问速度较慢,因为它不是按照顺序进行分配。

  5. 变量生命周期:栈上的变量生命周期受限于其所在函数或代码块的执行期间,一旦函数或代码块执行结束,变量就会被自动释放。而堆上的变量生命周期由手动管理,需要显示地释放分配的内存。

2.栈的大小,堆的大小,malloc可申请的最大内存,受到什么限制?

栈和堆的大小都受到一定限制,具体取决于操作系统和编译器的设置。

  1. 栈的大小:栈空间通常较小,一般在几兆字节到几十兆字节之间,具体大小可以由操作系统或编译器进行设置。当函数调用层级很深或者局部变量占用空间过大时,可能会发生栈溢出错误。

  2. 堆的大小:堆空间相对较大,它的大小理论上是有限制的。在32位操作系统中,由于虚拟地址空间限制为4GB(或更小),实际可用的堆空间会受到这个限制。而在64位操作系统中,理论上可以提供更大范围的堆空间。

  3. malloc可申请的最大内存:malloc函数通过动态分配堆内存来满足程序需要。其返回值是void指针,所以能够申请的最大内存也受到指针长度的限制。在32位系统中,默认情况下malloc函数能够申请的最大内存约为2GB左右;而在64位系统中,理论上malloc函数可以申请非常大范围的内存。

3、static关键词的作用

  1. 在函数内部:当static修饰一个局部变量时,该变量被称为静态局部变量。静态局部变量在函数调用之间保持其值,并且仅初始化一次。这使得它们适合用于需要持久性和共享状态的场景。

  2. 在全局范围内:当static修饰全局变量时,该变量被限制在当前源文件中使用,无法被其他源文件访问。这提供了一种封装数据和信息隐藏的方式。

  3. 在函数声明前面:当static修饰一个函数时,该函数被限制在当前源文件中使用,无法被其他源文件调用。这样可以避免与其他文件中相同名称的函数产生冲突。

4、volatile关键字的作用

  1. 防止编译器优化:编译器为了提高程序的性能,可能会对代码进行优化,包括对变量的读写操作进行重排或省略。然而,在某些特定场景下,我们需要确保每次读取该变量时都是最新的值,不受编译器优化的影响。通过将变量声明为volatile,可以防止编译器对其进行优化。

  2. 多线程访问同一变量:当多个线程同时访问同一个共享变量时,由于线程之间可能存在缓存不一致或指令重排等问题,使用volatile可以确保每次读取和写入该变量时都直接与内存交互,而不是使用缓存副本。这样可以避免出现意外行为或数据不一致的情况。

5、extern关键字

  1. 声明外部全局变量:当在一个源文件中定义了一个全局变量,并且希望在其他源文件中也能够访问该变量时,可以使用extern关键字进行声明。这样其他源文件就可以引用该全局变量而不需要重新定义。

  2. 声明外部函数:当在一个源文件中定义了一个函数,并且希望在其他源文件中调用该函数时,也可以使用extern关键字进行声明。这样其他源文件就可以引用该函数而不需要重新定义。

extern关键字通常和头文件一起使用,在头文件中声明全局变量或函数,并在需要引用它们的源文件中包含该头文件。这样可以提高代码的模块化和可维护性。

6、引用和指针的区别

  1. 定义形式:引用使用&符号定义,而指针使用*符号定义。例如,int &ref = x; 定义了一个整型引用ref,并绑定到变量x;而int *ptr = &x; 定义了一个整型指针ptr,并指向变量x。

  2. 空值:指针可以为空,即指向空地址或NULL。但是引用必须在声明时进行初始化,并且不能为null。

  3. 内存地址:指针保存的是变量的内存地址,通过解引用操作符*可以获取该地址处的值。而引用直接是原变量的别名,没有自己的内存地址。

  4. 重新赋值:可以将指针重新赋值为其他地址,从而改变所指向的对象。但是引用一旦绑定了某个对象,在之后就无法更改其绑定关系。

  5. 空间占用:由于引用只是对已经存在的对象起别名,并不占据额外的内存空间;而指针本身需要占据一定大小的内存空间来存储地址信息。

  6. 操作符重载:可以对指针进行算术运算、递增/递减等操作。而对于引用则无法进行这些操作。

7、malloc的用法和注意点

malloc() 是 C 语言中的动态内存分配函数,用于在运行时从堆上分配指定大小的内存空间。它的基本用法是:

void* malloc(size_t size);

其中,size 是需要分配的字节数,返回值是一个 void 指针,指向分配的内存空间的起始地址。

使用 malloc() 函数时需要注意以下几点:

  1. 引入头文件:首先需要包含 <stdlib.h> 头文件以访问 malloc() 函数的声明。

  2. 内存足够性检查:在调用 malloc() 之后,应该检查返回值是否为 NULL。如果为 NULL,则表示内存分配失败,可能是由于内存不足等原因。

  3. 分配失败处理:如果 malloc() 返回了 NULL,表示无法成功分配所需的内存。此时可以选择适当地处理错误情况,并释放已经成功分配的其他资源。

  4. 内存大小计算:需要根据实际需求确定要分配的内存大小(以字节为单位),确保满足程序所需数据结构和操作的要求。

  5. 内存释放:在使用完动态分配的内存后,应及时通过调用 free() 函数将其释放掉,防止出现内存泄漏问题。

  6. 注意类型转换:由于 malloc() 返回一个 void 指针,在使用时通常需要将其转换为所需类型的指针,例如 int* ptr = (int*)malloc(sizeof(int))

8、C和C++的区别

  1. 语法和特性:C是一种面向过程的编程语言,注重函数的设计和过程的控制;而C++是在C的基础上发展起来的面向对象编程语言,支持封装、继承、多态等特性。

  2. 标准库:C标准库提供了一系列函数和头文件用于常见操作,如字符串处理、输入输出等;而C++标准库除了包含C标准库外,还引入了STL(Standard Template Library)作为其核心组件,提供了丰富的容器类和算法。

  3. 扩展性:C++兼容C语言,可以直接调用C代码,并且在此基础上提供了更多功能。通过类、模板等特性,使得程序更易于组织、扩展和复用。

  4. 异常处理:C++引入了异常处理机制,允许程序员捕获并处理运行时错误或异常情况。而在C中,则通常使用返回值或全局变量来进行错误处理。

  5. 内存管理:C需要手动管理内存分配与释放,在使用malloc()和free()等函数时需要注意内存泄漏和悬挂指针问题;而在C++中引入了构造函数和析构函数以及RAII(资源获取即初始化)的概念,使用new和delete来自动管理内存。

9、C语言的编译过程

C语言的编译过程可以分为四个主要步骤:预处理、编译、汇编和链接。

  1. 预处理(Preprocessing):在这一步,预处理器将对源代码进行处理。它会根据以"#"开头的指令,例如#include和#define,展开头文件、宏定义,并去除注释等。生成的结果是一个经过宏替换和条件编译后的纯C代码文件。

  2. 编译(Compilation):在这一步,编译器将把预处理后的源代码转化为汇编语言。它会将C语言代码翻译成相应的汇编指令,但还没有生成可执行代码。生成的结果是一个以".s"或".asm"为扩展名的汇编文件。

  3. 汇编(Assembly):在这一步,汇编器将把汇编语言代码转化为机器码指令,即可执行的二进制文件。它会将每条汇编指令翻译成机器码,并生成目标文件(通常以".obj"或".o"为扩展名),其中包含了已经转换好的机器码指令。

  4. 链接(Linking):最后一步是链接器对目标文件进行链接操作,将所有需要用到的函数库和对象文件合并成一个可执行文件。链接器解析符号引用并确定函数和变量在内存中的地址,生成最终的可执行文件(通常以".exe"为扩展名)。

10、重载、重写、隐藏的区别

重载(Overloading):指在同一个作用域内,根据函数名相同但参数列表不同的多个函数。通过重载,可以定义具有相同名称但参数不同的函数,以便处理不同类型或数量的参数。编译器根据调用时提供的参数来决定调用哪个重载函数。

重写(Override):指子类重新定义父类中已有的方法。当子类继承自父类并且想要改变父类中某个方法的行为时,可以在子类中使用相同的方法名、返回类型和参数列表来重新实现该方法。通过重写,可以覆盖掉父类中已有的方法。

隐藏(Hiding):指在派生类中定义与基类中相同名称的成员。当派生类拥有与基类相同名称但具有不同实现或特性的成员时,它会隐藏基类中对应的成员。如果通过基类指针或引用访问被隐藏的成员,则会调用基类中对应的成员;而通过派生类对象访问则会调用派生类中对应的成员。

11、智能指针

智能指针(Smart pointers)是C++中的一种特殊类型,用于管理动态分配的内存资源。它们提供了自动化的内存管理,可以减少内存泄漏和悬空指针等问题。

C++标准库提供了三种主要的智能指针类型:

  1. shared_ptr:允许多个指针共享同一个对象,并且会跟踪共享对象的引用计数。当引用计数变为零时,自动删除所管理的对象。

  2. unique_ptr:独占所管理的对象,不能进行复制或共享。它拥有对对象的唯一所有权,当其超出范围或被显式释放时,自动删除所管理的对象。

  3. weak_ptr:弱引用指针,可以观测shared_ptr所管理对象的生命周期,但不会增加其引用计数。使用weak_ptr不影响对象销毁时间。

这些智能指针类型通过在构造函数、析构函数和赋值运算符等操作中处理内存分配和释放来简化代码编写,并避免常见的资源管理错误。

12、内存泄漏和内存溢出

内存泄漏和内存溢出是与内存管理相关的两个常见问题:

  1. 内存泄漏(Memory Leak)指在程序运行过程中,动态分配的内存空间没有被正确释放或回收。这种情况下,当不再需要使用这块内存时,无法再访问到它,导致这块内存变得无法被重新利用。如果程序中存在大量的内存泄漏,最终会耗尽可用的系统内存资源,导致程序崩溃或性能下降。

  2. 内存溢出(Memory Overflow)指尝试向已经分配满了的内存区域写入数据。这可能发生在尝试将过多的数据写入数组、栈溢出、堆溢出等情况下。通常情况下,当向超过申请空间大小范围之外写入数据时,会覆盖到其他变量、代码和数据结构等,并可能引发未定义行为、崩溃或安全漏洞。

解决内存泄漏和内存溢出问题的一般方法包括:

  • 确保及时释放动态分配的内存资源:在使用完动态分配的对象后调用相应的delete或析构函数来释放资源。

  • 使用智能指针:智能指针可以自动管理动态分配的内存资源,确保及时释放。

  • 避免不必要的动态内存分配:尽可能使用栈上的局部变量来避免频繁地申请和释放内存。

  • 注意边界检查和数据大小控制:确保在操作数组或缓冲区时进行越界检查,并合理控制数据大小。

13、深拷贝浅拷贝

深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是在编程中常用的两种对象拷贝方式。

  1. 浅拷贝:浅拷贝是指将一个对象的引用复制给另一个对象,使得这两个对象指向同一块内存地址。简单来说,浅拷贝只复制了对象的引用,而不会创建新的独立内存空间。当修改其中一个对象时,另一个对象也会随之改变。

  2. 深拷贝:深拷贝是指创建一个新的独立对象,并将原始对象中所有数据进行复制。这样就在内存中生成了两个完全独立、相互没有任何关联的对象。即使对其中一个对象进行修改,也不会影响到另一个对象。

通常情况下,使用浅拷贝可以提高性能和节省内存开销,因为只需要复制引用而不需要复制整个数据结构。但是需要注意的是,在某些情况下可能会导致意外修改原始数据或出现错误。

如果需要确保对原始数据的修改不会影响到其他相关的数据,则应该使用深拷贝。

在编程语言中,可以通过重载赋值操作符、复制构造函数或使用特定的库函数来实现深拷贝和浅拷贝。有些编程语言提供了默认的浅拷贝行为,而对于深拷贝需要手动实现。

14、回调函数

回调函数(Callback Function)是一种常见的编程概念,它允许我们将一个函数作为参数传递给另一个函数,并在需要时由另一个函数调用。

具体而言,回调函数通常在异步编程中使用。当某个操作完成或满足特定条件时,会调用预先定义好的回调函数来处理相应的结果或执行相关操作。

以下是回调函数的一般使用方式:

  1. 定义回调函数:首先需要定义一个希望被调用的回调函数。该函数接受某些参数,并执行特定的操作或返回特定的结果。

  2. 传递回调函数:将回调函数作为参数传递给其他需要它的函数。这可以通过直接将其作为参数传递,或者通过设置某个变量来实现。

  3. 调用回调函数:当满足触发条件时,在相应的位置上调用传递进来的回调函数,并将所需参数传递给它。

通过使用回调函数,我们可以在异步编程中更好地处理事件、处理结果、实现非阻塞操作等。它使得程序可以按照事件驱动方式运行,并且可以提高代码的灵活性和可重用性。

注意:在使用回调函数时,需要注意正确处理错误、避免产生过多嵌套和提供必要的上下文信息,以便于代码的可读性和维护性。

15、函数调用过程,参数怎么压栈?

在函数调用过程中,参数的传递方式和参数压栈的具体实现可能因编程语言、操作系统和编译器等不同而有所差异。下面是一般情况下参数压栈的基本原理:

  1. 函数调用:当一个函数被调用时,会将控制权从当前函数转移到被调用函数。

  2. 参数准备:在函数调用之前,需要将实际参数(也称为实参)传递给被调用函数。这些参数可以是常量、变量或表达式。

  3. 参数传递方式:参数传递方式通常有值传递(pass-by-value)、引用传递(pass-by-reference)和指针传递(pass-by-pointer)等。具体采用哪种方式取决于编程语言和函数定义时的声明方式。

  4. 参数压栈:在参数压栈过程中,首先会分配一定大小的内存空间来保存参数值。对于较小的数据类型(如整数),它们通常直接放入寄存器中进行传递;对于较大的数据类型(如结构体),则会在栈上分配内存并将值拷贝到该内存位置。

  5. 压栈顺序:对于多个参数,在压栈时一般遵循从右至左的顺序进行,即后面的参数先进栈,先被调用函数使用。

  6. 参数访问:被调用函数可以通过栈指针或者寄存器来访问传递进来的参数值。具体的访问方式取决于编程语言和底层架构。