12. 内存管理
12.1 简介
内存是一种用于存储数据的电子设备,程序运行过程中的数据都存储于其中,其容量有限且成本较高。为了更高效、合理地利用内存资源,Rust 会根据变量的存活时长、大小以及是否可变对其进行分类管理,将不同类型的数据分配到不同的内存区域,并采用不同的策略进行管理。
与变量相关的内存区域主要包括:只读数据区、可写数据区、BSS 区、栈(Stack)和堆(Heap)。其中,栈和堆属于动态存储区,在程序运行过程中根据实际需求进行内存分配和释放;而只读数据区、可写数据区和 BSS 区属于静态存储区,存放于静态存储区的数据,在编译阶段会被写入可执行文件,并在程序启动时由操作系统加载到内存中。下图以 Linux x86_64 系统下的进程内存布局为例:
Rust 编程语言中的核心概念:变量所有权和生命周期,与内存管理机制??
Rust 编程语言中的核心概念——变量所有权和生命周期,与内存管理机制密切相关。所有权决定了数据由谁负责管理,生命周期则决定了数据在程序中的有效范围。Rust 通过所有权、借用和生命周期等机制,对数据的使用和内存资源的释放进行约束,从而在编译阶段发现潜在的内存安全问题,并在不依赖垃圾回收机制的情况下实现自动内存管理。
12.2 只读数据区
只读数据区用于存放程序运行期间不可修改的数据,该区域的数据内容及所需存储空间大小在编译阶段确定,编译器会将其写入可执行文件中。程序启动时,操作系统会将该区域加载到内存中,并在整个程序运行期间都不会释放回收,直到进程结束后由操作系统统一释放,因此,该区域的数据无需在程序运行过程中进行动态内存分配和释放,避免了相应的管理开销。只读数据区的大小会影响可执行文件体积以及运行时的内存占用,对于体积或内存敏感的应用(如嵌入式系统、移动端应用等),需要关注该区域的大小。Rust 中的不可变静态变量、非简单类型常量和字符串字面量均存放在此区域。
12.2.1 不可变静态变量
不可变静态变量使用 static 关键字定义,在定义时必须初始化,其值在程序运行过程中不可被修改,编译器会将其存放在只读数据区。下述示例代码中,分别定义了一个不可变的全局静态变量和一个不可变的局部静态变量,大小分别为 100MB 和 200MB。
// 不可变全局静态变量,大小 100MB
static GLOBAL_DATA: [u8; 1024 * 1024 * 100] = [0x00; 1024 * 1024 * 100];
fn main() {
// 不可变局部静态变量,大小 200MB
static LOCAL_DATA: [u8; 1024 * 1024 * 200] = [0x00; 1024 * 1024 * 200];
// 引用一下,避免被编译器当成未使用的变量优化掉了
let _global_data = &GLOBAL_DATA;
let _local_data = &LOCAL_DATA;
// 无限循环,用于保持程序运行以便进行内存分析
loop {
}
}
使用 cargo build 命令编译后,可以看到生成的可执行文件大小为 304MB,其中有 300MB 是不可变静态变量。
# 编译
shell> cargo build
......
# 查看可执行文件大小,大小为 304MB(Windows 系统需要在 git-bash 下执行)
shell> ls -lh target/debug/app
-rwxrwxr-x 2 root root 304M target/debug/app
编译生成的可执行文件通常遵循特定的文件格式规范,其中包含多个区域(通常被称为段),用于存放不同类型的数据。程序加载时,操作系统会将可执行文件的段按需映射到进程地址空间的对应内存区域,供程序运行时访问。不同操作系统和平台采用不同的可执行文件格式:Windows 系统使用 PE(Portable Executable)格式,Linux 系统使用 ELF(Executable and Linkable Format)格式,而 macOS 系统则使用 Mach-O(Mach Object)格式。以 ELF 可执行文件格式为例:用于存放只读数据的段称为 .rodata,用于存放已初始化可写数据的段称为 .data,用于存放未初始化数据的段称为 .bss。
使用 objdump 命令可以查看可执行文件各个段的详细信息,从执行结果中可以看到,编译生成的可执行文件中,只读数据段大小约为 0x12c052b8(300MB 多一点),其中 Rust 运行时本身也会占用一小部分空间。
#![allow(unused)]
fn main() {
查看可执行文件各个段的详细信息,Linux 系统下执行结果如下
shell> cargo-objdump -- --section-headers
app: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .interp 0000001c 00000000000002e0 DATA
......
13 .rodata 12c052b8 0000000000007700 DATA # 只读数据段,有些系统可能叫 .rdata
30 .data 000009c0 0000000012c549e0 DATA
31 .bss 000000ca 0000000012c553a0 BSS
......
}
当程序加载时,操作系统会根据可执行文件的格式信息,将可执行文件中只读数据段 .rodata 的内容映射到进程内存空间的只读数据区,直至进程结束由操作系统回收释放。在 Linux 系统下,可以使用 pmap 命令查看进程各内存区域的大小。
# 在后面运行进程
shell> target/debug/app &
[1] 758952 # 进程号 758952
# 查看指定进程的内存布局
shell> pmap -p 758952
758952: target/debug/app
0000564c4ed96000 307276K r---- app # 只读数据区所在内存地址和大小,大小 300.074 MB
0000564c619a9000 248K r-x-- app
0000564c619e7000 12K r---- app
0000564c619ea000 8K rw--- app
0000564c926ce000 132K rw--- [ anon ]
00007faee8d8c000 12K rw--- [ anon ]
......
00007ffd619ff000 136K rw--- [ stack ]
00007ffd61b9f000 16K r---- [ anon ]
00007ffd61ba3000 8K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]
total 310300K
12.2.2 常量
基本类型的常量,在编译时通常会被内联到使用位置,直接替换为对应的数值,编译后相应的常量就不存在了。而对于非基本类型的常量(如数组等体积较大的数据),由于内联会导致代码体积膨胀,编译器通常会将其存放在只读数据区中,而不是进行内联。下述示例代码中,分别定义了一个大小为 100MB 的全局常量,以及一个大小为 200MB 的局部常量。
// 全局常量,100MB 内存
const GLOBAL_DATA: [u8; 1024 * 1024 * 100] = [0x00; 1024 * 1024 * 100];
fn main() {
// 局部常量,大小 200MB
const LOCAL_DATA: [u8; 1024 * 1024 * 200] = [0x00; 1024 * 1024 * 200];
// 引用一下,避免被编译器当成未使用的变量优化掉了
let _global_data = &GLOBAL_DATA;
let _local_data = &LOCAL_DATA;
// 无限循环,用于保持程序运行以便进行内存分析
loop {
}
}
使用 cargo build 命令编译后,生成的可执行文件大小约为 304 MB。进一步通过 objdump 查看各段信息可知,只读数据段 .rodata 的大小为 0x12c052b8(300 MB 以上),其中包含 Rust 运行时占用的一小部分空间。
# 编译
shell> cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
# 查看可执行文件大小,大小为 304MB(Windows 系统需要在 git-bash 下执行)
shell> ls -lh target/debug/app
-rwxrwxr-x 2 root root 304M target/debug/app
# 查看可执行文件各个段的详细信息,以 Linux 系统为例
shell> cargo-objdump -- --section-headers
app: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .interp 0000001c 00000000000002e0 DATA
......
13 .rodata 12c052b8 0000000000007700 DATA # 只读数据段,大小 300MB 多一点
14 .eh_frame_hdr 00001004 0000000012c0c9b8 DATA
15 .eh_frame 00004f38 0000000012c0d9c0 DATA
16 .text 0003ca13 0000000012c13900 TEXT
17 .init 0000001b 0000000012c50314 TEXT
18 .fini 0000000d 0000000012c50330 TEXT
19 .plt 00000050 0000000012c50340 TEXT
20 .tdata 00000020 0000000012c51390 DATA
21 .tbss 00000030 0000000012c513b0 BSS
22 .fini_array 00000008 0000000012c513b0 DATA
23 .init_array 00000010 0000000012c513b8 DATA
......
12.2.3 静态字符串
静态字符串(也称字符串字面量)是指直接在代码中定义的字符串常量,例如 let text = "Hello"; 中的 "Hello",其内容存放在只读数据区。需要注意的是,String 类型的字符串并不会存放在只读数据区,而是在运行时分配于堆内存中。下述示例代码中,定义了一个大小为 100MB 的静态字符串,以及一个大小为 200MB 的动态字符串。
fn main() {
// 定义一个静态字符串,大小 100MB,使用宏导入一个 100MB 文件实现
let large_str: &str = include_str!("../100mb.txt"); // 等同于 "...100MB..."
// 定义一个字符串, 大小 200MB
let large_string : String = "a".repeat(200 * 1024 * 1024);
// 引用一下,避免被编译器当成未使用的变量优化掉了
let _large_str = &large_str;
let _large_string = &large_string;
// 无限循环,用于保持程序运行以便进行内存分析
loop {
}
}
使用命令生成一个 100MB 的文件并进行编译后,可以看到生成的可执行文件大小约为 105MB,其中只读数据段 .rodata 的大小为 0x06405b10(100MB 多一点),String 类型的 200MB 数据并不会存放在只读数据区中。
# 生成一个 100MB 的文本文件,用于生成一个 100MB 的静态字符串
# 注:windows 系统下使用 `fsutil file createnew 100mb.txt 104857600` 命令生成
shell> head -c 100M < /dev/zero | tr '\0' 'a' > 100mb.txt
# 编译
shell> cargo build
Compiling app v0.1.0 (/file/rust/project/app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.32
# 查看生成的可执行文件大小为 105MB
shell> ls -lh target/debug/app
-rwxrwxr-x 2 root root 105M target/debug/app
# 查看可执行文件各个段的详细信息,以 Linux 系统为例
shell> cargo-objdump -- --section-headers
app: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .interp 0000001c 00000000000002e0 DATA
......
12 .rodata 06405b10 0000000000007870 DATA # 只读数据段,大小 100MB 多一点
13 .debug_gdb_scripts 00000022 000000000640d380 DATA
14 .eh_frame_hdr 0000108c 000000000640d3a4 DATA
15 .eh_frame 00005124 000000000640e430 DATA
16 .text 0003d7e3 0000000006414560 TEXT
17 .init 0000001b 0000000006451d44 TEXT
18 .fini 0000000d 0000000006451d60 TEXT
19 .plt 00000060 0000000006451d70 TEXT
20 .tdata 00000020 0000000006452dd0 DATA
21 .tbss 00000030 0000000006452df0 BSS
22 .fini_array 00000008 0000000006452df0 DATA
23 .init_array 00000010 0000000006452df8 DATA
......
12.3 可写数据区
可写数据区用于存放程序运行期间可以修改的数据,该区域的数据内容和所需空间大小在编译期确定,编译器会将数据内容直接嵌入到可执行文件中。程序启动时,操作系统会将该区域加载到内存中,且在整个程序运行期间也不会释放回收,直到进程结束操作系统才会释放回收,因此,该区域的数据无需在程序运行过程中进行动态内存分配和释放,避免了相应的管理开销。可写数据区的大小会影响可执行文件体积以及运行时的内存占用,对于体积或内存敏感的应用(如嵌入式系统、移动端应用等),需要关注该区域的大小。
Rust 中的初始化为非零值的可变静态变量存放在该区域,而初始化为值零的可变静态变量则存放在 BSS 区,以避免可执行文件体积膨胀。下述示例代码中,定义了一个可变的全局静态变量和一个可变的局部静态变量,大小分别为 100MB 和 200MB。
// 可变全局静态变量,数组大小为 100MB,值初始化为 2;
// 如果值为默认值 0,则会被放入 BSS 区
static mut GLOBAL_DATA: [u8; 1024 * 1024 * 100] = [1; 1024 * 1024 * 100];
fn main() {
// 可变局部静态变量,大小 200MB,值初始化为 2
static mut LOCAL_DATA: [u8; 1024 * 1024 * 200] = [2; 1024 * 1024 * 200];
// 引用变量,避免被编译器优化掉
let _global_data = &raw const GLOBAL_DATA;
let _local_data = &raw const LOCAL_DATA;
// 无限循环,保持程序运行,以便进行内存分析
loop {
}
}
使用 cargo build 命令编译后,可以看到生成的可执行文件大小约为 305MB。进一步通过 objdump 查看可执行文件的各个段信息,可以发现,可写数据段 .data 的大小为 0x12c009c0(300 MB 以上),其中包含 Rust 运行时占用的一小部分空间。
# 编译
shell> cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
# 查看可执行文件大小,大小为 304MB(Windows 系统需要在 git-bash 下执行)
shell> ls -lh target/debug/app
-rwxrwxr-x 2 root root 305M target/debug/app
# 查看可执行文件各个段的详细信息,以 Linux 系统为例
shell> cargo-objdump -- --section-headers
app: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .interp 0000001c 00000000000002e0 DATA
......
13 .rodata 000052b8 0000000000007700 DATA
14 .eh_frame_hdr 00001004 000000000000c9b8 DATA
15 .eh_frame 00004f38 000000000000d9c0 DATA
16 .text 0003ca13 0000000000013900 TEXT
17 .init 0000001b 0000000000050314 TEXT
18 .fini 0000000d 0000000000050330 TEXT
19 .plt 00000050 0000000000050340 TEXT
20 .tdata 00000020 0000000000051390 DATA
21 .tbss 00000030 00000000000513b0 BSS
22 .fini_array 00000008 00000000000513b0 DATA
23 .init_array 00000010 00000000000513b8 DATA
......
30 .data 12c009c0 00000000000549e0 DATA # 可写数据段 300MB 多一点
31 .bss 000000ca 0000000012c553a0 BSS
......
当程序加载时,操作系统会根据可执行文件的格式信息,将可执行文件中可写数据段 .data 的内容映射到进程内存空间的可写数据区,直至进程结束才由操作系统回收释放。在 Linux 系统下,可以使用 pmap 命令查看程序运行过程中进程可写数据区的大小。
# 运行示例程序
shell> target/debug/app &
[1] 769931
# 分析进程内存空间
shell> pmap -p 769931
pmap -p 769931
769931: target/debug/app
000055ab0baac000 76K r---- app
000055ab0babf000 248K r-x-- app
000055ab0bafd000 12K r---- app
000055ab0bb00000 307208K rw--- app # 可写数据区,大小 300MB 多一点
......
total 310300K
12.4 BSS 区
BSS 区(Block Started by Symbol)用于存放初始化为零值且在程序运行期间可以修改的数据。与可写数据区不同,BSS 区中的数据在编译阶段只记录大小信息,不会将具体内容写入可执行文件,因此,其大小不会影响可执行文件大小。当程序加载运行时,操作系统会为 BSS 段大小分配相应的内存空间,并将其统一初始化为零值。这些内存同样在程序整个运行期间都不会释放回收,直到进程结束后操作系统才会回收释放。
// 可变全局静态变量,大小 100MB,初始化为默认值 0
static mut GLOBAL_DATA: [u8; 1024 * 1024 * 100] = [0; 1024 * 1024 * 100];
fn main() {
// 可变局部静态变量,大小 200MB,初始化为默认值 0
static mut LOCAL_DATA: [u8; 1024 * 1024 * 200] = [0x00; 1024 * 1024 * 200];
// 引用变量,避免被编译器优化掉
let _global_data = &raw const GLOBAL_DATA;
let _local_data = &raw const LOCAL_DATA;
// 无限循环,保持程序运行,以便进行内存分析
loop {
}
}
使用 cargo build 命令编译后,生成的可执行文件大小只有约 4.2MB。但通过 objdump 查看可执行文件各段信息时,可以发现 .bss 段的大小为 0x12c000ca(300 MB 以上),其中包含 Rust 运行时占用的一小部分空间。
# 编译
shell> cargo build
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
# 查看可执行文件大小,大小为 304MB(Windows 系统需要在 git-bash 下执行)
shell> ls -lh target/debug/app
-rwxrwxr-x 2 root root 4.2M target/debug/app
# 查看可执行文件各个段的详细信息,以 Linux 系统为例
shell> cargo-objdump -- --section-headers
app: file format elf64-x86-64
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .interp 0000001c 00000000000002e0 DATA
......
13 .rodata 000052b8 0000000000007700 DATA
14 .eh_frame_hdr 00001004 000000000000c9b8 DATA
15 .eh_frame 00004f38 000000000000d9c0 DATA
16 .text 0003ca13 0000000000013900 TEXT
17 .init 0000001b 0000000000050314 TEXT
18 .fini 0000000d 0000000000050330 TEXT
19 .plt 00000050 0000000000050340 TEXT
20 .tdata 00000020 0000000000051390 DATA
21 .tbss 00000030 00000000000513b0 BSS
22 .fini_array 00000008 00000000000513b0 DATA
23 .init_array 00000010 00000000000513b8 DATA
24 .data.rel.ro 00001bb0 00000000000513c8 DATA
25 .dynamic 000001e0 0000000000052f78 DATA
26 .got 00000850 0000000000053158 DATA
27 .got.plt 00000038 00000000000539a8 DATA
28 .relro_padding 00000620 00000000000539e0 BSS
29 .tm_clone_table 00000000 00000000000549e0 DATA
30 .data 000009c0 00000000000549e0 DATA
31 .bss 12c000ca 00000000000553a0 BSS # BSS 段,大小 300 MB 以上
在 Linux 系统下,使用 pmap 命令查看进程各个内存区域的占用情况,可以发现 BSS 区域的大小约为 300MB 多一点。
# 在 Linux 系统中,使用 pmap 命令,分析运行时进程内存空间
shell> target/debug/app &
[1] 770310
shell> pmap -p 770310
770310: target/debug/app
0000562f347cb000 76K r---- app
0000562f347de000 248K r-x-- app
0000562f3481c000 12K r---- app
0000562f3481f000 8K rw--- app
0000562f34821000 307200K rw--- [ anon ] # BSS 段,大小 300MB 多一点
......
total 310300K
12.5 栈(Stack)
当函数执行时,通常会为其分配一块连续的内存区域,用于存放局部变量、函数参数、返回地址以及其他调用相关信息,该区域被称为栈帧(Stack Frame)。当函数执行结束后,对应的栈帧会被立即释放,其中存储的数据也随之失效,仅在函数调用期间有效。下述示例代码中,尝试返回一个指向函数局部变量的引用,但该变量会随着函数执行结束、对应栈帧释放而失效,导致引用指向无效内存。为了保证内存安全,避免产生悬垂引用(Dangling Reference),Rust 编译器会在编译阶段检测到这一问题,并拒绝这种不安全的操作。
fn add(a: i32, b: i32) -> &i32 {
let sum = a + b;
// 局部变量在函数执行结束后随栈帧释放,该引用将指向无效内存(即“悬垂引用”)
return ∑ // 编译报错
}
fn main() {
let sum = add(5, 6);
println!("{sum}");
}
shell> cargo run
fn add(a: i32, b: i32) -> &i32 {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
......
所有函数的栈帧都存储在栈(Stack)内存区域中,栈帧的创建与释放通过调整栈区域大小实现:每次函数调用时,增加栈区域的大小创建栈帧,函数执行完毕返回时,减少栈区域大小释放对应的栈帧,整个过程仅需简单的加减运算即可完成,因此栈内存的分配和释放效率极高。由于函数必须等待被其调用的其他函数执行完毕并返回后,才能释放自身的栈帧,所以先存储在栈中的栈帧会后被释放,即先进后出(First In, Last Out, FILO)。
Rust 的堆栈回溯(Backtrace)机制基于栈帧实现——通过回溯当前线程栈中的所有栈帧,记录并呈现完整的函数调用链,帮助开发者精准定位错误源头。我们可以借助堆栈回溯,查看当前线程栈中的所有栈帧。
use std::backtrace::Backtrace;
fn div(a: i32, b: i32)->Result<i32, &'static str> {
if b == 0 {
// 除数为 0 时,捕获调用堆栈,用于查看函数调用路径
let backtrace = Backtrace::capture();
// frames() 函数可以获取所有栈帧,不过 API 还没有稳定(Rust v1.94)
// let frames = backtrace.frames();
eprintln!("{:?}", backtrace);
return Err("除数不能为 0");
}
return Ok(a / b);
}
fn main() {
let _ = div(6, 0);
}
# 启用堆栈回溯并运行
shell> RUST_BACKTRACE=1 cargo run
Backtrace [
{ fn: "app::div", file: "./src/main.rs", line: 7 },
{ fn: "app::main", file: "./src/main.rs", line: 21 },
......
{ fn: "main" }, # Rust 标准库中的 main 函数,会调用 Rust 程序的 main 函数
{ fn: "__libc_start_main", file: "glibc-2.31/csu/../csu/libc-start.c", line: 308 },
{ fn: "_start" }
]
栈区域的容量是有限的,其大小受到操作系统的限制,Linux 系统栈区域大小默认为 8MB。如果栈帧数量过多或栈帧过大,超出这一限制可能导致栈溢出异常。例如,在函数中声明过大的局部变量,或函数的嵌套层次过深(尤其是在递归调用的情况下),都可能引发栈溢出问题。
fn sum_recursive(n: u32) -> u32 {
if n == 0 {
return 0; // 计算完毕
}
// 递归调用自身,每次调用都会创建一个新的栈帧,放在栈区域
return n + sum_recursive(n - 1);
}
fn large_stack_allocation() -> usize {
// 定义一个大小为 7MB 局部变量,该变量存放在栈区域
let large_array = [0_u8; 7 * 1024 * 1024];
return large_array.len();
}
fn main() {
// 如果栈帧数量过多,超出栈内存区域,会导致栈溢出异常
let sum = sum_recursive(90000);
println!("sum_recursive={sum}");
// 如果栈帧过大,超出栈内存区域,会导致栈溢出异常
let len = large_stack_allocation();
println!("large_stack_allocation={len}");
}
# 查看栈大小(Linux x64系统),Linux/MacOS 默认栈大小为 8MB
shell> ulimit -s
8192 # 单位是 KB
# 栈大小 8MB 运行代码,可以正常执行
shell> cargo run
sum_recursive=4050045000
large_stack_allocation=7340032
# 修改栈大小为 2MB,运行出现栈溢出异常
shell> ulimit -s 2048
shell> cargo run
thread 'main' (873385) has overflowed its stack
fatal runtime error: stack overflow, aborting
Aborted (core dumped)
12.6 堆(Heap)
只读数据区、可写数据区和 BSS 区的数据,在程序运行期间无法释放或回收,其大小必须在编译阶段确定,无法根据运行时需求动态调整(例如用户输入),而栈区域中的函数栈帧数据仅在函数执行期间有效,且无法跨函数共享,栈帧的大小也是在编译时确定,同样无法动态调整大小。堆是一块动态大小的内存区域,程序可以在运行时根据实际需求申请和释放任意大小的内存空间,其容量通常只受系统可用内存限制。
在 C/C++ 等语言中,堆内存的分配与释放通常由程序员手动管理,需要显式调用 malloc/free 或 new/delete 等函数来控制内存生命周期。一旦操作不当,就可能引发野指针、悬垂指针、内存泄漏、双重释放等问题(见下方表格)。为了避免内存安全隐患并降低手动管理的复杂度,Python、JavaScript、Java、Go、C# 等语言引入了垃圾回收(GC, Garbage Collection)机制,由运行时自动追踪和回收不再使用的内存,但该机制会带来一定的性能开销,以及不可预测的垃圾回收 GC 停顿。
| 常见内存安全问题 | 说明 |
|---|---|
| 野指针 | 未初始化或指向非法地址的指针。 |
| 悬垂指针 | 指向已被释放的内存的指针。 |
| 内存泄漏 | 已分配的内存未及时释放。 |
| 双重释放 | 对同一块内存释放两次。 |
| 缓冲区溢出 | 写入的数据超出分配的内存边界。 |
Rust 语言采用了不同于传统语言的堆内存管理设计思路:通过所有权(Ownership)和生命周期(Lifetime)机制在编译阶段对内存使用进行严格检查,在无需垃圾回收器(Garbage Collector,GC)的情况下实现内存安全,从而兼顾程序性能与安全性。String、Vec、HashMap、HashSet 和 LinkedList 等需要动态扩展容量的数据结构,其实际存储的数据位于堆内存中,而栈上仅保存指向堆内存的指针,以及长度、容量等描述信息。
12.6.1 Box 指针类型
Box 指针类型用于在堆上分配内存存储数据,通过调用 Box::new() 分配内存,成功后将堆内存地址封装在 Box 结构体中返回。Box 的大小与 usize 相同,在 32 位平台上为 4 字节,在 64 位平台上为 8 字节。Box 实现了 Deref trait,访问时会自动解引用为堆中存储的数据,同时实现了 DerefMut trait,支持对数据的可变访问。
fn main() {
// 将数据 100 存放在堆上,成功后返回的 Box 结构体存放在栈上的普通局部变量中
let mut number: Box<i32> = Box::new(100);
// 查看数据内存地址
println!("存放数据的堆内存地址:{:p}", number);
println!("存放Box的栈内存地址:{:p}", &number);
// Box 类型大小取决于系统架构:在 64 位系统下为 8 字节,在 32 位系统下为 4 字节
// 由于存放的是 堆内存地址,无论数据大小,Box 大小是固定的
println!("Box<i32> 指针大小:{} bytes", size_of::<Box<i32>>());
println!("Box<i64> 指针大小:{} bytes", size_of::<Box<i64>>());
// Box 实现了 Deref trait,访问时会自动解引用 ,返回堆内存中存储的数据
println!("Deref trait:{}", number);
// Box 实现了 DerefMut trait,解引用修改存储在堆上的数据
*number = 200;
println!("DerefMut trait:{}", number);
}
shell> cargo run
数据在堆区域的内存地址:0x55b173364b90
Box<i32> 指针大小:8 bytes
Box<i64> 指针大小:8 bytes
Deref trait:100
DerefMut trait:20
另外,Box 类型还实现了资源释放 Drop trait,用于释放其持有的堆内存资源。当 Box 变量离开作用域时,Rust 会自动调用其 drop() 方法,释放对应的堆内存;如果需要提前释放资源,也可以通过 std::mem::drop() 函数主动触发清理过程。此外,可以使用 Box::leak() 函数绕过释放机制,使其在整个程序运行期间都不释放。
use std::ops::Deref;
#[derive(Debug)]
struct UserID(i32);
impl Drop for UserID {
fn drop(&mut self) {
println!("drop called:{:?}", self);
}
}
impl Deref for UserID {
type Target = i32;
// 实现自定义解引用行为,解引用将 UserID 转为 i32
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
{
// 将数据 100 存放在堆上,成功后返回的 Box 结构体存放在栈上的普通局部变量中,当
// 局部变量离开作用域时,会自动调用 drop 函数释放堆内存,Box 局部变量则随栈帧释放
let number = Box::new(UserID(100));
println!("自动释放:{:?}", number);
}
{
// 手动调用 drop() 释放内存
let number = Box::new(UserID(200));
drop(number);
println!("手动释放完毕");
}
{
// 使用 leak() 函数绕过释放机制,使其内存在整个程序运行期间都不会被释放
let number = Box::new(UserID(300));
let leaked_number = Box::leak(number);
println!("使用 leak() 函数绕过释放机制:{:?}", leaked_number);
}
}
#![allow(unused)]
fn main() {
shell> cargo run
自动释放:UserID(100)
drop called:UserID(100)
drop called:UserID(200)
手动释放完毕
使用 leak() 函数绕过释放机制:UserID(300)
}
需要注意的是,如果直接使用 Box::new() 将一个数组保存到堆上,实际上会先在栈上创建该数组,再将其拷贝到堆内存中,这个过程会引入额外的开销。另外,如果数组的大小超出栈的容量限制,还可能导致栈溢出(如下方示例代码所示)。
fn main() {
// 将一个 100MB 大小的数组保存到堆上,实际上会先在栈上创建,再复制到堆上
let array = Box::new([0x01_u8; 100 * 1024 * 1024]);
println!("{}", array.len());
}
shell> cargo run
thread 'main' (928293) has overflowed its stack # 栈内存溢出异常
fatal runtime error: stack overflow, aborting
Aborted (core dumped)
针对这一问题,可以使用 Box::new_uninit_slice() 在堆上直接分配一块未初始化的内存区域,用于存放较大的数组,避免在栈上创建数组并将其复制到堆上所带来的额外开销,同时也能避免栈溢出的问题。由于分配的是未初始化的内存区域,使用时需初始化内存中的数据。
fn main() {
// 在堆上分配一块未初始化的内存,大小为 100MB
let mut buffers = Box::<[u8]>::new_uninit_slice(100 * 1024 * 1024);
// 逐个初始化数组每个元素
for i in 0..buffers.len() {
buffers[i].write(0_u8);
}
// 批量初始化,比逐个初始化效率更高
unsafe {
buffers.as_mut_ptr().write_bytes(1_u8, buffers.len());
};
// 初始化完成后,转换为已初始化的切片
let slice = unsafe { buffers.assume_init() };
// 使用已初始化的切片
println!("第一个元素: {}", slice[0]);
println!("最后一个元素: {}", slice[slice.len() - 1]);
}
shell> cargo run
第一个元素: 1
最后一个元素: 1
Box 还实现了 Clone trait,但前提是它所存储的元素类型也实现了 Clone trait。当调用 clone() 方法时,Box 会调用元素类型的 clone() 方法创建一份数据副本,并为这份副本重新分配一块堆内存,返回一个新的 Box。因此,克隆得到的新 Box 与原 Box 分别拥有各自的数据副本,二者相互独立。
// 定义 Foo 结构体,不实现 Clone trait
struct Foo;
// 定义 Bar 结构体,并实现 Clone trait
#[derive(Debug)]
struct Bar(usize);
impl Clone for Bar {
fn clone(&self) -> Self {
println!("Bar: clone called");
Self(self.0)
}
}
fn main() {
// 如果元素没有实现 Clone trait,无法调用 Box 的 clone()
let _foo = Box::new(Foo);
// _foo.clone(); // 编译报错:Foo 没有实现 Clone
// 如果元素实现 Clone trait,调用 clone() 克隆数据并存放到新 Box
let bar1 = Box::new(Bar(1));
let bar2 = bar1.clone();
// bar1 和 bar2 是独立的 Box,指向的数据各自独立,bar1 释放回收不影响 bar2
drop(bar1);
println!("bar2: {:?}", bar2);
}
shell> cargo run
Bar: clone called
bar2: Bar(1)
12.6.2 Rc 指针类型
Rc 指针类型也可以在堆上分配内存来存储数据,但可以让多个所有者共享同一份数据。通过调用 Rc::new() 函数来分配内存,成功后会将堆内存的地址存放在一个 Rc 结构体中并返回。Rc 结构体的大小与 usize 类型相同,在 32 位平台上为 4 字节,在 64 位平台上为 8 字节。Rc 结构体同样实现了 Deref trait,当访问该结构体时,会自动解引用为堆内存中存储的数据,但 Rc 指针并未像 Box 指针那样实现 DerefMut trait,因此无法通过解引用来修改数据。
use std::rc::Rc;
fn main() {
// 将数据 100 存放在堆上,成功后返回的 Rc 结构体存放在栈上的普通局部变量中
let number: Rc<i32> = Rc::new(100);
// 查看数据内存地址
println!("存放数据的堆内存地址:{:p}", number);
println!("存放Rc的栈内存地址:{:p}", &number);
// Rc 类型大小取决于系统架构:在 64 位系统下为 8 字节,在 32 位系统下为 4 字节
// 由于存放的是 堆内存地址,无论数据大小,Rc 大小是固定的
println!("Rc<i32> 指针大小:{} bytes", size_of::<Rc<i32>>());
println!("Rc<i64> 指针大小:{} bytes", size_of::<Rc<i64>>());
// Rc 实现了 Deref trait,访问时会自动解引用,返回堆内存中存储的数据
println!("Deref trait:{}", number);
}
shell> cargo run
存放数据的堆内存地址:0x55a956196ba0
存放Rc的栈内存地址:0x7ffdbe156168
Rc<i32> 指针大小:8 bytes
Rc<i64> 指针大小:8 bytes
Deref trait:100
Rc 基于引用计数实现多个所有者共享同一份数据,当使用 Rc 指针进行堆内存分配时,除了为存储数据分配内存空间外,Rc 还会在堆上额外分配一部分内存用于存储强引用计数(strong count)和弱引用计数(weak count),每个计数值各占用一个 usize 大小的空间。强引用计数用来跟踪当前所有者的数量;弱引用计数则主要用于解决循环引用问题,避免因循环引用导致的内存泄漏。
Rc 通过实现 Clone 和 Drop trait 实现强引用计数的增减,每次调用 clone() 时,并不会复制底层的数据结构,而是将强引用计数加一;当某个所有者离开作用域时或手动调用 drop() 函数时,强引用计数会减一。当强引用计数归零时,Rc 所指向的数据会被自动释放,确保内存得到正确回收。引用计数机制非常高效,尤其在处理大数据时,它能有效减少不必要的内存分配和数据复制开销。
use std::rc::Rc;
#[allow(dead_code)]
#[derive(Debug)]
struct Number(i32);
impl Drop for Number {
fn drop(&mut self) {
println!("强引用计数等于 0,释放内存!");
}
}
fn main() {
// 使用 Rc 指针在堆上分配内存存放数据,还会同时分配
let number = Rc::new(Number(100));
println!("堆内存地址(new): {:p}", number);
println!("强引用计数(new): {}", Rc::strong_count(&number));
println!("弱引用计数(new): {}", Rc::weak_count(&number));
// 克隆共享堆数据,内存地址相同,仅增加强引用计数
let cloned_number = number.clone();
println!("堆内存地址(cloned): {:p}, {:p}", number, cloned_number);
println!("强引用计数(cloned): {}, {}",
Rc::strong_count(&number), Rc::strong_count(&cloned_number));
// 手动调用 drop() 函数时,强引用计数会减一
drop(cloned_number);
println!("强引用计数(drop): {}", Rc::strong_count(&number));
{
// 作用域结束后,强引用计数会减一
let cloned = number.clone();
println!("强引用计数(within scope): {}, {}",
Rc::strong_count(&number), Rc::strong_count(&cloned));
}
println!("强引用计数(final): {}", Rc::strong_count(&number));
}
shell> cargo run
堆内存地址(new): 0x18caf9b1a30
强引用计数(new): 1
弱引用计数(new): 0
堆内存地址(cloned): 0x18caf9b1a30, 0x18caf9b1a30
强引用计数(cloned): 2, 2
强引用计数(drop): 1
强引用计数(within scope): 2, 2
强引用计数(final): 1
强引用计数等于 0,释放内存!
Rust 的引用遵循 “共享不可变、可变独占” 原则,即同一时刻可以存在多个不可变引用,但最多只能存在一个可变引用,并且可变引用存在时不能同时存在其他引用。Rc 类型允许多个所有者共享同一份数据,但其共享数据本身不能直接修改,如果需要在多个所有者共享数据的同时修改数据,则需要借助内部可变性机制。Cell 和 RefCell 都提供了内部可变性,允许通过共享引用修改其内部数据,同时仍需遵循 Rust 的借用规则。
Cell不使用引用,通过值复制或替换访问和修改数据,要求类型实现Copy,适合小型数据。RefCell通过引用直接修改数据,无需复制,但同一时刻仍只能有一个可变引用或多个不可变引用。
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
struct Number(i32);
fn main() {
// Cell
let number = Rc::new(Cell::new(Number(100)));
// 获取时会复制数据,修改不影响原数据
let copied = number.get(); // 值复制
number.set(Number(200)); // 值替换
println!("Cell 值={:?}, 复制值={:?}", number, copied);
// RefCell
let number = Rc::new(RefCell::new(Number(200)));
// 通过不可变引用访问数据
let value = *number.borrow();
println!("RefCell 值={:?}", value);
// 通过可变引用修改数据
let mut mutable_ref = number.borrow_mut();
*mutable_ref = Number(300);
println!("RefCell 修改后={:?}", mutable_ref);
// 不能存在多个可变借用
// let mut another = shared_refcell.borrow_mut();
}
shell> cargo run
Cell 值=Cell { value: Number(200) }, 复制值=Number(100)
RefCell 值=Number(200)
RefCell 修改后=Number(300)
需要注意是,如果多个 Rc 指针相互引用,形成循环引用,其强引用计数将无法归零,将导致内存无法释放,造成内存泄漏。下述示例中 Teacher 与 Student 之间的循环引用,会导致强引用计数无法归零,从而造成内存泄漏。这种情况需要手动破坏循环引用,才能释放堆内存。
use std::cell::Cell;
use std::rc::Rc;
struct Teacher {
name: String,
student: Cell<Option<Rc<Student>>>,
}
struct Student {
name: String,
teacher: Cell<Option<Rc<Teacher>>>,
}
impl Drop for Teacher {
fn drop(&mut self) {
println!("Teacher drop: {}", self.name);
}
}
impl Drop for Student {
fn drop(&mut self) {
println!("Student drop: {}", self.name);
}
}
fn main() {
let teacher = Rc::new(Teacher {
name: "Teacher A".to_string(),
student: Cell::new(None),
});
let student = Rc::new(Student {
name: "Student B".to_string(),
teacher: Cell::new(None),
});
// 相互引用,形成循环引用,强引用计数将无法归零,会导致内存无法释放,出现内存泄漏
teacher.student.set(Some(Rc::clone(&student)));
student.teacher.set(Some(Rc::clone(&teacher)));
println!("强引用数量:{} {}", Rc::strong_count(&teacher), Rc::strong_count(&student));
// 手动将一方的引用置为 None,就可以切断循环引用,使双方的引用计数都降为 0,让内存正常释放
// teacher.student.set(None);
// student.teacher.set(None);
}
shell> cargo run
强引用数量:2 2
除了手动解除循环引用外,更安全的方式是通过 Weak 弱引用避免循环引用导致的内存泄漏。weak() 函数不会增加强引用计数,仅增加弱引用计数,因此不会阻止 Rc 的值被释放,可以通过 upgrade 方法将 Weak 转换为 Rc,如果原值已经被释放,则返回 None。
use std::cell::Cell;
use std::rc::{Rc, Weak};
struct Teacher {
name: String,
student: Cell<Weak<Student>>,
}
struct Student {
name: String,
teacher: Cell<Option<Rc<Teacher>>>,
}
impl Drop for Teacher {
fn drop(&mut self) {
println!("Teacher drop: {}", self.name);
}
}
impl Drop for Student {
fn drop(&mut self) {
println!("Student drop: {}", self.name);
}
}
fn main() {
let teacher_rc = Rc::new(Teacher {
name: "Teacher A".to_string(),
student: Cell::new(Weak::new()),
});
let student_rc = Rc::new(Student {
name: "Student B".to_string(),
teacher: Cell::new(None),
});
// 使用弱引用避免相互引用,导致内存泄漏
teacher_rc.student.set(Rc::downgrade(&student_rc));
student_rc.teacher.set(Some(Rc::clone(&teacher_rc)));
println!("强引用数量: student={}, teacher={}",
Rc::strong_count(&student_rc), Rc::strong_count(&teacher_rc));
println!("弱引用数量: student={}", Rc::weak_count(&student_rc)); // 弱引用计数变为 1
// 通过 upgrade() 方法可以将 Weak 指针转换为 Rc 指针
let student_weak = teacher_rc.student.take();
match student_weak.upgrade() {
None => println!("已被释放"),
Some(student) => println!("{}", student.name),
}
}
shell> cargo run
强引用数量: student=1, teacher=2
弱引用数量: student=1
Student B
Student drop: Student B
Teacher drop: Teacher A
为了支持多线程场景(见线程安全章节),Rust 提供了 Rc 的线程安全版本 Arc(Atomic Reference Counted),用于在多个线程之间安全共享堆上的数据,两者使用方式相同。 Arc 通过原子操作维护引用计数,确保在并发环境中引用计数的正确性和数据一致性。
use std::sync::Arc;
fn main() {
let arc = Arc::new(10);
println!("arc={}", arc);
}
shell> cargo run
arc=10
12.6.3 堆内存分配器
堆内存分配器负责管理堆区域的内存分配,Box、Rc 和 Arc 指针在堆上分配内存时,会调用堆内存分配器分配内存,也可以直接使用堆内存分配接口 std::alloc::alloc 和 std::alloc::dealloc 来分配和释放内存。
use std::alloc::{alloc, dealloc, Layout};
fn main() {
unsafe {
// 在堆上分配内存
let layout = Layout::new::<u16>();
let ptr = alloc(layout);
// 判断是否分配成功
if ptr.is_null() {
panic!("分配内存失败!");
}
// 修改数据
*(ptr as *mut u16) = 100;
println!("ptr={:?}", *ptr);
// 释放堆内存
dealloc(ptr, layout);
}
}
shell> cargo run
ptr=100
堆内存分配接口 alloc 和 dealloc 的默认实现类型为 std::alloc::System,其底层通过操作系统提供的 C 运行时库或系统堆管理 API 完成内存的分配与释放。开发者也可以自行实现 GlobalAlloc trait 来定义自定义堆分配器,并使用 #[global_allocator] 属性将其设置为全局分配器。在下述示例中,使用一个静态数组作为堆内存区域,并通过自定义分配器在该区域分配内存。
use std::alloc::{GlobalAlloc, Layout};
use std::ptr::addr_of_mut;
const HEAP_SIZE: usize = 100 * 1024 * 1024;
static mut HEAP_MEMORY: [u8; HEAP_SIZE] = [0x00; HEAP_SIZE];
static mut HEAP_INDEX: usize = 0;
struct CustomAllocator;
// 实现 GlobalAlloc trait 自定义堆内存分配策略
unsafe impl GlobalAlloc for CustomAllocator {
// 分配堆内存时,会调用该函数
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let align = layout.align();
// 计算开始和结束位置,并处理内存对齐
let start = unsafe { HEAP_INDEX };
let aligned_start = (start + align - 1) & !(align - 1);
let end = aligned_start + size;
// 检查是否超出堆内存范围
if end > HEAP_SIZE {
panic!("Out of memory");
}
// 更新堆指针
unsafe { HEAP_INDEX = end };
let heap_ptr = addr_of_mut!(HEAP_MEMORY);
return unsafe { heap_ptr.cast::<u8>().add(aligned_start) };
}
// 释放堆内存时,会调用该函数
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// 简单示例不支持释放内存
}
}
// 设置为全局堆内存分配器
#[global_allocator]
static GLOBAL: CustomAllocator = CustomAllocator;
fn main() {
let heap_ptr = &raw const HEAP_MEMORY;
println!("堆内存区域: {:p} ~ 0x{:x}", heap_ptr, heap_ptr as usize + HEAP_SIZE);
let number = Box::new(100_u8);
println!("Box 分配的内存地址: {:p}, value: {}", number, number);
}
# Box 分配的堆内存地址,在自定义的堆内存区域
shell> cargo run
堆内存区域: 0x560317771f69 ~ 0x56031db71f69
Box 分配的内存地址: 0x560317772591, value: 100
在 Rust 社区中,有多种成熟的堆内存分配器可供选择,如 jemalloc、mimalloc 和 tcmalloc 等。它们在性能和应用场景上各有侧重,因此在选择时需要根据程序的特点权衡,如分配速度、并发支持、内存碎片以及是否适用于嵌入式或实时系统。下述示例,介绍如何使用 mimalloc 作为全局分配器。
use mimalloc::MiMalloc;
// 设置 mimalloc 为全局堆内存分配器
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
// 在堆上分配内存时,会能完 MiMallo 分配和管理
let number = Box::new(100);
println!("{:?}", number);
}
# 添加 mimalloc内存分配器依赖
shell> cargo add mimalloc@0.1.52
shell> cargo run
number=100
12.7 生命周期机制
不同内存区域采用不同的管理策略,其数据的分配与释放时机也各不相同,即使位于同一内存区域,不同作用域中的数据,其有效期也可能不同,因此,每个变量都有自己的生命周期(lifetime)。为了确保引用始终指向有效的数据,每个引用都会受到相应的生命周期约束,用于保证引用的有效期不会超过其所指向数据的有效期。编译器在编译时,会根据引用的使用情况分析并检查这些生命周期约束,避免引用指向已经失效的数据,在编译期防止悬垂引用(dangling reference)、野指针(wild pointer)等内存访问问题。与依赖运行时检查的内存安全机制不同,生命周期检查主要在编译期完成,不会引入额外的运行时检查开销,从而在保证内存安全的同时,仍能实现接近 C/C++ 的运行时性能。
12.7.1 生命周期参数
生命周期参数(lifetime parameter)用于描述多个引用之间的生命周期关系,帮助编译器检查这些引用是否始终有效。在绝大多数情况下,编译器能够根据引用的使用情况自动推断其生命周期,不需要开发者显式标注生命周期参数。当引用之间存在无法通过上下文自动确定的生命周期关系时,才需要显式标注生命周期参数。生命周期参数的声明格式为 <’a>,在引用类型中使用时标注为 &’a T。其中,’a 是生命周期参数的名称,可以自定义,通常使用单个小写字母表示。
当结构体包含引用类型的字段时,编译器无法确定引用是否会在结构体实例之前被释放,导致悬垂引用。因此,必须显式标注引用生命周期参数,告诉编译器引用的生命周期不会短于结构体实例的生命周期,确保结构体实例存活期间,其引用字段始终指向有效的数据。同样地,如果枚举的某些变体包含引用类型的字段,也需要通过生命周期参数描述引用与枚举实例之间的生命周期关系,确保引用始终有效。
// 为结构体声明一个生命周期参数,'a 代表结构体实例的生命周期
#[allow(dead_code)]
struct User<'a> {
id: i32,
name: &'a str, // 标注使用 ‘a 生命周期,即要求数据生命周期不能小于结体体
}
fn main() {
// name 与结构体在同一个作用域内,生命周期相同,满足生命周期标注要求
let name = String::from("张三");
let mut user = User {
id: 1,
name: &name,
};
{
// 该变量仅在局部代码块内有效,生命周期小于 user 结构体实例,
// 不满足生命周期标注要求,所以不能直接赋值给 user.name
let name = String::from("李四");
user.name = &name; // 编译报错
}
println!("User: id={}, name={}", user.id, user.name);
}
shell> cargo run
error[E0597]: `name` does not live long enough
--> src\main.rs:20:21
|
19 | let name = String::from("李四");
| ---- binding `name` declared here
20 | user.name = &name; // 编译报错,name 生命周期不够长
| ^^^^^ borrowed value does not live long enough
21 | }
| - `name` dropped here while still borrowed
22 |
23 | println!("User: id={}, name={}", user.id, user.name);
| ------- borrow later used here
Rust 内置了一个 'static 生命周期参数,用于表示在整个程序运行期间始终有效的数据。位于只读数据区、可写数据区和 BSS 段中的静态数据,以及通过 Box::leak() 主动泄漏而获得的数据,都属于 'static 生命周期。
#[allow(dead_code)]
#[derive(Debug)]
struct User<'a> {
id: i32,
name: &'a str, // 要求数据生命周期不能小于结体体
desc: &'static str, // 要求数据的生命周期在整个程序运行期间始终有效
}
fn main() {
// 静态字符串,存放在只读数据区,在整个程序运行期间都有效
let mut user = User {
id: 1,
name: &String::from("张三"),
desc: "这是一个用户", // 静态字符串
};
{
// 静态字符串在整个程序运行期间都有效,生命周期大于 user 结构体实例,
// 不会导致悬垂引用,可以赋值
let name: &'static str = "李四";
user.name = &name; // 不会编译报错
}
println!("{user:?}");
}
shell> cargo run
User { id: 1, name: "李四", desc: "这是一个用户" }
当函数的返回值是引用时,编译器需要确定该引用的生命周期,以确保函数返回后,该引用所指向的数据仍然有效。
- 当函数只有一个引用类型的参数时,编译器会将该参数的生命周期赋予返回值的引用,返回值可以省略生命周期标注。
- 当函数有多个引用类型的参数时,编译器无法确定返回值引用与哪个参数关联,返回值必须显式标注生命周期。
- 当函数的多个引用参数中有一个
&self或&mut self时,编译器会将self参数的生命周期赋予返回值的引用,返回值可以省略生命周期标注。
// 当函数的返回值是引用时,编译器需要知道该引用的生命周期,确保返回的引用仍指向有效的数据
// 使用 'static 表示返回值引用的数据在整个程序运行期间都有效
fn foo() -> &'static i32 {
let number = Box::new(10);
let leak: &'static i32 = Box::leak(number);
return leak;
}
//当函数只有一个引用类型的参数时,编译器会将该参数的生命周期赋予返回值的引用,返回值可以省略生命周期标注
fn bar(number: &i32) -> &i32 {
return number;
}
// 当函数有多个引用类型的参数时,编译器无法确定返回值引用与哪个参数关联,返回值必须显式标注生命周期
fn max<'a>(v1: &'a i32, v2: &'a i32) -> &'a i32 {
return match v1 > v2 {
true => v1,
false => v2,
};
}
#[allow(dead_code)]
struct User {
id: u32,
name: String,
}
impl User {
// 当函数的多个引用参数中有一个 `&self` 或 `&mut self` 时,编译器会将 `self`
// 参数的生命周期赋予返回值的引用,返回值可以省略生命周期标注。
fn introduce(&self, greeting: &str) -> &str {
println!("{},我是 {}", greeting, self.name);
&self.name // 返回自己的名字
}
}
fn main() {
// foo 返回一个 'static 生命周期的引用
let static_ref: &'static i32 = foo();
println!("foo() -> {}", static_ref);
// bar 的返回值引用和参数生命周期相同
let num = 20;
let bar_ref = bar(&num);
println!("bar(&num) -> {}", bar_ref);
// max 返回参数中较大的值的引用
let v1 = 31;
let v2 = 32;
let max_ref = max(&v1, &v2);
println!("max(&v1, &v1) -> {}", max_ref);
// User 的 introduce 方法返回 self 的引用
let user = User { id: 1, name: String::from("Rust") };
user.introduce("你好");
}
#![allow(unused)]
fn main() {
shell> cargo run
foo() -> 10
bar(&num) -> 20
max(&v1, &v1) -> 32
你好,我是 Rust
}
12.7.2 生命周期约束
当函数存在多个引用参数且本身生命周期不同时,如果统一使用同一个生命周期参数进行标注,编译器会要求这些引用均满足该生命周期的约束,导致一些实际上安全的代码无法通过编译(见下述示例)。
fn max<'a>(v1: &'a i32, v2: &'a i32) -> &'a i32 {
return match v1 > v2 {
true => v1,
false => v2,
}
}
fn main() {
// 在同一个作用域,生命周期相同
let v1 =10;
let v2 = 20;
// 生命周期相同,可以正常编译
let max = max(&v1, &v2);
{
// v3 生命周期短于 v1,不满足 max 函数生命周期参数一样的要求,编译报错(误报),
// 实际上返回引用仅在局部使用,不会产生悬垂引用,是内存安全的
let v3 = 30;
let max = max(&v1, &v3);
println!("max={max}");
}
}
#![allow(unused)]
fn main() {
shell> cargo run
Compiling app v0.1.0 (/file/rust/project/app)
error[E0618]: expected function, found `&i32`
--> src/main.rs:21:19
|
1 | fn max<'a>(v1: &'a i32, v2: &'a i32) -> &'a i32 {
| ----------------------------------------------- this function of the same name is available here, but it's shadowed by the local binding
...
15 | let max = max(&v1, &v2);
| --- `max` has type `&i32`
...
21 | let max = max(&v1, &v3);
| ^^^----------
| |
| call expression requires function
}
当存在多个引用且它们的生命周期不同时,如果无法使用单一生命周期参数统一描述,可以为每个引用分别声明独立的生命周期参数,从而明确各自的生命周期关系,使编译器能够准确判断每个引用的有效范围,避免因生命周期约束过强而导致编译错误。
fn foo<'a, 'b>(v1: &'a i32, _v2: &'b i32) -> &'a i32 {
return v1;
}
fn main() {
// 在同一个作用域,生命周期相同
let v1 =100;
let v2 = 200;
// 生命周期相同,可以正常编译
let value = foo(&v1, &v2);
println!("value: {value}");
{
// 生命周期不同,也可以正常编译(v3 生命周期小于 v1)
let v3 = 50;
let value = foo(&v1, &v3);
println!("value: {value}");
}
}
shell> cargo run
value: 100
value: 100
每个引用只有一个生命周期参数,如果需支持多个具有不同生命周期的引用,则需要添加生命周期约束,明确各参数之间的相对关系(例如指定一个生命周期不短于另一个),以帮助编译器正确推断引用的有效期。生命周期约束既可以直接写在生命周期参数列表中,也可以通过 where 子句声明。
// 函数返回的引用只能与一个生命周期参数关联,无法返回标注了其他生命周期的引用
fn foo<'a, 'b>(v1: &'a i32, _v2: &'b i32) -> &'a i32 {
return v1;
// return v2; // 会编译报错,生命周期参数不匹配
}
// 方式1:在生命周期参数列表中添加生命周期约束,要求 b 生命周期大于等于 a
fn max<'a, 'b: 'a>(v1: &'a i32, v2: &'b i32) -> &'a i32 {
// 由于约束要求 b 生命周期大于等于 a,无论返回哪一个引用都满足返回值引用的生命周期参数要求
return match v1 > v2 {
true => v1,
false => v2,
};
}
// 方式2:使用 where 子句添加生命周期约束,要求 b 生命周期大于等于 a
fn min<'a, 'b>(v1: &'a i32, v2: &'b i32) -> &'a i32
where 'b: 'a {
return match v1 < v2 {
true => v1,
false => v2,
};
}
fn main() {
let v1 = 100;
let v2 = 200;
// 返回值引用只能关联 'a,因此无法直接返回 'b 的引用
let foo = foo(&v1, &v2);
println!("foo: {}", foo);
// 生命周期相同,可以正常编译
let max = max(&v1, &v2);
println!("max: {}", max);
{
// 生命周期不同,也可以正常编译(v3 生命周期小于 v1)
let v3 = 300;
let min = min(&v1, &v3);
println!("min: {}", min);
}
}
shell> cargo run
foo: 100
max: 200
min: 100
12.8 原始指针类型
原始指针(也称裸指针)与引用类似,本质上也是一个内存地址,都可以指向任意内存,可以直接访问或修改所指向的数据,不保证数据的有效性和安全性,也不受借用检查器和生命周期机制约束,开发者需自行保证内存安全和线程安全(见多线程章节),主要用于跨语言调用(FFI)或手动管理内存等场景。其大小与 usize 类型相同,取决于平台:在 32 位平台上为 4 字节,在 64 位平台上为 8 字节。
原始指针分为不可变指针 *const T 和可变指针 *mut T 两种,其中 *const T 只能读取指向的数据,不能修改,而 *mut T 则可以读取和修改指向的数据。
fn main() {
let mut number = 100;
// 创建不可变原始指针(只读),原始指针和引用本质都是一个内存地址,可以直接将引用转为原始指针
let ptr: *const i32 = &number as *const i32;
// 可变原始指针(可读可写),也可以显式声明为原始指针类型,编译器会自动推导
let ptr_mut: *mut i32 = &mut number;
// 原始指针的大小取决于平台:在 32 位平台上为 4 字节,在 64 位平台上为 8 字节
println!("原始指针大小: *const i32={}", size_of::<*const i32>());
// 原始指针大小与指向的数据类型无关
println!("原始指针大小: *mut i32={}", size_of::<*mut i32>());
println!("原始指针大小: *mut i128={}", size_of::<*const i128>());
// 读取数据,原始指针不保证数据的有效性或安全性,解引用必须放在 unsafe 代码块中
unsafe {
println!("通过不可变指针读取: {}", *ptr);
println!("通过可变指针读取: {}", *ptr_mut);
}
// 通过可变指针修改数据(必须 unsafe)
unsafe {
*ptr_mut = 200;
println!("修改后: number={}", number);
}
}
shell> cargo run
原始指针大小: *const i32=8
原始指针大小: *mut i32=8
原始指针大小: *mut i128=8
通过不可变指针读取: 100
通过可变指针读取: 100
修改后: number=200
原始指针不保证所指向数据的有效性和安全性,可能指向已经失效的内存,访问此类内存可能导致未定义行为,甚至引发程序崩溃。例如,当所指向的内存被释放后,原始指针便成为悬垂指针。下述示例中,原始指针指向 foo 函数中的局部变量;当 foo 函数执行结束后,该局部变量的内存失效,原始指针随之成为悬垂指针。随后,这块内存可能被 bar 函数重新使用并写入其他数据,导致原始指针访问到被覆盖后的内容,出现数据异常。
// 当函数执行完毕,局部变量被释放回收,返回的原始指针指向无效数据
#[allow(dangling_pointers_from_locals)]
fn foo() -> *const i32 {
let number = 100;
return &number as *const i32;
}
// bar 函数复用了 foo 函数释放的内存区域,导致原始指针指向的数据被覆盖,出现数据异常
fn bar() {
let _number = 200;
}
fn main() {
let ptr = foo();
// 访问已释放的内存(错误操作),此时还没有被其他函数使用,值还是 100
let num = unsafe { *ptr };
// 已释放的内存被 bar 函数回收复用,值被覆盖为 200,程序出现未定义行为
bar();
println!("ptr={}, num={}", unsafe { *ptr}, num);
}
# 使用不同模式编译运行,执行结果还不一样
shell> cargo run
ptr=200, num=100
shell> cargo run --release
ptr=100, num=100
Box、Rc 和 Arc 都提供了 into_raw() 和 from_raw() 函数,分别用于在智能指针与原始指针之间进行转换。into_raw() 用于获取原始指针,而 from_raw() 用于将原始指针重新转换为对应的智能指针。
fn foo() -> *mut i32 {
// 将 Box 转为原始指针,并将所有权也转移给原始指针,
// Box 离开作用域后不会再释放内存,导致返回的原始指针变成悬垂指针
let number = Box::new(100);
return Box::into_raw(number);
}
fn main() {
let raw_number = foo();
println!("raw_number: {}", unsafe { *raw_number });
// 将原始指针转为 Box
let number = unsafe { Box::from_raw(raw_number) };
println!("number: {}", number);
}
shell> cargo run
raw_number: 100
number: 100
原始指针除了可以读取和修改所指向的数据外,还支持指针运算,根据偏移量改变指向的内存位置,从而访问连续内存中的不同位置,常用于访问数组、切片或手动管理的连续内存。
fn main() {
let mut arr = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
// 将数组转为原始指针,原始指针指向第一个元素
let mut ptr: *mut i32 = arr.as_mut_ptr();
unsafe {
// 按字节偏移指针,以字节为单位
let v0 = *ptr.byte_add(0);
let v1 = *ptr.byte_add(4); // i32 大小为 4 字节,必须是 4 的倍数
println!("byte_add(0): {}, {:p}", v0, &v0);
println!("byte_add(4): {}, {:p}", v1, &v1);
// 按元素数量偏移指针,步长为 T 类型大小(这里是 i32,4 字节)
let v0 = *ptr.add(0);
let v1 = *ptr.add(1);
println!("add(0): {}, {:p}", v0, &v0);
println!("add(1): {}, {:p}", v1, &v1);
// 与 add 相反,按元素数量反向移动指针
ptr = ptr.add(5);
let v0 = *ptr.sub(0);
let v1 = *ptr.sub(1);
println!("sub(0): {}, {:p}", v0, &v0);
println!("sub(1): {}, {:p}", v1, &v1);
// 与 add/sub 类似,但接受 isize 类型参数(可正可负),而 add/sub 只能处理正数
let v0 = *ptr.offset(1);
let v1 = *ptr.offset(-1);
println!("offset(1): {}, {:p}", v0, &v0);
println!("offset(-1): {}, {:p}", v1, &v1);
}
}
shell> cargo run
byte_add(0): 10, 0x7fffd23d8ac8
byte_add(4): 11, 0x7fffd23d8acc
add(0): 10, 0x7fffd23d8b60
add(1): 11, 0x7fffd23d8b64
sub(0): 15, 0x7fffd23d8bf8
sub(1): 14, 0x7fffd23d8bfc
offset(1): 16, 0x7fffd23d8c90
offset(-1): 14, 0x7fffd23d8c94
12.9 所有权移动与自引用结构体
在 Rust 中,若变量的类型未实现 Copy trait,将其赋值给另一个变量、作为参数传入函数或从函数返回时,变量的所有权会转移给新的变量或调用方,原变量随即失效,不再可用,这一过程称为所有权移动(Move)。从内存角度看,所有权移动通常只需将变量在栈上的数据按位复制到新的存储位置,而堆上的数据本身不会被复制或移动。对于 static 变量,其数据需要在整个程序运行期间始终存在,因此不能像普通变量一样进行所有权移动,只能通过引用访问其数据。
#[derive(Debug)]
struct Foo {
id: u32,
data: Box<i32>,
}
#[allow(dead_code)]
#[derive(Debug)]
struct Bar {
id: u32,
data: i32,
}
fn main() {
// 所有权移动只会复制栈上的数据,堆上的数据不会被复制或移动,
// 移动后,栈上存放的 `id` 地址会改变,而堆上 `data` 指向的地址保持不变。
let foo1 = Foo { id: 1, data: Box::new(100) };
println!("foo1: id={:p}, data={:p}", &foo1.id, foo1.data);
let foo2 = foo1;
println!("foo2: id={:p}, data={:p}", &foo2.id, foo2.data);
// static 变量所有权不能移动
static _BAR1: Bar = Bar{ id: 2, data: 200 };
// let bar2 = _BAR1; // 编译报错
}
# 编译运行,可以看到栈上的数据 id 内存地址变了,堆上的数据 data 地址保持不变
shell> cargo run
foo1: id=0x7ffee0053fb0, data=0x55a176006b90
foo2: id=0x7ffee0054010, data=0x55a176006b90
对于包含指向自身字段的引用或指针的结构体(即自引用结构体),若结构体实例位于栈上,当所有权发生移动时,其内部的引用或指针所指向的数据并不会随之更新,而是仍然指向因所有权移动而失效的原变量,变为悬垂指针,访问可能导致未定义行为(如程序崩溃、数据损坏或内存错误)。为了保证安全,必须在移动后修正内部引用或指针,使其重新指向新的有效位置。
use std::fmt::Display;
use std::ptr;
struct SelfRef {
value: u32,
ptr: *const u32, // 指向自己内部的 value
}
impl Display for SelfRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "value={}, &value={:p}, ptr={:p}", self.value, &self.value, self.ptr)
}
}
fn main() {
// 原始对象
let mut original = SelfRef { value: 100, ptr: ptr::null() };
original.ptr = &original.value;
println!("原始值: {original}");
// 移动后的对象,指针指向的数据不会随之更新,指向失效的原变量,变为悬垂指针
let mut moved = original;
println!("移动: {moved}");
// 修正自引用,指向移动后的有效数据
moved.ptr = &moved.value;
println!("修复后: {moved}");
}
shell> cargo run
原始值: value=100, &value=0x7ffcc6104348, ptr=0x7ffcc6104348
移动: value=100, &value=0x7ffcc6104378, ptr=0x7ffcc6104348 # 此时 ptr 不是指向数据
修复后: value=100, &value=0x7ffcc6104378, ptr=0x7ffcc6104378
除手动修正内部引用或指针外,还可以将自引用结构体存储在堆上,并通过 Box 等指针持有,使结构体本身的内存地址保持稳定。这样,所有权移动时,移动的只是指向堆内存的指针,堆上的结构体本身不会随之移动,从而避免因结构体移动导致内部引用或指针失效。
use std::fmt::Display;
use std::ptr;
struct SelfRef {
value: u32,
ptr: *const u32, // 指向自己内部的 value
}
impl Display for SelfRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "value={}, &value={:p}, ptr={:p}", self.value, &self.value, self.ptr)
}
}
fn main() {
{
// 数据在栈上
let mut original = SelfRef { value: 100, ptr: ptr::null() };
original.ptr = &original.value;
println!("原始值: {original}");
// 引用
let refence = &original;
println!("引用: {refence}");
// 移动引用
let moved_ref = refence;
println!("移动引用: {moved_ref}");
}
{
// 数据在堆上
let mut original = Box::new(SelfRef { value: 100, ptr: ptr::null() });
original.ptr = &original.value;
println!("原始值: {original}");
// 直接移动,不会导致自引用失效
let moved = original;
println!("移动值: {moved}");
}
}
shell> cargo run
原始值: value=100, &value=0x7ffcb3803468, ptr=0x7ffcb3803468
引用: value=100, &value=0x7ffcb3803468, ptr=0x7ffcb3803468
移动引用: value=100, &value=0x7ffcb3803468, ptr=0x7ffcb3803468
原始值: value=100, &value=0x559a68f58b98, ptr=0x559a68f58b98
移动值: value=100, &value=0x559a68f58b98, ptr=0x559a68f58b98