← Back to DevBytes

Memory Management in V: A Deep Dive

Introduction to Memory Management in V

Memory management in V takes a refreshingly pragmatic approach. Unlike languages that rely solely on garbage collection (Go, Java) or demand meticulous manual allocation/deallocation (C, C++), V employs a hybrid strategy centered around compile-time reference counting and an autofree engine. This means the compiler analyzes your code and inserts the necessary cleanup logic automatically, giving you deterministic, predictable memory behavior without the runtime overhead of a tracing garbage collector.

The core philosophy is simple: give developers the convenience of automatic memory management while preserving the performance characteristics of manual control. V's memory model is one of its defining features and a key reason why the language can compile to C without dragging along a heavy runtime.

How Memory Management Works in V

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

V's memory management operates on three interconnected layers:

The Reference Counting Core

At the heart of V's memory management is reference counting. Every heap-allocated object carries a reference counter that tracks how many pointers reference it. When you assign a reference, the counter increments; when a reference goes out of scope or is reassigned, it decrements. Once the counter reaches zero, the memory is freed immediately — no deferred collection cycles, no pauses.

This reference counting is not done at runtime by the program; it's inserted by the compiler at compile time. The V compiler performs an escape analysis to determine which variables can live purely on the stack and which must be heap-allocated. For heap-allocated objects, the compiler injects reference count increment/decrement operations at the appropriate points in the generated C code.

// Example: Reference counting in action
struct Person {
    name string
    age  int
}

fn main() {
    // p is heap-allocated with ref count starting at 1
    p := &Person{name: 'Alice', age: 30}
    
    // q takes a reference — ref count goes to 2
    q := p
    
    // Both references exist here, ref count is 2
    println(p.name)
    println(q.name)
    
    // q goes out of scope — ref count drops to 1
    // p goes out of scope — ref count drops to 0, memory freed
}

The compiler tracks each reference and inserts decrement operations exactly where variables go out of scope. This gives you deterministic freeing — you always know when memory will be released, unlike garbage-collected languages where collection timing is unpredictable.

The Autofree Engine

Autofree is V's compile-time memory management pass. It analyzes your entire program and determines precisely which allocations can be freed and where to insert the free operations. This is not reference counting per se but a complementary static analysis that handles cases where reference counting alone would be insufficient or too conservative.

Autofree excels at identifying temporary allocations, function-local heap objects, and patterns where the compiler can prove that an object's lifetime is bounded. It transforms heap allocations into effectively stack-like allocations when the lifetime analysis permits it.

// Autofree automatically handles these temporary allocations
fn process_data() {
    // This allocation is detected as function-local by autofree
    data := []int{len: 1000}
    
    for i in 0 .. data.len {
        data[i] = i * 2
    }
    
    // data is automatically freed when the function exits
    // No reference counting overhead needed
}

Autofree works at compile time, so it adds zero runtime overhead. The analysis is conservative — it only frees memory when it can prove it's safe to do so. In cases where the analysis cannot guarantee safety, V falls back to reference counting or leaves the decision to you.

Manual Memory Management

Sometimes you need explicit control. V provides manual allocation and deallocation functions that bypass both reference counting and autofree. This is essential for performance-critical sections, custom allocators, or interfacing with C libraries.

import unsafe

fn manual_example() {
    // Allocate raw memory (no ref counting, no autofree)
    ptr := unsafe.malloc(4096)
    
    // You are responsible for this memory now
    // ... work with the raw pointer ...
    
    // Must explicitly free
    unsafe.free(ptr)
    
    // Failing to free causes a memory leak — be careful
}

The unsafe module gives you direct access to raw memory operations. Use it when you need maximum control, but always pair every malloc with a corresponding free. V's philosophy is to make manual memory management explicit and visible through the unsafe namespace, reminding you that you're stepping outside the safety net.

Why V's Memory Management Matters

The design of V's memory management has profound implications for real-world development:

Memory Management Patterns in Practice

Working with Structs and References

Structs in V are value types by default. When you use & to take a reference, the compiler may heap-allocate the struct and begin reference counting. Understanding when heap allocation occurs helps you write efficient code.

struct Point {
    x f64
    y f64
}

fn struct_examples() {
    // Stack-allocated struct — no heap allocation, no ref counting
    p1 := Point{x: 10.0, y: 20.0}
    
    // Taking a reference forces heap allocation
    p2 := &Point{x: 30.0, y: 40.0}  // Now heap-allocated with ref count
    
    // p3 is also heap-allocated, ref count on same object is now 2
    p3 := p2
    
    // Modifying through references works naturally
    p3.x = 50.0
    
    println(p2.x)  // Prints: 50.0 — both references point to same object
}

Arrays and Slices

Arrays in V are value types with fixed size known at compile time. Slices (dynamic arrays) involve heap allocation and are managed by the autofree engine and reference counting.

fn array_management() {
    // Fixed array — purely on stack, no allocation
    fixed := [1, 2, 3, 4, 5]!  // The ! indicates fixed array
    
    // Slice — heap allocated, managed by autofree
    dynamic := [1, 2, 3]  // This is a slice, heap-allocated
    
    // Appending may trigger reallocation
    dynamic << 4
    dynamic << 5
    
    // When dynamic goes out of scope, memory is freed automatically
    
    // Slices of slices share underlying memory
    slice1 := [10, 20, 30, 40, 50]
    slice2 := slice1[1..4]  // References same backing array
    
    // Be aware: modifications through slice2 affect slice1's backing array
    slice2[0] = 99
    println(slice1[1])  // Prints: 99
}

Cyclic References and Weak Pointers

A classic challenge with reference counting is cyclic references — object A references object B, and B references A, creating a cycle that prevents either reference count from reaching zero. V provides weak references to break these cycles.

struct Node {
    value string
    next &Node       // Strong reference
    prev &Node       // Strong reference — creates a cycle!
}

struct SafeNode {
    value string
    next  &SafeNode  // Strong reference
    prev  &SafeNode  // Weak reference — breaks the cycle
    // Use weak references for back-pointers in doubly-linked structures
}

When designing data structures with bidirectional links, use weak references for one direction to prevent reference count cycles. The compiler will warn you about potential cycles in many cases, but the ultimate responsibility for cycle-free designs rests with you.

Best Practices for Memory Management in V

1. Prefer Value Types When Possible

Structs passed by value avoid heap allocation entirely. For small, immutable data, pass by value to keep memory purely on the stack and eliminate reference counting overhead.

// Good: small struct, passed by value — no allocation
fn distance(a Point, b Point) f64 {
    dx := a.x - b.x
    dy := a.y - b.y
    return math.sqrt(dx*dx + dy*dy)
}

// Also good: for large structs, use references consciously
fn transform_large_data(data &LargeStruct) {
    // data is a reference, but only one exists — ref count stays at 1
}

2. Scope Variables Tightly

The autofree engine works best when variable lifetimes are clear and bounded. Declare variables in the narrowest scope possible. This helps the compiler prove that allocations can be freed early.

fn tight_scoping_example() {
    // Outer scope
    result := []int{}
    
    // Inner scope — temp_data will be freed after this block
    {
        temp_data := calculate_temporary_values()
        result = process_and_filter(temp_data)
        // temp_data goes out of scope here, autofree frees it
    }
    
    // Only result remains allocated
    println(result.len)
}

3. Use unsafe Sparingly and Consciously

Reserve the unsafe module for performance-critical sections, C interop, or cases where you need raw pointer arithmetic. When you do use it, clearly document why and keep the unsafe region as small as possible.

fn safe_unsafe_usage() {
    // Keep unsafe operations isolated in small blocks
    data := []byte{len: 1024}
    
    // Fill the buffer normally
    for i in 0 .. data.len {
        data[i] = byte(i & 0xFF)
    }
    
    // Only this tiny block uses unsafe for a raw pointer operation
    result := unsafe {
        ptr := &data[0]
        process_raw_buffer(ptr, data.len)
    }
    
    // Back to safe territory — data will be freed automatically
}

4. Be Mindful of Shared State

When multiple references point to the same heap object, modifications through any reference affect all others. This is powerful but can lead to subtle bugs. Use V's shared and lock keywords for concurrent access, and prefer immutable designs when practical.

// Thread-safe shared memory pattern
shared shared_data := &SharedCounter{count: 0}

fn increment_counter(shared sc SharedCounter) {
    lock sc {
        sc.count++
    }
}

5. Test for Memory Leaks During Development

V includes built-in leak detection for development builds. Run your program with the -d leak_detector flag to enable runtime leak tracking. This will report any allocations that were never freed, helping you catch missing cleanup logic early.

// Build with leak detection
// v -d leak_detector run myprogram.v

// Or during testing
// v -d leak_detector test mymodule/

6. Understand the .free() Method Pattern

For types that manage resources (file handles, network connections, GPU buffers), V supports a .free() method convention. When you call .free() on an object, it triggers deterministic cleanup of the underlying resource. This works hand-in-hand with autofree — the compiler can even insert .free() calls automatically when it detects the object is no longer reachable.

struct Resource {
    handle int
    data   []byte
}

fn (r &Resource) free() {
    // Close handle, release system resources
    println('Cleaning up resource with handle ${r.handle}')
    unsafe { r.data.free() }
}

fn resource_example() {
    res := &Resource{
        handle: acquire_system_resource(),
        data:   []byte{len: 4096}
    }
    
    // Use the resource...
    process_data(res)
    
    // Explicit free is optional — autofree will call .free() automatically
    // when res goes out of scope if the compiler can prove it's safe
}

Advanced Topics

Escape Analysis in Detail

V's compiler performs escape analysis to decide which variables must be heap-allocated. A variable "escapes" if it outlives the function that created it — for example, when you return a reference to a local variable. The compiler detects this and promotes the allocation to the heap with reference counting.

fn create_person(name string, age int) &Person {
    // The Person struct escapes this function (returned as reference)
    // Compiler automatically heap-allocates it
    return &Person{name: name, age: age}
}

fn no_escape_example() {
    // This Person does NOT escape — it's only used locally
    // Compiler can stack-allocate it
    p := Person{name: 'Local', age: 25}
    println(p)
    // No heap allocation, no reference counting
}

The -autofree Flag and Manual Override

You can control autofree behavior with compiler flags. The -autofree flag enables aggressive autofree analysis (it's on by default in most configurations). You can disable it with -no-autofree if you want pure reference counting or manual control. Additionally, the [no_autofree] attribute on functions tells the compiler to skip autofree analysis for that specific function.

[no_autofree]
fn manual_lifetime_management() {
    // The compiler will not insert autofree operations here
    // You manage memory manually or rely solely on reference counting
    data := []int{len: 1000}
    
    // You must handle cleanup explicitly
    unsafe { data.free() }
}

Conclusion

V's memory management strikes a remarkable balance between safety and performance. By moving memory management logic to compile time — through reference counting injection, autofree static analysis, and escape analysis — V delivers deterministic, predictable memory behavior without the runtime burden of a garbage collector. This design makes V suitable for domains where GC pauses are unacceptable: games, real-time systems, embedded devices, and high-performance servers.

The key to mastering memory management in V is understanding the interplay between value types (stay on the stack), references (trigger heap allocation with reference counting), and the autofree engine (handles temporary allocations automatically). Prefer value types for small data, scope variables tightly to help the compiler optimize, use unsafe sparingly and with clear intent, and always be mindful of reference cycles in complex data structures. With these principles in mind, you can write V code that is both memory-safe and blazingly fast — enjoying the convenience of automatic memory management while retaining the performance characteristics typically associated with manual control.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles