第四章 内存系统
不同的编程语言对内存有着不同的管理方式。
按照内存的管理方式可将编程语言大致分为两类:
- 手动管理类
- 手动内存管理类需要开发者使用malloc和free等函数显式管理内存。
- 自动内存管理类
- 自动内存管理类GC(Gargage Collection,垃圾回收)来对内存进行自动化管理,而无须开发者手动开辟和释放内存,Java, C#, Ruby, Python
手动内存管理的另一个常见问题就是悬垂指针 Dangling Pointer。 如果某个指针引用的内存被非法释放掉了, 而该指针却依旧指向被释放的内存,这种情况下的指针就叫作悬垂指针。如果将悬垂指针分配给某个其他的对象,将会产生无法预料的后果。
GC最大的问题是会引起“世界暂停”,GC 在工作的时候必须保证程序不会引入新的“垃圾”, 所以要使运行中程序暂停,这就造就了性能问题。
Rust既能无GC又可以安全地进行手动内存管理,还不缺乏更高地抽象,可以像其他高级语言一样进行快速地开发。
Rust允许开发者直接操作内存,所以了解内存如何工作对于编写出高效地Rust代码至关重要。
通用概念
现代操作系统在保护模式下都采用虚拟内存管理技术。虚拟内存是一种对物理存储设备的统一抽象,其中物理存储设备包括物理内存、磁盘、寄存器、高速缓存等。
这样统一的好处是,方便同时运行多道程序,使得每个进程都有各自独立的进程地址空间,并且可以通过操作系统调度将外存当作内存来使用。
这就引出了一个新的概念:虚拟地址空间。
虚拟地址空间是线性空间,用户所接触到的地址都是虚拟地址,而不是真实的物理地址。利用这种虚拟地址不但能保护操作系统,让进程在各自的地址空间内操作内存,更重要的是,用户程序可以使用比物理内存更大的地址空间。
虚拟地址空间被人为分为两部分:
- 用户空间内核空间
- 用户空间中有的栈和堆
- 用户空间中有的栈和堆
以linux为例,32位计算机的地址空间大小是4GB, 寻址范围是0x00000000~0XFFFFFFF。然后通过内存分页等底层复杂的机制来把虚拟地址翻译为物理地址。
栈向下(由高地址向低地址)增长,堆向上(由低地址向高地址)增长,这样是为来更加有效利用内存。
栈
栈,也被称为堆栈。栈一般有两种定义,一种是数据结构,一种是栈内存。
在数据结构中,栈是一种特殊的线性表,其特殊在于限定了插入和删除数据只能在线性表固定的一端进行。
操作栈的一端称为栈顶,相反的一端称为栈底。
从栈顶压入数据叫作入栈,从栈顶弹出数据叫作出栈。
最后一个入栈的数据会第一个出栈,所以栈被称为后进先出线性表。
物理内存本身不区分堆和栈,但是虚拟内存空间需要一部分内存,用于支持CPU入栈和出栈的指令操作,这部分内存空间就是栈内存。
栈内存拥有和栈数据结构相同的特性,支持入栈和出栈操作,数据压入的操作使栈顶的地址减少,数据弹出的操作使栈顶的地址增多。
栈顶由栈寄存器ESP保存,起初栈顶指向栈底的位置,当有数据入栈时,栈顶地址向下增长,地址由高地址变成低地址;当有数据被弹出时,栈顶地址向上增长,地址由低地址变成高地址。
降低ESP的地址等价于开辟空间,增加ESP的地址等价于回收栈空间。
栈内存最重要的作用是在程序运行过程中保存函数调用所维护的信息。
存储每次函数调用所需信息的记录单元被称为栈帧,有时也被称为活动记录,因为栈内存被栈帧分割成了N个记录块,而且这些记录块都是大小不一。
栈帧一般包括三方面的内容:
- 函数的返回地址和参数
- 临时变量。包括函数内部的非静态局部变量和编译器产生的临时变量。
- 保存的上下文
EBP指针是帧指针,它指向当前栈帧的一个固定的位置,而ESP始终指向栈顶,EBP指向的值是调用该函数之前的旧的ESP值,这样在函数返回时,就可以通过该值恢复到调用前的值。由EBP和ESP指针构成的区域就是一个栈帧,一般是指当前栈帧。
栈帧的分配非常快,其中的局部变量都预分配内存,在栈上分配的值都是可以预先确定大小的类型。当函数结束调用的时候,栈帧会被自动释放,所以栈上数据的生命周期都是在一个函数调用周期内的。
1 fn foo(x: u32) {
2 let y = x;
3 let z = 100;
4 }
5 fn main() {
6 let x = 42;
7 foo(x);
8 }
main函数为入口函数,所以首先被调用。main函数中声明了变量x,在调用foo函数前,main函数先在栈里开辟了空间,压入了x变量。栈帧里EBP指向起始位置,变量x保存在帧指针EBP-4偏移处。
在调用foo函数时,将返回地址压入栈中,然后由PC指针(程序计数器)引导执行函数调用指令,进入foo函数栈帧中。此时同样在栈中开辟空间,依次将main函数的EBP地址,参数x以及局部变量y和z压入栈中。EBP指针依旧指向地址为0的固定位置,表明当前是在foo函数栈帧中,通过EBP-4, EBP-8, EBP-12就可以访问参数和变量。当foo函数执行完毕时,其参数或局部变量会依次弹出,直到得到main函数的EBP地址,就可以跳回main函数栈帧中,然后通过返回地址就可以继续执行main函数中其余的代码。
调用main和foo函数时,栈帧ESP地址降低,因为要分配栈内存,栈向下增长,当foo函数执行完毕时,ESP地址会增长,因为栈内存会被释放。
随着栈内存的释放,函数中的局部变量也会被释放。
所以,全局变量不会被存储到栈中,该过程说来简单,但其实底层涉及寻址、寄存器、汇编指令等比较复杂的协作过程,这些都是由编译器或解析器自动完全的,对于上层开发者来说,只需要了解栈内存的工作机制即可。
堆
堆也是有两种定义,一种是指数据结构,另一种指堆内存。
在数据结构中,堆表示一种特殊的树形数据结构,特殊之处在于此树是一棵完全二叉树,它的特点是父节点的值要么大于两个字节点的值,称为大顶堆。
要么都小于两个子节点的值,称为小顶堆。 一般用于实现堆排序或优先队列。
栈数据结构和栈内存在特性上还有所关联,但堆数据结构和堆内存并无直接的联系。
程序不可以主动申请栈内存,但可以主动申请堆内存。
在堆内存中存放的数据会在程序运行过程中一直存在,除非该内存被主动释放掉。
在实际工作中,对于事先直到大小的类型,可以分配到栈中,比如固定大小的数组。但是如果需要动态大小的数组,则需要使用堆内存。开发者只能通过指针来掌握已分配的堆内存,这本身就带来来安全隐患,如果指针指向的堆内存被释放掉但指针没有被正确处理,或者该指针指向一个不合法的内存,就会带来内存不安全问题。
面向对象大师Bertand Meyer: 要么保证软件质量,要么使用指针,两者不可兼得。
堆是一大块内存空间,程序通过malloc申请到内存空间是大小不一,不连续且无序的,所以如何管理堆内存是一个问题。
堆分配算法,分为两大类: 空闲链表和位图标记。
空闲链表实际上就是把堆中空闲的内存地址记录为链表,当系统收到程序申请时,会遍历链表;当找到适合的空间堆节点时,会将此节点从链表中删除;当空间被回收以后,再将其加到空闲链表中。空闲链表的优势是实现简单,但如果链表遭到破坏,整个堆就无法正常工作。
位图的核心思想是将整个堆划分为大量大小相等的块。当程序申请内存时,总是分配整个块的空间。每块内存都用一个二进制位来表示其状态,如果该内存被占用,则相应位图中的位置置为1; 如果该内存空闲,则相应位图中的位置置为0,位图的优势是速度快,如果单个内存块数据遭到破坏,也不会整个堆,但缺点是容易产生内存碎片。
不管是什么算法,分配的都是虚拟地址空间,所以当堆空间被释放时,并不代表指物理空间也马上被释放。
堆内存分配函数malloc和回收函数free背后是内存分配器,比如glibc的内存分配器ptmallac2,或者FreeBSD平台的jemalloc。 这些内存分配器负责管理申请和回收堆内存,当堆内存释放时,内存被归还给内存分配器。内存分配器会对空闲的内存进行统一整理,在适合的时候,才会把内存归还给系统,也就是指释放物理空间。
Rust编译器目前自带两个默认分配器: alloc_system,alloc_jemalloc。
在Rust 2015版本下,编译器产生的二进制文件默认使用alloc_jemalloc, 而对于静态或动态链接库,默认使用alloc_system. 在Rust 2018版本下,默认使用alloc_sysem,并且可以由开发者自己指派Jemalloc或其他第三方分配器。
Jemalloc的优势有以下几点:
- 分配或回收内存更快速
- 内存碎片更少
- 多核友好
- 良好的可伸缩性
Jemalloc 是现代的业界流行的内存分配解决方案,它整块批发内存(chunk)以供程序员使用,而非频繁地使用系统调用brk, mmap来向操作系统申请内存。其内存管理采用层级架构,分别是线程缓存tcache,分配区arena,和系统内存,system memory, 不同大小的内存块对应不同的分配区,每个线程对应一个tcache,tcache负责当前线程所使用内存块的申请和释放,避免线程间锁的竞争和同步。tcache是对arena中内存块的缓存,当没有tcachea时则使用arena分配内存。
arena采用内存池思想对内存区域进行合理划分和管理,在有效保证低碎片的前提下实现了不同大小内存块的高效管理。当arena中有不能分配的超大内存时,再直接使用mmap从系统内存中申请,并使用红黑树进行管理。
即使堆分配算法再好,也只是解决了堆内存合理分配和回收地问题,其访问性能不如栈内存。存放在堆上地数据要通过其存放于栈上地指针进行访问,这就至少多了一层内存中地跳转。所以能放在栈上地数据最好不要放到堆上,因此,Rust的类型默认都是放在栈上的。
内存布局
内存中数据的排列方式称为内存布局。不同的排列方式,占用的内存不同,也会间接影响CPU访问内存的效率,为了权衡空间占用情况和访问效率,引入了内存对齐规则。
CPU在单位时间内能处理的一组二进制数称为字。这组二进制数的位数称为字长。如果是32位CPU,其字长为32位,也就是4个字节。一般来说字长越大,计算机处理信息的速度就越快,例如,64位CPU就比32位CPU效率高。
以32位CPU为例,CPU每次只能从内存中读取4个字节的数据,所以每次只能对4的倍数进行读取。
假如现有一行数据类型的数据,首地址并不是4的倍数,不妨设0x3,则该类型存储在地址范围是0x3~0x7的存储空间中,因此,CPU如果想读取该数据,则需要分别在0x1和0x5处进行两次读取,而且还需要对读取到的数据进行处理才能得到该数据,CPU的处理速度比从内存中读取数据的速度要快的多,因此减少CPU对内存空间的访问是提高程序性能的关键。
通过内存对齐策略是提高程序性能的关键。 因为是32位CPU,所以只需要按4字节对齐,CPU只需要读取一次。
因为对齐的是字节,所以内存对齐也叫字节对齐,内存对齐是编译器或虚拟机的工作,不需要人位指定,,但是作为开发者需要了解内存对齐的规则,这有助于编写出合理利用内存的高性能程序。
内存对齐包括基本数据对齐和结构体(或联合体)数据对齐,对于基本数据类型,默认对齐方式是按其大小进行对齐的,也被称为自然对齐。比如Rust中32类型占4字节,则它默认对齐方式为4字节。对于内部含有多个基本类型的结构体来说,对齐规则稍微点复杂。
假设对齐字节数为N(N=1,2,4,8,16),每个成员内存长度为len, Max(len)为最大成员内存长度。
如果没有外部明确的规定,N默认按Max(len)对齐。字节对齐规则:
- 结构体的起始地址能够被Max(len)整除
- 结构体中每个成员相对于结构体起始地址的偏移量,即对齐值,应该是Min(N, Len)的倍数,若不满足对齐值的要求,编译器会在成员之间填充若干字节。
- 结构体的总长度应该是Min(N, Max(len))的倍数,若不满足总长度的要求,则编译器会在为最后一个成员分配空间后,在其后面填充若干个字节。
1 struct A {
2 a: u8, //1 补齐值Min(4, 1)
3 b: u32, //4 b已经是对齐的
4 c: u16, //2 c是结构体中最后一个成员,
5 }//当前结构体A的总长度为a,b,c之和。占8个字节。正好是Min(4, 4),也就是4的倍数,所以成员c不需要再补齐,
6 //因此结构体A实际占用也是8个字节
7 //A没有明确指定字节对齐值,所以默认按其最长成员值来对齐,结构体A中最长的成员是b,占4个字节。
8 fn main() {
9 println!("{:?}", std::mem::size_of::()); // 8
10 }
联合体和结构体不同地方在于,联合体中的所有成员都共享一端内存,所以联合体以最长成员为对齐数。
union U {
f1: u32,
f2: f32,
f3: f64,
}
fn main() {
println!("{:?}", std::mem::size_of::());//8
}
Rust 中的资源管理
Rust可以静态地在编译时确定何时需要释放内存,而不需要在运行时去确定。Rust有一套完整的内存管理机制保证资源合理利用和良好的性能。
变量和函数
变量有两种:全局变量和局部变量。 全局变量分为常量变量和静态变量。局部变量是指在函数中定义的变量。
常量使用const关键字定义,并且需要显式指定类型,只能进行简单赋值,只能使用支持CTFE的表达式。常量没有固定的内存地址,因为其生命周期是全局的,随着程序消亡,并且会被编译器有效地内联到每个使用到它的地方。
静态变量使用static 关键字定义,跟常量一样需要显式指明类型,进行简单赋值,而不能使用任何表达式。静态变量的生命周期也是全局的,但它并不会被内联,每个静态变量都有一个固定的内存地址。
静态变量并非分配到栈中,也不是堆中,而是和程序代码一起被存储于静态存储区中,静态存储区是伴随着程序的二进制文件的生成(编译时)被分配的,并且在程序的整个运行期都会存在,Rust中的字符串字面量同样是存储于静态内存中的。
检测是否声明未初始化变量
在函数中定义的局部变量都会被默认存储到栈中。不同的是Rust编译器可以检查未初始化的变量,以保证内存安全。
1 fn main() {
2 let x: i32;
3 pritln!("{}", x); // use of possibly uninitialized variable 'x'
4 }
Rust编译器会对代码做基本的静态分支流程分析,x在整个main函数中并没有绑定任何值,这样的代码会引起很多内存不安全的问题,比如计算结果非预期,程序崩溃,所以Rust编译器必须报错。
检测分支流程是否产生未初始化变量
Rust编译器的静态分支流程比较严格。
1 fn main() {
2 let x: i32;
3 if true {
4 x = 1;
5 } else {
6 x = 2;
7 }
8 println!("{}", x);
9 }
if分支的所有情况都给变量x绑定了值,所以他可以正确的运行。
但是如果去掉else分支,编译器就会报错:
error: use of possibly uninitialized variable 'x'
这说明编译器已经检查出变量x并未正确初始化,去掉else分支之后,编译器的静态分支流程分析判断出if表达式之外的println!也用到了变量x,但并未有任何值绑定行为。编译器的静态分支流程分析并不能识别if表达式中的条件true, 所以他要检查所有的分支情况。
如果把代码中的else分支和println!语句都去掉,则可以正常编译运行。因为在if表达式之外再没有使用到x的地方,在唯一使用到x的if表达式中已经绑定了值,所以编译正常。
检查循环中是否产生为初始化变量
当在循环中使用break关键字的时候,break会将分支中的变量值返回。
1 fn main() {
2 let x: i32;
3 loop {
4 if true {
5 x = 2;
6 break;
7 }
8 }
9 println!("{}", x); //2
10 }
从Rust编译器的分支流程分析可以直到,break会将x的值返回,所以在loop循环之外的println!语句可以正常打印x的值。
空数组或向量可以初始化变量
当变量绑定空的数组或向量时,需要显式指定类型,否则编译器无法推断期类型。
1 fn main() {
2 let a: Vec = vec![];
3 let b: [i32; 0] = [];
4 }// 如果不加显式类型标注,编译器会报错; error : type annotations needed
空数组或变量可以用来初始化变量,当目前暂时无法用于初始化常量或静态变量。
转移所有权产生了为初始化变量
当将一个已初始化的变量y绑定给另一个变量y2时,Rust会把变量y看作逻辑上的未初始化变量。
fn main() {
let x = 42; // x原生类型,实现了Copy trait,所以这里变量x并未发生任何变化。
let y = Box::new(5);
println!("{:p}", y);
let x2 = x;
let y2 = y; //会将y的值移动给y2,而变量y会被编译器看作一个未初始化的变量,当再使用时就会报错。
//此时如果再重新绑定一个新值,y依然可用。这个过程称为重新初始化。
// println!("{:?}", y);
}
当main函数调用完毕时,栈帧会被释放,变量x和y也被清空。Box
Box
智能指针与RAII
Rust中的指针大致可以分为三种: 引用,原生指针(裸指针)和智能指针。
引用就是Rust提供的普通指针,用&和&mut操作符来创建,形如&T和&mut T, 原生指针是指形如const T和 mut T这样的指针。
引用和原生指针类型之间的异同:
- 可以通过as操作符随意转换,& T as * const T, &mut T as *mut T.
- 原生指针可以在unsafe块下任意使用,不受Rust的安全检查规则的限制,而引用则必须收到编译器安全检查规则的限制。
智能指针
智能指针实际上是一个结构体,行为类似指针。智能指针是对指针的一层封装,提供了一些额外的功能,比如自动释放内存。
智能指针区别于常规结构体的特性在于,它实现了Deref和Drop这两个trait. Deref提供了解引用能力,Drop提供了自动析构的能力,正是这两个trait让智能指针拥有了类似指针的行为。 类似决定行为,同时类型也取决于行为,不是指针胜似指针,所以称其为智能指针。
智能指针结构体中实现了Deref,重载了解引用运算符的行为,String和Vec也是一种智能指针。都实现了Deref和Drop
1 fn main() {
2 let s = String::from("hello");
3 // let deref_s : str = *s; // str是大小不确定的类型,所以编译器会报错
4 let v = vec![1, 2, 3];
5 // let deref_v: [u32] = *v; //[u32]也是大小不确定的类型
6 }
String类型和Vec类型的值都是被分配到堆内存并返回指针的,通过将返回的指针封装来实现Deref和Drop,以自动化管理解引用和释放堆内存。
当main函数执行完毕后,栈帧释放,变量s和v被清空之后,其对应的已分配堆内存会被自动释放,这是因为它们实现了Drop
Drop对于智能指针来说非常重要,因为它可以帮助智能指针在丢弃时自动执行一些重要的清理工作,比如释放堆内存。除了释放内存,Drop还可以做很多其他的工作,比如释放文件和网络。
确定性析构
这种资源管理的方式有一个术语: RAII(Resource Acquisition is Initialization) 资源获取及初始化,RAII和智能指针均起源于现代C++, 智能指针就是基于RAII机制实现的。
RAII将资源托管给创建堆内存的指针对象本身来管理,并保证资源在其生命周期始终有效,一旦声明周期终止,资源马上会被回收,
GC是由第三方只针对内存来统一回收垃圾的,这样就会很被动。
Rust没有现代C++所拥有的那种构造函数,而是直接对每个成员的初始化来完成构造,也可以直接通过封装一个静态函数来构造“构造函数“
Rust中的Drop就是析构函数
Drop被定义在std::ops模块中,
1 #[lang = "drop"]
2 pub trait Drop {
3 fn drop(&mut self);
4 }
Drop已经被标记为语言项,这表明该trait为语言本身所用,比如智能指针被丢弃后自动触发析构函数时,编译器直到去哪里找Drop
1 use std::ops::Drop;
2 #[derive(Debug)]
3 struct S(i32);
4 impl Drop for S {
5 fn drop(&mut self ) {
6 println!("drop {}", self,0);
7 }
8 }
9
10 fn main() {
11 let x = S(1);
12 println!("crate x: {:?}", x);
13 {
14 let y = S(2);
15 println!("crate y: {:?}", y);
16 println!("exit inner scope");
17 }
18 println!("exit main");
19 }
RAII 也叫做作用域界定的资源管理(Scope -Bound Resource Management , SBRM)
Drop的特性,它允许在对象即将消亡之时,自行调用指定代码(drop方法)
Rust中的一些常见类型都实现来Drop, Vec, String, File
drop-flag
编译器使用drop-flag,在函数调用栈中为离开作用域的变量自动插入布尔标记,标记是否调用析构函数,这样,在运行时就可以根据编译期做的标记来调用析构函数。
对于结构体或枚举体这种复合类型来说,并不存在隐式的drop-flag,只有在函数调用时,这些复合结构实例被初始化之后,编译器才会加上drop-flag,如果复合结构体本身实现了Drop,则会调用它自己的析构函数函数;否则,会调用其成员的析构函数。
当变量被绑定给另一个变量,值发生移动时,也会被加上drop-flag, 在运行时会调用析构函数,加上drop-flag的变量意味着生命周期的结束,之后再也不能被访问。
可以使用花括号构造显示作用域来“主动析构“那些需要提前结束生命周期的变量。
1 fn main() {
2 let mut v = vec![1, 2, 3];
3 {
4 v
5 };
6
7 //v.push(4);
8 }
对于实现Copy 的类型,是没有析构函数的,因为实现Copy的类型会复制,其生命周期不受析构函数的影响,所以也就没必要存在析构函数。
变量遮蔽并不会导致其生命周期提前结束
1 use std::ops::Drop;
2 #[derive(Debug)]
3 struct S(i32);
4 impl Drop for S {
5 fn drop(&mut self) {
6 println!("drop for {}", self.0);
7 }
8 }
9
10 fn main(){
11 let x = S(1);
12 println!("create x: {:?}", x);
13 let x = S(2);
14 println!("create shadowing x: {:?}", x);
15 }
内存泄漏于内存安全
制造内存泄漏
需要对同已堆内存快满进行多次引用,
创建一个链表
1 struct Node {
2 data: T,
3 next: NodePtr,
4 }
5 type NodePtr = Option>>
6 //这里NodePtr首先是一个Option, 因为链表的结尾节点之后有可能不存在下一节点,所以需要Some和None
还需要一个智能指针来保持节点之间的连接,
Box
1 type NodePtr = Option>>;
2 struct Node {
3 data: T,
4 next: NodePtr,
5 }
6 fn main() [
7 let mut first = Box::new(Node { data: 1, next: None});
8 let mut second = Box::new(Node { data: 2, next: Nonde});
9 first.next = Some(seond);// value moved here
10 // 将second节点指定给了first, 因为seond 使用了Box指针,此时second发生了值移动,变成了未初始化变量,
11 second.next = Some(first);
12 }
Rust提供了智能指针Rc
Rc
use std::rc::Rc;
type NodePtr = Option>>;
struct Node {
data: T,
next: NodePtr,
}
fn main() [
let mut first = Box::new(Node { data: 1, next: None});
let mut second = Box::new(Node { data: 2, next: Nonde});
first.next = Some(seond.clone()); //cannot mutably borrow immutable field
second.next = Some(first.clone());
}
变量first和second使用了clone方法,但并不会真的复制,Rc
编译器提示,不能对不可变字段进行修改,
Rust提供了另一种智能指针RefCell
1 use std::rc::Rc;
2 use std::cell:RefCell;
3 type NodePtr = Option>>>;
4
5 struct Node {
6 data: T,
7 next: NodePtr,
8 }
9
10 impl Drop for Node {
11 fn drop(&mut self) {
12 println!("Dropping");
13 }
14 }
15
16 fn main() [
17 let mut first = Rc::new(RefCell::new(Node { data: 1, next: None}));
18 let mut second = Rc::new(RefCell::new(Node { data: 2, Some(first.clone() }));
19 first.borrow_mut().next = Some(seond.clone()); //cannot mutably borrow immutable field
20 second.borrow_mut().next = Some(first.clone());
21 }
出现了一个循环引用,first和second节点互相指向对方,但是编译运行之后没有看到任何输出。这说明析构函数并没有执行,这里存在内存泄漏。
内存安全的含义
内存泄漏 memoty leak并不再内存安全概念范围内。
只要不会出现以下内存问题即内存安全:
- 使用未定义内存
- 空指针
- 悬垂指针
- 缓冲区溢出
- 非法释放未分配的指针或已经释放过的指针
Rust中的变量必须初始化以后才可以使用,否则无法通过编译器检查。所以Rust不会允许开发式使用未定义内存
空指针就是指java中的null, C++中的nullptr, 在Rust中,开发者没有任何办法创建一个空指针,因为Rust不支持将整数转换为指针,也不支持初始化变量。
Rust中使用option类型来代替指针,Option实际是枚举体,包含两个值,Some(T)和None, 分别表示两种情况,有和无,迫使开发者必须对这两种情况都做处理,以保证内存安全。
悬垂指针 dangling pointer,是指堆内存已经被释放,但其本身还没做任何处理,依旧指向已回收地址的指针。如果悬垂指针被程序使用,则会出现无法预期的后果。
1 fn foo<'a>() -> &'a str {
2 let a = "hello".to_stirng();
3 &a // 局部变量a在离开foo函数之后会被销毁
4 // ‘a' does not live long enough
5 }
6 fn main() {
7 let x = foo();
8 }
缓冲区是指一块连续的内存区域,可保存相同类型的多个实例,缓冲区可以是堆内存,栈内存。 Rust编译器在编译期就能检查除数组越界问题,从而完美地避免缓冲区溢出
Rust不会出现未分配的指针,所以不存在非法释放的情况,Rust的所有权机制严格地保证了析构函数只会调用一次,所以不会出现非法释放已经释放的情况
内存泄漏的原因
在Rust中可以导致内存泄漏的情况
- 线程崩溃,析构函数函数无法执行
- 使用引用计数时造成循环引用
- 调用Rust标准库中的forget函数主动泄漏
对于线程崩溃没有什么好的办法阻止
析构函数会做很多事,除了释放内存,还可以释放其他资源,如果析构函数不能执行,不仅仅会导致内存泄漏,从更广的角度来看,还会导致其他资源泄漏。
内存泄漏是指没有对应该释放的内存进行释放,属于没有对合法的数据进行操作。
内存不安全操作是对不合法的数据进行了操作,两者性质不同,造成的后果也不同。
主动泄漏,通过FFI与外部函数打交道,把值交由C代码去处理,在Rust这边要使用forget来主动泄漏,防止rust调用析构函数引起问题。
复合类型的内存分配和布局
1 use std::mem;
2 struct A {
3 a: u32, //基本数字类型
4 b: Box,
5 }
6 struct B(i32, f64, char);
7 struct N; // 0
8 enum E {
9 H(u32),
10 M(Box),
11 }
12 union U {
13 u: u32,
14 v: u64,
15 }
16
17 println!("Box : {:?} ", std::mem::size_of::>());
18 println!("A: {:?}", std::mem::size_of::());
19 println!("B: {:?}", std::mem::size_of::());
20 println!("N: {:?}", std::mem::size_of::());
21 println!("E: {:?}", std::mem::size_of::());
22 println!("U: {:?}", std::mem::size_of::());
当结构体A在函数中有实例被初始化时,该结构体会被放到栈中,首地址为第一个成员变量a的地址,长度为16个字节,其中成员b是Box
枚举体实际上是一种标签联合体,和普通联合体的共同点在于,其成员变量也公用一块内存,所以联合体也称为共用体。不同点在于,标签联合体中每个成员都有一个标签,用于显式地表明同一时刻哪一个成员在使用内存,而且标签也需要占用内存,操作枚举体的时候,需要匹配处理其所有成员,这也是称为枚举体的原因。
枚举体和联合体在函数中有实例被初始化,与结构体一样,也会被分配到栈中,占相应的字节长度,如果成员的值存放与堆上,那么栈中就存放其指针。