MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Rust内存分配的基本知识

2022-04-107.8k 阅读

Rust 内存分配基础概念

在 Rust 中,理解内存分配是掌握这门语言高效编程的关键。Rust 的内存管理系统旨在提供内存安全和高性能,这得益于它独特的所有权系统和借用规则。

栈(Stack)与堆(Heap)

在 Rust 以及大多数编程语言中,内存主要分为栈和堆两部分。

  • :栈是一种后进先出(LIFO,Last In First Out)的数据结构。在栈上分配内存非常快,因为它只需要简单地移动栈指针。栈上的数据大小在编译时必须是已知的。例如,基本数据类型(如 i32bool)和固定大小的结构体,如果其所有字段大小在编译时已知,都会被分配到栈上。
fn stack_example() {
    let num: i32 = 42;
    let boolean: bool = true;
    let fixed_struct = (1, "hello");
}

在上述代码中,numbooleanfixed_struct 都会被分配到栈上。

  • :堆用于存储大小在编译时未知的数据。当在堆上分配内存时,程序需要在堆空间中找到一块足够大的空闲区域来存储数据,这涉及到更复杂的内存管理操作,所以堆分配相对较慢。动态大小的数据结构,如 Vec<T>(动态数组)、String(字符串)等,其数据存储在堆上。
fn heap_example() {
    let mut v: Vec<i32> = Vec::new();
    v.push(1);
    v.push(2);
    v.push(3);

    let s: String = String::from("hello");
}

在这段代码中,vs 的实际数据存储在堆上,而栈上只存储指向堆数据的指针等少量元数据。

Rust 的所有权系统

所有权系统是 Rust 内存管理的核心,它通过确保每个值都有一个唯一的所有者来保证内存安全。

所有权规则

  • 每个值在 Rust 中都有一个变量作为其所有者:例如 let s = String::from("hello");,这里 s 是字符串 hello 的所有者。
  • 同一时间只能有一个所有者:这意味着不能同时有两个变量拥有同一个值的所有权。
let s1 = String::from("hello");
// 下面这行代码会报错,因为 s1 已经是所有者,不能再让 s2 拥有相同字符串的所有权
// let s2 = s1;
  • 当所有者离开作用域时,值将被丢弃
{
    let s = String::from("hello");
} // s 离开作用域,字符串 "hello" 占用的堆内存被释放

所有权转移(Move)

当一个拥有堆数据的变量被赋值给另一个变量时,发生所有权转移(Move)。

let s1 = String::from("hello");
let s2 = s1; // s1 的所有权转移给 s2,此时 s1 不再有效

// 下面这行代码会报错,因为 s1 已经无效
// println!("{}", s1);

在这个例子中,s1 对字符串 hello 的所有权被转移给了 s2s1 不再能访问该字符串,试图访问会导致编译错误。

克隆(Clone)

对于一些类型,如果你想创建一个数据的副本而不是转移所有权,可以使用 clone 方法。不过,并非所有类型都实现了 Clone 特征。

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1: {}, s2: {}", s1, s2);

这里 s2s1 的一个完全独立的副本,两个字符串都有自己独立的堆内存,s1s2 都可以独立使用。

借用(Borrowing)

虽然所有权系统能有效管理内存,但有时我们需要在不转移所有权的情况下访问数据。这就引入了借用的概念。

借用规则

  • 在同一时间,你可以有任意数量的不可变借用(Immutable Borrow):不可变借用允许你读取数据,但不能修改它。
fn read_string(s: &String) {
    println!("The string is: {}", s);
}

fn main() {
    let s = String::from("hello");
    read_string(&s);
    println!("s is still valid: {}", s);
}

read_string 函数中,s 是一个不可变借用,函数可以读取字符串内容,但不能修改它。

  • 在同一时间,只能有一个可变借用(Mutable Borrow):可变借用允许你修改数据,但为了避免数据竞争,同一时间只能有一个可变借用。
fn change_string(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut s = String::from("hello");
    change_string(&mut s);
    println!("s has been changed: {}", s);
}

change_string 函数中,s 是一个可变借用,函数可以修改字符串内容。但如果在同一个作用域内同时存在可变借用和不可变借用,会导致编译错误。

let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &mut s; // 这行代码会报错,因为已经有了不可变借用 r1

生命周期(Lifetime)

生命周期是借用的一个重要概念,它描述了引用(借用)在程序中有效的时间段。Rust 编译器使用生命周期来确保所有引用都是有效的。

  • 显式生命周期标注:当函数参数中有多个引用时,有时需要显式标注生命周期。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这里的 <'a> 是生命周期参数,它表明 xy 和返回值都有相同的生命周期 'a

  • 生命周期省略规则:在很多情况下,Rust 编译器可以根据一些规则自动推断生命周期,不需要我们显式标注。例如,函数只有一个输入引用参数,并且返回值是对输入参数的引用,编译器可以自动推断生命周期。
fn first_char(s: &str) -> &char {
    &s.chars().next().unwrap()
}

智能指针(Smart Pointers)

智能指针是一种数据结构,它在堆上分配内存,并在指针超出作用域时自动释放内存。

Box

Box<T> 是最简单的智能指针,它将数据分配到堆上,并在 Box 离开作用域时释放堆内存。

let b = Box::new(5);
println!("The value in the box is: {}", b);

这里 b 是一个 Box<i32>,它将整数 5 分配到堆上。当 b 离开作用域时,5 占用的堆内存会被释放。

Rc(引用计数)

Rc<T> 用于在堆上分配数据,并允许多个所有者共享所有权。它通过引用计数来跟踪有多少个变量引用了堆上的数据,当引用计数降为 0 时,数据被释放。

use std::rc::Rc;

let s1 = Rc::new(String::from("hello"));
let s2 = s1.clone();
let s3 = s1.clone();

println!("s1 ref count: {}", Rc::strong_count(&s1));
println!("s2 ref count: {}", Rc::strong_count(&s2));
println!("s3 ref count: {}", Rc::strong_count(&s3));

在这个例子中,s1s2s3 都共享同一个字符串的所有权,通过 Rc::strong_count 可以查看引用计数。

RefCell

RefCell<T> 提供了内部可变性(Interior Mutability),允许在不可变引用的情况下修改数据。它在运行时检查借用规则,与编译时检查借用规则的普通引用不同。

use std::cell::RefCell;

let cell = RefCell::new(5);
let num = cell.borrow();
println!("The value in the cell is: {}", num);

let mut num_mut = cell.borrow_mut();
*num_mut = 10;
println!("The value in the cell has been changed to: {}", num_mut);

这里通过 borrow 获取不可变引用,通过 borrow_mut 获取可变引用。需要注意的是,RefCell<T> 只能在单线程环境中安全使用。

自定义内存分配器

在 Rust 中,你还可以自定义内存分配器,以满足特定的性能或内存管理需求。

实现 GlobalAlloc 特征

要自定义内存分配器,需要实现 std::alloc::GlobalAlloc 特征。

#![feature(allocator_api)]
use std::alloc::{GlobalAlloc, Layout};

struct MyAllocator;

unsafe impl GlobalAlloc for MyAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // 简单的内存分配实现,这里可以替换为实际的分配逻辑
        std::alloc::alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // 简单的内存释放实现,这里可以替换为实际的释放逻辑
        std::alloc::dealloc(ptr, layout)
    }
}

使用自定义分配器

定义好自定义分配器后,可以通过 #[global_allocator] 属性来指定使用它。

#![feature(allocator_api)]
use std::alloc::{GlobalAlloc, Layout};

struct MyAllocator;

unsafe impl GlobalAlloc for MyAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        std::alloc::alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        std::alloc::dealloc(ptr, layout)
    }
}

#[global_allocator]
static ALLOC: MyAllocator = MyAllocator;

fn main() {
    let v: Vec<i32, _> = Vec::with_capacity_in(10, &ALLOC);
    v.push(1);
    v.push(2);
    v.push(3);
    println!("{:?}", v);
}

在这个例子中,Vec 使用了自定义的分配器 MyAllocator 来分配内存。

内存分配优化

在实际编程中,合理的内存分配策略可以显著提高程序的性能。

减少不必要的分配

尽量避免在循环中进行频繁的内存分配。例如,预先分配足够大小的 Vec 而不是不断地 push 导致动态扩容。

// 不好的做法
let mut v = Vec::new();
for i in 0..1000 {
    v.push(i);
}

// 好的做法
let mut v = Vec::with_capacity(1000);
for i in 0..1000 {
    v.push(i);
}

选择合适的数据结构

根据实际需求选择合适的数据结构。例如,如果需要快速的插入和删除操作,LinkedList 可能比 Vec 更合适;如果需要高效的随机访问,Vec 则更优。

// 使用 Vec 进行随机访问
let v: Vec<i32> = (0..1000).collect();
let value = v[500];

// 使用 LinkedList 进行插入和删除
use std::collections::LinkedList;
let mut list = LinkedList::new();
list.push_back(1);
list.push_back(2);
list.push_front(0);
list.remove(&2);

内存对齐

内存对齐对于提高内存访问效率很重要。Rust 会自动处理大多数情况下的内存对齐,但在某些特定场景下,如自定义数据结构,可以通过 repr(align = N) 来指定对齐方式。

#[repr(align(16))]
struct MyStruct {
    data: [u8; 16],
}

在这个例子中,MyStruct 结构体将以 16 字节对齐,这可以提高内存访问效率,尤其是在处理 SIMD 指令等场景下。

通过深入理解 Rust 的内存分配机制,包括栈与堆的概念、所有权系统、借用、智能指针、自定义分配器以及内存分配优化等方面,开发者可以编写出高效、安全的 Rust 程序,充分发挥这门语言在内存管理方面的优势。