Memory bloat and GC pauses are the most common performance problems in production Ruby services.
CRuby stores objects in heap slots. Since Ruby 3.2, variable width allocation gives size pools (40, 80, 160, 320 and 640 bytes), so small strings and arrays live directly in the slot; bigger data is allocated with malloc. Immediate values (small Integers, Symbols, nil, true, false, most Floats on 64-bit) aren't heap objects at all.
The garbage collector is mark-and-sweep with two key optimisations:
- Generational: most objects die young, so frequent minor GCs scan only young objects, and objects surviving a few collections are promoted to the old generation, scanned only in major GCs. Write barriers track old-to-young references.
- Incremental marking splits major GCs into steps to shorten pauses.
GC.compact(orGC.auto_compact = true) moves objects to reduce fragmentation.
Tools: GC.stat (counts of minor and major GCs, heap pages), ObjectSpace.count_objects, ObjectSpace.memsize_of, and the memory_profiler and derailed_benchmarks gems to find which lines allocate the most.
Allocation is the cost you control. Freeze string literals, avoid building throwaway arrays in hot loops (each instead of map when you ignore the result, sum instead of map.sum), and use << rather than += for strings. Object shapes (3.2) cache instance-variable layout per class; setting ivars in a consistent order, ideally all in initialize, keeps lookups fast for the interpreter and YJIT.
In multi-threaded servers, glibc malloc arena fragmentation can bloat memory; jemalloc or MALLOC_ARENA_MAX=2 is a common fix.
Example
# frozen_string_literal: true
def allocations
GC.disable
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
ensure
GC.enable
end
nums = (1..1000).to_a
p allocations { nums.map { it * 2 }.sum } # 3: includes a new 1000-element array
p allocations { nums.sum { it * 2 } } # 1: no intermediate array
p allocations { s = +""; 100.times { s << "x" } } # ~5: appends in place
p allocations { s = ""; 100.times { s += "x" } } # ~100: new string each time
p 42.frozen?, :sym.frozen? # true, true (immediates)
stat = GC.stat
p stat.slice(:minor_gc_count, :major_gc_count).keys # [:minor_gc_count, :major_gc_count]
require "objspace"
p ObjectSpace.memsize_of("x" * 10_000) > 10_000 # trueRemember
- Small integers, symbols, nil and booleans are immediates, not heap objects
- The generational GC makes short-lived objects cheap but not free
- Measure allocations with GC.stat and memory_profiler before optimising
Watch out for
- Optimising by guesswork instead of measuring allocations in the real hot path.