Ruby’s memory management combines multiple techniques to balance performance, predictability, and ease of use. Understanding these mechanisms clarifies how Ruby’s garbage collector interacts with the Ruby VM and GVL.
RVALUE: The Fundamental Unit
Ruby represents objects as RVALUEs (Ruby values)—fixed-size structures containing either the object data directly (for small objects) or pointers to heap-allocated data (for large objects).
RVALUEs provide uniform access to diverse Ruby types: Fixnums, Symbols, Arrays, Hashes, custom objects. The VM allocates RVALUEs from heap pages organized into size pools, enabling efficient memory management without fragmenting the address space.
Key characteristics:
- Fixed 40-byte size on 64-bit systems
- Contains object header (flags, type, GC metadata)
- Embeds small objects; points to external storage for large objects
- Enables fast allocation through free lists
This design embodies architectural sympathy—uniform object size simplifies allocation, deallocation, and GC scanning at the cost of some space overhead for tiny objects.
Object Allocation Strategy
Ruby uses bump-pointer allocation within heap pages. When allocating an object:
- Check the current page’s free list for available RVALUEs
- If available, allocate in O(1) time
- If page full, allocate new page from OS
- Organize pages into size pools for variable-width objects (Ruby 3.2+)
This strategy prioritizes allocation speed—typically a few CPU cycles. The garbage collector pays the cost later during collection, exemplifying the throughput vs. latency tradeoff.
Variable Width Allocation (Ruby 3.2) refines this by maintaining separate pools for different object sizes. Large objects live in dedicated pools, preventing small object allocations from wasting space in large slots. This reduces fragmentation while maintaining fast allocation.
Generational Hypothesis in Practice
Ruby’s generational GC exploits the observation that most objects die young. The collector tracks object age through GC survival count:
- New objects: Created since last GC, age 0
- Young objects: Survived 1-2 GC cycles
- Old objects: Survived 3+ GC cycles (promoted to old generation)
Minor GC runs frequently, marking only young objects. Fast because young objects are few and mostly dead. Major GC runs less frequently, marking all objects. Slower but reclaims more memory.
This creates interesting dynamics with fiber-based concurrency. Fibers that pause mid-execution keep objects alive longer than traditional request-response patterns, potentially promoting more objects to old generation and changing GC behavior.
Write Barriers and Remembered Sets
The generational strategy requires tracking cross-generational references. When an old object points to a young object, minor GC needs to know—otherwise the young object appears unreachable.
Write barriers intercept reference assignments, maintaining a remembered set of old objects that reference young objects. Minor GC scans:
- Young objects themselves
- The remembered set (old → young references)
- Roots (stack, globals)
This enables minor GC to run without scanning the entire old generation. The remembered set stays small because cross-generational references are relatively rare—most references stay within generations.
The incremental marking also uses write barriers to maintain tri-color invariants, showing how the same mechanism serves multiple purposes.
Heap Compaction
Ruby 2.7 introduced heap compaction to fight fragmentation. Over time, allocation and deallocation patterns create “holes” in heap pages—pages with some live objects and some free slots. These pages can’t be returned to the OS, wasting memory.
Compaction works by:
- Identifying sparse pages (few live objects)
- Moving live objects to fuller pages
- Updating all references to moved objects
- Returning empty pages to the OS
This requires scanning the entire object graph to find and update references—expensive but effective. Ruby 3 enables automatic compaction via GC.auto_compact = true
, running compaction opportunistically during major GC.
The Immix algorithm achieves similar goals through different means—opportunistic evacuation rather than full compaction. Ruby’s approach separates concerns: mark-and-sweep handles liveness, compaction handles layout.
GC and the GVL
Ruby’s Global VM Lock serializes Ruby execution. Only one thread holds the GVL and runs Ruby code at a time. The garbage collector must acquire the GVL to run safely.
This interaction creates subtle performance characteristics:
During GC:
- GC holds the GVL
- All Ruby threads pause (stop-the-world)
- Native extensions running outside GVL continue
- GC releases GVL between incremental marking steps
Impact on Concurrency:
- Long GC pauses block all threads longer
- Incremental GC reduces individual pause times
- Threads queue behind GC just like any GVL holder
- IO-bound threads benefit most from shorter pauses
For fiber-based async, this matters less—fibers don’t provide parallelism anyway. But for multi-threaded applications, GC pauses multiplied by thread count significantly impact latency.
Tuning Knobs
Ruby exposes several GC parameters for tuning:
GC.stat: Returns GC statistics (count, time, heap size) RUBY_GC_HEAP_GROWTH_FACTOR: How aggressively to grow heap RUBY_GC_HEAP_GROWTH_MAX_SLOTS: Maximum slots to add per growth RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR: When to trigger major GC GC.auto_compact: Enable automatic compaction
Tuning reflects application characteristics. Long-lived servers benefit from compaction. High-throughput batch jobs might disable incremental GC for maximum throughput. Request-response services want minimal pause times.
This echoes JIT tuning—no one-size-fits-all solution. Profile, understand your workload, tune accordingly.
Memory Growth Patterns
Ruby’s heap grows but rarely shrinks. The VM requests memory from the OS as needed but returns it conservatively. This “high water mark” behavior means peak memory usage tends to stick.
Compaction helps by consolidating live objects, enabling some page returns. But generally, a Ruby process that once used 2GB will stay near 2GB even if current object count drops.
This design prioritizes allocation speed and reduces system call overhead, but can surprise developers expecting memory to shrink after load drops. It reflects the tradeoff between space and time—hold memory for faster future allocations.
Interaction with Native Extensions
Ruby’s memory management becomes complex when native C extensions enter the picture. Extensions can:
- Allocate objects outside Ruby’s GC
- Hold references to Ruby objects without GC awareness
- Disable GC around performance-critical sections
- Create reference cycles between Ruby and native objects
The VM provides mechanisms (marking functions, write barriers) for safe extension integration, but extensions must use them correctly. Many performance issues trace to extension memory management.
The GVL interaction also matters—extensions that release the GVL during long operations let GC run concurrently, improving throughput.
Comparing to Other Languages
Ruby’s memory model differs significantly from other languages:
vs. Java: JVM has more sophisticated GC (concurrent, low-latency collectors) but Ruby prioritizes implementation simplicity
vs. Python: Similar reference counting + GC hybrid in CPython, but Ruby’s generational approach differs
vs. Go: Go’s concurrent GC minimizes pauses more aggressively, but Ruby’s incremental approach achieves similar goals with different tradeoffs
vs. Rust: No GC—manual memory management through ownership. Maximum performance, maximum complexity
Each choice reflects language philosophy. Ruby prioritizes developer happiness and implementation maintainability over maximum GC sophistication.
Performance Implications
Understanding Ruby’s memory management clarifies performance characteristics:
Allocation is Fast: Bump-pointer allocation takes nanoseconds. Create objects freely in hot paths.
GC Pauses Vary: Minor GC takes microseconds, major GC milliseconds. Incremental marking spreads this out.
Memory Doesn’t Shrink: Peak usage persists. Monitor peak, not steady-state.
Concurrency Interacts: GC pauses block all threads. Shorter pauses multiply benefits in multi-threaded apps.
Extensions Matter: Native code bypassing GC can cause issues. Profile carefully.
These insights inform pragmatic optimization decisions—know when GC matters, when it doesn’t, and how to measure the difference.