Vol. 9 · № 02 · Un atelier RubyA Ruby workshop

Allocations cachées & le GC, ou compter plutôt que craindre Hidden allocations & the GC, or counting rather than fearing

Chaque littéral "…"/[]/{} alloue un objet, et le GC paie pour le libérer. Le coût n'est pas dans le littéral isolé mais dans sa fréquence : un objet par tour de boucle chaude pèse, le même sur un chemin froid non. On ne le lit pas — on le compte (GC.stat, ObjectSpace, memory_profiler). Le piège : geler à l'aveugle. Every literal "…"/[]/{} allocates an object, and the GC pays to free it. The cost isn't in the lone literal but in its frequency: one object per hot-loop turn weighs, the same on a cold path doesn't. You don't read it — you count it (GC.stat, ObjectSpace, memory_profiler). The trap: freezing blindly.

AudienceAudience
Dev qui craint le GC et veut savoir où il alloue vraiment Dev who fears the GC and wants to know where they really allocate
Format
Self-paced
ChapitresChapters
5
Date
Juil 2026 Jul 2026
≈ 19 min ●●●○ AllocationGCmemory_profiler
SommaireContents ·
№ 01CadrageFraming3 min

Chaque "…", [], {} alloue — et le GC paie pour libérer.Every "…", [], {} allocates — and the GC pays to free.

Après la résolution, l'autre coût du dynamisme : la mémoire. En Ruby, presque tout est un objet (Vol 1), et créer un objet l'alloue sur le tas. Chaque littéral de chaîne, de tableau, de hash que tu écris fabrique un nouvel objet — y compris les identiques, réalloués à chaque passage. Plus tard, le ramasse-miettes (GC) parcourt le tas pour libérer ce qui n'est plus référencé : c'est un travail, proportionnel au nombre d'objets créés. Une seule allocation ne coûte rien ; un million dans une boucle chaude déclenche des cycles de GC qui, eux, se voient. La règle reste celle du volume : on ne devine pas une allocation, on la compte.After resolution, the other cost of dynamism: memory. In Ruby, almost everything is an object (Vol 1), and creating an object allocates it on the heap. Every string, array, hash literal you write makes a new object — including identical ones, reallocated on each pass. Later, the garbage collector (GC) scans the heap to free what's no longer referenced: that's work, proportional to the number of objects created. A single allocation costs nothing; a million in a hot loop triggers GC cycles that do show. The rule stays the volume's: you don't guess an allocation, you count it.

Les littéraux allouent ; les symboles et petits entiers, nonLiterals allocate; symbols and small ints don't
# chaque LITTÉRAL crée un objet — invisible, mais réel
"abc".object_id == "abc".object_id   # => false  deux String distincts
[].object_id == [].object_id         # => false  deux Array
{}.object_id == {}.object_id         # => false  deux Hash

# … sauf ceux qui sont uniques par nature (Vol 1) :
:abc.object_id == :abc.object_id     # => true   un seul Symbol
1.object_id   == 1.object_id         # => true   un seul Integer

# compter, plutôt que deviner :
before = GC.stat(:total_allocated_objects)
1_000.times { "ligne".dup }
GC.stat(:total_allocated_objects) - before   # => ~2000 objets : le littéral ET sa copie, à chaque tour
Allouer, puis ramasserAllocate, then collect
littéralliteral
"…" · [] · {}
alloue 1 objetallocates 1 object
→
le tasthe heap
objets vivantslive objects
empiléspiling up
→
GC
balaie & libèresweeps & frees
un coût mesurablea measurable cost

L'allocation est bon marché, mais pas gratuite : plus tu crées d'objets, plus le GC doit travailler pour les libérer. Le coût n'est pas dans le littéral isolé — il est dans le nombre, et le nombre se compte avec GC.stat.Allocation is cheap, but not free: the more objects you create, the harder the GC works to free them. The cost isn't in the lone literal — it's in the count, and the count is measured with GC.stat.

Le réflexe du numéroThe issue's reflex

« Cette boucle chaude alloue-t-elle un objet par tour que je pourrais hisser ou geler ? » Un littéral constant créé à chaque itération, une chaîne réallouée mille fois : ce sont les cibles. Mais on ne les traite qu'après les avoir comptées — GC.stat, memory_profiler, pas l'intuition."Does this hot loop allocate an object per turn that I could hoist or freeze?" A constant literal created each iteration, a string reallocated a thousand times: those are the targets. But you treat them only after counting — GC.stat, memory_profiler, not intuition.

Le réflexe frozen string literal.The frozen string literal reflex.

Le Vol 4 l'a montré : # frozen_string_literal: true gèle et partage les littéraux de chaîne — un seul objet réutilisé, au lieu d'un par évaluation. C'est l'optimisation d'allocation la plus simple de Ruby, gratuite et sûre. Mais même elle obéit à la règle : son gain réel, dans ton code, se mesure. Elle n'est pas encore le défaut — Ruby 3.4 se contente de rendre les littéraux « chilled » et d'avertir (sous -W:deprecated) quand on les mute — mais posée en tête de fichier, elle évite des milliers d'allocations dans les boucles qui répètent des chaînes constantes.Vol 4 showed it: # frozen_string_literal: true freezes and shares string literals — one object reused, instead of one per evaluation. It's Ruby's simplest allocation optimization, free and safe. But even it obeys the rule: its real gain, in your code, is measured. It isn't the default yet — Ruby 3.4 only makes literals "chilled" and warns (under -W:deprecated) when you mutate them — but placed at the top of a file, it avoids thousands of allocations in loops that repeat constant strings.

№ 02La surpriseThe surprise4 min

Deux codes identiques à l'œil allouent très différemment.Two codes identical to the eye allocate wildly differently.

La surprise du numéro : l'allocation est invisible dans le texte. Un littéral écrit à l'intérieur d'une boucle — un séparateur ", ", un hash de défaut, un tableau temporaire — est recréé à chaque itération, alors qu'à la lecture rien ne distingue ce cas d'un littéral hissé, alloué une seule fois. Sur cent mille lignes, c'est cent mille objets de différence, donc autant de pression sur le GC. On ne le voit pas en relisant ; on le voit en comptant. GC.stat(:total_allocated_objects) avant et après donne le nombre exact d'objets créés — et transforme une intuition (« ça doit allouer ») en fait (« ça alloue tant »).The issue's surprise: allocation is invisible in the text. A literal written inside a loop — a separator ", ", a default hash, a temporary array — is recreated on each iteration, while at a glance nothing distinguishes it from a hoisted literal, allocated once. On a hundred thousand lines, that's a hundred thousand objects of difference, hence that much GC pressure. You don't see it on rereading; you see it by counting. GC.stat(:total_allocated_objects) before and after gives the exact number of objects created — turning an intuition ("that must allocate") into a fact ("that allocates this much").

Le séparateur réalloué à chaque tourThe separator reallocated each turn
# le SÉPARATEUR est recréé à chaque ligne — invisible à l'œil
def to_csv(rows)
  rows.map do |row|
    row.join(", ")          # ", " : un littéral réalloué à CHAQUE tour
  end
end

before = GC.stat(:total_allocated_objects)
to_csv(rows_100k)
GC.stat(:total_allocated_objects) - before
# => bien plus que 100_000 : chaque ", " compte, + les chaînes jointes

Rien ne signale le problème : row.join(", ") se lit comme du Ruby propre. Mais ", " est un littéral, donc un objet neuf à chaque ligne. Sur 100 000 lignes, c'est 100 000 chaînes de séparateur jetées aussitôt — invisibles, jusqu'à ce que GC.stat les compte ou qu'un cycle de GC se montre dans le profil.Nothing flags the problem: row.join(", ") reads as clean Ruby. But ", " is a literal, hence a fresh object per line. On 100,000 lines, that's 100,000 separator strings discarded at once — invisible, until GC.stat counts them or a GC cycle shows in the profile.

L'allocation ne se lit pas dans le code — elle se compte avec GC.stat. Deux lignes jumelles peuvent créer mille objets d'écart.Allocation isn't read in the code — it's counted with GC.stat. Two twin lines can create a thousand objects' difference.
№ 03La mécaniqueThe mechanics5 min

Trois instruments : GC.stat, ObjectSpace, memory_profiler.Three instruments: GC.stat, ObjectSpace, memory_profiler.

On ne raisonne sur les allocations qu'avec des chiffres, et Ruby en fournit trois sources. GC.stat ouvre le tableau de bord du ramasse-miettes : total_allocated_objects (le cumul depuis le démarrage, idéal en delta avant/après), les objets vivants, le nombre de cycles déjà déclenchés. ObjectSpace.count_objects donne une photo par type — combien de String, d'Array, de Hash existent à l'instant T. Et memory_profiler, le plus directement utile, enveloppe un bloc et te dit où la mémoire est partie : par gem, par fichier, par ligne. C'est lui qui transforme « ça alloue trop quelque part » en « ligne 14, un Hash par row, deux mégaoctets ».You reason about allocations only with numbers, and Ruby provides three sources. GC.stat opens the garbage collector's dashboard: total_allocated_objects (the cumulative since startup, ideal as a before/after delta), live objects, the number of cycles already triggered. ObjectSpace.count_objects gives a snapshot by type — how many String, Array, Hash exist right now. And memory_profiler, the most directly useful, wraps a block and tells you where the memory went: by gem, by file, by line. It's the one that turns 'it allocates too much somewhere' into 'line 14, a Hash per row, two megabytes'.

GC.stat et ObjectSpace : les compteurs brutsGC.stat and ObjectSpace: the raw counters
# GC.stat : l'état global du tas et le total alloué depuis le départ
GC.stat(:total_allocated_objects)   # compteur cumulatif d'objets créés
GC.stat(:heap_live_slots)           # objets vivants actuellement
GC.stat(:minor_gc_count)            # combien de cycles de GC déjà passés

# ObjectSpace : combien d'objets, par type, à l'instant T
ObjectSpace.count_objects
# => { T_STRING: 48210, T_ARRAY: 9123, T_HASH: 1840, ... }
memory_profiler : il nomme la ligne coupablememory_profiler: it names the guilty line
require "memory_profiler"

report = MemoryProfiler.report do
  import(rows)                # le bloc qu'on suspecte
end
report.pretty_print          # alloué par GEM, par FICHIER, par LIGNE, par CLASSE

# le rapport NOMME le coupable :
#   allocated memory by location
#     2.10 MB  app/import.rb:14   ← le Hash + 5 String par ligne, ×100_000
# on ne cherche plus à l'aveugle : on lit où la mémoire part vraiment
Le GC moderne amortit beaucoup.The modern GC amortizes a lot.

Le ramasse-miettes de Ruby est générationnel et incrémental : les objets jeunes (la plupart des temporaires) sont collectés vite et à bas coût, et le travail est étalé pour éviter les longues pauses. Conséquence : allouer beaucoup d'objets éphémères est souvent moins grave qu'on ne le craint. Le vrai problème, ce sont les allocations massives sur un chemin chaud, ou les objets qu'on garde vivants trop longtemps. Encore une raison de mesurer plutôt que de craindre : seul le profil dit si tes allocations sont du bruit jeune ou un vrai poids.Ruby's garbage collector is generational and incremental: young objects (most temporaries) are collected fast and cheaply, and the work is spread to avoid long pauses. So allocating many ephemeral objects is often less serious than feared. The real problem is massive allocations on a hot path, or objects kept alive too long. One more reason to measure rather than fear: only the profile says whether your allocations are young noise or a real weight.

La méthode tient en trois temps : profile (memory_profiler autour du bloc suspect), lis où part la mémoire (la ligne, la classe), corrige ce point précis (hisser un constant, geler une chaîne, éviter un intermédiaire) — puis re-mesure pour confirmer le gain. Jamais l'ordre inverse : optimiser d'abord, espérer ensuite.The method is three beats: profile (memory_profiler around the suspect block), read where the memory goes (the line, the class), fix that precise spot (hoist a constant, freeze a string, avoid an intermediate) — then re-measure to confirm the gain. Never the reverse: optimize first, hope after.

№ 04Le piègeThe trap4 min

Geler et « pooler » partout — en optimisant les allocations qui ne pèsent pas.Freezing and "pooling" everywhere — optimizing the allocations that don't weigh.

Le contre-exemple obligatoire du numéro. Convaincu que « le GC est lent », on éparpille des .freeze, on pré-alloue des buffers, on évite les littéraux partout — guidé par l'intuition, pas par un profil. Résultat : on durcit du code froid (un en-tête rendu une fois par heure, des constantes de boot) pour des gains nuls, et on alourdit la lisibilité au passage. Pendant ce temps, le vrai point chaud — une boucle d'import qui alloue un hash et cinq chaînes par ligne, cent mille fois — n'a jamais été ouvert, parce qu'on ne l'a pas mesuré. On a optimisé les allocations qu'on imaginait, pas celles que memory_profiler aurait comptées. Le réflexe du volume vaut aussi pour la mémoire : profile, corrige le point chaud que le rapport nomme, laisse le reste lisible.The issue's mandatory counter-example. Convinced that 'the GC is slow', you scatter .freeze, pre-allocate buffers, avoid literals everywhere — guided by intuition, not a profile. Result: you harden cold code (a header rendered once an hour, boot constants) for zero gain, and hurt readability along the way. Meanwhile, the real hot spot — an import loop allocating a hash and five strings per line, a hundred thousand times — was never opened, because you didn't measure it. You optimized the allocations you imagined, not the ones memory_profiler would have counted. The volume's reflex holds for memory too: profile, fix the hot spot the report names, leave the rest readable.

Durcir le froid, ignorer le chaudHardening the cold, ignoring the hot
# « le GC est lent, je gèle et je pool PARTOUT » — sans memory_profiler
KEYS = %w[host port user].map(&:freeze).freeze   # micro-gain… au boot (froid)

def render_header
  buf = String.new                # « éviter une allocation »
  buf << "X-App: ".freeze          # .freeze éparpillés par superstition
  buf                              # ← cette méthode tourne 1×/heure
end

# pendant ce temps, le VRAI point chaud n'a jamais été regardé :
def import(rows)
  rows.map { |r| { id: r[0], name: r[1], tags: r[2].split("|") } }
end                                # un Hash + des String + un Array par ligne,
                                   # × 100_000 lignes — invisible, jamais profilé
Profiler nomme le chaud ; on corrige làProfiling names the hot; you fix there
# D'abord profiler, ensuite corriger LE point chaud
require "memory_profiler"
MemoryProfiler.report { import(rows) }.pretty_print
#   2.4 MB  import.rb:2   ← le rapport pointe la boucle d'import, pas le header

# on corrige CE que le profil montre, et on re-mesure le gain :
TAG_SEP = "|"
def import(rows)
  rows.map { |r| { id: r[0], name: r[1], tags: r[2].split(TAG_SEP) } }
end
# le header à 1×/heure ? on le laisse lisible : son allocation ne compte pas
Le contre-exemple obligatoire.The mandatory counter-example.

Le piège n'est pas de geler — c'est de geler à l'aveugle. Une allocation ne « pèse » qu'en proportion de sa fréquence : un .freeze sur un chemin froid ne gagne rien, le même sur une boucle à cent mille tours peut tout changer. Sans memory_profiler, on ne sait pas lequel est lequel — donc on disperse l'effort sur le visible plutôt que sur le coûteux. La discipline : profile d'abord, corrige la ligne que le rapport pointe, re-mesure, et n'éparpille pas des optimisations là où elles ne se voient pas. La lisibilité est un coût, lui aussi — ne le paie que contre un gain mesuré.The trap isn't freezing — it's freezing blindly. An allocation only "weighs" in proportion to its frequency: a .freeze on a cold path gains nothing, the same on a hundred-thousand-turn loop can change everything. Without memory_profiler, you don't know which is which — so you scatter effort on the visible rather than the costly. The discipline: profile first, fix the line the report points to, re-measure, and don't scatter optimizations where they don't show. Readability is a cost too — pay it only against a measured gain.

Une allocation ne pèse qu'à proportion de sa fréquence. Geler à l'aveugle, c'est durcir le froid et ignorer le chaud.An allocation only weighs in proportion to its frequency. Freezing blindly hardens the cold and ignores the hot.
№ 05Bilan & éditoWrap-up & editorial3 min

Compte les allocations — puis corrige celle qui pèse.Count the allocations — then fix the one that weighs.

Chaque littéral alloue un objet, et le GC paie pour le libérer. Le coût n'est pas dans le littéral isolé mais dans sa fréquence : un objet créé par tour de boucle chaude, ×100 000, pèse ; le même sur un chemin froid ne pèse rien. On ne le lit pas dans le code — on le compte : GC.stat pour le delta, ObjectSpace pour la photo, memory_profiler pour nommer la ligne. Le réflexe frozen string literal et le hissage de constants sont les corrections simples, mais on ne les applique qu'au point que le profil a désigné. Geler à l'aveugle durcit le froid et rate le chaud.Every literal allocates an object, and the GC pays to free it. The cost isn't in the lone literal but in its frequency: an object created per hot-loop turn, ×100,000, weighs; the same on a cold path weighs nothing. You don't read it in the code — you count it: GC.stat for the delta, ObjectSpace for the snapshot, memory_profiler to name the line. The frozen string literal reflex and hoisting constants are the simple fixes, but you apply them only to the spot the profile designated. Freezing blindly hardens the cold and misses the hot.

L'instrumentThe instrumentCe qu'il ditWhat it tellsLe réflexeThe reflex
GC.statGC.stattotal_allocated_objects : le delta avant/après.total_allocated_objects: the before/after delta.Combien d'objets ce bloc a-t-il créés ?How many objects did this block create?
ObjectSpaceObjectSpacecount_objects : combien, par type, à l'instant T.count_objects: how many, by type, right now.Quel type d'objet domine le tas ?Which object type dominates the heap?
memory_profilermemory_profilerOù part la mémoire : gem, fichier, ligne.Where memory goes: gem, file, line.Il nomme le coupable — corrige LÀ.It names the culprit — fix THERE.
frozen_string_literalfrozen_string_literalGèle et partage les littéraux de chaîne.Freezes and shares string literals.Le défaut sûr ; le gain reste à mesurer.The safe default; the gain stays to measure.
L'allocation est invisible et le GC silencieux — c'est pourquoi on les compte. Une optimisation sans chiffre n'est qu'une superstition coûteuse.Allocation is invisible and the GC silent — that's why you count them. An optimization with no number is just a costly superstition.
Mot de l'éditeurFrom the editor

La mémoire, comme la résolution : localisée, pas diffuse.Memory, like resolution: localized, not diffuse.

Le numéro précédent l'a dit de la résolution de méthode ; celui-ci le redit de la mémoire, et c'est la thèse du volume entier. Le coût du dynamisme n'est pas un brouillard uniforme qui ralentirait tout — c'est une poignée de points précis, identifiables, mesurables. La plupart des allocations de Ruby sont des temporaires jeunes que le GC balaie pour presque rien ; le poids réel se concentre dans quelques boucles chaudes que memory_profiler pointe du doigt. La leçon n'est pas « allouez moins » — c'est « sachez où vous allouez ». Un développeur qui mesure traite trois lignes et gagne ; un développeur qui imagine durcit cent endroits et n'y gagne que de l'illisibilité.The previous issue said it of method resolution; this one repeats it of memory, and it's the whole volume's thesis. The cost of dynamism isn't a uniform fog slowing everything — it's a handful of precise, identifiable, measurable spots. Most of Ruby's allocations are young temporaries the GC sweeps for almost nothing; the real weight concentrates in a few hot loops that memory_profiler points at. The lesson isn't 'allocate less' — it's 'know where you allocate'. A developer who measures fixes three lines and wins; a developer who imagines hardens a hundred places and gains only illegibility.

Prochain numéro : la concurrence — le GVL, threads, Ractors, fibers. Le dernier de la série, et le coût le plus mal compris. Pourquoi les threads de MRI n'accélèrent pas le calcul (le GVL sérialise l'exécution Ruby), mais bien l'attente d'I/O. On verra les vraies issues selon le goulot : threads ou async pour l'I/O, Ractors ou processus pour le CPU. Et toujours la même règle pour clore la série : mesure d'abord, choisis ensuite.Next issue: concurrency — the GVL, threads, Ractors, fibers. The series' last, and the most misunderstood cost. Why MRI's threads don't speed up computation (the GVL serializes Ruby execution), but do speed up I/O waiting. We'll see the real options by bottleneck: threads or async for I/O, Ractors or processes for CPU. And always the same rule to close the series: measure first, choose after.