• 腾讯二面——程序崩溃问题连问


    1.你编程过程中遇到过使程序崩溃的问题

    C++编程过程中常见程序崩溃的原因有以下几种

    1.使用空指针

    执行对空指针的解引用操作,或者将空指针传递给函数,都会导致程序崩溃。

    空指针调用函数情况分析

    • 函数若是静态成员函数时,由于没有使用到 this 指针,故不会崩溃。

    • 函数若是虚函数时,由于需要通过 this 指针获取到虚函数表指针,这个过程中由于 this 为空,则崩溃。

    • 函数是普通成员函数,由于该函数中没有使用 this,故不会崩溃。

    • 函数是普通成员函数,若该函数中使用了 this,则崩溃。

      总的来说,空的对象指针调用函数时,若调用过程或者函数内部使用了 this,则崩溃。

    1. #include
    2. class CA
    3. {
    4. public:
    5. static void s_fun() {}
    6. virtual void v_fun() {}
    7. void fun1() {}
    8. void fun2() { i = 2; }
    9. private:
    10. int i = 0;
    11. };
    12. int main()
    13. {
    14. CA *p = nullptr;
    15. p->s_fun(); // 不崩溃
    16. //p->v_fun(); // 调用虚函数,崩溃
    17. p->fun1(); // 不崩溃
    18. //p->fun2(); // 函数中成员变量赋值调用了this指针,崩溃
    19. return 0;
    20. }

    2.内存越界。

    内存越界使用,使用了不该使用的内存。比如数组通过下表访问元素,其下标超过数组可访问的下标上限。内存越界使用,这样的错误引起的问题存在极大的不确定性,有时大,有时小,有时可能不会对程序的运行产生影响,正是这种不易重现的错误,才是最致命的,一旦出错破坏性极大。

    3.内存溢出

     表现在程序逻辑存在较大漏洞,使得运行过程中,内存不断飙升,最终内存不足,程序崩溃。

    内存泄露(分配的内存忘了释放。)会导致可用内存变少,积累的多了会导致内存溢出

    4. 内存被多次释放

    • 存在继承关系的类之间,创建的类对象,在使用结束后,多次手动或者自动调用析构函数,导致已经被析构的类对象,再次被析构。
    • 默认拷贝构造函数只是做了简单的赋值操作,即浅拷贝,两次释放都指向同一块内存空间,堆区的内存重复释放

    解决办法:

    利用深拷贝解决浅拷贝带来的风险,即让P2对象中的指针指向另一个内存单元,这样2个对象调用析构函数后,各自释放自己的内存单元

    5. 内存释放顺序错误

    后被调用的析构函数中需要其子类的对象。

    C++析构的时候先调用子类的析构,再去调用其父类的析构函数。*
    由于先析构的是子类,那么子类的成员变量会被释放掉内存,而其父类的析构函数中又间接调用了子类的数据成员。故而造成崩溃。

    6.栈溢出:

    如果您在程序中递归调用函数,并且每次调用时向栈中添加大量数据,则可能导致栈溢出并且程序崩溃。

    栈的大小通常是1M-2M,所以栈溢出包含两种情况,一是分配的的大小超过栈的最大值,二是分配的大小没有超过最大值,但是接收的buf比原buf小

    7.多线程竞争:

    如果您的程序中使用多个线程,并且这些线程之间存在竞争条件,则可能导致程序崩溃。

    2.野指针与悬空指针

    野指针:

    未初始化的指针,既不指向合法的内存空间,也没有使用 NULL/nullptr 初始化指针。其指针内容为一个垃圾数。


    悬空指针:

    指针正常初始化,曾指向过一个正常的对象,但是对象销毁了,该指针未置空,就成了悬空指针。

    有三种情况会产生悬空指针

     1.释放指针资源后,未再次赋值前。

    1. #include
    2. using namespace std;
    3. int main()
    4. {
    5. int *p = new int(5);
    6. cout<<"*p = "<<*p<
    7. free(p); // p 在释放后成为悬空指针
    8. p = NULL; // 非悬空指针
    9. return 0;
    10. }

    p 指针在被 free 后,成为悬空指针,被 NULL 赋值后不再是悬空指针。这里 free 掉的是 p 的内存空间,并不是变量 p,free 前后 p 的地址是不变的,free 释放的是 p 指向的内存空间,释放后表示该快内存可以重新分配了,至于 free 后 *p 的值,视不同编译器情况而不同。

    2,超出了变量的作用范围。

    1. #include
    2. using namespace std;
    3. int main()
    4. {
    5. int *p;
    6. {
    7. int tmp = 10;
    8. p = &tmp;
    9. }
    10. //p 在此处成为悬空指针
    11. return 0;
    12. }

    变量 tmp 的作用范围为最近的一层括号内,在括号外引用便超出了变量的作用范围。

    3.指向了函数局部变量。

    1. #include
    2. using namespace std;
    3. int* getVal() {
    4. int tmp = 10;
    5. return &tmp;
    6. }
    7. int main()
    8. {
    9. int *p = getVal(); //悬空指针
    10. cout<<"*p = "<<*p<
    11. return 0;
    12. }

    在函数 getVal 执行完后,局部变量的内存空间会被释放,而这里 p 指向了函数内的局部变量,p 便成为了悬空指针,可以将 tmp 变为 static 的。

    3.继承中可能存在的的内存泄露问题

    如果一个类被继承,同时定义了基类以外的成员对象,而且基类析构函数不是virtual修饰的,那么当基类指针或者引用指向派生类对象并析构(例如自动对象在函数作用域结束时;或者通过delete)时,只会调用基类的析构函数而导致派生类定义的成员没有被析构,产生内存泄露等问题。 

    1. #include
    2. #include
    3. using namespace std;
    4. class Shape{
    5. public:
    6. Shape() {
    7. cout<<"Base::Draw()"<
    8. }
    9. ~Shape() {
    10. cout<<"Base::Erase()"<
    11. }
    12. };
    13. class Polygon:public Shape{
    14. public:
    15. Polygon() {cout<<"Polygon::Draw()"<
    16. ~Polygon() {cout<<"Polygon Erase()"<
    17. };
    18. class Rectangle:public Polygon{
    19. public:
    20. Rectangle() {cout<<"Rectangle::Draw()"<
    21. ~Rectangle() {cout<<"Rectangle Erase()"<
    22. };
    23. int main()
    24. {
    25. Shape *p = new Polygon();
    26. /* 输出:
    27. Base::Draw()
    28. Polygon::Draw()
    29. */
    30. delete p;
    31. /*输出:Base::Erase()*/
    32. return 0;
    33. }

    析构函数定义成virtual的可以解决这个问题

    4.C++函数调用的一般压栈过程:

    1.压入返回地址:

    在函数调用之前,将下一条指令的地址(函数调用后执行的下一条指令)压入栈中,以便函数执行完后能够返回到正确的位置。

    2.压入参数:

    将函数调用时传递的参数按照从右到左的顺序压入栈中,以便在函数内部能够访问这些参数。

    3.压入返回值地址(仅适用于有返回值的函数):

    如果函数有返回值,则在调用函数之前会为返回值分配一块内存,并将其地址压入栈中,以便函数返回后将结果存储到返回值地址所指向的位置。

    4.分配局部变量空间:

    在函数调用时,会为局部变量分配内存空间。这些局部变量的存储空间通常位于栈帧(Stack Frame)中,栈帧是每个函数调用所使用的栈空间。

    5,执行函数调用:

    跳转到被调用函数的入口点,开始执行函数体。

    1. 函数执行完毕后,将返回值存放在寄存器中(或者放在栈内存中),然后将栈帧弹出,恢复返回地址,跳转回调用点。
    2. 在返回之前,可以进行一些清理工作,例如释放内存、关闭文件等。
    1. #include
    2. void funcB(int x) {
    3. int y = x + 1;
    4. std::cout << "Inside funcB: y = " << y << std::endl;
    5. }
    6. void funcA(int a, int b) {
    7. int c = a + b;
    8. funcB(c);
    9. std::cout << "Inside funcA: c = " << c << std::endl;
    10. }
    11. int main() {
    12. int numA = 5;
    13. int numB = 10;
    14. funcA(numA, numB);
    15. std::cout << "Inside main: numA = " << numA << ", numB = " << numB << std::endl;
    16. return 0;
    17. }

    在上述示例中,main函数调用了funcA函数,而funcA函数又调用了funcB函数。通过观察示例代码,我们可以看到函数调用的压栈过程。

    1.main函数压栈过程:

    1. main函数开始执行,将函数调用后执行的下一条指令的地址压入栈中。
    2. numA和numB作为参数传递给funcA函数,按照从右到左的顺序压入栈中。
    3. 为funcA函数的局部变量分配内存空间(在此示例中,c变量)。

    2.funcA函数压栈过程:

    1. funcA函数开始执行,将函数调用后执行的下一条指令的地址压入栈中。
    2. a和b参数值分别从栈中弹出,并分配给funcA函数的局部变量。
    3. 为funcB函数的参数c分配内存空间。

    3.funcB函数压栈过程:

    1. funcB函数开始执行,将函数调用后执行的下一条指令的地址压入栈中。
    2. x参数值从栈中弹出,并分配给funcB函数的局部变量。
    3. 为funcB函数的局部变量y分配内存空间。

    执行完毕后,栈会按照相反的顺序将数据出栈,恢复到调用函数的状态。
     

    普通函数、类成员函数、类虚函数的调用过程


    普通函数调用流程

    1.  开辟栈帧空间
    2.  函数参数从右至左进行压栈
    3. 函数返回地址进行压栈
    4. 压入返回值地址(仅适用于有返回值的函数)
    5. 函数局部变量进行压栈

    普通成员函数调用流程(大体)

    1. 由于函数地址在编译期间已确定,所以直接找到该函数地址
    2. this指针,作为隐含参数传入该函数
    3. 之后的调用和普通函数调用方式一致

    注意:如果该函数中,使用了实例的成员变量,若用空指针调用,程序会报错。


    虚函数调用流程(大体)

    查找this指针(也就是实例)的地址
    根据this指针,查找虚函数表(函数指针数组)的地址
    从虚函数表中,取出相应的函数地址

    5.什么是栈帧

    关于栈帧的背景知识

    相关寄存器

    1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。

    (2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

    (3)pc指针寄存器,也叫做程序计数器,它永远指向当前指令的下一条指令。

    函数栈帧:ESP和EBP之间的内存空间为当前栈帧,EBP标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。

    计算机运算的基本过程

    取指令–分析指令–执行指令

    程序执行的过程中,pc指针指向下一个指令,那么当一个函数执行的时候,它会指向这个 函数,

    另外任何一个函数都有自己的ebp和esp,即是任何一个函数都有一个栈底和栈顶。但是在我们的cpu中,ebp和esp不可能有很多,可是我们的函数却可以很多,所以在函数执行的时候,ebp和esp都被新的函数覆盖掉了,那个原来的ebp和esp的值都应该保存,这样之后我们在执行完一个函数之后才可以回到上面一个函数。所以我们的ebp和esp始终指向当前正在执行得函数的栈底和栈顶。
     

    栈地址的生长方向

    栈的生长方向是从高地址往低地址生长的,那么随着我们函数的调用的层层深入,我们的ebp和esp会越来越小。

    函数调用过程栈帧调整

    func1调用函数func2时,先保存当前栈帧状态值,已备后面恢复本栈帧时使用(将func1堆栈原先的基址(EBP)入栈),。然后将栈顶指针ESP的值赋给EBP,将之前的栈顶作为新的基址(func2的栈底),然后再这个基址上开辟相应的空间(把ESP减去所需空间的大小)用作被调用函数的堆栈。函数返回后,从EBP中可取出之前func1的ESP值,使栈顶恢复函数调用前的位置;再从恢复后的栈顶可弹出之前func1的基址EBP值,因为这个值在函数调用前一步被压入堆栈。这样,EBP和ESP就都恢复了调用前的位置,堆栈恢复函数调用前的状态。

    6.为什么会有栈溢出,为什么栈会设置容量?


    栈空间是预设的,它通常用于存放临时变量,如果你在函数内部定义一个局部变量,空间超出了设置的栈空间大小,就会溢出。不仅如此,如果函数嵌套太多,也会发生栈溢出,因为函数没有结束前,函数占用的变量也不被释放,占用了栈空间。分配的大小没有超过最大值,但是接收的buf比原buf小也会发生栈溢出

    原因:是栈的地址空间必须连续,如果任其任意成长,会给内存管理带来困难。对于多线程程序来说,每个线程都必须分配一个栈,因此没办法让默认值太大。 

    7.为什么参数从右至左压栈


    1.C方式参数入栈顺序(从右至左)的好处就是可以动态变化参数个数。通过栈堆分析可知,自左向右的入栈方式,最前面的参数被压在栈底。这样的话,除非知道参数个数,否则是无法通过栈指针的相对位移求得最左边的参数。这样就变成了左边参数的个数不确定,正好和动态参数个数的方向相反。

    2. 更符合习惯。 

    采用这种顺序,是为了让程序员在使用C/C++的“函数参数长度可变”这个特性时更方便。

    8.this指针了解吗?它有什么用

    背景知识:C++ 成员变量和成员函数分开存储,

    只有非静态成员变量才属于类的对象上,静态成员变量 不属于类上对象,非静态成员函数 不属于类上对象,静态成员函数 不属于类上对象

    每一个非静态成员函数只会诞生一份函数实例,也就是说多个同类型的对象会共用一块代码,那么问题是:这—块代码是如何区分那个对象调用自己的呢?

    C++通过提供特殊的对象指针,this指针,解决上述问题。this指针指向被调用的成员函数所属的对象,this指针是隐含每一个非静态成员函数内的—种指针,this指针不需要定义,直接使用即可

    this指针的特点

    •  this指针隐含在每一个非静态成员函数内,每一个非静态成员函数都隐含有一个this指针。当对象调用非静态成员函数时,this指针指向该对象。
    • this指针是是在非静态成员函数的开始前构造,并在非静态成员函数的结束后清除的,不需要声明,直接使用即可。
    • this指针的本质是指针常量,存储了调用该非静态成员函数的对象的地址,所以this指针的指向是不可以修改的。

    注意:每一个非静态成员函数都隐含有一个this指针,静态成员函数是没有this指针的,所以静态成员函数的函数体内只能访问静态成员变量和静态成员函数,不能访问非静态成员变量和非静态成员函数。

    this指针的用途:

    ·当形参和成员变量同名时,可用this指针来区分
    ·在类的非静态成员函数中返回对象本身,即返 回*this。

    空指针调用成员函数可能会崩溃

    C++中是允许空指针调用成员函数的,这时this = NULL;所以在非静态成员函数中,如果用到this指针,应该在用到this指针前,加以判断this指针是否为NULL。但是在程序设计的过程中,我们应该尽量禁用用空指针调用成员函数,稍微不注意,可能会导致我们的程序出现崩掉的情况。

    9.const修饰成员函数
    常函数:

    成员函数后加const后我们称为这个函数为常函数

    • .常函数内不可以修改成员属性
    • 成员属性声明时加关键字mutable后,在常函数中依然可以修改

    mutable int m_B;//特殊变量,即使在常函数中,也可修饰这个值,加关键字mutable

    常对象:

    ·声明对象前加const称该对象为常对象
    ·常对象只能调用常函数

    10.算法题 

    地图压缩,用 滑窗法

    遇到的问题,使用vector容器装自定义的Status类,在使用resize()函数时,运行的时候报错。

    cppreference.com上面对resize()的描述:

    resize方法用于重设容器的大小以容纳count个元素。

    1. 若当前大小大于 count ,则减小容器为其首 count 个元素。
    2. 若当前大小小于 count :
    •  在尾部附额外的默认插入的元素
    • 在尾部附额外的 value 的副本

    相对于自定义类型来说,这个规则可以解读为:

    1. 若当前大小大于 count ,则减小容器为其首 count 个对象。
    2. 若当前大小小于 count :
    • 则调用默认构造函数创建对象来进行插入
    • 在尾部添加额外的给定的对象

    默认构造函数包含三种类型

    • 无参构造函数:就是没有任何参数的构造函数
    • 带有形参:并且带有默认值的构造函数
    • 编译器自动生成的默认构造函数(就是无参构造函数)

    编译器默认构造函数生成原则

    当用户定义了有参构造函数,c++就不再提供默认无参构造,但会提供默认拷贝构造
    当用户定义了拷贝构造函数,c++就不再提供其他构造函数

    问题分析:

    resize需要调用默认构造函数,但是重载了构造函数后,c++就不再提供默认无参构造

    解决方法

    1.是重载一个无参的默认构造函数,

    2.把构造函数改造成默认参数的默认构造函数,

    3.把初始化一个对象加入resize

     扩展:解决“不存在默认构造函数”的问题

    加法运算符重载时,在类person里建了一个返回类型为类person的运算符重载的函数,在这个函数里建了个对象temp;想实现  :对象p1+对象p2,但是报错了,错误为:类 "person" 不存在默认构造函数

    1. #include
    2. using namespace std;
    3. class person {
    4. public:
    5. //需要在此加上perosn(){};
    6. person operator+(const person& p) {
    7. person temp;//此处报错
    8. temp.age_a = this->age_a + p.age_a;
    9. temp.age_b = this->age_b + p.age_b;
    10. return temp;
    11. }
    12. person(int age_a, int age_b) {
    13. this->age_a = age_a;
    14. this->age_b = age_b;
    15. }
    16. public:
    17. int age_a;
    18. int age_b;
    19. };
    20. void test() {
    21. person p1(10, 20);
    22. person p2(20, 30);
    23. person p3 = p1 + p2;
    24. cout << p3.age_a << p3.age_b << endl;
    25. }
    26. int main() {
    27. test();
    28. return 0;
    29. }

    解决方法:

    1.在person类中先创建个无参的构造函数类型;

    1. class person {
    2. public:
    3. perosn(){};
    4. person operator+(const person& p) {
    5. person temp;
    6. temp.age_a = this->age_a + p.age_a;
    7. temp.age_b = this->age_b + p.age_b;
    8. return temp;
    9. }
    10. person(int age_a, int age_b) {
    11. this->age_a = age_a;
    12. this->age_b = age_b;
    13. }

    2.temp构造一个初始值0

     person temp(0, 0);

    3.把构造函数改造成默认参数0的默认构造函数,

    1. class person {
    2. public:
    3. //需要在此加上perosn(){};
    4. person operator+(const person& p) {
    5. person temp;
    6. temp.age_a = this->age_a + p.age_a;
    7. temp.age_b = this->age_b + p.age_b;
    8. return temp;
    9. }
    10. person(int age_a = 0, int age_b = 0) {
    11. this->age_a = age_a;
    12. this->age_b = age_b;
    13. }

    4.如果程序中已定义构造函数,默认情况下,编译器就不再隐含生成默认构造函数。如果此时依然希望编译器隐含生成默认构造函数,可以使用"=default"。

    1. class person {
    2. public:
    3. perosn(){} = default;
    4. person operator+(const person& p) {
    5. person temp;
    6. temp.age_a = this->age_a + p.age_a;
    7. temp.age_b = this->age_b + p.age_b;
    8. return temp;
    9. }
    10. person(int age_a, int age_b) {
    11. this->age_a = age_a;
    12. this->age_b = age_b;
    13. }

  • 相关阅读:
    瑞吉外卖代码优化
    PMSM中常用的两种坐标变换——两种参数的由来
    Spring Cloud Gateway:打造可扩展的微服务网关
    git将当前分支A强制推送远程分支pro上
    PMO大会的主办方是PMO评论
    LeetCode刷题笔记【27】:贪心算法专题-5(无重叠区间、划分字母区间、合并区间)
    构建工具vite/webpack
    sql一些常用的函数--decode,case when ,nvl
    记一次问题排查
    操作系统学习笔记(Ⅰ):概述
  • 原文地址:https://blog.csdn.net/zhendong825/article/details/134255928