← Writing

Understanding Memory Management in Rust: From Dynamically Sized Types to Fat Pointers

2025-06-09 rust · memory · basic


Rust’s approach to memory management is both powerful and complex.
🟠 In this article, I want to take a deep dive into how Rust handles memory for the type system under the hood, and try to answer questions like:


    Some Definitions

ZST (Zero Sized Types) - the entire size of this type is collapsed to zero at compile time. (always impl Sized)

DST (Dynamically Sized Types) - is a non-sized (!Sized) type that does not contain information about its length in the type itself, but the length is stored at runtime as metadata associated with the slice pointer.
At compile time, Rust does not know how much memory needs to be allocated for this type (str, [T], Arc.. ).
Variables of these types can only be used behind references that include metadata about the length. (&str, &[T], Box<[T]>…)

Machine Word (word) - is the amount of data that the CPU can process per unit of time.
The memory address range is determined by the size of the machine word in the current CPU, for a 64 bit processor it is 64 bits respectively (or 8 bytes).
The size of the reference (usize) is equal to a machine word. (ptr, cap, len).


    1. Basic concepts of memory management

After compiling the executable file. Read more about Rust compiling pipeline in my article. This file stores data, metadata, and machine instructions for the CPU in specific platform-dependent formats. (ELF on Linux, PE on Windows,Mach-O on macOS). No matter what formats, the way they are executed is almost the same.

When a program (process) is started on the target platform, the kernel allocates the necessary amount of memory based on the segment metadata (.text, .data, .bss, etc) from the ELF headers. This memory is a solid range of addresses to use, called the virtual address space of the program.
The kernel will map segments to the allocated memory at startup, the number of segments required depends on the compiler.
Since memory is allocated lazily, the kernel and hardware will do the mapping to physical addresses in RAM only on the first access.
From the point of view of the process, it sees a solid range of memory from 0 to the maximum value.

section               size        addr
.vector_table          192  0x10000100
.text                33044  0x100001d4
.rodata               4904  0x100082e8
.data                    0  0x20000000
.bss                     4  0x20000000
...

  Stack

Located at the top of the user space and grows downwards⬇️.

The stack is unique to each individual thread of the process. On 64-bit Linux Systems, Rust programs use an 8Mb stack for the main thread.
The Rust std supports specifying the stack size for any thread that the program creates. The default value is 2Mb.

Although the size of the stack for the main thread is 8Mb, this memory is not allocated immediately, it is allocated by the kernel only on the first access.
The stack grows downwards - to a lower memory area.
It can only grow until total stack size for that particular thread (if it is the main thread - up to 8Mb)
If a program thread uses more stack space than it is allocated, the kernel will terminate the execution of that program with an error - stack overflow

  Heap

All threads share one common heap.
The heap memory address starts close to zero (after code and data) and grows upwards⬆️.
The heap can grow to huge sizes (theoretically up to 128 TB).


    Rust on Stack

The main purpose of the stack memory is to store the data of the currently executing function (all parameter of the function, its local variables and the return address).
Only variables with a fixed size and whose size is known at compile time can be placed on the stack.

Allocation and deallocation on the stack requires only increasing or decreasing the hight of the stack pointer and is fast, since no system calls are required.

  1. First, a stack frame is created for the main function, all local variables with known sizes are placed in it.
  2. The main() calls another function, for which its own stack frame is created with enough memory to store its data.
  3. The return address (0x23f) is the next instruction in the stack frame of the main() (the assignment operator let b = …), to which execution should return after the add_one() function is finished.
  4. At this point, the stack pointer will be raised, but the stack frame allocated memory for the add_one() function will not be fully deallocated, these addresses will be overwritten when another function is called.

    Rust on Heap

In Rust, Heap Allocator is described through the GlobalAlloc trait - it defines the functions that the allocator must provide.
Rust uses malloc from the standard C library, libc, to work with memory. Under the hood, Rust assumes that the running platform has libc.
Allocating and working with memory on the heap is slow primarily because of system calls to libc and the allocator searching for a suitable place for the data.
To reduce the number of system calls, memory allocator requests memory in blocks.

  1. The main stack frame is created for the main() function
  2. A nested stack frame is created for the heap() function
  3. Box - allocates space on the heap for the number 23 (i32 = 4 bytes), and a pointer (0x5f21) to this space is written to the b variable on the stack, since the pointer has a fixed size. Therefore, the size of b in the stack is usize = 8 bytes.
  4. Through the return address from heap(), the pointer to Box is written to the result variable in the main() function.
  5. Now, even if the stack frame of the heap() function is deallocated, the result variable contains the address of the data in the heap.

    Rust Data Types

  Integer

Stored entirely on stack. Signed and unsigned numbers indicate how many bit they can store.

i8 / u8i16 / u16i32 / u32i64 / u64i128 / u128f32 / f64isize / usize
1 byte2 bytes4 bytes8 bytes16 bytes4 / 8 bytes4 / 8 bytes

  Char

The char type in Rust is a primitive type that takes up a fixed amount of memory (4 bytes).
Like other primitives (i32, bool, etc.), it is stored on the stack by default.
The char type in Rust represents a single Unicode Scalar Value.
Regardless of the specific character (whether it’s a, €, or 🦀), the char type in Rust always takes up exactly 4 bytes (32-bit).

  Tuple

A type consists of a fixed set of values ​​of diffrent types.
If all composite types are stored on a stack, then the entire tuple is stored on a stack.
The size of all composite types is in order, but with alignment taken into account.
Alignment is performed by the compiler so that CPUs can read data more efficiently.

size_of::<(char, u8, i32)>();   // 12
align_of::<(char, u8, i32)>();  // 4

  Reference

  Array

let a: [i32; 3] = [55, 66, 77];

An array has a fixed size (constant after creation), and this size is part of the type.
Values ​​of an array type are strictly of the same type and are placed one after another on the stack.

  Vector

let v: Vec<i32> = vec![55, 66, 77];

An alternative to a constant array that can change its size while keeping the data types inside the same. Vector will store 3 pointers (ptr, len, cap) in stack.

  Slice

[T] - similar to a fixed size array, except that we don’t have to specify the size and data type.

let s1: [i32] = a[0..2];
let s2: [i32] = v[0..2];

Usually a slice reference is used - which can be placed on the stack.
&[T] - fat pointer 2 pointers - ptr + len

let a: [i32; 3] = [55, 66, 77];
let v: Vec<i32> = vec![55, 66, 77];

let s1: &[i32] = &a[0..2];
let s2: &[i32] = &v[0..2];

Here s1 and s2 are a slices that contains ptr и len. This information is stored in a fat pointer, which consists of:

  String, str, &str

String is a Vec<u8> where each value is a separate Unicode character in UTF-8 encoding.
Like a vector, String stores 3 pointers to stack (ptr, cap, len)

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

If you store a string literal directly into a variable, the data type of that variable will be a reference to a slice of the string with a static lifetime.
These strings are stored in .rodata (read-only data) directly in the binary code.
In this case, s2 on the stack will be represented by a fat pointer (ptr + len).
ptr will point to a specific range in static memory.

let s2: &'static str = "hello";
let ptr: *const u8 = s2.as_ptr();

The str type in Rust is a DST.
Therefore, you cannot create a variable of type str without using a pointer or reference.

It is possible to get a part of the String using ranges, but this will return a slice of the String.

This slice does not copy the data, but only references it, and includes information about the length of the slice.

let s1: &str = &s[1..3];;

  Struct

There are 3 types of structs in Rust

  Enum

Rust has several different syntaxes for enums.

enum HTTPStatuses{
	OK,
	NoFound,
}
enum HTTPStatuses{
	OK = 200,
	NoFound = 404,
}
enum Data{
	Empty,
	Number(i32),
	Array(Vec<i32>),
}

  Enum with Box

Box is a pointer to some memory allocated on the heap.
The most obvious way to optimize the memory of the entire enum is to limit the size of its maximum variant, instead of storing the vector directly in Array, you can store a pointer to vector.

enum Data{
	Empty,
	Number(i32),
	Array(Box<Vec<i32>>),
}

In this case, the amount of memory required for this option is halved.

In the stack of the function call, memory will be allocated for ptr, to store the memory address to which the vector points.
The Heap will store (ptr, cap, len), necessary for representing the vector. At the same time, if the vector contains values, they will also be stored in the heap.

  Option Enum

None variant does not store any values, only the integer tag 0
Some variant stores the actual data along with the integer tag 1

Rust uses the null pointer optimization for pointer types that can never represent the value 0 (null).
This allows Option<Box<T>> to take up exactly the same amount of memory as Box<T>, because the value None is encoded in a null pointer, while Some(ptr) is encoded in a non-null pointer to data.

  Copy vs Move

For primitive types, assigning one variable a value to another variable makes a bit-by-bit copies of those values.
This is possible because the values ​​of those variables can be represented using only bytes on the stack.

For values ​​that require heap allocation. For example, a vector of heap-allocated strings.

In the case where it is necessary to assign the value of the vector v to another variable, the ownership is transferred to the new variable and it is now responsible for clearing the allocated memory on the heap. It is written 3 machine words, which represented v
If it is necessary to create a variable that will own a full copy of the vector data with a new allocation of data on the heap, it is necessary to explicitly call the clone() method.
In this case, each variable owns its own memory area with the same data.

  Reference Counter (Rc)

When you want a single value to have multiple owners.
In most cases, you can use regular references to share values, but the problem is that when an owner goes out of scope, you can’t use those references anymore.
Instead, you want each value to have a common owner and remove value from memory only when all owners go out of scope.

use std::rc::Rc;

let v: Rc<Vec<String> = Rc::new(vec![
	"Odin".to_string,
	"Thor".to_string,
	"Loki".to_string,
]);

let v2 = v.clone();

 Send and Sync

Send marks a type as safe to share ownership between threads (i.e. &T is Send) Sync marks a type as safe to share by reference between threads (i.e. T is Sync) Rc - is not send\sync because if multiple threads have Rc - an attempt to increment a shared counter may result in a data race.

  Arc

If you need shared data between threads - you need to use Atomic Reference Counter (Arc).
It works the same way as Rc, but changing the counter is an atomic operation, which can be safely performed from multiple threads.
However, you have to pay a little performance for atomicity.
By default, Arc is immutable, even if multiple threads have a pointer to the same data - they are not allowed to change it.

  Mutex

If you need mutable access to shared data between multiple threads - you can wrap Mutex inside Arc.
Now, if two threads try to access the same data, they will first need to get a lock on that data.
And only one thread will be able to access the data to modify it.

let data: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
{
    let mut locked_data = data.lock().unwrap();
    *locked_data += 1;
} 

  Trait Object

A pointer to a trait type is called a Trait Object
There are several ways to convert a concrete type to a Trait Object, both of which convert a Vec<u8> to an object that implements trait Write.

use std::io::Write;

    let mut buffer: Vec<u8> = vec![];
    let w: &mut dyn Write = &mut buffer;
    fn main() {
        let mut buffer: Vec<u8> = vec![];
        writer(&mut buffer);
    }

    fn writer(w: &mut dyn Write){ ... }

In memory, Trait Object is a fat pointer of two pointers (data pointer + vtable pointer)

Rust can convert normal references to Trait Object
The same can be done with smart pointers Box, Rc, Arc …
In these cases - they also become fat pointer - type Box<dyn Write> means that the value has a Writer on the heap.