• 【重学C++】01| C++ 如何进行内存资源管理?


    文章首发

    【重学C++】01| C++ 如何进行内存资源管理?

    前言

    大家好,我是只讲技术干货的会玩code,今天是【重学C++】的第一讲,我们来学习下C++的内存管理。

    与java、golang等自带垃圾回收机制的语言不同,C++并不会自动回收内存。我们必须手动管理堆上内存分配和释放,这往往会导致内存泄漏和内存溢出等问题。而且,这些问题可能不会立即出现,而是运行一段时间后,才会暴露出现,排查也很困难。因此,了解和掌握C++中的内存管理技巧和工具是非常重要的,可以提高程序性能、减少错误和增加安全性。

    内存分区

    在C++中,将操作系统分配给程序的内存空间按照用途划分了代码段、数据段、栈、堆几个不同的区域,每个区域都有其独特的内存管理机制。

    代码区

    代码区是用于存储程序代码的区域,代码段在程序真正执行前就被加载到内存中,在程序执行期间,代码区内存不会被修改和释放。

    由于代码区是只读的,所以会被多个进程共享。在多个进程同时执行同一个程序时,操作系统只需要将代码段加载到内存中一次,然后让多个进程共享这个内存区域即可。

    数据段

    数据段用于存储静态全局变量、静态局部变量和静态常量等静态数据。在程序运行期间,数据段的大小固定不变,但其内容可以被修改。按照变量是否被初始化。数据段可分为已初始化数据段和未初始化数据段。

    C++中函数调用以及函数内的局部变量的使用,都是通过栈这个内存分区实现的。栈分区由操作系统自动分配和释放,是一种"后进先出"的一种内存分区。每个栈的大小是固定的,一般只有几MB,所以如果栈变量太大,或者函数调用嵌套太深,容易发生栈溢出(stack overflow)。

    先来一段示例代码,看看C++是如何使用栈进行使用栈来进行函数调用的。

    #include 
    
    void inner(int a) {
        std::cout << a << std::endl;
    }
    void outer(int n) {
    	int a = n + 1;
        inner(a);
    }
    
    int main() {
        outer(4);
    }
    

    上面这段代码运行过程中的栈变化如下图
    image.png

    每当程序调用一个函数时,该函数的参数、局部变量和返回地址等信息会被压入栈中。当函数执行完毕,再将这些信息从栈中弹出。根据之前压入的外层调用者压入栈的返回地址,返回到外层调用者未执行的代码继续执行。

    本地变量是直接存储在栈上的,当函数执行完成后,这些变量占用的内存就会被释放掉了。前面例子中的本地变量是简单类型,在C++中称为POD类型。对于带有构造和析构函数的非POD类型变量,栈上的内存分配同样有效。编译器会在合适的时机,插入对构造函数和析构函数的调用。

    这里有个问题,当函数执行发生异常时,析构函数还会被调用吗?
    答案是会的,C++对于发生异常时对析构函数的调用称为"栈展开"。通过下面这段代码演示栈展开。

    #include 
    #include 
    
    class Obj {
    public:
        std::string name_;
        Obj(const std::string& name):name_(name){std::cout << "Obj() " << name_ << std::endl;};
        ~Obj() {std::cout << "~Obj() " << name_ << std::endl;};
    };
    
    
    void bar() {
        auto o = Obj{"bar"};
        throw "bar exception";
    }
    
    int main() {
        try {
            bar();
        } catch (const char* e) {
            std::cout << "catch Exception: " << e << std::endl;
        }
    }
    

    执行代码的结果是:

    Obj() bar
    ~Obj() bar
    catch Exception: bar exception
    

    可以发现,发生异常时,bar函数中的本地变量o还是能被正常析构。

    栈展开的过程实际上是异常发生时,匹配catch子句的过程。

    1. 程序抛出异常,停止当前执行的调用链,开始寻找与异常匹配的catch子句。
    2. 如果异常发生在try中,则会首先检查与该try块匹配的catch子句。如果异常所在函数体没有try捕获异常。则会直接进入下一步。
    3. 如果第二步未找到匹配的catch,则会在外层的try块中查找,直到找到为止。
    4. 如果到了最外层还没有找到匹配的catch,也就是说异常得不到处理,程序会调用标准库函数terminate终止函数的执行。

    在这期间,栈上所有的对象都会被自动析构。

    堆是C++中用来存储动态分配内存的内存分区,堆内存的分配和释放需要手动管理,可以通过new/delete或malloc/free等函数进行分配和释放。堆内存的大小通常是不固定的,当我们需要动态分配内存时,就可以使用堆内存。

    堆内存由程序员手动分配和释放,因此使用堆内存需要注意内存泄漏和内存溢出等问题。当程序员忘记释放已分配的内存时,会导致内存泄漏问题。而当申请的堆内存超过了操作系统所分配给进程的内存限制时,会导致内存溢出问题。

    C++程序绝大多数的内存泄露,都是由于忘记调用delete/free来释放堆上的资源。

    还是上代码

    #include 
    #include 
    
    class Obj {
    public:
        std::string name_;
        Obj(const std::string& name):name_(name){std::cout << "Obj() " << name_ << std::endl;};
        ~Obj() {std::cout << "~Obj() " << name_ << std::endl;};
    };
    
    Obj* makeObj() {
    	Obj* obj = nullptr;
    	try {
    		obj = new Obj{"makeObj"};
    		...
    	} catch(...) {
    		delete obj;
    		throw;
    	}
    	return obj;
    }
    
    Obj* foo() {
    	Obj* obj = nullptr;
    	try {
    		obj = makeObj();
    		...
    	} catch(...) {
    		delete obj;
    	}
    	return obj;
    }
    int main() {
        Obj* obj = foo();
        ...
        delete obj;
    }
    

    可以看到,由makeObj函数创建的堆变量obj, 在每个获取该变量的上层调用中,都需要关心对该变量的处理。这无疑极大得增加了开发者的心智负担。

    RAII

    想在堆上创建对象,又不想处理这么复杂的内存释放操作。C++没有像java、golang其他语言创建一套垃圾回收机制,而是采用了一种特有的资源管理方式 --- RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

    RAII利用栈对象在作用域结束后会自动调用析构函数的特点,通过创建栈对象来管理资源。在栈对象构造函数中获取资源,在栈对象析构函数中负责释放资源,以此保证资源的获取和释放。

    下面给出一个通过RAII来自动释放堆内存的例子

    #include 
    
    class AutoIntPtr {
    public:
        AutoIntPtr(int* p = nullptr) : ptr(p) {}
        ~AutoIntPtr() { delete ptr; }
    
        int& operator*() const { return *ptr; }
        int* operator->() const { return ptr; }
    
    private:
        int* ptr;
    };
    
    void foo() {
    	AutoIntPtr p(new int(5));
        std::cout << *p << std::endl; // 5
    }
    
    int main() {
        foo();
    }
    
    

    上面例子中,AutoIntPtr类封装了一个动态分配的int类型的指针,它的构造函数用于获取资源(ptr = p),析构函数用于释放资源(delete ptr)。当AutoIntPtr超出作用域时,自动调用析构函数来释放所包含的资源。

    基于RAII,C++11引入了std::unique_ptrstd::shared_ptr等智能指针用于内存管理类,使得内存管理变得更加方便和安全。这些内存管理类可以自动进行内存释放,避免了手动释放内存的繁琐工作。值得一提的是,上面的AutoIntPtr就是一个简化版的智能指针了。

    在实际开发中,RAII的应用很广。不仅仅用于自动释放内存。还可以用来关闭文件、释放数据库连接、释放同步锁等。

    总结

    本文介绍了C++中的内存管理机制,包括内存分区、栈、堆和RAII技术等内容。通过学习本文,我们可以更好地掌握C++的内存管理技巧,避免内存泄漏和内存溢出等问题。

  • 相关阅读:
    香港的Web3从业者们 出走新加坡还是选择留下?
    IReport常见问题及处理方法
    Chrome扩展的核心:manifest 文件(中)
    9.构造器与垃圾收集器 对象的前世今生
    机器学习(24)---AdaBoost(课堂笔记)
    政安晨:【Keras机器学习示例演绎】(十七)—— 用于图像分类的 RandAugment 可提高鲁棒性
    Gram矩阵+Gram矩阵和协方差矩阵的关系
    Mysql ProxySQL的学习
    详解Java堆的应用场景,思路分析,代码实现
    C++-CGAL5.5.1-功能介绍-全部模块简介-功能相当庞大-需要再去阅读相应模块-模块指南
  • 原文地址:https://www.cnblogs.com/huiwancode/p/17417705.html