XiaoboTalk

内存管理基础

我个人的认知,学习一门编程语言,开始阶段,语法规则可以粗枝大叶的学习,剩下的可以在实践中去慢慢掌握;但是语言的内存管理模型,还是需要提早去深入研究的;这好比是练武的内功心法,只要对一门语言的内存管理比较了解,那么后续学习其他的就游刃有余。对我来说以前学习 javascript ,就是这样:
Javascript 原型链
事实上,对于 JS 来说,你可以完全不需要掌握它的内存管理或者原型链。也完全能快速开发出应用。但是对于 Rust,深入学习内存是学习该语言的必经之路。
Rust 之前,编程语言可以通过内存管理分为两大阵营:
  • 完全由开发者手动控制管理:C, C++, Pascal
  • 运行时全自动管理:Javascript, Java, Python, Go, Haskell
Rust 提出了新的混合管理方式:
程序完全控制 + 强大安全的编译器保证内存管理。并且引入了一个比较新颖的词 所有权(ownership)

Stack 和 Heap

这里是计算机基础的一些知识,如果了解一些汇编,会很轻松的理解堆栈。任何编程语言的高级特性,类型,继承等。最后到汇编层,执行单位都是函数。
程序执行以函数为单位,需要执行的函数会被压栈,压栈具体做的是在寄存器上使用相应的内存空间,把函数的入参和返回值要存放的地方预先留好。同时把函数体内的每条指令也都存入到指令寄存器。开始从上到下一条一条执行(寄存器好比是函数执行的上下文环境)。
如果某个值本身是一个地址(指向内存的某个地方),那么这个地方一般就是堆,这时候就涉及到访存,先访问堆内存,根据地址把值取回来,才能继续执行后边的函数指令。
函数内部定义的,都是局部变量,也叫 auto 变量;auto 即 automatic (自动)。意思是,这些变量在寄存器内自动生成(不需要访存,通常都会被编译为字面量)。函数执行完成,后这些变量会被自动弹出函数栈(通过移动栈指针),所以叫 auto 变量。事实上翻译为局部变量并不利于理解。
总结:
  • Stack :连续的内存区域,local 运行
    • 每个值在编译期间就确定了固定的内存大小
    • 非常快速,执行的时候只需要移动栈指针,事实底层函数的执行都会跑在寄存器上,所以更快,如果全部都运行在栈上,没有内存寻址。那么运行速度就是最快的。
    • 轻松管理内存,事实上完全有函数自身完成,所以函数内部的变量,也叫 auto 变量。意思就是自动创建,自动弹出。不需要程序员干预。
    • 强大的内存局部性调用,局部性原理,表示,一段相关的程序,每条指令或者值,在内存中相距不会太远,这样来回切换就很快。
  • Heap: 存储在函数外部的值
    • 堆上的值,大小不固定,可以在运行时动态变化
    • 堆会比栈慢一些,通过一些手段,使得堆不会比栈慢很多。
    • 无法保证内存局部性(对比栈)。
 

Rust 堆栈示例代码:

fn main() { let s1 = String::from("Hello"); }
Stack Heap .- - - - - - - - - - - - - -. .- - - - - - - - - - - - - - - -. : : : : : s1 : : : : +-----------+-------+ : : +----+----+----+----+----+ : : | ptr | o---+---+-----+-->| H | e | l | l | o | : : | len | 5 | : : +----+----+----+----+----+ : : | capacity | 5 | : : : : +-----------+-------+ : : : : : `- - - - - - - - - - - - - - - -' `- - - - - - - - - - - - - -'
上边的代码在执行的时候,s1 变量会被放入寄存器,但 s1 中存放的值是一个指针,指向了堆内存的一个地址入口。Rust 中,String 是由 Vec 实现的,所以它有长度,容量,并且大小可增长(通过在堆上 reallocation)。
使用下列代码,可以访问到变量的堆内存地址,大小,容量:
fn main() { let mut s1 = String::from("Hello"); s1.push(' '); s1.push_str("world"); unsafe { let (ptr, capacity, len): (usize, usize, usize) = std::mem::transmute(s1); println!("ptr = {ptr:#x}, len = {len}, capacity = {capacity}"); } }

手动管理内存

完全通过写代码管理,或者程序员管理,C 就是手动管理:
void foo(size_t n) { // malloc 开辟一块内存(在堆上) int* int_array = malloc(n * sizeof(int)); // 释放内存 free(int_array); }
如果忘记调用 free 释放,就会造成内存泄露。

基于作用域的内存管理

面向对象语言经常这么干,一个对象实例,通常都是一个指针,但事实上它还封装了其他行为,结合编译器,每个对象实例在退出作用域的时候,会释放它内部指向的内存。这在 C++ 中叫做 RAII (Resource Acquisition is initialization),资源获取即初始化。通过将资源的生命周期与对象的生命周期绑定在一起,实现了自动资源管理。
在RAII中,资源的获取和初始化是通过对象的构造函数来完成的,而资源的释放则是在对象的析构函数中进行。这意味着当对象被创建时,资源被自动获取和初始化,而当对象被销毁时,资源会自动释放。
通过使用RAII,可以确保在对象不再使用时,资源会被正确释放,避免了资源泄漏和内存泄漏等问题。RAII还能够处理异常情况下的资源释放,即使在发生异常时,对象被销毁资源获取即初始化(Resource Acquisition Is Initialization,RAII)是一种编程范式,主要用于C++编程语言中的资源管理。它的核心思想是将资源的获取和初始化与对象的生命周期绑定在一起。
这种自动资源管理的好处是,确保资源在使用完毕后被正确释放,避免了资源泄漏的问题。它使得程序员不需要手动跟踪和管理资源的获取和释放,减少了出错的可能性,并提高了代码的可靠性和可维护性。
常见的使用RAII的场景包括使用动态内存分配(如new和delete操作符)、文件操作(如打开和关闭文件)、互斥锁的获取和释放等。通过RAII,可以简化对这些资源的管理,提高代码的安全性和可读性。
C++ 示例:
void say_hello(std::unique_ptr<Person> person) { std::cout << "Hello " << person->name << std::endl; }
  • std::unique_ptr 对象被分配在栈上,但是指向了堆空间的一块内存。
  • say_hello 结束的时候 std::unique_ptr 的析构函数(destructor) 会被执行。
  • 执行析构函数,内部会释放(free)堆上的内存。

自动内存管理 (Automatic Memory Management)

  • 编程人员不需要显式的调 allocate 或者 deallocate
  • GC(garbage collector) 会自动找到不需要的内存,并释放它。
Java 就是 GC 的典型代表:
void sayHello(Person person) { System.out.println("Hello " + person.getName()); }
GC 的算法,一般有两种:
  • 标记清除 : Javascript,Golang,Java 使用
  • 自动引用计数 :Objective-C 使用
标记清除是内存安全的,开发者完全不需要关心对象的内存释放,GC 会再程序 Runtime 阶段动态执行标记清除算法,帮助回收内存;一个明显的问题是,这样会带来一定的性能损耗,因为程序的 Runtime 需要定期执行标记清除算法。
标记清除算法的基本原理是,GC 会预先创建一个根对象 Root-Object,程序运行中,所有的创建的对象都会被统计起来。第一个被统计到的对象会作为根对象的子节点;后续被统计到的对象,也会根据它的引用关系,挂到这颗对象树上。一轮统计下来,那些无法挂载到对象树上的对象,都会被释放。由于这个机制,所以即使循环引用的对象也能被检测到并释放。
自动引用计数 (ARC) 相比标记清除,性能会好很多,因为不需要 Runtime 定期集中的跑算法来释放对象。只要一个对象的引用个数为 0,内存就会自动被释放。但是弊端就是,这种算法无法处理循环应用。循环引用需要开发者自己发现并处理,所以使用 ARC 内存管理的语言,一个受欢迎的面试题,就是如何检查或者避免循环应用。

Rust 中的内存管理

Rust 在设计之初,就想着如何让语言的内存管理,像使用标记清除的语言一样安全,但是有能实现像 C (开发者手动释放内存)一样高效。所以 Rust 设计了所有权系统。实现了:
  • 像 Java 一样安全的内存管理,但是没有 GC
  • 像 C++ 一样的的 Scoped-based 内存管理,但是编译器会更加强势的要求你遵守规范
  • Rust 用户可以根据情况选择正确的抽象,有些甚至像 C 一样在运行时没有成本。
Rust 通过所有权 (ownership) 实现了这些功能,事实上 Rust 这样的设计,会让学习这么语言变的比较困难,因为你不能像写 javascript 或者 java 一样,完全不关心内存。同时还需要清楚编译器规则,不然很可能无法写出一段可编译的代码。