Appearance
内存布局
本文中所描述的,只是一般情况的正常描述,与在实际情况应该会有所差异。
在实际中,具体的内存分配和布局是由 Rust 运行时和操作系统的内存管理机制负责管理和安排的,具体细节可能因系统和环境的不同而有所变化。
[]:byte
[4]:4个 byte
[ptr]:具体多少 byte,取决于计算机的体系结构。
64
位系统上,为8
个字节;32
位系统上,为4
个字节。ptr(pointer):
usize
类型,指向字符数据的起始地址的指针。64
位系统上,指针大小为8
个字节;32
位系统上,指针大小为4
个字节。cap(capacity):表示可变长度的类型(如
Vec
或String
)的内部缓冲区的容量。 对于&str
类型,它是一个不可变的字符串切片,其长度和内容是固定的。因此,它没有cap
容量的概念。 需要注意的是,&str
是对字符串数据的引用,它通常是由String
或&str
所拥有的。 事实上,字符串切片&str
可以被看作是一个指向字符数据的指针和长度信息的组合,并且它的长度是由底层字符串数据决定的。len(length):
usize
类型,用于记录字符数据的长度,以字节为单位。在64
位系统上占据8
个字节,在32
位系统上占据4
个字节。
Rust 的内存布局是指 Rust 中各种数据类型在内存中的存储方式。Rust使用下图所示的内存布局,但并没有严格定义其使用的内存模型,即没有相关规范说明。
Rust 中的数据类型都有一个数据对齐属性。一种类型的大小是它对齐属性的整数倍,这保证了这种类型的值在数组中的偏移量都是其类型尺寸的整数倍,可以按照偏移量进行索引。 需要注意的是,动态尺寸类型的大小和对齐可能无法静态获取。
对于结构体,其对齐属性等于它所有成员的对齐属性中最大的那个。Rust会在必要的位置填充空白数据,以保证每一个成员都正确地对齐,同时整个类型的尺寸是对齐属性的整数倍。 Rust编译器会进行优化,对于泛型结构体,其顺序可能不一样。
使用std::mem
,可以求解结构体的大小,有两种方法,分别是size_of_val
和size_of
。
基础类型
常见类型的字节(byte)表示
类型 | 字节 |
---|---|
bool /u8 /i8 | [] |
u16 /i16 | [][] |
u32 /i32 /f32 | [][][][] |
u64 /i64 /f64 | [][][][][][][][] |
u128 /i28 | [][][][][][][][][][][][][][][][] |
char | [][][][] |
isize
和 usize
是用来表示指针大小或索引的特殊整数类型,对齐方式通常遵循与其大小相对应的规则, 在 32 位系统中,是4
字节大小的有符号和无符号整数类型,而在 64 位系统中,是8
字节大小的有符号和无符号整数类型。
机器字(machine word)是计算机体系结构中用于处理和存储数据的最大位数单元,通常以字节为单位。 具体的机器字大小取决于计算机的体系结构,例如 16 位、32 位、64 位等。
栈(Stack)和堆(Heap)
在 Rust 中,内存布局涉及栈(Stack)和堆(Heap)两个重要的内存区域。
栈(Stack): 栈是一种具有固定大小的内存区域,用于存储局部变量和函数调用的信息。在函数被调用时,其局部变量会在栈上分配空间。 栈的特点是具有快速的分配和释放速度,以及内存访问的局部性。栈的生命周期是自动管理的,当函数执行结束或者变量超出作用域时,相应的内存会自动被释放。
堆(Heap): 堆是一种动态分配的内存区域,用于存储在运行时动态创建的数据。在堆上分配内存需要显式的分配和释放,通过使用
Box<T>
、Vec<T>
等堆分配类型进行操作。 堆的特点是具有灵活的大小和生命周期,但是在分配和释放内存方面相对较慢。
在 Rust 中,栈和堆之间的内存布局如下:
高地址
↓
┌──────┐
│ │
│ Stack│
│ │
├──────┤
│ │
│ Heap │
│ │
├──────┤
│ │
│ Code │
│ │
├──────┤
│ │
│ Data │
│ │
├──────┤
│ │
│ BSS │
│ │
├──────┤
│ │
│ Text │
│ │
└──────┘
低地址
其中:
- 栈位于高地址处,用于存放函数调用和局部变量。栈的大小在编译时就确定下来。
- 堆位于栈下方,用于存放动态分配的数据。堆的大小是动态变化的。
- Code(代码)区域存放程序的指令。
- Data 区域存放全局变量的初始值。
- BSS 区域存放未初始化的全局变量和静态变量的空间。
- Text 区域(也称为只读数据段)存放程序的只读常量数据。
需要注意的是,这只是一种常见的内存布局示例,具体实现可能因操作系统、编译器和目标平台而有所不同。
栈(Stack)和堆(Heap)在内存管理上有以下区别:
分配方式:
- 栈:栈的分配是自动的,由编译器负责在函数调用时为局部变量分配空间。栈上的数据遵循后进先出(LIFO)的原则,也就是最后进入栈的数据最先释放。
- 堆:堆的分配是手动的,需要使用类似
Box<T>
、Vec<T>
等类型来进行堆分配。堆上的数据遵循手动控制的原则,需要显式地进行分配和释放。
空间大小:
- 栈:栈的大小在编译时确定,并且是有限的。栈的大小受到操作系统或编译器的限制,超过栈的最大容量可能会导致栈溢出错误。
- 堆:堆的大小是动态分配的,可以根据需要进行扩展。堆的大小取决于可用的系统内存。如果内存不足,堆分配可能会失败。
访问速度:
- 栈:由于栈是具有固定大小的连续内存区域,所以对栈上的数据的访问速度非常快。栈上的数据存储在缓存中,可以通过指针直接访问。
- 堆:堆上的数据访问速度相对较慢,因为需要通过指针间接访问。此外,堆上的数据可能分散在内存的不同位置,导致缓存未命中。
在 Rust 中,数据分布情况如下:
栈:
- 在函数调用时,函数的局部变量和参数会被分配到栈上。
- 栈上的数据是自动管理的,当函数调用结束或变量超出作用域时,栈上的数据会自动释放。
- 栈上的数据大小是固定的,编译时确定。
堆:
- 通过使用
Box<T>
、Vec<T>
等类型进行手动堆分配。 - 堆上的数据大小是动态的。
- 堆上的数据需要显式地进行分配和释放,使用
Box::new()
和Box::drop()
等方法进行管理。
- 通过使用
总结起来,栈适合用于存储具有固定大小和生命周期较短的数据,而堆适合用于存储动态分配的数据、大型数据结构和拥有较长生命周期的数据。
希望能够解答你的问题!如果还有其他问题,请随时提问。
tuple
rust
let a:(char, u8, i32) = ('a', 7, 354);
size_of::<(char, u8, i32)>(); // 打印结果 12
align_of::<(char, u8, i32)>(); // 打印结果 4
该元组由三个元素构成 —— char、u8 和 i32,由上面的基本类型中可知 char 占4
bytes,u8 占1
byte, i32 占4
bytes,那么初步计算出来这个 tuple 占用的总内存应为4+1+4=9
bytes。 接着,Rust 会选择 Tuple 中对齐值最大的元素为a该元组的对齐值,由此上例 alignment 是4
。 有了整体对齐值,Rust 会在内存中加入一段填充(padding)来让整体内存占用是alignment的整数倍,本例中加在 u8 与 i32 中间是为了保障 i32 自身的内存对齐。
由于 Rust 有多种数据排布风格(默认的 Rust 风格,还有 C 语言风格,primitive 和 transparent 风格),在 Rust 风格中,Rust 可以对元组中的元素做任意重排, 也包括 padding 的位置,因而图中的排列只是一种可能,也许 i32 和 char 的位置在 Rust 中会进行互换, Rust 是根据其优化算法做出其认为最优的排序,对最终排序结果并没有统一规则。
txt
图示:
char | u8 |padding| i32
+--–----+–-–-–-–+–-–V–-–+––-V–-–+
a │ [4] │ [1] │ [3] │ [4] │
+-–--–-–+–--––––+–-––-––+––--–––+
stack
----------------------------------------------------------------
heap
Reference
reference(引用)是 Rust 中的一个重要概念,相关规则也是支撑了 Rust 内存安全的重要支柱。
rust
let a: i8 = 6;
let b: &i8 = &a;
a
是一个 i8,b
是一个指向a
的 reference,可以看下它们的内存分布:
txt
图示:
a b
+<––––––––––––––––<––-----+
----+-––V-–-+––-–––––-–––-–-––+–––V–––+––--
... │ [1] │ ............... │ [ptr] │....
stack ----+-––––-–+–––––––––––-–––––+––-––––+–---
------------------------------------------------------------------
heap
首先,Rust 会在栈上分配一个大小为1
byte的 i8 存储a
,接着会在内存另外一个空间(不一定和a
连续)分配b
,b
中存储的内存空间会指向a
所在的内存空间, 同时b
的内存占用大小即 pointer 的大小。
需要注意的是,&T
和&mut T
在内存分布上规则一致,他们的区别是在使用方式和编译器处理方式上。
Array, Vector
rust
let a: [i8; 3] = [1, 2, 3];
let b: Vec<i8> = vec![4, 5, 6];
txt
图示:
+-–---+––-––+––-––+ +–––-–––+–––-–––+–––-–––+
a │ 1 │ 2 │ 3 │ b │ [ptr] │ [cap] │ [len] │
stack +-––-–+–––––+–––––+ +–––│–––+––-––––+–––-–––+
---------------------------------------------│--------------------------
heap +-–V--+--–-–+–-–-–+
│ 4 │ 5 │ 6 │
+----–+–––-–+–––-–+
Vec<T>
的容量是根据其内部缓冲区的大小来确定的。当向一个Vec<T>
添加元素时,如果当前元素数量已经达到了容量上限,Vec<T>
会自动增加其内部缓冲区的大小。 增加缓冲区大小的规则是依据 Rust 的增长策略,具体来说,当容量不足时,新的容量将为原容量的两倍(或更大)。
Slice 数组切片
接下来,我们通过 Array 和 Vector 来看下Rust中切片的内存分布实现。
假设我们想获取到上面例子中a
和b
两个 Array 和 Vector 的前两个元素。
rust
let s1: [i32] = a[0..2];
let s2: [i32] = b[0..2]
然而,对于[i32]
,Rust 没法在编译时明确这个变量需要多少内存,因而也没法在栈上分配内存,因而上例中的s1
、s2
实际上会编译失败。 这样的变量称之为 DST,后续会讲到 string slice 和 trait object 也属于这个范畴。
因而,通常我们使用一个 reference 来指向一个 Slice 切片,让我们看下例:
rust
let s1: &[i32] = &a[0..2]
let s2: &[i32] = &b[0..2]
当 reference 指向 dynamically sized type 时,Rust实际会使用到一个胖指针(fat pointer),其中包含:
pointer (1 machine word):指向实际被切片的数据。 length (1 machine word):切片长度,即有多少个 T(本例中 T 为 i32)。
我们可以看下上述例子的内存分布图:
txt
图示: +------<----–-+
{---V---} │
+---+––-+–––+ │ +–-–---–+-------+
a │ 1 │ 2 │ 3 │ +––-<–--––-│-[ptr] │ [len] │
+–-–+–––+–––+ +–-–---–+–------+
+–––-–––+–––-–––+–––-–––+ +–-–---–+–------+
b │ [ptr] │ [cap] │ [len] │ s2 │ [ptr] │ [len] │
stack +–––│–––+––-––––+–––-–––+ +–-–│--–+-------+
-----------------│------------------------------│-----------------------------
heap │ +--------------<------------+
{--│--V-----}
+-–V--+--–-–+–-–-–+
│ 4 │ 5 │ 6 │
+----–+–––-–+–––-–+
String, str, &str
rust
let s1: String = String::from("hello");
let s2: &'static str = "ЗдP"; // д -> Russian Language
let s3: &str = &s1[1..3];
String
s1: String
:s1
是一个可变的、拥有所有权的字符串类型。它的内存布局包括指向堆上字符串数据的指针、字符串的长度和字符串的容量。 在这种情况下,s1
的内容是"hello",长度为5
,容量可能大于或等于5
(取决于分配器的行为)。 s1
的大小在编译时是未知的,指针本身在栈上分配,而字符串数据实际上位于堆上。因为它可以动态地增长或缩小。 它的大小取决于字符串的长度和编码方式。
&'static str
s2: &'static str
:s2
是一个静态字符串切片类型,它是一个指向静态字符串数据的引用。 这个 string 数据不会存储在堆 heap 上,而是会直接存在编译后的二进制中,同时他们具有 static 生命周期,即直到程序结束前都不会被释放。
s2
的内存布局包括指向静态字符串数据的指针和字符串的长度。 s2
的内容是 "ЗдP",长度为5
: 字符 'З' 的码点是 U+0417,经过 UTF-8 编码后得到的字节序列是[208, 151]
。 字符 'д' 的码点是 U+0434,经过 UTF-8 编码后得到的字节序列是[208, 180]
。 字符 'P' 的码点是 U+0050,它在 UTF-8 编码中只需要1
个字节表示,即为[80]
。 将这些字节序列组合在一起就得到了[208, 151, 208, 180, 80]
,它占用了5
个字节空间。
由于'static str
是一个引用类型,它的大小在编译时是已知的,通常为8
个字节(64 位系统)。
&str
s3: &str
:s3
是一个字符串切片类型,它是一个指向字符串数据的引用。在这个例子中,s3
是&s1[1..3]
的结果,即指向s1
的子字符串 "el"。 s3
是一个胖指针(fat pointer),它的内存布局包括指向s1
中字符串片段的数据的指针(ptr)和字符串的长度(len)。 在这种情况下,s3
的长度为2
。与'static str
一样,&str
的大小在编译时是已知的,通常为8
个字节(64 位系统)。
txt
图示:
+–-–----+––-----+–------+ +–––----+–--––--+ +––-––-–+–––––––+
s1 │ [ptr] │ [cap] │ [len] │ s2 │ [ptr] │ [len] │ s3 │ [ptr] │ [len] │
+–-│--–-+––----–+––----–+ +–--│--–+–--–--–+ +–--│--–+–––––––+
stack │ +------------<------------│-------------<---------V
––––––––––-––│––––-│–-–----------–––––––––––-│–––––––––––––––––––––––––––––––––––––––––
heap │ {---V---} │ │ Read-only Data
+-V-+–-–+–––+–––+–––+–– │ +--V-–+–---–+–––+
│ h │ e │ l │ l │ o │ │ │ 3 │ д │ P │
+–--+–––+–-–+–––+–––+–– │ +–----+––--–+–-–+
让我们逐个计算变量 s1、s2 和 s3 的字节数。
rust
let s1: String = String::from("hello");
let s2: &'static str = "ЗдP"; // д -> Russian Language
let s3: &str = &s1[1..3];
println!("Size of s1: {}, {} bytes", mem::size_of::<String>(), mem::size_of_val(&s1));
println!("Size of s2: {}, {}, {} bytes", mem::size_of::<&'static str>(), mem::size_of_val(&s2), mem::size_of_val(s2));
println!("Size of s3: {}, {}, {} bytes", mem::size_of::<&str>(), mem::size_of_val(&s3), mem::size_of_val(s3));
运行之后结果:
txt
Size of s1: 24, 24 bytes
Size of s2: 16, 16, 5 bytes
Size of s3: 16, 16, 2 bytes
s1
:ptr + cap + len; s2
:ptr + len; s2
占用了5
个字节空间 s3
:ptr + len; s3
占用了2
个字节空间
计算 ptr、cap、len
rust
fn main() {
let my_string = String::from("hello world");
let my_vec = vec![1, 2, 3, 4, 5];
let string_ptr = my_string.as_ptr();
let string_cap = my_string.capacity();
let string_len = my_string.len();
let vec_ptr = my_vec.as_ptr();
let vec_cap = my_vec.capacity();
let vec_len = my_vec.len();
println!("String: ptr: {:?}, cap: {}, len: {}", string_ptr, string_cap, string_len);
println!("Vec: ptr: {:?}, cap: {}, len: {}", vec_ptr, vec_cap, vec_len);
}
运行结果:
txt
String: ptr=0x55a7dbf329d0, cap=11, len=11
Vec: ptr=0x55a7dbf329f0, cap=5, len=5
对于 String 对象,当你使用 String::from
创建一个新的字符串时,它会分配足够的内存来存储字符串,并将容量设置为初始字符串的字节数。 对于 Vec 对象,当你使用 vec!
宏创建一个新的向量时,它会预先分配一定数量的内存来容纳元素,并将容量设置为分配的内存可以容纳的元素数量。
len()
和capacity()
方法得到的长度都是字节个数,而非字符个数。 所以在该例子中,my_string
的容量(cap)和长度(len) 都为11
,my_vec
的容量(cap)和长度(len)都为5
。
如果容量不足以容纳新的数据,String 和 Vec 会进行内部的重新分配,扩大容量以适应更多的数据。 这个过程可能会导致内存重新分配和数据复制,因此建议在预知数据数量较大时,尽可能提前预分配好足够的容量,以避免频繁的内存重新分配。
接下来,演示重新分配后的容量和初始容量之间的差异:
rust
fn main() {
// String 内部其实是一个Vec<u8>,是一个可变长度的类型,末尾应该可以追加字符。
let mut my_string = String::with_capacity(5);
let mut my_vec = Vec::with_capacity(5);
println!("String: initial len={}, capacity={}", my_string.len(), my_string.capacity());
println!("Vec: initial len={}, capacity={}", my_vec.len(), my_vec.capacity());
my_string.push_str("hello world");
my_vec.push(1);
my_vec.push(2);
my_vec.push(3);
my_vec.push(4);
my_vec.push(45);
my_vec.push(6);
println!("String: after push_str, len={}, capacity={}", my_string.len(), my_string.capacity());
println!("Vec: after push, len={}, capacity={}", my_vec.len(), my_vec.capacity());
}
运行结果:
txt
String: initial len=0, capacity=5
Vec: initial len=0, capacity=5
String: after push_str, len=11, capacity=11
Vec: after push, len=6, capacity=10
可以看到,初始容量都是5
。 然而,当添加了新数据后,重新分配发生了。字符串my_string
的容量增加到了11
,可以容纳更长的字符串。向量my_vec
的容量增加到了10
,可以容纳更多的元素。 这个示例展示了在不断添加数据时,字符串和向量会动态地重新分配内存,并自动调整其容量以适应新增的数据需求。
truct
Rust 有三种结构体类型定义方式:
unit-like Struct
rust
struct Data;
由于并没有定义 Data 结构体的细节,Rust 也不会为其分配任何内存:
txt
图示:
stack
----------------------------------------------------------
heap
Struct with named fields && tuple-like struct
这两种结构体的内存分配方式是类似的,看一个例子就好。
rust
struct Data {
nums: Vec<usize>,
dimension: (usize, usize),
}
txt
图示:之前谈到 Rust 风格的数据排布是可以做任意重排的,所以图中的 padding 就没有画出了
nums | dimension
+–------+------–+---–---+--------–+--------–+
│ [ptr] │ [cap] │ [len] │ [usize] │ [usize] │
+---│--–+--–---–+-----–-+--------–+--------–+
stack │
––––––––––-–––│-–------––––-–--------------–––––––--–––––––--–––––––--–––––––--–––––––--
heap │
+––-V-------+
│ [usize] │
+––---------+
首先,nums
是 Vec,占用 3 个 machine word(ptr
+ cap
+ len
),ptr
指向 heap 上实际动态数组的值; dimension
是两个 usize 组成的 tuple,占用 2 个 machine word。
Enum
Enum 的默认整数值是可以指定的,例如:
rust
enum HttpStatus {
Ok = 200,
NotFound = 404,
}
本例中,Rust 会选择占用2
byte的 i16 来存储 enum(以满足存储 404 )。
接着我们来看更复杂一些的Enum:
rust
enum Data {
Empty,
Number(i32),
Array(Vec<i32>),
}
首先如果一个枚举类型存在多个枚举值,那么它会为每个枚举值分配一个标签,从0
开始计数,占1
byte,tag 用于标识属于 Enum 中具体哪个变量。 此例中,Empty
的 tag 为0
,需要1
个字节来存储整数标记即可。但是为了满足对齐要求,编译器还会为它填充31
字节的 padding 。
看一下 Number 变体,它存储一个 i32 类型,占4
个字节。它也需要一个整数标记值,这里占用1
个字节。 由于所有的变体都具有相同大小,编译器会为其填充,直到填满32
个字节。
在 64 位系统上,这个 enum Data 总共需要 32 字节大小。
(padding 的位置是不固定的,Rust 会根据具体数据结构的内存分布调整 padding 位置来做优化)
txt
图示:
tag|
+–--+------–-–--------------------------+
Data::Empty │ 0 │ [padding] │
+---+--–--------------–--–--------------+
+–--+------–----+-----------+-----------+
Data::Number(25) │ 1 │ [padding] │ 25 │ [padding] │
+---+--–-------–+-------–---+-----------+
+–--+------–+---–---+--------–+--------–+
Data::Array(vec![]) │ 2 │ [padding] │ [ptr] │ [cap] │ [len] │
+---+--–---–+-----–-+--------–+--------–+
│<--------------- 32 bytes -│---------->│
stack
––––––––––-–––--–------––––-–--------------–––––––--–––––––--–––––––--–––––––--–––––––--
heap
可以看到每一个 Enum 所占的空间由其中占用空间最大的变体所决定,如果要优化 Enum 内存占用的一个技巧就是降低其最大变体的大小。 该例子中,相比于直接把 Vec 存储在 Array 变体中,如果我们选择只存储 Vec 的指针,这个变体需要的最大内存便可以直接降低一半。
Box 是指向堆上数据的指针,因此 Box 在栈上的部分只需要由1
个usize
来存储堆上数据的地址,在 64 位系统上就是8
个字节。 更改之后内存占用为16
个 byte,减少了一半。
txt
图示:一个被装箱的 Vec 的内存布局如图所示:
tag|
+–--+------–-–--------------------------+
Data::Empty │ 0 │ [padding] │
+---+--–--------------–--–--------------+
+–--+------–----+-----------+-----------+
Data::Number(25) │ 1 │ [padding] │ 25 │ [padding] │
+---+--–-------–+-------–---+-----------+
+–--+----------–+---–----------–-------–+
Data::Array(vec![]) │ 2 │ [padding] │ [ptr] │
+---+--–-------–+-----–-----│--–-------–+
│<--------------- 16 bytes -│---------->│
stack │
––––––––––-–––--–------––––-–--------------–––––––--––––––│--–––––––--–––––––-
heap +-----------<-----------+
+---V---+------–+------–+
│ none │ 0 │ 0 │
+---–---+------–+------–+
ptr │ cap │ len
我们可通过print_memory
来打印出 enum 数据的内存结构:
rust
enum DataBox {
Empty,
ArrBox(Box<Vec<i32>>), // 使用 Box 代替
}
enum Data{
Empty,
Arr(Vec<i32>),
}
fn main() {
print_memory(&DataBox::Empty);
print_memory(&DataBox::ArrBox(Box::new(vec![1,2,3])));
print_memory(&Data::Empty);
print_memory(&Data::Arr(vec![1,2,3]));
}
fn print_memory<T>(v: &T) {
let bytes = unsafe {
core::slice::from_raw_parts(v as *const _ as *const u8, std::mem::size_of_val(v))
};
for byte in bytes {
print!("{:02X} ", byte);
}
println!();
}
执行结果:
txt
00 00 00 00 00 00 00 00
F0 59 7A 26 6E 55 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00 F0 59 7A 26 6E 55 00 00 03 00 00 00 00 00 00 00
可以明显看到,使用 Box
带来的内存优化差异。
Option
rust
pub enum Option<T> {
None,
Some(T)
}
Rust 中的 Option 实质上是便是一种 Enum,Rust 通过None
和Some
的区分,避免了其他语言中可能发生的空指针访问问题。 我们可以看Option<Box<i32>>
这个例子,其实就表示一个指向了在堆上分配的i32
类型的指针,同时也表示它的0
值也就是还没有被初始化的状态。 编译器能对此做出一些优化,如果Option
里是Box
或者是类似的指针类型,编译器就会省略掉整数标记,并使用值为0
的指针表示None
。
txt
图示:`Option`里是`Box`
+–-----------+
None │////////////│
+----–--–----+
+–--------–--+
Some(Box::new(42)) │////////////│
+-----│------+
stack │
––––––––––-–––--–------––––-–-------│--–––––––--––
heap │
+---V---+
│ 42 │
+---–---+
这种特性使得 Rust 中被包装在 Option 内的智能指针像其他语言里的指针一样,不会占用多余的内存。同时还能够提前找到并消除空指针异常。
Box
对于通常默认分配在栈上的变量,使用 Box 可以将其分配到堆上,栈空间上只用分配指向堆数据的指针。
我们以一个 tuple 为例let t: (i32, String) = (5, "Hello".to_string);
,在没有经过 Box 处理前,它的内存分布如下图:
txt
图示:省去了 padding
+–-–--+––---------+–------+–------+–------+
t │ [4] │ [padding] │ [ptr] │ [cap] │ [len] │
+–----+––---------+---│--–+––----–+––----–+
stack │
––––––––––-––––––-–-----------------│-–----------–––––––––––--–––––
heap │
+-–V-–+–-–+–––+–––+–––+
│ H │ e │ l │ l │ o │
+––--–+–––+–-–+–––+–––+
如果我们将该数据结构放到 Box b
中,即
rust
let t: (i32, String) = (5, "Hello".to_string);
let mut b = Box::new(t);
内存分布则如下图:
txt
图示:
+–------+
b │ [ptr] │
+---│--–+
stack │
––––––––––-–––-│-–------––––-–--------------–––––––--–––––––--–––––––--–––
heap │
+–-V--+–------+–------+–------+ +-–--–+–-–+–––+–––+–––+
t │ [4] │ [ptr] │ [cap] │ [len] │ │ H │ e │ l │ l │ o │
+–----+–-│---–+––----–+––----–+ +––^-–+–––+–-–+–––+–––+
│ │
+--–-->-------------->-------+
可以看到,原本在栈上的内容都被转移到 Heap 上,减少了我们在栈上的内存空间消耗。
Copy 和 Move 内存布局
在 Rust 中,Copy 和 Move 语义、智能指针(如 Box
、Rc
和 Arc
)的内存分布有一些区别,请看下面的详细说明:
Copy 和 Move 语义:
- 对于实现了
Copy
trait 的类型,其值可以通过简单的位复制来创建副本。这些类型的内存布局通常是在栈上。 - 对于没有实现
Copy
trait 的类型,它们的值会被移动(move)到新的位置,原来的所有权会被转移。这些类型的内存布局可能涉及堆上或栈上的内存。
- 对于实现了
智能指针(Smart Pointers):
Box<T>
是一种堆分配的智能指针,用于持有类型为T
的值。它在堆上分配内存来存储该值,并包含一个指向堆上数据的指针。Rc<T>
是一种引用计数的智能指针,用于多个所有者之间共享数据。它在堆上分配内存来存储引用计数和类型为T
的值,并包含一个指向堆上数据的指针。Arc<T>
是Rc<T>
的线程安全版本,使用了原子操作来进行引用计数。它在堆上分配内存来存储引用计数和类型为T
的值,并包含一个指向堆上数据的指针。
总结起来,内存布局如下:
Copy
类型的值通常位于栈上。- Move 语义类型的值可能位于堆上或栈上。
Box<T>
、Rc<T>
和Arc<T>
这些智能指针类型的数据位于堆上,而智能指针本身(包括引用计数等信息)位于栈上。
需要注意的是,这只是一种一般情况下的描述,具体的内存布局和操作受到编译器、优化等因素的影响。
希望对您有所帮助!如果还有其他问题,请随时提问。
智能指针
智能指针是一种用于管理动态分配内存的工具,在 Rust 中有几种常见的智能指针,包括 Box<T>
、Rc<T>
和 Arc<T>
。这些智能指针在内存布局方面有一些特点:
Box<T>
:Box<T>
是最简单的智能指针,它在堆上分配内存,并且具有独占所有权。Box<T>
的内存布局是连续的,存储了指向堆分配数据的指针。这个指针通常只占据一个机器字大小的空间,而实际的数据则存储在堆上。Box<T>
在运行时会自动释放内存,无需手动管理。
Rc<T>
:Rc<T>
是引用计数智能指针,允许多个所有者共享同一段堆分配数据。Rc<T>
的内存布局包含一个指向堆分配数据的指针,以及一个计数器用于记录当前有多少个Rc<T>
共享同一份数据。- 计数器会随着新的
Rc<T>
的创建和销毁而递增和递减,当计数器为零时,堆上的数据会被释放。
Arc<T>
:Arc<T>
是原子引用计数智能指针,与Rc<T>
类似,但是在多线程环境下可以安全地共享。Arc<T>
的内存布局和Rc<T>
相似,不同之处在于计数器使用原子操作来确保线程安全性。
无论是 Box<T>
、Rc<T>
还是 Arc<T>
,它们都提供了方便的内存管理功能,使得程序的内存分配和释放更加灵活和安全。 内存布局方面,它们会额外占用一些空间来存储引用计数或其他元数据,具体的布局可能会有一些细微差别,但整体上与普通的数据类型类似。
希望这可以回答你的问题!如果还有其他问题,请随时提问。
类型区别
txt
Internal sharing? -[no]--> Allocates? -[no]--> Internal mutability? -[no]--> Ownership? -[no]-----------------------------------> &mut T
\ \ \ `-[yes]----------------------------------> T
\ \ \
\ \ `-[yes]-> Thread-safe? -[no]--> Internal references? -[no]---> Cell<T>
\ \ \ `-[yes]--> RefCell<T>
\ \ \
\ \ `-[yes]-> Internal references? -[no]---> AtomicT
\ \ \ `-[one]--> Mutex<T>
\ \ `--[many]-> RwLock<T>
\ \
\ `-[yes]------------------------------------------------------------------------------------> Box<T>
\
`-[yes]-> Allocates? -[no]-------------------------------------------------------------------------------------> &T
\
`-[yes]-> Thread-safe? -[no]---------------------------------------------------------------> Rc<T>
`-[yes]--------------------------------------------------------------> Arc<T>
其他知识点
内存对齐(Memory Alignment): 内存对齐是指数据在内存中的存储位置需要满足特定的对齐要求。大多数计算机体系结构要求某些数据类型的地址必须是特定大小的整数倍。 在 Rust 中,通常为了优化内存访问的效率和对齐的要求,编译器会自动进行内存对齐操作。你也可以使用
#repr(align(n))
注解来手动控制结构体或枚举的对齐方式。堆栈溢出(Stack Overflow): 栈的大小是有限的,在递归调用或者过多的局部变量声明等情况下,可能会导致栈空间耗尽,从而发生堆栈溢出错误。 为了避免这种情况,可以通过优化递归算法,减少对栈空间的依赖,或者增加栈的大小限制。
进程虚拟地址空间: 在现代操作系统中,每个进程都有自己的虚拟地址空间,用于存储代码、数据和堆栈等。 不同进程的虚拟地址空间是相互独立的,这使得每个进程都可以使用相同的地址来访问其私有内存区域。操作系统通过地址转换技术将虚拟地址映射到物理内存上。
动态内存分配和释放: 在一些情况下,需要在程序运行时动态地分配和释放内存,这就涉及到了堆的管理。 在 Rust 中,可以使用
Box<T>
、Vec<T>
等类型进行堆分配,并使用drop()
方法手动释放内存。 为了避免内存泄漏和悬垂指针等问题,需要合理地管理动态分配的内存。内存安全与所有权机制: Rust 通过引入所有权机制来保证内存安全。在 Rust 中,每个值都有一个对应的所有者,只能有一个所有者,当所有者超出作用域时,该值会被自动释放。 这种所有权机制消除了使用非法指针和内存泄漏等常见的内存安全问题。
size_of
,size_of_val
,align_of
在 Rust 中,size_of
和 align_of
是两个用于获取类型大小和对齐方式的函数,它们都属于 std::mem
模块。
具体来说:
mem::size_of
函数可以用来获取指定类型占用的字节数(size),不包括填充字节:mem::size_of_val
函数用来返回所指向值的大小(以字节为单位),包括填充字节在内的大小:
这个函数通常与std::mem::size_of::<T>()
返回相同的结果,但当 T 没有静态已知大小时(例如,切片[T]
或特征对象),可以使用size_of_val
来获取动态已知大小。
比如获取 String 类型的静态大小 mem::size_of::<String>()
: 因为 String 的实现结构中包含了三个字段:指向字符串数据的指针(ptr)、字符串的长度(len)和字符串的容量(cap)。 对于 64 位系统来说,一个指针的大小通常为 8 字节。因此,ptr
字段占用了 8 字节的空间。 len
字段和cap
字段都是 usize 类型,表示无符号整数,它的大小与平台相关。在 64 位系统上,usize 是 8 字节大小。 所以 String 类型的静态大小通常为 24 字节(在 64 位系统上)。 需要注意的是,这里的静态大小只是 String 结构本身所占用的内存大小,并不包括字符串数据本身。字符串数据是在堆上分配的,并由ptr
字段指向。 这里提到的大小是一种典型情况,具体的大小可能根据编译器、操作系统以及 Rust 版本等因素有所不同。
align_of
函数可以用来获取指定类型所需的最大对齐方式(alignment)。对齐方式是指变量在内存中存储时必须满足的对齐要求,例如某些架构要求变量必须按照 4 字节或 8 字节对齐。
以下是一个简单的示例,演示了如何使用 size_of
和 align_of
函数:
rust
use std::mem;
struct Example {
a: i32,
b: bool,
c: u16,
}
fn main() {
let example = Example { a: 10, b: true, c: 20 };
let size = mem::size_of::<Example>();
let align = mem::align_of::<Example>();
println!("Size of Example is {} bytes, alignment is {} bytes", size, align);
}
在上述代码中,首先定义了一个结构体 Example
,其中包含一个 i32
类型字段、一个 bool
类型字段和一个 u16
类型字段。 接着,在 main
函数中创建了一个 Example
类型的变量 example
。 使用 mem::size_of
函数和 mem::align_of
函数分别求出 Example
类型的大小和对齐方式,并输出到控制台。
在这个例子中,我们可以看到,Example
类型的大小为 8 字节,而其对齐方式为 4 字节(在本地测试中)。 这是因为 i32
类型和 u16
类型各自占用 4 字节和 2 字节,加起来总共 10 字节,需要在内部填充 2 字节的空间,才能满足 4 字节的对齐要求。
获取填充的字节大小
我们可以使用 Rust 标准库中的 mem::size_of_val()
和 mem::align_of_val()
,来计算上个例子中 padding(填充)字节大小。具体来说,需要执行以下步骤:
- 调用
mem::size_of_val(&example)
函数获取example
实例的总大小,包括 padding 字节。 - 调用
mem::align_of_val(&example)
函数获取example
实例的对齐方式,并计算出该对齐方式下的 padding 字节大小。 - 用总大小减去实际占用空间大小,即可得到 padding 字节大小。
代码如下:
rust
use std::mem;
struct Example {
a: i32,
b: bool,
c: u16,
}
fn main() {
let example = Example { a: 10, b: true, c: 20 };
let size = mem::size_of_val(&example);
let align = mem::align_of_val(&example);
let used = mem::size_of::<Example>();
let padding = size - used;
println!("Padding bytes of Example is {} bytes", padding);
}
在运行该程序时,输出结果为:
txt
Padding bytes of Example is 0 bytes
说明 Example
结构体中不存在 padding 字节。这是因为,在默认情况下,Rust 编译器会尝试以最紧凑的方式排列结构体中的成员变量,从而避免不必要的内存浪费。 如果需要手动控制结构体成员变量的排列顺序和对齐方式,可以使用 Rust 提供的一些属性如 #[repr(C)]
、#[repr(packed)]
等。
内存对齐
对齐(alignment)是指内存中变量的存储位置必须满足的一种要求。 在现代计算机架构中,变量的存储位置必须按照一定的规则进行对齐,其目的是为了提高内存读写速度和处理器性能。
对于某些数据类型,如 i32
、f64
等,它们需要以固定的字节格式存储,因此内存地址必须按照特定的字节数进行对齐,否则可能会导致读写操作无法正常进行,甚至出现程序崩溃等问题。 另一方面,某些计算机体系结构可以通过直接读取内存中的连续字节来提高处理速度,对齐要求也是为了支持这种优化。
在 Rust 中,对齐方式和内存布局有关。在内存中,每个变量都要占用一定的空间。 当内存中出现多个变量时,不同的对齐方式会影响它们的布局方式,进而影响内存的占用情况和读写效率。
在 Rust 中,默认情况下,数据类型的对齐方式是与其大小相对应的。
通常按照以下默认对齐规则进行:
u8
、i8
、bool
和char
类型对齐到 1 字节边界。u16
、i16
类型对齐到 2 字节边界。u32
、i32
、f32
和指针类型对齐到 4 字节边界。u64
、i64
和f64
类型对齐到 8 字节边界。
通常来说,一个数据类型的对齐方式是它包含的成员变量中对齐要求最高的那个。Rust 语言标准规定,任何变量都必须满足其类型的对齐要求,否则系统会强制对齐。
Rust 中,默认按照数据类型的大小进行对齐。通过 #[repr(packed)]
属性可以手动设置紧凑对齐方式。 在实际应用中,也可以通过调整数据结构布局和对齐方式来优化程序性能。
参考
Is repr(C) a preprocessor directive?