The GC answers comparisons, not counts¶
Level: 301 · someone who has asked "when does Ruby free this?" and wants to know which answers are promises
One line: GC.start, GC.stat, ObjectSpace and WeakRef let a program watch its own heap, but only comparisons are stable — a WeakRef may or may not be dead after GC.start and a finalizer runs at exit at the latest — while CPython's reference counting frees an object, and runs its __del__, the moment the last reference goes.
Ruby's collector is a generational mark-and-sweep: it starts from the roots (constants, globals, every live stack frame), marks what it can reach and sweeps the rest, in small "minor" passes that look only at young objects and occasional "major" ones that look at everything (row 10). It scans the machine stack conservatively — any word that looks like a pointer keeps its object alive — which is why the page cannot promise when an object dies. What it can promise is monotonic counters and booleans: GC.start runs a full collection and GC.count goes up (row 1); GC.stat(:total_allocated_objects) is a running total that grows by at least the thousand strings we kept (row 2); GC.disable and GC.enable return the previous state (row 3); GC.latest_gc_info says why the last collection ran (row 12). ObjectSpace.each_object(Widget) enumerates every live instance (row 4 prints only any?, because how many dead ones are still sitting in the heap depends on when the last sweep ran).
ObjectSpace::WeakMap (row 5) and WeakRef (rows 6–7) hold an object without keeping it alive. Row 7 is the whole point of the page: after w = nil; GC.start, weakref_alive? is either nil or true, and the page prints only that it is one of the two — a stale copy of the reference may still be on the stack, so a program that uses a WeakRef must check weakref_alive? and rescue WeakRef::RefError rather than reason about when. A finalizer (row 8) is a proc that runs after its object is collected, or at exit if it never was: the line it prints appears after row 13, and the proc must not mention the object, or the object can never die. Ruby keeps no reference counts at all — ObjectSpace.count_objects counts heap slots by type (row 9) — and Integers, Symbols, nil, true, false and most Floats are immediates that need no heap slot: 1.object_id is the same number every time, while two "a" literals are two objects (row 11). The objspace extension adds per-object sizes (row 13).
Verified output of object_space_and_gc_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. GC.count grows after GC.start true
2. GC.stat(:total_allocated_objects) grew by >= 1000 true
3. [GC.disable, GC.disable, GC.enable] (previous state) [false, true, true]
4. each_object(Widget).any? before / after Widget.new [false, true]
5. ObjectSpace::WeakMap: wm.key?(key), wm[key] [true, "value"]
6. WeakRef while w is referenced: alive?, __getobj__ [true, true]
7. after w = nil; GC.start: alive? is nil or true true
8. define_finalizer(KEPT, proc) returned [Integer, Proc] [Integer, Proc]
9. count_objects keys :TOTAL, :FREE, :T_STRING (no refcounts) [true, true, true]
10. GC.stat keys :minor_gc_count, :major_gc_count [true, true]
11. 1.object_id == 1.object_id, "a".object_id == "a".object_id [true, false]
12. GC.latest_gc_info keys :major_by, :gc_by [true, true]
13. ObjectSpace.memsize_of("x" * 1000) > 1000 true
finalizer ran for KEPT (after the last row: at exit)
Reading the output¶
Rows 1–3 are the counters and switches; every value is a comparison against a reading taken a moment earlier, never the reading itself. Row 4 goes from false to true when the first Widget is made. Rows 5–6 show a weak map and a weak reference behaving like strong ones while the object is referenced. Row 7 is true for "nil or true": the program does not know which, and neither should you. Row 8 is what define_finalizer returns, an [Integer, Proc] pair; the finalizer's own line is the last line of the output, after row 13, printed while the process exits. Rows 9–10 and 12 are membership checks on the statistics hashes. Row 11 is the immediate-versus-heap distinction: [true, false]. Row 13: a thousand-character string is bigger than a thousand bytes.
Compared with Python¶
CPython frees an object the instant its reference count reaches zero, so the events Ruby cannot schedule happen on a predictable line: del w in row 7 makes the weak reference dead at once, and the log shows __del__ had already run before the row printed. sys.getrefcount (row 9) is printed as comparisons only — the absolute number includes the call's own argument, and Python 3.12 made None, small integers and other "immortal" objects report a fixed count. The gc module is not the memory manager; it is only the cycle collector, there for the one thing counting cannot free: row 12 makes two objects point at each other, deletes both names, and they survive until gc.collect() finds them. It is generational with three generations (row 10) and tracks only container objects — an int or a str cannot be part of a cycle, so it is not tracked (row 11). gc.collect() returns how many unreachable objects it found (row 1, printed as >= 0), gc.get_objects() lists the tracked ones (rows 2 and 4), weakref.WeakValueDictionary is the weak map (row 5), and weakref.finalize (row 8) runs at exit if the object is still alive, so its line follows row 13 exactly as Ruby's finalizer does. sys.getsizeof is memsize_of (row 13). Two things the twin does not print because the two CI Pythons disagree: gc.get_threshold() (700 in 3.12, 2000 since the 3.13 incremental collector) and sys._is_gil_enabled(), which exists only from 3.13. (Not machine-checked here.)
Verified output of object_space_and_gc_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. gc.collect() returns an int (objects it freed) >= 0 ['int', True]
2. len(gc.get_objects()) grew by >= 1000 after 1000 lists True
3. [isenabled(), after disable(), after enable()] [True, False, True]
4. any Widget in gc.get_objects() before / after Widget() [False, True]
5. weakref.WeakValueDictionary: "key" in wd, wd["key"] is value [True, True]
6. weakref.ref(w) while w is referenced: alive, r() is w [True, True]
7. after del w: r() is None at once; __del__ already ran [True, ['__del__ ran for a Widget']]
8. weakref.finalize(keep, print, ...): alive, atexit [True, True]
9. sys.getrefcount: +1 with an alias, back after del [True, True]
10. gc.get_stats(): generations, keys of one [3, ['collected', 'collections', 'uncollectable']]
11. gc.is_tracked(1), gc.is_tracked("a"), gc.is_tracked([]) [False, False, True]
12. a cycle survives del; gc.collect() frees it [True, True, True]
13. sys.getsizeof("x" * 1000) > 1000 True
finalize ran for keep (after the last row: at exit)
Try it¶
- Write
allocations { … }that returns how many objects a block allocated, fromGC.stat(:total_allocated_objects)before and after with the collector disabled in between. Print comparisons: a hundredString.newcalls allocate at least a hundred objects,1 + 2allocates no more than an empty block does, and[[1], [2], [3]]allocates at least four. Say in a comment why you would not print the raw numbers. - Give
Widgeta finalizer whose proc closes over the widget itself, and show withObjectSpace.each_object(Widget).any?after severalGC.startcalls that it is never collected. Then fix it with a class methodWidget.finalizer_for(name)that returns a proc holding only the name.
Solution to 1
object_space_and_gc_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: count what a block allocates. GC.stat(:total_allocated_objects) is a
# running total, so its difference across a block is the block's allocations.
# Only comparisons are printed: an exact count is an implementation detail
# (the first call of `allocations` itself allocates one object, a call cache,
# which is why it is called once to warm up before anything is measured).
def allocations
GC.disable
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
ensure
GC.enable
end
allocations { }
empty = allocations { }
strings = allocations { 100.times { String.new("s") } }
arithmetic = allocations { 1 + 2 }
symbol = allocations { :sym }
arrays = allocations { [[1], [2], [3]] }
puts "1. an empty block allocates nothing: #{empty == 0}"
puts "2. 100 String.new calls allocate at least 100 objects: #{strings >= 100}"
puts "3. 1 + 2 allocates nothing (Integers are immediates): #{arithmetic == 0}"
puts "4. a symbol literal allocates nothing: #{symbol == 0}"
puts "5. [[1], [2], [3]] allocates at least 4 objects: #{arrays >= 4}"
Verified output of object_space_and_gc_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- TracePoint sees every call and return — the other runtime you can watch from inside the program
- Ruby shows you its bytecode and its syntax tree — the next layer down: what the VM executes
dup,cloneand frozen state — copies are new heap objects; this page is about when the old ones go- Variables are references —
w = nildrops a reference, it does not free anything - Ractors share nothing — the GC is one per process even when Ractors run in parallel
Rc: the clone that copies a pointer ↗ — the Rust library on reference counting as an opt-in type, the mechanism CPython applies to every object- Assignment drops the old value ↗ — deterministic destruction on a known line, which Python's refcount gives and Ruby's tracing collector does not
GC↗,ObjectSpace↗ andWeakRef↗ — the reference forstat,each_object,define_finalizerandweakref_alive?gc↗ andweakref↗ — the cycle collector and weak references in Python- YJIT and ZJIT are switches, not defaults — MRI (
RUBY_ENGINE == "ruby") always runs YARV bytecode through its interpreter; YJIT (since 3.1) and ZJIT…