vlambda博客
学习文章列表

From Java to C++ 之内存管理篇

在前面三篇中,从快速入门,再细节到C++实参传递特点,这次我们从最最基础,恰巧也是最最重要的部分,内存管理,为什么说它重要呢?因为在C++中并没有提供像Java一样的完善的垃圾回收机制,就算有也是比较简单的,并不能作为完美的依靠,但恰巧是因为开发可以自己控制内存,来达到更加高效的内存管理,虽然现在这个年代好像说内存并不那么的重要,所以来让Java、Python这种语言火了起来,说白了它们就是用空间换时间,但是作为一个追求完美的内存管理者,我们不光追求更短的时间,也在追求更小的空间,这些就离不开C或者C++,我们都知道Java中的堆栈,其实Java作为C++的后继语言,它其实就是借鉴了C++的做法,但C++中有RALL,是C++所特有的资源管理方式,下面我们先来学习下这三个概念。

在内存管理下,它是函数调用过程中产生的变量,函数参数值、返回变量等的一块内存区域,和栈数据结构类似,遵循后进先出的特点。在Java中栈(虚拟机栈、本地方法栈)是线程私有的内存。其实栈的内存管理很好理解,就是出栈后,随之变量和对象都会被释放,那它是如何释放的呢,我们来看个例子简单类型的释放应该很简单,如果是对象的话,它有构造函数等,在释放的时候其实就会调用对应的析构函数,哪怕发生了异常退出,C++内存管理都会执行对象的析构函数来释放。所以你是不是了解了析构函数的作用了呢?

在C++中,和Java一样都是属于动态分配的区域,而且都是靠 new关键字来申请空间,但唯一不同的是,在C++中需要显示的 delete,才可以释放掉,而Java则是通过GC回收。所以C++中,如果你用new来创建对象,那就要和delete成队出现,但C++中还有个问题,一般你不会new完以后直接delete,实际的场景其实是你new完以后,需要很多操作,然后在delete,但这中间有可能发生崩溃,导致程序未能按照以前的想法执行delete操作,所以,这就产生了内存泄漏,内存永远无法释放掉,时间久了就会导致应用内存占满,无法申请新的空间,其实C++给我们提供了智能指针等可以优雅的释放该内存,后续我们专门找个课题研究这个如何更好的回收内存。如图,你也看到了当我们new的时候,其实内存管理,它经历了分配内存,其实这块内存在分配和释放时,还会考虑如下场景:

  1. 内存充足,从可用的内存里取出一块合适大小的内存
  2. 内存充足但可用内存中没有合适的大小,这里的情况其实内存管理还会做一个操作就是合并未使用的内存,为什么会是这样呢?请看图你就明白了

比如我要的内存是4,其实这个状态是够用的,但不连续,所以这种情况,就需要内存管理来做整理,其实还好,由于C++有专门的内存碎片管理机制,所以第二种情况也不用你管理什么,我们只关心正确的new和delete就行了。

  1. 内存不足时要从操作系统申请新的内存

RALL

英文是Resource Acquisition Is Initialization,直译是「资源获取即初始化」,完全不理解,这东西源于C++,其实Java中也有运用,具体怎么用我也不知道,感兴趣的可以研究下。它的来源:比雅尼·斯特劳斯特鲁普和安德鲁·柯尼希在设计C++异常时,为解决资源管理时的异常安全性而使用了它。RAII要求:资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。说了这么多你肯定也跟我一样不懂,再现实一点,RAII 依托栈和析构函数,来对所有的资源——包括堆内存在内——进行管理,所以说它管理的东西可多了,栈、堆以及其他资源吧。在C++中,栈上面是可以创建对象的,但是栈内存一般会很小,且它是一块连续的内存区域,不像堆一样可以使用不连续的内存区域,底层用链表构成,在Window下,栈的大小是2MB,Linux下,默认栈空间大小为8MB,当然也可以修改。所以,如果你将对象都创建的栈上,而不用堆的内存,那肯定是不够的。所以不管是参数,函数内声明的变量,还是返回值,如果是对象的话,我们大部分是依赖的引用或者指针,而引用的值和指针的值其实是放在堆里的。Java也是一样。为了能更好的理解RALL,我们先来看个例子


class TestRALL {
public:
TestRALL() {
std::cout << "TestRALL done" << std::endl;
};

~TestRALL() {
std::cout << "~TestRALL done" << std::endl;
};

void print() {
std::cout << 1 << std::endl;
}
};

TestRALL *createTest() {
return new TestRALL();
}

void print() {
auto ta = createTest();
ta->print();
}

int main() {
print();
return 0;
}

执行main后输出如下:

TestRALL done
1

发现,并没有调用析构函数,意味着TestRALL对象一直在。如果我加入这么一行

void print() {
auto ta = createTest();
ta->print();
delete ta;
}

打印

TestRALL done
1
~TestRALL done

其实我这里就是显式的调用了delete,其实平时我们这样用不科学,那我该如何做呢?再来看下面的例子


class TestRALL {
public:
TestRALL() {
std::cout << "TestRALL done" << std::endl;
};

~TestRALL() {
std::cout << "~TestRALL done" << std::endl;
};

void print() {
std::cout << 1 << std::endl;
}
};

TestRALL *createTest() {
return new TestRALL();
}


class TRDelete {
public:
explicit TRDelete(TestRALL *tr = nullptr) : tr_(tr) {}

~TRDelete() {
delete tr_;
}

TestRALL *get() const { return tr_; }

private:
TestRALL *tr_;
};

void print() {
TRDelete trDelete(createTest());
trDelete.get()->print();
}

int main() {
print();
return 0;
}

首先解释个新东西:

explicit

构造函数被explicit修饰后, 就不能再被隐式调用,什么是隐式调用?请看个例子:

#include <iostream>
using namespace std;

class Point {
public:
int x, y;
Point(int x = 0, int y = 0)
: x(x), y(y) {}
};

void displayPoint(const Point& p)
{
cout << "(" << p.x << ","
<< p.y << ")" << endl;
}

int main()
{
displayPoint(1);
Point p = 1;
}

displayPoint就是隐式调用,看着是简化了代码的写法,但为什么会不推荐呢?来自Effective C++,因为如下:被声明为explicit的构造函数通常比其 non-explicit 兄弟更受欢迎, 因为它们禁止编译器执行非预期 (往往也不被期望) 的类型转换. 除非我有一个好理由允许构造函数被用于隐式类型转换, 否则我会把它声明为explicit. 我鼓励你遵循相同的政策。回过头来看上面的TRDelete,在它的析构函数中,我们delete TestRALL,在print函数执行完后,我们并没有执行delete trDelete 那它为什么会执行TRDelete的析构函数呢?哈哈其实很简单,因为TRDelete并不是通过new创建的,无需delete,它在函数出栈的时候,自然会调用到TRDelete自己的析构函数,这就是RALL的一个基本用法。其实还有更加智能的用法,以后我们再学习讨论。

简单总结

这次我们对栈、堆、RALL的内存管理特点做了学习和练习,也知道了可以通过RALL对栈和堆的内存统一管理的一个小用法,当然我们学习要循循渐进,一步一个juo印,欢迎你扫描下方二维码留言讨论哈。