当前位置: 首页 > 科技观察

编译器的自动内存管理,静态GC算法

时间:2023-03-21 12:56:45 科技观察

C语言几乎唯一的缺点就是需要手动管理内存。除了这一点,我觉得其他语言都不如C语言。因此,虽然自动内存管理比较复杂,但我还是在scf编译框架中加入了静态GC算法。在编程方面,自动内存管理一般称为GC算法,是英文GarbageCollection的缩写。栈内存的管理比较简单,由编译器根据函数调用链自动管理。堆内存的管理由程序员在C语言中手动管理。程序员错误管理堆内存导致的BUG是C语言中最常见也是最难处理的BUG。因此,后来的编程语言都简化了内存管理,比如C++智能指针。C++的智能指针是一种半自动的内存管理机制:它在类的成员变量中放置一个指向堆内存的指针,在局部对象离开作用域时使用析构函数来完成堆内存的释放。因此,C++的效率要比其他语言快很多,因为当局部对象离开作用域时,在编译时就可以确定,不需要在运行时进行额外处理。也就是说,C++的智能指针是一种静态GC算法。在编译时处理的算法是静态算法。在运行时处理的算法是动态算法。动态算法依赖于运行时状态,对程序速度影响很大:因为框架处理对象内存的回收,所以用户程序不得不挂起,否则双方都会出现racecondition,这和C语言BUG中的野指针是一样的。写过C语言的都知道,多线程的野指针是非常难找BUG的,因为程序跑路了不知道核心会在哪里,BUG也不一定找得到。为什么程序员害怕拥有主控软件的车辆?因为程序员都知道多线程+竞争条件+野指针==随机crash+之后找不到第一种场景的动态GC算法,为了避免第二种情况,那么只能使用第一种情况。1、GC算法需要动态的吗?其实大可不必,不然C语言怎么手动管理内存。C语言的free()代码肯定是编译前写好的!只要free()位置写对了,C语言就不会出现bug,也不会出现内存泄漏。因此,只要编译器代替程序员加上free(),就可以自动管理内存。free()加入的地方,当然是在变量离开作用域的时候。如上图:有4个对象变量m0、m1、m2、m3,当main()函数返回时,它们离开了作用域,所以在main函数末尾自动加上释放代码,程序员做不需要手动释放内存。2.如何检测变量何时离开作用域?在编译器后端:1)代码的每个基本块是流程图上的一个节点,2)基本块之间通过跳转连接,3)基本块内部的代码是顺序运行的。因此,需要在两个基本块之间加入释放内存的代码。上面main()函数的流程图上图是前面main()函数的流程图。创建对象分为两步:第一步调用malloc()申请内存,第二步调用构造函数__init()初始化内存。(为了简化代码,我没有检查返回值为NULL)在第八个基本块m3=m0+m1+m2之后,m0,m1,m2不再使用,即它们3离开作用域.尽管m0、m1、m2在源码层面还在main()函数的范围内,但已经离开了后端的范围,因为后面的基本块不再使用它们。因此,在第8和第9个基本块之间应添加m0、m1和m2的释放代码。9号基本块会将指针m3->data赋值给dd,这样会将(m3->data)内存的引用计数加1。m3的释放代码可以放在9号和10号之间,m3之后将不再使用:这将使m3->data的引用计数为-1。此时内存数据有且只有1个引用计数(一开始就自带1),同时有且只有指针dd指向它。指针dd的释放是在for循环之后,也就是第10到11之间:这里的释放会将引用计数减为0,当引用计数为0时,调用free()函数将内存归还给系统。GC算法的三个要点:1)什么时候调用malloc(),2)有指针赋值时,引用计数应该+1,3)什么时候离开作用域,即对象变量以后不用了,引用计数应该为-1,如果减为0,再调用free()。3、跨函数指针分析,有时候,申请的内存不会在当前函数中释放,而是返回给上层调用函数。这时候GC算法就需要跨越函数调用链,进行指针解析。mat类的构造函数__init()前面的mat对象的成员指针m3->data是一个需要跨函数分析的指针。它是在构造函数中分配的内存,因为它是成员变量,所以必须在析构函数中释放。如果是局部变量,则在当前函数中释放:因为局部变量的作用域是当前函数。mat类的声明,成员变量的一些成员变量的有效时间,伴随着当前对象。局部变量的生效时间伴随着当前函数。成员变量在构造函数返回时仍然有效,所以它是malloc()请求的信息,传递给上层函数。这样:1)只知道在main()中被malloc()申请了,2)当dd=m3->data时只知道它指向的内存的引用计数+1,3)当释放m3,destruct函数设置引用计数-1后,引用计数不为0:内存仍然有效,dd指针仍然指向它。否则,dd就是一个野指针!mat类析构函数的__release()函数调用链在语义分析时很容易确定。抽象语法树AST上的每个函数调用都必须有一个调用函数和一个被调用函数。主调用者和被调用者构成了整个程序的函数调用图:最上面的是main()函数,最下面的是malloc()函数。以malloc为起点,main为终点,对图进行广度优先搜索,即可得到整个调用链。然后从离malloc最近的函数开始,逐层分析。函数调用图必须使用图的广度优先搜索(BFS)!不能使用深度优先搜索(DFS),因为一个上层函数可能会调用多个下层函数,而内存是在这多个下层函数中分配的。如上图:如果是DFS,分析顺序是A->D,这样D调用B申请的内存就会漏掉。如果是BFS,那么解析顺序是A->B->C->D->E,这样如果任何一个函数申请的内存传递给上层,(解析上层函数时)就不会错过了。4、递归调用的指针分析上图中C()和E()的相互调用构成了递归,在函数调用图上表现为循环。在这种情况下,两个函数中申请的内存会相互传递,这是最复杂的情??况!编译器中的处理方式是:do{delivery=check_delivery();}while(0==delivery);使用dowhile循环检查内存的传递,记录传递的变量和计数,直到没有变化为止。最后,在合适的地方加上free()代码:最后的总是最简单的,最后的才是最简单的。