Memory, GC and object shapes

Ruby · Expert · 16 min

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 (or GC.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       # true

Remember

  • 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.

Check yourself

  1. Why is nums.sum { it * 2 } cheaper than nums.map { it * 2 }.sum?
  2. What does a minor GC scan?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ruby-lang.org/en/3.4/GC.html