首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >内存管理之堆、栈、RAII

内存管理之堆、栈、RAII

作者头像
公众号guangcity
发布2019-12-16 11:58:46
1K0
发布2019-12-16 11:58:46
举报
文章被收录于专栏:光城(guangcity)光城(guangcity)

内存管理之堆、栈、RAII

0.导语

半个月没有敲代码了,终于复活了!

最近在极客时间上看到吴老师的《现代C++实战30讲》,觉得很是不错,于是学习一下,本文中的一些文字概念引用自这里。同时,对于这个课的代码我放在了我的《C++那些事》仓库里面,点击阅读原文,或者下面链接,即可获取。欢迎star!

https://github.com/Light-City/CPlusPlusThings

1.基本概念

C++里面的堆,英文是 heap,在内存管理的语境下,指的是动态分配内存的区域。这个堆跟数据结构 里的堆不是一回事。这里的内存,被分配之后需要手工释放,否则,就会造成内存泄漏。

C++ 标准里一个相关概念是自由存储区,英文是 free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集:

(1)new 和 delete 操作的区域是 free store

(2)malloc 和 free 操作的区域是 heap

但 new 和 delete 通常底层使用 malloc 和 free 来实现,所以 free store 也是 heap。

英文:stack,同数据结构中的stack,满足后进先出。

  • RAII

英文是 Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。有少量其他语言,如 D、Ada 和 Rust 也采纳了 RAII,但主流的编程语言中, C++是唯一一个依赖 RAII 来做资源管理的。

原理:RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理。 对 RAII 的 使用,使得 C++ 不需要类似于 Java 那样的垃圾收集方法,也能有效地对内存进行管理。RAII 的存在,也是垃圾收集虽然理论上可以在 C++ 使用,但从来没有真正流行过的主要原因。

2.深入学习

2.1 堆

堆牵扯的通常是动态分配内存,在堆上分配内存,有些语言可能使用 new 这样的关键字,有些语言则是在对象的构造时隐式分配,不需要特殊关键字。不管哪种情况,程序通常需要牵涉到三个可能的内存管理器的操作:

  1. 让内存管理器分配一个某个大小的内存块
  2. 让内存管理器释放一个之前分配的内存块
  3. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放

例如:C++ 通常会做上面的操作 1 和 2。Java 会做上面的操作 1 和 3。而 Python 会做上面的操 作 1、2、3。这是语言的特性和实现方式决定的。

下面详细阐述上述三个步骤:

第一,分配内存要考虑程序当前已经有多少未分配的内存。内存不足时要从操作系统申请新 的内存。内存充足时,要从可用的内存里取出一块合适大小的内存,做簿记工作将其标记为 已用,然后将其返回给要求内存的代码。

注意:代码只被允许使用其被分配的内存区域,剩余的内存区域属于未分配状态。如果内存管理器支持垃圾收集的话,分配内存的操作可能会出触发垃圾收集。

第二,释放内存不只是简单地把内存标记为未使用。对于连续未使用的内存块,通常内存管 理器需要将其合并成一块,以便可以满足后续的较大内存分配要求。毕竟,目前的编程模式 都要求申请的内存块是连续的。

第三:垃圾收集操作有很多不同的策略和实现方式,以实现性能、实时性、额外开销等各方 面的平衡。C++中这个不是重点。

在作者文档中,提到一个new与delete例子,非常有意思,这里引用过来。

void foo()
{
	bar* ptr = new bar();
	…
	delete ptr;
}

这里存在两个问题:

  • 中间省略部分若抛出异常,则导致delete ptr得不到执行。
  • 更重要的,这个代码不符合 C++ 的惯用法。在 C++ 里,这种情况下有 99% 的可能性不应该使用堆内存分配,而应使用栈内存分配。

第二点非常重要,于是作者给出了一个更常见、也更合理的作法,分配和释放不在一个函数里:

bar *make_bar() {
    bar *ptr = nullptr;
    try {
        ptr = new bar();
    } catch (...) {
        delete ptr;
        throw;
    }
    return ptr;
}
// 独立出函数  分配和释放不在一个函数里
void foo1() {
    cout << "method 2" << endl;
    bar *ptr = make_bar();
    delete ptr;
}

2.2 栈

函数调用、本地变量入栈出栈会取决于计算机的试剂架构,原理都是后进先出。栈是向上增长,在包括 x86 在内的大部分计算机体系架构中,栈的增长方向是低地址,因而上方意味着低地址

本地变量所需的内存就在栈上,跟函数执行所需的其他数据在一起。当函数执行完成之后,这些内存也就自然而然释放掉了。因此得出栈的分配与释放:

  • 分配

移动一下栈指针

  • 释放

函数执行结束时移动一下栈指针

POD类型:本地变量是简单类型,C++ 里称之为 POD 类型(Plain Old Data)。

对于有构造和析构函数的非 POD 类型,栈上的内存分配也同样有效,只不过 C++ 编译器会在生 栈上的分配极为简单,移动一下栈指针而已。栈上的释放也极为简单,函数执行结束时移动一下栈指针即可。由于后进先出的执行过程,不可能出现内存碎片。成代码的合适位置,插入对构造和析构函数的调用。

栈展开:编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用,还有一个专门的术语,叫栈展开(stack unwinding)。

在 C++ 里,所有的变量缺省都是值语义——如果不使用 * 和 & 的话,变量不会像 Java 或Python 一样引用一个堆上的对象。对于像智能指针这样的类型,你写 ptr->call() 和ptr.get(),语法上都是对的,并且 -> 和 . 有着不同的语法作用。而在大部分其他语言里,访问成员只用 .,但在作用上实际等价于 C++ 的 ->。这种值语义和引用语义的区别,是 C++ 的特点,也是它的复杂性的一个来源。

2.3 RAII

C++ 支持将对象存储在栈上面。但是,在很多情况下,对象不能,或不应该,存储在栈 上。比如:

  • 对象很大;
  • 对象的大小在编译时不能确定;
  • 对象是函数的返回值,但由于特殊的原因,不应使用对象的值返回。

实际例子如下:

enum class shape_type {
    circle,
    triangle,
    rectangle,
};

class shape {
public:
    shape() { cout << "shape" << endl; }

    virtual void print() {
        cout << "I am shape" << endl;
    }

    virtual ~shape() {}
};
class circle : public shape {
public:
    circle() { cout << "circle" << endl; }

    void print() {
        cout << "I am circle" << endl;
    }
};
class triangle : public shape {
public:
    triangle() { cout << "triangle" << endl; }
    void print() {
        cout << "I am triangle" << endl;
    }
};
class rectangle : public shape {
public:
    rectangle() { cout << "rectangle" << endl; }
    void print() {
        cout << "I am rectangle" << endl;
    }
};
// 利用多态 上转 如果返回值为shape,会存在对象切片问题。
shape *create_shape(shape_type type) {
    switch (type) {
        case shape_type::circle:
            return new circle();
        case shape_type::triangle:
            return new triangle();
        case shape_type::rectangle:
            return new rectangle();
    }
}

int main() {
    shape *sp = create_shape(shape_type::circle);
    sp->print();
    delete sp;
    return 0;
}

函数返回值在这里需要注意,只能为指针,而不能是值类型,当把shape* 改为shape的时候,会引发对象切片(object slicing)。

例如:

class shape {
   int foo;
};

class circle : public shape {
   int bar;
};

因此,B类型的对象有两个数据成员:foo和bar。

调用如下:

circle b;

shape a = b;

编译器不会报错,但结果多半是错的。然后,circle中关于成员bar的信息在shape中丢失。

那么,我们怎样才能确保,在使用 create_shape 的返回值时不会发生内存泄漏呢?

答案就在析构函数和它的栈展开行为上。我们只需要把这个返回值放到一个本地变量里,并确保其析构函数会删除该对象即可。一个简单的实现如下所示:

class shape_wrapper {
public:
    explicit shape_wrapper(shape *ptr = nullptr) : ptr_(ptr) {}

    ~shape_wrapper() {
        delete ptr_;
    }

    shape *get() const {
        return ptr_;
    }

private:
    shape *ptr_;
};

void foo() {
    shape_wrapper ptr(create_shape(shape_type::circle));
    ptr.get()->print();
}

对于上述代码,当ptr_为空指针的时候,delete是合法的。

在析构函数里做必要的清理工作,这就是 RAII 的基本用法。这种清理并不限于释放内存,也可以是:

  • 关闭文件 fstream 的析构就会这么做
  • 释放同步锁, 例如:使用lock_guard代替mutex直接操作。
  • 释放其他重要的系统资源
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-12-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 光城 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 内存管理之堆、栈、RAII
    • 0.导语
      • 1.基本概念
        • 2.深入学习
          • 2.1 堆
          • 2.2 栈
          • 2.3 RAII
      相关产品与服务
      对象存储
      对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档