Garbage Collection and Memory

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Ruby programs never free memory explicitly: the garbage collector (GC) finds the objects that can no longer be reached and reuses their memory. For most programs that is all there is to know, but a long-running service, a batch job that loads a large catalogue, or a library with a C extension eventually has to answer questions such as how much memory a structure really costs, why a process keeps growing, or why a request occasionally pauses. This page explains how the CRuby collector is organised, how to measure what it is doing with GC.stat, GC.latest_gc_info and ObjectSpace, what the GC module and the RUBY_GC_* environment variables let you change, how heap compaction and Process.warmup work, what the modular GC of Ruby 3.4 is, and how to find and remove the usual causes of memory growth.

Everything here describes CRuby, the reference implementation. The GC and ObjectSpace interfaces are marked implementation-specific in the documentation: other implementations (JRuby, TruffleRuby) use the collectors of their own runtimes and answer these calls differently or not at all. The page builds on Object Lifecycle, Freezing and Copying, which covers finalizers, ObjectSpace::WeakMap, ObjectSpace::WeakKeyMap and WeakRef from the point of view of a single object. Measuring time rather than memory, and the profilers that attribute allocations to code, are in Performance, Benchmarking and Profiling; the JIT compilers (which have a code collector of their own) are in YJIT, ZJIT and the VM; writing C code that cooperates with the collector (marking, pinning and write barriers) is in C Extensions, FFI and WebAssembly. Threads and Ractors, which the collector has to stop while it works, are in Threads and the GVL and Ractors. The environment variables and command-line switches are in Ruby Command Line and Environment, and the traps that cause memory growth are repeated in Common Mistakes and Gotchas. Languages that make other choices are the Rust reference (ownership instead of a collector) and the Go reference (a concurrent collector with almost no knobs).

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The examples use the shelf gem (a small library catalogue, see the shelf gem source) and are run with ruby -Ilib. The output shown is the real output of one run, but memory and collector numbers differ from run to run, from machine to machine and from release to release: page counts, slot counts, timings, address-dependent values and even the number of collections that a program triggers are illustrative. The examples therefore draw conclusions from differences and from comparisons, never from absolute values.

How CRuby manages memory

An object lives in a slot: a fixed-size cell on a heap page. When the program creates an object, the allocator takes a free slot from a page; when no slot is free, or when memory obtained through malloc grows past a limit, the collector runs. CRuby uses a tracing collector, described in the GC documentation as a mark-and-sweep mechanism: it starts from the roots (global variables, constants, the VM stacks of every thread and Fiber, and the machine stack), marks every object it can reach, and sweeps the pages to turn the unmarked slots back into free slots. Sweeping is lazy by default: a page is swept when the allocator needs its slots, and GC.start(immediate_sweep: false) leaves sweeping unfinished on purpose. Around that core, CRuby adds several refinements that matter when you read GC.stat:

flowchart TD A["allocation finds no free slot, or a malloc limit is crossed"] --> B{"which kind of collection?"} B -->|"young objects only"| C["minor: mark young objects and the remembered set"] B -->|"limits exceeded, or GC.start"| D["major: mark every object"] C --> E["marking, possibly in small incremental steps"] D --> E E --> F["sweeping, usually lazy: pages are swept when slots are needed"] F --> G{"compaction requested?"} G -->|"GC.compact, auto_compact, Process.warmup"| H["move objects together and update references"] G -->|"no"| I["Ruby code runs again"] H --> I
Ruby heap and generations. Left: five size pools of 64 KiB pages with 40, 80, 160, 320 and 640 byte slots, and a zoomed page showing free, young and old slots. Right: an object’s age rising from 0 to 3 as it survives collections, what minor and major collections mark, and the write barrier that records an old object pointing to a young one.

The refinements, and the release that introduced each (all from the release notes linked in the references):

Feature Since What it does

Generational collection (RGenGC)

2.1

Objects that survive several collections are old and are skipped by cheap minor collections; a full major collection marks everything.

Incremental marking

2.2

Marking a major collection can be split into small steps interleaved with the program, shortening pauses. GC.latest_gc_info(:state) reports the phase.

Garbage-collectable symbols

2.2

Symbols created with to_sym or intern are collected when nothing refers to them; see Symbols.

GC.compact

2.7

Moves live objects together so fewer pages are needed and the heap is friendlier to copy-on-write after fork.

GC.auto_compact

3.0

Compacts during major collections; off by default, and the 3.0 release notes warn to test first because it adds overhead.

Variable Width Allocation (VWA)

3.2 on by default

Objects of different sizes are placed in slots of the fitting size class, with their payload embedded in the slot when it fits.

GC.measure_total_time, GC.total_time

3.1

Measure the time spent in the collector.

Process.warmup, ObjectSpace::WeakKeyMap

3.3

Prepare a freshly booted application for a long life; a weak-keyed map with equality semantics.

Gentler promotion and more write barriers

3.3

Young objects referenced by old objects are no longer promoted at once; many core classes (Time, Enumerator, MatchData, Method and others) gained write barriers, which makes minor collections cheaper.

GC.config, modular GC

3.4

A configuration hash (rgengc_allow_full_mark) and loadable collector implementations (experimental).

Independent size-pool growth

4.0

Heaps of different slot sizes grow independently, and the object_id table is built only when first used.

The rest of the page takes the pieces in this order: the heap layout, measuring, generations, controlling the collector, compaction, tuning, the modular GC, ObjectSpace, weak references and finally the practical business of reducing memory use.

The heap: pages, slots and size pools

A heap page is 64 KiB. The standard slot size is 40 bytes on a 64-bit platform, and since Variable Width Allocation there are five size pools whose slots are 40, 80, 160, 320 and 640 bytes. A new object goes to the smallest pool whose slots can hold it together with the payload it can embed. What does not fit (a long string, a large array, a hash table, the data of a C extension) is allocated with malloc outside the heap and referenced from the slot. ObjectSpace.memsize_of (library objspace) returns the slot size plus that external memory.

# frozen_string_literal: true

require "objspace"

consts = GC::INTERNAL_CONSTANTS
puts "page size: #{consts[:HEAP_PAGE_SIZE]} bytes, slots per 40-byte page: #{consts[:HEAP_PAGE_OBJ_LIMIT]}"
puts "slot sizes: #{GC.stat_heap.map { |_id, heap| heap[:slot_size] }.inspect}"

samples = {
  "empty string" => +"",
  "23-byte string" => +"a" * 23,
  "100-byte string" => +"a" * 100,
  "1000-byte string" => +"a" * 1000,
  "empty array" => [],
  "10-element array" => Array.new(10, 0),
  "100-element array" => Array.new(100, 0),
  "empty hash" => {},
  "20-entry hash" => (1..20).to_h { |i| [i, i] }
}
samples.each do |label, object|
  puts format("%-18<label>s memsize_of: %<size>5d bytes", label:, size: ObjectSpace.memsize_of(object))
end
page size: 65536 bytes, slots per 40-byte page: 1638
slot sizes: [40, 80, 160, 320, 640]
empty string       memsize_of:    40 bytes
23-byte string     memsize_of:    80 bytes
100-byte string    memsize_of:   160 bytes
1000-byte string   memsize_of:  1041 bytes
empty array        memsize_of:    40 bytes
10-element array   memsize_of:   160 bytes
100-element array  memsize_of:   840 bytes
empty hash         memsize_of:   160 bytes
20-entry hash      memsize_of:   992 bytes

Things to take from the example:

  • GC::INTERNAL_CONSTANTS and GC::OPTS are CRuby details that describe the build (HEAP_PAGE_SIZE, BASE_SLOT_SIZE, HEAP_COUNT and so on). They are not part of the documented interface and exist for tools and for curiosity.

  • The size of an object is not the size of its content. An empty string and an empty array take one 40-byte slot, a string of 100 bytes takes a 160-byte slot, and the 1000-byte string needs a slot plus an external buffer of roughly a kilobyte. An empty hash is reported as 160 bytes in this build.

  • memsize_of is, in the words of its documentation, only a hint: it is incomplete, and the size of objects backed by C data (T_DATA) may be wrong. It reports the memory of one object, not of the objects it refers to, so the figure for a catalogue is the figure of its array, not of the books inside it. ObjectSpace.memsize_of_all(klass) adds up the instances of a class, and its documentation states that it does not return the total malloc-ed memory.

  • Since the heap is built from pages, memory is obtained from the operating system in page-sized steps and, as shown in Why the process does not shrink, not necessarily handed back when objects die.

Observing the collector

GC.count is the number of collections since the process started. GC.stat returns a hash of counters, GC.stat(key) returns one value (without building the hash, which matters when the measurement itself should not allocate), and GC.stat(hash) fills a hash you pass in. The entries the documentation describes are:

Key Meaning

count, time

Collections so far (minor and major together) and the milliseconds spent in them.

minor_gc_count, major_gc_count, compact_count

Collections by kind, and compactions.

heap_allocated_pages, heap_empty_pages, heap_eden_pages

Pages the heap owns, pages with no live object (which could be released), and pages with at least one live slot.

heap_available_slots, heap_live_slots, heap_free_slots, heap_final_slots

All slots, slots holding live objects, free slots, and slots whose finalizers are still pending.

heap_marked_slots

Objects marked by the last collection.

total_allocated_objects, total_freed_objects

Cumulative counts since the process started; their difference is the number of live objects.

total_allocated_pages, total_freed_pages

Cumulative page counts.

malloc_increase_bytes, malloc_increase_bytes_limit

External memory allocated since the last collection, and the limit at which it triggers one.

oldmalloc_increase_bytes, oldmalloc_increase_bytes_limit

The same measure for old objects; crossing it triggers a major collection.

old_objects, old_objects_limit

Live old objects (those that survived at least three collections) and the count at which a major collection is triggered.

remembered_wb_unprotected_objects, remembered_wb_unprotected_objects_limit

Objects without write barriers and the count that triggers a major collection.

total_moved_objects, read_barrier_faults

Objects moved by compaction and the number of times the read barrier fired during it.

The documentation also warns that the entries are implementation-specific and "may change in the future without notice"; the set of keys in 4.0.7 differs from the sample in the reference (the descriptions still list heap_allocatable_pages and heap_sorted_length, while 4.0.7 returns heap_allocatable_slots instead). Print GC.stat.keys before depending on a name. GC.stat_heap gives the per-size-pool view (slot_size, heap_eden_pages, heap_eden_slots, total_allocated_objects and so on, with force_major_gc_count counting the major collections that one pool forced by running out of slots), and GC.latest_gc_info describes the most recent collection: why it ran (gc_by: :newobj, :malloc, :method, :stress), whether it was major and why (major_by), whether a major one is already pending (need_major_by), whether sweeping was immediate, the current state (:none, :marking, :sweeping) and the weak-reference counters.

# frozen_string_literal: true

require "shelf"

def isbn_for(number)
  body = format("978%09d", number)
  sum = body.chars.each_with_index.sum { |digit, i| i.even? ? digit.to_i : digit.to_i * 3 }
  Shelf::Isbn.new(body + ((10 - (sum % 10)) % 10).to_s)
end

author = Shelf::Author.new("Ada Lovelace")
catalog = Shelf::Catalog.new

before = GC.stat
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
5_000.times do |i|
  catalog.add(Shelf::Book.new(isbn: isbn_for(i), title: "Book #{i}", author:))
end
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
after = GC.stat

delta = ->(key) { after[key] - before[key] }
puts "books in the catalogue: #{catalog.count}"
puts "objects allocated:      #{delta.call(:total_allocated_objects)}"
puts "objects freed:          #{delta.call(:total_freed_objects)}"
puts "minor collections:      #{delta.call(:minor_gc_count)}"
puts "major collections:      #{delta.call(:major_gc_count)}"
puts "live slots now:         #{after[:heap_live_slots]} of #{after[:heap_available_slots]}"
puts "took #{(elapsed * 1000).round} ms"
books in the catalogue: 5000
objects allocated:      370027
objects freed:          334827
minor collections:      32
major collections:      4
live slots now:         60702 of 65176
took 27 ms

Source: GC.stat

The program built 5000 books and the collector ran 36 times while it did so, 32 minor and 4 major; most of the 370,000 objects were short-lived (the ISBN check digit helper allocates strings and arrays for every book) and were freed again, so a book costs far fewer live objects than allocations. Two habits make such numbers useful:

  • Compare a difference of a monotonic counter (total_allocated_objects, count, time) taken around the code of interest, as the example does. The absolute values depend on everything that ran before.

  • Remember that GC.stat reads the counters at that instant: with lazy sweeping heap_live_slots still counts dead objects that have not been swept, so for a precise live count run GC.start first.

GC.measure_total_time (3.1) controls whether the collector measures its own time; it is on by default, and the documentation notes that measuring has a cost. GC.total_time returns the accumulated time in nanoseconds, while the time entry of GC.stat is in milliseconds. A service can export GC.stat(:count), GC.stat(:time) and GC.stat(:heap_live_slots) to its metrics system to see collector pressure over time.

Generations: minor and major collections

Most objects die young. A minor collection exploits that by marking only the young objects, plus the remembered set (the old objects that were changed to point at young ones), and assumes that every old object is still alive. An object becomes old after surviving three collections (the old_objects entry of GC.stat is documented as objects that "have survived at least 3 garbage collections"). A major collection marks every object and is the only one that can reclaim old garbage.

To keep minor collections correct the collector needs to know when an old object is made to point at a young one. That is the write barrier: assignments to instance variables, array and hash elements and so on go through a barrier that records the old object in the remembered set. Objects of a type that does not implement the barrier are called write-barrier unprotected (wb_unprotected); they can never be treated as old, which makes minor collections more expensive, and the remembered_wb_unprotected_objects_limit entry shows the count at which a major collection is forced. This is why the 3.3 and 4.0 release notes list write barriers added to core classes as a performance improvement, and why a C extension that stores references without the barrier macros costs the whole process collector time (see C Extensions, FFI and WebAssembly).

A major collection is triggered when one of the limits shown in GC.stat is crossed (old_objects_limit, oldmalloc_increase_bytes_limit, remembered_wb_unprotected_objects_limit), when a heap runs out of free slots (force_major_gc_count), or when the program calls GC.start. GC.latest_gc_info(:major_by) records the reason and :need_major_by records a major collection that has been requested but not yet run. ObjectSpace.dump (library objspace, JSON text) shows the age and flags of one object, which makes the machinery visible:

# frozen_string_literal: true

require "objspace"
require "json"

# The GC bits that ObjectSpace.dump reports for one object.
def gc_flags(object)
  JSON.parse(ObjectSpace.dump(object)).fetch("flags").slice("age", "old", "wb_protected")
end

titles = Array.new(3) { |i| "title #{i}" }
puts "allocated:       #{gc_flags(titles)}"

3.times do |round|
  GC.start(full_mark: false)
  puts "after minor ##{round + 1}:  #{gc_flags(titles)}"
end

minor = GC.stat(:minor_gc_count)
major = GC.stat(:major_gc_count)
GC.start(full_mark: false)
puts "minor: +#{GC.stat(:minor_gc_count) - minor}, major: +#{GC.stat(:major_gc_count) - major}"
puts "last: #{GC.latest_gc_info.slice(:major_by, :gc_by)}"

GC.start
puts "last: #{GC.latest_gc_info.slice(:major_by, :gc_by)}"
allocated:       {"age" => 0, "wb_protected" => true}
after minor #1:  {"age" => 2, "wb_protected" => true}
after minor #2:  {"age" => 3, "old" => true, "wb_protected" => true}
after minor #3:  {"age" => 3, "old" => true, "wb_protected" => true}
minor: +1, major: +0
last: {major_by: nil, gc_by: :method}
last: {major_by: :force, gc_by: :method}

The array is created young (age 0), is promoted to old by the second explicit minor collection and then stays old. The exact age after the first collection depends on how many automatic collections ran in between (here one did, because building the strings and parsing the JSON allocate), so the progression 0, then 2, then 3 is not something to rely on; what matters is that a handful of collections turns a surviving object into an old one. GC.start(full_mark: false) is a minor collection (major_by stays nil), and plain GC.start is a forced major one (major_by: :force).

GC.config (3.4) reads and changes collector settings. The only entry of the default implementation is rgengc_allow_full_mark. Setting it to false stops the collector from starting major collections on its own, which also disables promotion to the old generation; the documentation recommends calling Process.warmup before doing it. It is a setting for processes with a short, bounded life or an external restart policy, not a general tuning knob.

# frozen_string_literal: true

p GC.config
p GC.config(rgengc_allow_full_mark: false) # young-to-old promotion is now off
p GC.config(unknown_key: 1)                # unknown keys are ignored
p GC.config(rgengc_allow_full_mark: true)  # restore the default

begin
  GC.config(implementation: "mmtk")
rescue ArgumentError => e
  puts e.message
end
{rgengc_allow_full_mark: true, implementation: "default"}
{rgengc_allow_full_mark: false, implementation: "default"}
{rgengc_allow_full_mark: false, implementation: "default"}
{rgengc_allow_full_mark: true, implementation: "default"}
Attempting to set read-only key "Implementation"

Source: GC.config

The second p shows the merged configuration after the change, the third shows that an unknown key is silently ignored, and the read-only implementation entry cannot be changed (the message is shown capitalised exactly as Ruby prints it).

Controlling the collector

GC.start runs a collection even if collection is disabled. Its keyword arguments, documented as implementation- and version-dependent, select the kind and how much work is deferred:

Call Effect

GC.start

A major collection that finishes marking and sweeping before returning (full_mark: true, immediate_mark: true, immediate_sweep: true).

GC.start(full_mark: false)

A minor collection: only young objects are marked.

GC.start(immediate_mark: false)

Marking proceeds incrementally, interleaved with the program, and may not be complete when the call returns.

GC.start(immediate_sweep: false)

Sweeping is deferred (lazy), and may not be complete when the call returns.

ObjectSpace.garbage_collect

An alias of GC.start, as is the instance method GC#garbage_collect.

GC.disable, GC.enable

Stop and resume automatic collections; each returns whether collection was disabled, so GC.disable returns false the first time. GC.start still works while disabled.

GC.stress = true

Collect at every allocation opportunity (an integer selects flags: 0x01 no major collection, 0x02 no immediate sweep, 0x04 full mark after malloc). Debugging only; the documentation says it degrades performance.

# frozen_string_literal: true

def collections = GC.stat(:count)

puts "GC.disable returns #{GC.disable.inspect} the first time and #{GC.disable.inspect} the second"
before = collections
Array.new(2_000_000) { Object.new }
puts "collections while disabled: #{collections - before}"
puts "GC.start still works:       #{GC.start.inspect}, count +#{collections - before}"
puts "GC.enable returns #{GC.enable.inspect} (it was disabled)"

before = collections
GC.start(full_mark: false, immediate_sweep: false)
puts "partial collection: +#{collections - before}, state afterwards: #{GC.latest_gc_info(:state).inspect}"

GC.measure_total_time = true
spent = GC.total_time
GC.start
puts "GC.total_time grew: #{GC.total_time > spent} (nanoseconds); GC.stat[:time] is in milliseconds"

before = collections
GC.stress = true
Array.new(20) { +"x" }
GC.stress = false
puts "collections caused by 20 allocations under GC.stress: #{collections - before}"
GC.disable returns false the first time and true the second
collections while disabled: 0
GC.start still works:       nil, count +1
GC.enable returns true (it was disabled)
partial collection: +1, state afterwards: :sweeping
GC.total_time grew: true (nanoseconds); GC.stat[:time] is in milliseconds
collections caused by 20 allocations under GC.stress: 26

Two million objects were allocated with collection disabled and no collection ran, which is exactly the danger of GC.disable: the process’s memory grows until someone calls GC.enable or the machine runs out. The partial collection of the example returns while sweeping is still in progress (state: :sweeping), which is what immediate_sweep: false asks for. Under GC.stress even 20 small allocations triggered 26 collections; the mode exists to shake out bugs in C extensions that forget to protect an object from the collector.

Guidelines for calling the collector yourself:

  • Do not call GC.start in a request path or inside a loop. Automatic collections are driven by the limits above and are almost always better timed than a manual call, which also promotes objects to the old generation.

  • Legitimate uses are a test suite that wants a clean slate before measuring, a benchmark that collects before taking a baseline (the examples on this page do that), and a pre-forking server that prepares its master process with Process.warmup before forking workers.

  • Wrap GC.disable around a short, bounded block of work only, and always re-enable it (ensure). Never disable the collector for the lifetime of a service.

Heap compaction

Long-running processes fragment the heap: after many objects die, free slots are scattered over many pages, and a page with a single live object cannot be released. Compaction moves live objects next to each other and updates every reference to them, so that pages become empty and can be given back. GC.compact (2.7) does it on demand, and GC.auto_compact = true (3.0) compacts during major collections. Neither is described on the 4.0 reference pages for GC, so the release notes are the place to look (and ri has no entry for them either); the methods are GC.compact, GC.auto_compact, GC.auto_compact=, GC.latest_compact_info and GC.verify_compaction_references. On 4.0.7 GC.compact returns the statistics of the compaction as a hash.

# frozen_string_literal: true

require "shelf"

# Allocate many small objects, then drop most of them to leave holes in the heap.
garbage_and_keepers = Array.new(200_000) { |i| Shelf::Author.new("Author #{i}") }
keepers = garbage_and_keepers.each_slice(10).map(&:first)
garbage_and_keepers.clear
GC.start

puts "auto_compact: #{GC.auto_compact}"
puts "before: pages=#{GC.stat(:heap_eden_pages)} live=#{GC.stat(:heap_live_slots)} free=#{GC.stat(:heap_free_slots)}"

stats = GC.compact
puts "after:  pages=#{GC.stat(:heap_eden_pages)} live=#{GC.stat(:heap_live_slots)} free=#{GC.stat(:heap_free_slots)}"
puts "compactions: #{GC.stat(:compact_count)}, objects moved: #{GC.stat(:total_moved_objects).positive?}"
puts "compact stats keys: #{stats.keys}"
puts "keepers still valid: #{keepers.size} #{keepers.first}"

# Process.warmup runs a full collection and a compaction, among other things.
puts "Process.warmup: #{Process.warmup.inspect}"
auto_compact: false
before: pages=321 live=58710 free=439557
after:  pages=53 live=58735 free=9824
compactions: 1, objects moved: true
compact stats keys: [:considered, :moved, :moved_up, :moved_down]
keepers still valid: 20000 #<data Shelf::Author name="Author 0">
Process.warmup: true

Two hundred thousand authors were created and nine in ten of them dropped, which leaves holes in the heap; after compaction the same live objects occupy a sixth of the pages (53 instead of 321 here), and the program’s own references (keepers) still work because moved objects are transparently updated. The page count after GC.compact is not guaranteed to shrink; it does so here because the program created a large block of garbage first.

What to know before using compaction:

  • Objects can only move if everything that refers to them can be updated. Objects referenced from C code that does not declare the reference as movable stay pinned in place, so extensions need to mark with rb_gc_mark_movable and read with rb_gc_location; see the C extension page. GC.verify_compaction_references (used by extension authors in their test suites) compacts aggressively to expose references that were not updated.

  • Compaction takes time while the program is paused. The 3.0 release notes say it "adds significant overhead to major collections" and ask users to test first, so GC.auto_compact is off by default and is best left that way unless a measurement shows a benefit.

  • The main payoffs are fewer pages (see the next section for whether the memory actually returns to the operating system) and better copy-on-write behaviour in pre-forking servers, because forked workers share pages with the master until they write to them.

Process.warmup (3.3) is the one-call version for the end of application boot. The documentation says that on CRuby it performs a major collection, compacts the heap, promotes all surviving objects to the old generation, precomputes the coderange of all strings, frees all empty heap pages and calls malloc_trim where available, and that the actual optimisations are implementation-specific and may change. In a pre-forking deployment call it once in the original process before the first fork, not in the workers.

Why the process does not shrink

GC.stat counts slots and pages, but the operating system sees the resident set size (RSS) of the process. The two diverge, which is the most common surprise when investigating memory.

# frozen_string_literal: true

require "shelf"

def rss_mb = (IO.popen(["ps", "-o", "rss=", "-p", Process.pid.to_s], &:read).to_i / 1024.0).round

def report(label)
  stat = GC.stat
  puts format("%-24<label>s %4<rss>d MB  pages %<pages>5d  live slots %<live>8d  empty pages %<empty>4d",
              label:, rss: rss_mb, pages: stat[:heap_allocated_pages], live: stat[:heap_live_slots],
              empty: stat[:heap_empty_pages])
end

def load_books(count)
  author = Shelf::Author.new("Ada Lovelace")
  isbn = Shelf::Isbn.new("978-0-306-40615-7")
  Array.new(count) { |i| Shelf::Book.new(isbn:, title: "Book #{i}", author:) }
end

report("start")
books = load_books(1_000_000)
report("#{books.size} books alive")
books.clear
report("cleared, not collected")
GC.start
report("after GC.start")
GC.compact
report("after GC.compact")
start                      19 MB  pages    36  live slots    25466  empty pages    0
1000000 books alive       133 MB  pages  1579  live slots  2185193  empty pages    0
cleared, not collected    133 MB  pages  1579  live slots  2185213  empty pages    0
after GC.start            102 MB  pages  1079  live slots    18754  empty pages 1042
after GC.compact          102 MB  pages  1079  live slots    18778  empty pages 1050

Source: GC.stat

(The example reads the RSS from ps, which exists on macOS and Linux.) A million books raised the process from 19 MB to 133 MB. Clearing the array changed nothing until a collection ran; the collection freed about 31 MB and left more than a thousand empty pages that the heap keeps for reuse, so the process stays near 102 MB although almost nothing is alive. GC.compact did not release them here either. This is not a leak: the empty pages are reused by the next allocations, and the figure to watch for a leak is whether the live slot count keeps growing across repeated cycles of the same work. Process.warmup explicitly frees empty pages (see above), and for services the usual remedies are restarting workers after a number of requests, and, on Linux with glibc, limiting the number of malloc arenas (the MALLOC_ARENA_MAX environment variable) or using an alternative allocator such as jemalloc. Those two are general malloc behaviour rather than Ruby features, and were not run for this page.

Tuning with the RUBY_GC environment variables

The collector reads its parameters from environment variables at start-up. They were introduced with the generational collector (the Ruby 2.1 release notes list the first of them) and are not described on the 4.0 reference pages for GC, so treat them as implementation-specific and re-measure after every upgrade. The names below were read from the 4.0.7 binary, and the defaults come from Ruby itself: running ruby -w with a variable set prints a line with the default value, and a value outside the allowed range prints that it is ignored.

Variable Default What it controls

RUBY_GC_HEAP_0_INIT_SLOTS to RUBY_GC_HEAP_4_INIT_SLOTS

10000

Slots allocated at boot for each size pool (one variable per pool). The unnumbered RUBY_GC_HEAP_INIT_SLOTS was deprecated in 3.3 and 4.0.7 prints a deprecation warning when it is set.

RUBY_GC_HEAP_FREE_SLOTS

4096

Free slots the collector tries to keep available after a collection.

RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO

0.20

Below this ratio of free slots the heap is grown (must be lower than 1).

RUBY_GC_HEAP_FREE_SLOTS_GOAL_RATIO

0.40

The ratio of free slots the growth aims for (must be lower than 0.65).

RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO

0.65

Above this ratio the heap may shrink (must be lower than 1).

RUBY_GC_HEAP_GROWTH_FACTOR

1.8

Factor by which the number of slots grows when more are needed.

RUBY_GC_HEAP_GROWTH_MAX_SLOTS

0

Cap on the slots added in one growth step; 0 means no cap.

RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR

2.0

Old-object growth, relative to the count after the last major collection, that triggers a new major collection.

RUBY_GC_HEAP_REMEMBERED_WB_UNPROTECTED_OBJECTS_LIMIT_RATIO

0.01

The share of write-barrier-unprotected objects that triggers a major collection (the tuning variable of the 3.3 release notes).

RUBY_GC_MALLOC_LIMIT, RUBY_GC_MALLOC_LIMIT_MAX, RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR

16777216, 33554432, 1.4

The external-memory limit (bytes) that triggers a collection, its ceiling, and how fast it grows.

RUBY_GC_OLDMALLOC_LIMIT, RUBY_GC_OLDMALLOC_LIMIT_MAX, RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR

16777216, 134217728, 1.2

The same for old objects, where crossing the limit triggers a major collection.

The effect is easy to see by running one program under different settings. The script builds 300,000 authors and prints what the collector did:

# frozen_string_literal: true

require "shelf"

authors = Array.new(300_000) { |i| Shelf::Author.new("Author #{i}") }

stat = GC.stat
puts "collections: #{stat[:count]} (minor #{stat[:minor_gc_count]}, major #{stat[:major_gc_count]})"
puts "heap pages: #{stat[:heap_allocated_pages]}, slots: #{stat[:heap_available_slots]}"
puts "malloc limit: #{stat[:malloc_increase_bytes_limit]}"
puts "authors kept: #{authors.size}"
$ ruby -w -Ilib e5_tune.rb
collections: 24 (minor 18, major 6)
heap pages: 626, slots: 747957
malloc limit: 16777216
authors kept: 300000

$ RUBY_GC_HEAP_0_INIT_SLOTS=600000 ruby -w -Ilib e5_tune.rb
RUBY_GC_HEAP_0_INIT_SLOTS=600000 (default value: 10000)
collections: 9 (minor 6, major 3)
heap pages: 589, slots: 742934
malloc limit: 16777216
authors kept: 300000

$ RUBY_GC_HEAP_GROWTH_FACTOR=2.0 ruby -w -Ilib e5_tune.rb
RUBY_GC_HEAP_GROWTH_FACTOR=2.000000 (default value: 1.800000)
collections: 25 (minor 18, major 7)
heap pages: 532, slots: 736868
malloc limit: 16777216
authors kept: 300000

$ RUBY_GC_MALLOC_LIMIT=64000000 ruby -w -Ilib e5_tune.rb
RUBY_GC_MALLOC_LIMIT=64000000 (default value: 16777216)
collections: 24 (minor 18, major 6)
heap pages: 629, slots: 750416
malloc limit: 64000000
authors kept: 300000

Source: GC.stat

Starting the first size pool with 600,000 slots cut the collections from 24 to 9, because the program no longer had to collect (and grow the heap step by step) while it was loading its data: the usual use of the initial-slot variables is a service whose steady-state heap size is known. Changing the growth factor or the malloc limit made no visible difference for this workload (the malloc limit shows up in GC.stat(:malloc_increase_bytes_limit), as the last run confirms), which is typical: most programs run best with the defaults. A sound procedure is to change one variable at a time, compare GC.stat(:count), GC.stat(:time) and the process RSS on a realistic workload, and keep the change only if the numbers justify it.

The modular GC (3.4, experimental)

Ruby 3.4 split the built-in collector into its own source (gc/default/default.c) behind an interface defined in gc/gc_impl.h, so that other collectors can be plugged in. The release notes describe it as follows: configure Ruby with --with-modular-gc at build time, build a collector as a shared library (for example make modular-gc MODULAR_GC=default), and choose it at run time with the RUBY_GC_LIBRARY environment variable. An experimental collector based on MMTk, a memory-management toolkit, can be built with MODULAR_GC=mmtk and selected with RUBY_GC_LIBRARY=mmtk; it needs the Rust toolchain on the build machine. All of it is experimental, and it is a facility for implementers rather than something an application configures.

The 4.0.7 build used here was not configured with --with-modular-gc, so none of this was run: GC.config reports implementation: "default" and setting RUBY_GC_LIBRARY had no effect on this build (the process started and ran normally, without a message). Check GC.config[:implementation] to see which collector a process is using. The GC.config hash and the GC methods are the same interface whatever the implementation, but, as the documentation says, the entries that exist are implementation-specific.

ObjectSpace: counting, finding and tracing objects

ObjectSpace (the core part, plus the objspace library that extends it) is the tool for asking which objects exist. Its documentation says the library is mainly for memory-profiler developers and MRI developers, and that you should not use it unless you know the implementation. In practice a handful of calls cover most investigations:

Call Use

ObjectSpace.count_objects

A hash of how many slots hold each internal type (:TOTAL, :FREE, :T_OBJECT, :T_STRING, :T_DATA and so on); cheap and good for a trend.

ObjectSpace.each_object(Class)

Iterates over the live objects of a class or module (all objects without an argument); without a block returns an enumerator. It walks the whole heap and is slow, so it is for diagnostics.

ObjectSpace.memsize_of(obj), memsize_of_all(klass)

The memory of one object, or of all instances of a class (a hint, as described above).

ObjectSpace.reachable_objects_from(obj)

The objects directly referenced by obj; the building block of "who keeps this alive" searches.

ObjectSpace.trace_object_allocations with a block

Records where every object allocated inside the block was created, readable with allocation_sourcefile, allocation_sourceline, allocation_class_path, allocation_method_id and allocation_generation. The documentation warns that it causes a large performance decrease and a large increase in memory use.

ObjectSpace.dump(obj), dump_all

A JSON description of one object or of the whole heap, for offline analysis.

The example hunts the classic leak: a cache that is filled and never trimmed. The AuthorIndex keeps a hash of authors by name for the life of the process:

# frozen_string_literal: true

require "objspace"
require "shelf"

# A lookup cache that is never trimmed: a classic leak in a long-lived process.
class AuthorIndex
  def initialize = @by_name = {}

  def lookup(name)
    @by_name[name.dup] ||= Shelf::Author.new(name)
  end
end

index = AuthorIndex.new

ObjectSpace.trace_object_allocations do
  1_000.times { |i| index.lookup("Author #{i}") }

  sample = ObjectSpace.each_object(Shelf::Author).first
  file = File.basename(ObjectSpace.allocation_sourcefile(sample))
  puts "a leaked Author was allocated at #{file}:#{ObjectSpace.allocation_sourceline(sample)}"
end

puts "Author instances alive: #{ObjectSpace.each_object(Shelf::Author).count}"
puts "memsize_of(index):      #{ObjectSpace.memsize_of(index)} bytes (the object alone)"
puts "reachable from index:   #{ObjectSpace.reachable_objects_from(index).map(&:class).tally}"
puts "memsize of the hash:    #{ObjectSpace.memsize_of(index.instance_variable_get(:@by_name))} bytes"

counts = ObjectSpace.count_objects
puts "ObjectSpace.count_objects keys: #{counts.keys.first(4)} ..."
puts "T_DATA objects (C-backed): #{counts[:T_DATA].positive?}"
a leaked Author was allocated at e7_space.rb:11
Author instances alive: 1000
memsize_of(index):      40 bytes (the object alone)
reachable from index:   {Class => 1, Hash => 1}
memsize of the hash:    28832 bytes
ObjectSpace.count_objects keys: [:TOTAL, :FREE, :T_OBJECT, :T_CLASS] ...
T_DATA objects (C-backed): true

The reading of the result is the method. A thousand Author instances are still alive after the work is done, so something holds them; trace_object_allocations names the line that created one (line 11, the Shelf::Author.new call inside the cache); reachable_objects_from(index) shows that the index references only its class and one hash, and the hash is the container that grows (memsize_of of the index object alone is a meaningless 40 bytes, while its hash is 28 KB and growing). The fix is to bound the cache (an LRU), to key it weakly (next section), or to not cache at all. In a real process the trace is turned on briefly in a staging environment, and the sample object is chosen with a class filter, because tracing everything is expensive.

Two cautions from the documentation of each_object: it yields only live, non-immediate objects (small Integers, nil, true and static Symbols are not objects in the heap), and, because of a current Ractor limitation, once the process has entered multi-Ractor mode (the first Ractor.new) it no longer yields objects that are not Ractor-shareable. Counting instances after using Ractors can therefore under-report; see Ractors.

Weak references and finalizers in the context of memory

A cache that must not keep its entries alive needs weak references, which let the collector reclaim the referent. Object Lifecycle, Freezing and Copying explains the three families in detail; in the memory context the choices are:

Tool Best for

ObjectSpace::WeakKeyMap (3.3)

A cache keyed by values (strings, value objects): the key is weak and looked up by equality (eql? and hash), the value is strong. getkey returns the stored key.

ObjectSpace::WeakMap

Identity-keyed side tables: both keys and values are weak, so an entry disappears as soon as either is unreachable, and the value must be referenced from somewhere else to survive.

WeakRef (library weakref)

A single optional reference: weakref_alive? says whether the referent is still there, and using a collected one raises WeakRef::RefError.

ObjectSpace.define_finalizer

Releasing an external resource after an object dies. The documentation says finalization can be unpredictable and is never guaranteed to run except on exit; do not depend on it for correctness.

# frozen_string_literal: true

require "weakref"

# Search keys cached without keeping the strings alive.
KEYS = ObjectSpace::WeakKeyMap.new

def remember_titles(count)
  titles = Array.new(count) { |i| "Title #{i}" }
  titles.each { |title| KEYS[title] = title.length }
  titles.values_at(*0...10) # a copy: first(10) would share the whole buffer (see below)
end

def still_cached(count) = (0...count).count { KEYS.getkey("Title #{it}") }

kept = remember_titles(100)
puts "after filling: #{KEYS.inspect[/size=\d+/]}"

GC.start
puts "titles still cached after a GC: #{still_cached(100)} of 100"
puts "value for a kept title: #{KEYS[kept.first]}"
probe = "Title 0"
puts "getkey returns the stored key itself: #{KEYS.getkey(probe).equal?(kept.first)}"

ref = WeakRef.new(Object.new)
GC.start
puts "WeakRef alive afterwards? #{ref.weakref_alive?.inspect}"
after filling: size=100
titles still cached after a GC: 10 of 100
value for a kept title: 7
getkey returns the stored key itself: true
WeakRef alive afterwards? nil

Of the 100 title strings the cache saw, only the 10 still referenced from kept survived the collection, and their values (here the length) are still there. A WeakRef to an object nothing else references reports nil for weakref_alive? after the collection (the documentation says it returns true while the referent is alive; the library returns nil, which is falsy, once it has been collected). Weak structures are allowed to drop entries, not required to do so at a given moment: because the collector scans the machine stack conservatively, a stale pointer to an object can keep it alive a little longer, so tests that assert that something was collected should call GC.start and tolerate either outcome, or run the work in a method that has returned.

Memory bloat and reducing allocations

Memory problems come in two forms. A leak is growth of the live object count that never stops: something still references objects the program no longer needs, almost always a global, a class-level collection, a long-lived cache, a registry of callbacks or a thread-local that is never emptied. Bloat is a steady state that is simply too large, or too wasteful in allocations (with the collector time that goes with them). The tools above separate them: a trend in GC.stat(:heap_live_slots) or in ObjectSpace.count_objects across repeated identical work means a leak, which each_object, reachable_objects_from and allocation tracing then localise; a high but flat figure means bloat.

Count the allocations of alternatives

The number of objects a piece of code creates is easy to measure and is deterministic enough to compare:

# frozen_string_literal: true

require "shelf"

# Number of objects allocated while the block runs.
def allocations
  GC.start
  before = GC.stat(:total_allocated_objects)
  yield
  GC.stat(:total_allocated_objects) - before
end

author = Shelf::Author.new("Ada Lovelace")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
books = Array.new(10_000) { |i| Shelf::Book.new(isbn:, title: "Book #{i}", author:) }

chained = allocations do
  books.map { it.title.upcase }.select { it.end_with?("7") }
end
single_pass = allocations do
  books.filter_map { |book| book.title.upcase if book.title.end_with?("7") }
end
lazy = allocations do
  books.lazy.map { it.title.upcase }.select { it.end_with?("7") }.first(10)
end

plus = allocations do
  out = +""
  1_000.times { |i| out += "line #{i}\n" }
end
append = allocations do
  out = +""
  1_000.times { |i| out << "line #{i}\n" }
end

puts "map, then select:     #{chained}"
puts "filter_map:           #{single_pass}"
puts "lazy, first(10):      #{lazy}"
puts "String#+ in a loop:   #{plus}"
puts "String#<< in a loop:  #{append}"
map, then select:     20004
filter_map:           2004
lazy, first(10):      350
String#+ in a loop:   2993
String#<< in a loop:  1991
  • Upper-casing 10,000 titles and then filtering allocates ten times as much as filtering first (filter_map returns only the survivors, and builds the result array once). Do the work after the filter, and avoid chains of intermediate arrays when each step creates new objects.

  • A lazy enumerator that is only asked for ten results touches only enough elements to produce them; see Enumerators and Lazy Evaluation.

  • out += x creates a new string every time, and out << x appends in place (about a third fewer objects here, and much less copying for large strings). The interpolated string in the loop body is the part both versions share.

  • Keep string literals frozen where you can: # frozen_string_literal: true makes identical literals share one object, and the chilled strings of 3.4 are the migration step. See Strings. String#-@ (also dedup) returns a deduplicated frozen string, which saves memory when many equal strings are read from a file, at the cost of a table lookup.

  • Symbols are collectable since 2.2, so to_sym on user input no longer leaks, but a very large number of live symbols is still memory; see Symbols.

  • Stream instead of materialising: File.foreach or each_line instead of File.readlines (see IO, Files and Directories), each_slice for batches, and the lazy pipelines above for large intermediate collections.

  • Prefer value objects with a handful of attributes (Data, Struct) over hashes for records created in the millions, and Integer or Symbol identifiers over strings for repeated labels.

Slices can keep a whole array alive

Array#first(n), take and slicing a long array can return a new array that shares the original’s storage, in CRuby, so a small slice of a large array can keep every element of the large one alive. The same applies to a tail substring of a large string (str[100..]). The behaviour is an optimisation of the implementation and not in the documentation, but it is consistent across 3.2 through 4.0 and it explains memory that "should have been freed":

# frozen_string_literal: true

require "shelf"

def keep_ten(how)
  authors = Array.new(100) { |i| Shelf::Author.new("Author #{i}") }
  case how
  when "first" then authors.first(10)
  when "copy" then authors.first(10).map(&:itself)
  else raise ArgumentError, "unknown variant: #{how}"
  end
end

how = ARGV.fetch(0)
kept = keep_ten(how)
GC.start
alive = ObjectSpace.each_object(Shelf::Author).count
puts "#{how.ljust(5)} keeps #{kept.size} authors, #{alive} Author objects alive"
$ ruby -w -Ilib e11_shared.rb first
first keeps 10 authors, 100 Author objects alive
$ ruby -w -Ilib e11_shared.rb copy
copy  keeps 10 authors, 10 Author objects alive

Both variants return ten authors, but after a collection the shared slice still keeps all hundred alive. A method that builds a large array and returns a few of its elements, which is what a search over a big catalogue does, retains everything. Copying with map(&:itself) (or values_at, compact, sort_by or array + [], which all build a new array; dup, to_a, Array.new(slice) and flatten were observed to stay shared) breaks the link. The example runs each variant in its own process so that stale references from one do not influence the other.

A checklist for a growing process

Symptom Check

heap_live_slots grows steadily

Leak. Find the type with ObjectSpace.count_objects and each_object, then the holder with reachable_objects_from; look for class-level hashes and arrays, memoisation keyed by unbounded input, registries and at_exit/callback lists.

Live slots flat, RSS high

Bloat or fragmentation: peak usage was large, and empty pages or malloc arenas stay; try Process.warmup in a forked server, GC.compact, fewer arenas or restart policies.

Many collections, little growth

Allocation churn: count allocations with GC.stat(:total_allocated_objects), then use a profiler (see Performance, Benchmarking and Profiling).

Many major collections

Old-generation growth or unprotected objects: check old_objects, remembered_wb_unprotected_objects and GC.latest_gc_info(:major_by); consider the heap-size variables if the steady-state size is known.

Long pauses

Marking a huge old generation: smaller live set, or incremental marking and tuning; confirm with GC.stat(:time) before changing anything.

Memory grows only after native calls

A C extension or a gem with native memory: the Ruby heap looks small while malloc memory grows; ObjectSpace.memsize_of cannot see it reliably.

Common mistakes

Mistake Fix

Calling GC.start to "free memory"

The process rarely shrinks (see above), and the call pauses the program. Reserve it for tests, benchmarks and Process.warmup.

GC.disable for speed

Memory is not bounded while disabled. Use it for a short block only, with ensure GC.enable.

Relying on a finalizer or a weak reference at a given moment

Neither is guaranteed to run or clear when you expect, and a finalizer closing over its own object keeps it alive. Free external resources deterministically (close, ensure, a block API).

Reading GC.stat keys from a blog post

The set of keys changes between releases; print GC.stat.keys and read the 4.0 documentation.

Trusting memsize_of as a total

It is a shallow hint; sum the reachable graph with care and cross-check with RSS.

Leaving trace_object_allocations or GC.stress on

Both slow the program dramatically; use them in scoped blocks while debugging.

Treating one measurement as truth

Collector figures vary between runs; repeat, compare differences, and measure on the real workload.

Caches without bounds

Cap them, expire them, or key them weakly.

Expecting compaction to be free

It pauses the program; measure before enabling auto_compact.

Where older material is outdated

Tutorials and book chapters on performance that were written for Ruby 2.x or early 3.x often describe a simpler collector than the one in 4.0. The points worth correcting, each of which is shown above:

  • The collector is no longer a single stop-the-world mark and sweep over uniform 40-byte slots: it is generational (2.1), marks incrementally (2.2), sweeps lazily and uses Variable Width Allocation with five size pools (default in 3.2).

  • The names printed by GC.stat changed over time (the 2.2 release notes record a renaming) and 4.0.7 has entries that older articles do not mention (heap_allocatable_slots, marking_time, sweeping_time); the unnumbered RUBY_GC_HEAP_INIT_SLOTS is replaced by one variable per size pool and is deprecated.

  • GC.compact (2.7), GC.auto_compact (3.0), Process.warmup (3.3), GC.config and the modular GC (3.4) did not exist when most such articles were written.

  • ObjectSpace._id2ref, sometimes used to look an object up by its object_id, is deprecated in 4.0; keep an explicit registry (a WeakMap) instead. See Object Lifecycle, Freezing and Copying.

  • Allocation counting with GC.stat(:total_allocated_objects), the technique used by the performance chapters of the books this section draws on, is still valid and is the first example of the section on reducing allocations; ObjectSpace.each_object and memsize_of keep working but are diagnostics, not production APIs.

  • YJIT has a code collector of its own (3.2), which is unrelated to the object collector and was made opt-in in 3.3 (--yjit-code-gc); see YJIT, ZJIT and the VM.

Version notes

Version Change

2.1

Generational collector (RGenGC) and the first RUBY_GC_* variables.

2.2

Incremental marking for major collections; most symbols from to_sym and intern are collectable; GC.stat entries renamed; GC.latest_gc_info reports :state.

2.7

GC.compact.

3.0

GC.auto_compact and GC.auto_compact=.

3.1

GC.measure_total_time and GC.total_time.

3.2

Variable Width Allocation enabled by default; GC.latest_gc_info exposes whether a major collection is needed (the need_major_by entry of current releases); YJIT code collector.

3.3

Process.warmup; ObjectSpace::WeakKeyMap and ObjectSpace::WeakMap#delete; young objects referenced by old ones are no longer promoted at once; write barriers for many core types; RUBY_GC_HEAP_INIT_SLOTS deprecated in favour of the per-pool variables.

3.4

GC.config with rgengc_allow_full_mark; modular GC (--with-modular-gc, RUBY_GC_LIBRARY) and an experimental MMTk-based collector.

4.0

Size pools grow independently; faster sweeping of pages of large objects; the id-to-object table is built only when first used (ObjectSpace._id2ref is deprecated); more core classes are write-barrier protected; GC allocation counts are accurate under Ractors.