CrackedRuby CrackedRuby

Overview

Memory debugging identifies and resolves issues related to how applications allocate, use, and release memory. In Ruby applications, memory problems manifest as gradual performance degradation, increased server costs, application crashes, and unpredictable behavior under load. Unlike lower-level languages where developers manage memory explicitly, Ruby's automatic garbage collection masks many memory problems until they become severe.

Memory debugging differs from general performance optimization. While performance profiling tracks execution time and CPU usage, memory debugging focuses on heap allocation patterns, object retention, reference graphs, and garbage collection behavior. A method that executes quickly can still cause memory problems if it creates objects that persist beyond their intended lifetime.

Ruby's memory characteristics create specific debugging challenges. The garbage collector reclaims memory automatically, but cannot free objects that remain referenced. Applications often retain objects unintentionally through closures, class variables, caching structures, or event listeners. These retained objects accumulate over time, causing memory bloat that goes unnoticed during development but becomes critical in production.

# Memory leak example - objects retained in class variable
class RequestHandler
  @@cache = {}
  
  def process(request)
    result = expensive_operation(request)
    @@cache[request.id] = result  # Never cleared
    result
  end
end

# After processing 100,000 requests
# @@cache contains 100,000 entries, all retained in memory

Memory debugging requires understanding how the Ruby VM manages memory. The VM allocates objects in heap pages, tracks references through object graphs, and runs garbage collection cycles to reclaim unreachable objects. Each Ruby process has its own heap that grows but rarely shrinks. Once the heap expands to handle a memory spike, it remains at that size even after objects are collected.

Production environments amplify memory problems. Development typically processes small datasets with short session durations, while production handles thousands of concurrent users over days or weeks. Memory issues that take hours to manifest in development appear within minutes in production. Debugging production memory problems requires tools that operate with minimal overhead and extract meaningful data from running processes.

Key Principles

Memory debugging operates on several fundamental principles about object lifecycles and garbage collection behavior. Understanding these principles guides effective debugging strategies and problem identification.

Object Retention and Reachability

The garbage collector traces object references from root objects to determine reachability. Root objects include global variables, constants, thread-local storage, and stack frames. Any object reachable through references from roots remains live and cannot be collected. Memory leaks in Ruby occur when objects remain reachable unintentionally, preventing garbage collection.

# Reachability example
class DataProcessor
  CACHE = {}  # Root object (constant)
  
  def process(data)
    key = generate_key(data)
    CACHE[key] = data  # Data now reachable from CACHE
    # Data persists even after method returns
    transform(data)
  end
end

# All processed data remains in memory
# because it's reachable through CACHE constant

Reference types determine retention patterns. Strong references keep objects alive, while WeakRef allows garbage collection when no strong references remain. Most Ruby references are strong by default, meaning any reference in active code prevents collection. Circular references between objects do not prevent collection because the collector traces the entire reference graph.

Allocation Patterns and Memory Growth

Memory consumption derives from both allocation rate and retention duration. High allocation rates increase garbage collection frequency, causing performance degradation even when objects are short-lived. Long-lived objects expand the heap permanently, as Ruby rarely returns memory to the operating system. The combination of high allocation and long retention creates memory crises.

Allocation sources include explicit object creation, string operations, array manipulations, and internal VM operations. Each method call allocates frame objects, each string interpolation creates new string objects, and each array operation may allocate new backing storage. Hidden allocations accumulate quickly in hot code paths.

# High allocation rate in hot path
def format_records(records)
  records.map do |record|
    # Allocates: string interpolation, intermediate strings
    "ID: #{record.id}, Name: #{record.name}, Status: #{record.status}"
  end
end

# Processing 10,000 records allocates 30,000+ string objects
# Even though most are immediately discarded

Garbage Collection Mechanics

Ruby uses a generational garbage collector that divides objects into young and old generations. Most objects die young, so the collector runs frequent minor collections on young objects and infrequent major collections on all objects. This strategy optimizes for typical allocation patterns but creates problems when many objects survive into the old generation.

Minor collections examine only young objects using a mark-and-sweep algorithm. The collector marks objects reachable from roots, sweeps unmarked objects, and promotes survivors to the old generation after surviving several collections. Major collections examine all objects, including the old generation, using mark-and-sweep with compaction. The compaction phase moves objects to eliminate fragmentation, requiring updates to all references.

Collection triggers depend on allocation rates and heap capacity. When heap usage exceeds thresholds, the collector runs automatically. Tuning these thresholds trades memory usage against collection frequency. Higher thresholds reduce collections but increase memory consumption. Lower thresholds cause frequent collections that impact performance.

Memory Fragmentation

Ruby's heap consists of fixed-size pages containing object slots. Objects larger than a single slot span multiple pages. After many allocations and collections, the heap becomes fragmented with partially filled pages. Fragmentation prevents efficient memory reuse and inflates the process memory size beyond actual object requirements.

Fragmentation increases when applications create many long-lived objects of varying sizes. The collector cannot compact fragmented pages efficiently because moving large objects is expensive. Pages remain allocated even when mostly empty, wasting memory. Ruby 3 improved compaction to reduce fragmentation, but the problem persists in applications with complex object graphs.

Memory vs Resident Set Size

Applications track memory through multiple metrics. Object memory represents the total size of live objects in the heap. Heap memory includes object memory plus free slots available for allocation. Process memory is the total memory reserved by the process from the operating system. Resident set size (RSS) measures physical memory actually loaded in RAM.

The relationship between these metrics reveals memory health. Large gaps between object memory and RSS indicate fragmentation or heap bloat. Continuously growing RSS with stable object counts suggests memory leaks. Monitoring all metrics together provides a complete picture of memory usage.

Ruby Implementation

Ruby's memory model combines automatic garbage collection with a generational heap structure. The VM allocates objects in memory pages, manages references through object slots, and reclaims memory through garbage collection cycles. Understanding this implementation helps diagnose memory problems and optimize allocation patterns.

Object Allocation and Heap Management

The Ruby VM maintains a heap composed of pages, each containing multiple object slots. Each slot holds one Ruby object, with the slot size determined at VM compile time. Objects too large for a single slot receive multiple consecutive slots. The VM allocates new pages when existing pages fill, growing the heap dynamically.

# Inspecting heap statistics
GC.stat
# => {
#   count: 42,
#   heap_allocated_pages: 150,
#   heap_sorted_length: 150,
#   heap_eden_pages: 100,
#   heap_tomb_pages: 50,
#   total_allocated_objects: 1250000,
#   total_freed_objects: 1000000
# }

# Current object count
GC.stat(:total_allocated_objects) - GC.stat(:total_freed_objects)
# => 250000

The ObjectSpace module provides direct heap inspection. Methods like each_object enumerate all live objects of specific classes, revealing allocation patterns and retention issues. The space enumerates objects without allocating new ones, making it safe for production debugging.

# Count instances by class
def count_objects_by_class
  counts = Hash.new(0)
  ObjectSpace.each_object do |obj|
    counts[obj.class] += 1
  end
  counts.sort_by { |_, count| -count }.take(10)
end

# Find all string objects over 1KB
large_strings = []
ObjectSpace.each_object(String) do |str|
  large_strings << str if str.bytesize > 1024
end

Garbage Collection Control

Ruby exposes garbage collection control through the GC module. Applications can trigger collections manually, disable automatic collection temporarily, and adjust collection parameters. Manual control helps reproduce memory issues consistently and measure allocation without collection interference.

# Disable GC for allocation measurement
GC.disable
before = GC.stat(:total_allocated_objects)

# Code to measure
1000.times { [1, 2, 3].map(&:to_s) }

after = GC.stat(:total_allocated_objects)
allocated = after - before
GC.enable

puts "Allocated #{allocated} objects"

Collection timing affects application behavior. Running GC during idle periods reduces impact on request processing. Applications can trigger collections after completing large operations or before serving requests. This strategy trades increased collection frequency for more predictable latency.

# Force garbage collection after batch processing
def process_batch(records)
  records.each do |record|
    handle_record(record)
  end
  
  # Trigger collection before returning
  GC.start(full_mark: true)
end

Memory Profiling Hooks

Ruby provides hooks for tracking allocations and object lifecycle events. The TracePoint API captures allocation events, allowing precise tracking of where objects are created. Applications can record allocation stacks, count allocations per method, and identify high-allocation code paths.

# Track allocations by location
allocations = Hash.new(0)

trace = TracePoint.new(:a_return) do |tp|
  allocations[[tp.path, tp.lineno]] += 1
end

trace.enable
run_application_code
trace.disable

# Print top allocation sites
allocations.sort_by { |_, count| -count }.take(20).each do |(path, line), count|
  puts "#{path}:#{line} => #{count} allocations"
end

The allocation_tracer gem extends these hooks with more detailed tracking. It records allocation counts per class, per method, and per source location. The data reveals allocation patterns that contribute to memory growth and garbage collection pressure.

Reference Management

Ruby applications can manage references explicitly when automatic collection is insufficient. The WeakRef class creates references that do not prevent garbage collection. When the referenced object becomes unreachable through strong references, the garbage collector reclaims it even though WeakRef references exist.

require 'weakref'

# Cache with weak references
class WeakCache
  def initialize
    @cache = {}
  end
  
  def store(key, value)
    @cache[key] = WeakRef.new(value)
  end
  
  def fetch(key)
    return nil unless @cache.key?(key)
    
    weak_ref = @cache[key]
    return nil if weak_ref.weakref_alive? == false
    
    weak_ref.__getobj__
  rescue WeakRef::RefError
    @cache.delete(key)
    nil
  end
end

WeakRef solves caching problems where entries should not prevent garbage collection. Standard caches retain all entries indefinitely, causing memory growth. Weak reference caches allow the collector to reclaim cached objects when memory pressure increases. The cache automatically removes stale entries when referenced objects are collected.

Tools & Ecosystem

Ruby's memory debugging ecosystem includes profilers, analyzers, heap dumpers, and monitoring tools. Each tool addresses different aspects of memory debugging, from real-time allocation tracking to post-mortem heap analysis. Selecting appropriate tools depends on the debugging context and problem characteristics.

memory_profiler

The memory_profiler gem provides detailed allocation tracking with minimal overhead. It measures allocations and retentions during code execution, breaking down results by gem, file, location, and class. The profiler distinguishes between allocated objects (created during execution) and retained objects (surviving garbage collection).

require 'memory_profiler'

report = MemoryProfiler.report do
  1000.times do
    User.create(name: "Test", email: "test@example.com")
  end
end

# Print detailed allocation report
report.pretty_print

# Report shows:
# - Total allocated memory by class
# - Total retained memory by class
# - Allocation locations with source file and line
# - Retained object locations

Reports identify memory hotspots by ranking allocation sources. High allocation counts indicate performance problems from garbage collection pressure. High retention counts indicate memory leaks from objects that survive beyond their intended lifetime. Comparing allocated versus retained objects reveals whether problems stem from allocation rate or retention duration.

# Compare allocations across code changes
baseline = MemoryProfiler.report { run_baseline_code }
optimized = MemoryProfiler.report { run_optimized_code }

puts "Baseline allocated: #{baseline.total_allocated_memsize}"
puts "Optimized allocated: #{optimized.total_allocated_memsize}"
puts "Improvement: #{baseline.total_allocated_memsize - optimized.total_allocated_memsize}"

derailed_benchmarks

The derailed_benchmarks gem measures memory usage in Rails applications. It provides commands for memory profiling, leak detection, and allocation tracking integrated with Rails environments. The gem helps identify memory issues in controllers, views, and background jobs.

# In Gemfile
gem 'derailed_benchmarks', group: :development

# Profile memory usage of specific endpoint
$ bundle exec derailed exec perf:mem TEST_COUNT=100 PATH_TO_HIT=/users/1

# Output shows memory used per request iteration
# and identifies memory leaks if memory grows linearly

# Profile memory by requiring files
$ bundle exec derailed bundle:mem

# Shows memory consumed by each gem
# Helps identify heavy dependencies

The gem detects memory leaks by measuring memory growth across multiple iterations. If memory increases linearly with iteration count, a leak exists. The tool also profiles object allocations to identify high-allocation code paths in Rails controllers and actions.

heap dumping with objspace

Ruby's objspace library dumps heap contents to JSON for offline analysis. Heap dumps capture all live objects, their types, sizes, references, and creation locations. Analyzing dumps reveals object retention patterns and reference chains that prevent garbage collection.

require 'objspace'

# Generate heap dump
ObjectSpace.trace_object_allocations_start
run_application_code
File.open('heap.json', 'w') do |f|
  ObjectSpace.dump_all(output: f)
end
ObjectSpace.trace_object_allocations_stop

Heap dumps answer questions about what objects exist and why they remain allocated. Tools like heapy analyze dumps to find retained objects, compare dumps over time, and trace reference chains. Production systems can generate dumps without stopping the process, enabling investigation of live memory issues.

# Compare two heap dumps to find leaked objects
# Using heapy gem
$ heapy read heap_before.json heap_after.json

# Shows objects present in after but not before
# Indicates newly allocated objects retained in memory

allocation_tracer

The allocation_tracer gem tracks object allocations with detailed context about creation sites. It records the class, count, and source location for every allocation, creating allocation tables that identify hotspots. The gem operates with lower overhead than full memory profilers, making it suitable for longer profiling sessions.

require 'allocation_tracer'

ObjectSpace::AllocationTracer.setup(%i[path line type])
ObjectSpace::AllocationTracer.trace

# Run code to profile
process_user_requests(count: 1000)

# Get allocation results
result = ObjectSpace::AllocationTracer.stop

# Result hash maps [path, line, class] => allocation_count
result.sort_by { |_, count| -count }.take(20).each do |location, count|
  puts "#{location.join(':')} => #{count} allocations"
end

get_process_mem

The get_process_mem gem provides cross-platform memory measurements for Ruby processes. It reports resident set size, virtual memory size, and proportional set size. The gem works on Linux, macOS, and Windows, providing consistent measurements across platforms.

require 'get_process_mem'

mem = GetProcessMem.new

# Check memory at different points
before = mem.mb
perform_operation
after = mem.mb

puts "Memory increased by #{after - before} MB"

# Monitor memory over time
10.times do
  process_batch
  puts "Current memory: #{mem.mb} MB"
  sleep 1
end

The gem helps track memory growth in production processes. Applications can log memory metrics periodically, alert when memory exceeds thresholds, and correlate memory usage with application events. Continuous monitoring detects gradual memory leaks before they cause crashes.

Practical Examples

Memory debugging scenarios demonstrate how to identify, analyze, and fix common memory problems. These examples show complete debugging workflows from problem detection through verification of fixes.

Finding and Fixing a Class Variable Memory Leak

A Rails application shows steady memory growth in production. Investigating with memory_profiler reveals that User objects accumulate continuously. The application never destroys User objects, indicating a reference leak.

# Problematic code - global event listener cache
class EventTracker
  @@listeners = []
  
  def self.on_user_action(&block)
    @@listeners << block
  end
  
  def self.trigger_user_action(user)
    @@listeners.each { |listener| listener.call(user) }
  end
end

# Controller registers listeners for each request
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    
    # Registers closure that captures user reference
    EventTracker.on_user_action do |action_user|
      Rails.logger.info "User #{user.id} triggered action"
    end
    
    render json: user
  end
end

The problem: Each request registers a new event listener that captures the user object in its closure. The class variable @@listeners retains all closures, which retain all captured user objects. After processing 10,000 requests, the application holds 10,000 User objects in memory.

Debugging process:

# Step 1: Profile memory growth
report = MemoryProfiler.report do
  100.times do
    # Simulate request processing
    UsersController.new.show(params: { id: 1 })
  end
end

report.pretty_print
# Shows: User objects retained: 100
# Location: users_controller.rb:5 (the closure)

The report confirms User objects are retained and points to the closure as the retention source. Examining the code reveals the class variable that never removes listeners.

Fix implementation:

# Fixed version - listeners tied to request lifecycle
class EventTracker
  def initialize
    @listeners = []
  end
  
  def on_user_action(&block)
    @listeners << block
  end
  
  def trigger_user_action(user)
    @listeners.each { |listener| listener.call(user) }
  end
end

class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    tracker = EventTracker.new  # Instance per request
    
    tracker.on_user_action do |action_user|
      Rails.logger.info "User #{user.id} triggered action"
    end
    
    render json: user
  end
end

The fix creates a new EventTracker instance per request. When the request completes, both the tracker and its listeners become unreachable and are garbage collected. User objects captured in closures are released when listeners are collected.

Verification:

# Verify fix reduces retention
GC.start
before_count = ObjectSpace.each_object(User).count

100.times do
  UsersController.new.show(params: { id: 1 })
end

GC.start
after_count = ObjectSpace.each_object(User).count

puts "User objects retained: #{after_count - before_count}"
# Should show minimal or zero retention

Debugging String Allocation in Hot Paths

An API endpoint processes large datasets but shows poor performance. Profiling reveals the endpoint allocates millions of string objects per request, causing frequent garbage collections that slow request processing.

# High-allocation version
def format_records(records)
  records.map do |record|
    # Each iteration allocates multiple strings
    result = ""
    result += "ID: #{record.id}\n"
    result += "Name: #{record.name}\n"
    result += "Status: #{record.status}\n"
    result += "Created: #{record.created_at}\n"
    result
  end.join("\n---\n")
end

# Processing 10,000 records allocates 50,000+ string objects

Measurement with allocation tracking:

require 'allocation_tracer'

ObjectSpace::AllocationTracer.setup(%i[path line type])
GC.disable  # Prevent collection during measurement

ObjectSpace::AllocationTracer.trace
result = format_records(Record.limit(10_000))
allocations = ObjectSpace::AllocationTracer.stop

GC.enable

# Count String allocations
string_allocs = allocations.select { |key, _| key[2] == :T_STRING }
                          .sum { |_, count| count }
puts "String allocations: #{string_allocs}"
# Output: String allocations: 52,143

The measurement confirms excessive string allocation. Each string concatenation with += allocates a new string object because strings are immutable in Ruby. The interpolations also allocate strings.

Optimized implementation:

# Low-allocation version using mutable strings
def format_records(records)
  records.map do |record|
    # Use mutable string builder pattern
    +"ID: #{record.id}\n" \
     "Name: #{record.name}\n" \
     "Status: #{record.status}\n" \
     "Created: #{record.created_at}"
  end.join("\n---\n")
end

# Alternative: Use Array#join to avoid intermediate concatenations
def format_records_join(records)
  parts = records.flat_map do |record|
    [
      "ID: #{record.id}",
      "Name: #{record.name}",
      "Status: #{record.status}",
      "Created: #{record.created_at}",
      "---"
    ]
  end
  parts.join("\n")
end

The optimized version reduces allocations by using unary plus operator to create a single mutable string, then using implicit concatenation of adjacent string literals. The alternative approach uses Array#join which allocates once for the final string instead of creating intermediate concatenations.

Detecting Memory Growth with Heap Dumps

A background worker process shows memory growth that eventually causes OOM crashes. The growth is gradual, taking several hours to manifest. Standard profiling is impractical for such long durations.

# Worker with memory leak
class ReportGenerator
  @@report_cache = {}
  
  def generate_report(user_id)
    cache_key = "user_#{user_id}"
    
    return @@report_cache[cache_key] if @@report_cache.key?(cache_key)
    
    report = build_report(User.find(user_id))
    @@report_cache[cache_key] = report  # Leak: never cleared
    report
  end
end

Heap dump analysis workflow:

# Generate initial heap dump
require 'objspace'

ObjectSpace.trace_object_allocations_start
File.open('heap_before.json', 'w') do |f|
  ObjectSpace.dump_all(output: f)
end

# Run worker for extended period
worker = ReportGenerator.new
10_000.times do |i|
  worker.generate_report(rand(1000))
end

# Generate second heap dump
File.open('heap_after.json', 'w') do |f|
  ObjectSpace.dump_all(output: f)
end
ObjectSpace.trace_object_allocations_stop

Comparing dumps with heapy:

$ gem install heapy
$ heapy diff heap_before.json heap_after.json

# Output shows objects present in after but not before
# Hash entries: +10000
# String objects: +40000
# Report objects: +10000

# Indicates 10,000 reports cached with associated objects

The comparison reveals thousands of Report objects retained along with their associated strings and hash entries. The retention pattern matches the caching behavior in the code.

Inspecting specific objects:

# Load heap dump for analysis
require 'json'

objects = File.readlines('heap_after.json').map { |line| JSON.parse(line) }
reports = objects.select { |obj| obj['class'] == 'Report' }

puts "Total reports: #{reports.size}"

# Find references to these reports
report_addresses = reports.map { |r| r['address'] }.to_set
referencing_objects = objects.select do |obj|
  obj['references']&.any? { |ref| report_addresses.include?(ref) }
end

# Identify what holds reports
referencing_objects.group_by { |obj| obj['class'] }.each do |klass, objs|
  puts "#{klass}: #{objs.size} references to Report objects"
end
# Output: Hash: 1 references to Report objects
# The singleton Hash (@@report_cache) references all reports

The analysis confirms the class variable hash retains all reports. The fix implements cache eviction or uses a WeakRef-based cache that allows garbage collection under memory pressure.

Implementation Approaches

Memory debugging strategies depend on problem characteristics, environment constraints, and available tools. Different approaches suit different debugging scenarios, from development testing to production incident response.

Proactive Allocation Profiling

This approach profiles allocation patterns during development to prevent memory issues before deployment. The strategy measures allocations in test scenarios that simulate production workloads. Tests run with allocation tracking enabled, recording allocation counts, sources, and patterns.

# RSpec example with allocation tracking
require 'allocation_tracer'

RSpec.describe UsersController do
  it 'processes requests with bounded allocations' do
    ObjectSpace::AllocationTracer.setup(%i[path line type])
    ObjectSpace::AllocationTracer.trace
    
    # Execute test scenario
    100.times { get :show, params: { id: 1 } }
    
    allocations = ObjectSpace::AllocationTracer.stop
    total_allocated = allocations.values.sum
    
    # Assert allocation limits
    expect(total_allocated).to be < 100_000
  end
end

The approach catches allocation regressions during code review. Pull requests that increase allocations beyond thresholds fail automated tests. Teams establish allocation budgets for critical paths, treating excessive allocation as a defect. The strategy prevents memory problems from reaching production.

Continuous Memory Monitoring

Production applications implement continuous memory monitoring to detect issues early. Processes measure memory metrics at regular intervals, logging RSS, heap size, and object counts. Monitoring systems alert when memory exceeds thresholds or shows sustained growth trends.

# Memory monitoring middleware
class MemoryMonitor
  def initialize(app)
    @app = app
    @mem = GetProcessMem.new
  end
  
  def call(env)
    before_mem = @mem.mb
    result = @app.call(env)
    after_mem = @mem.mb
    
    # Log memory delta per request
    Rails.logger.info({
      event: 'request_memory',
      path: env['PATH_INFO'],
      memory_mb: after_mem - before_mem,
      total_memory_mb: after_mem
    })
    
    # Alert if memory exceeds threshold
    alert_high_memory if after_mem > 1024
    
    result
  end
  
  def alert_high_memory
    # Trigger heap dump for investigation
    File.open("heap_#{Time.now.to_i}.json", 'w') do |f|
      ObjectSpace.dump_all(output: f)
    end
  end
end

Monitoring detects gradual leaks that manifest over hours or days. The system correlates memory growth with application events, identifying which operations cause retention. Automatic heap dumps during high memory conditions capture data for post-mortem analysis.

Comparative Heap Analysis

This strategy compares heap states at different points to identify leaked objects. The approach takes heap dumps before and after suspected problematic code, then analyzes differences. Objects present in the second dump but not the first represent potential leaks.

# Comparative analysis framework
class HeapComparator
  def self.analyze(&block)
    GC.start  # Collect before first dump
    
    # Capture initial state
    before_objects = current_objects_by_class
    
    # Execute potentially leaky code
    yield
    
    GC.start  # Collect before comparison
    
    # Capture final state
    after_objects = current_objects_by_class
    
    # Calculate differences
    growth = after_objects.transform_values.with_index do |count, i|
      count - before_objects.fetch(after_objects.keys[i], 0)
    end
    
    growth.select { |_, count| count > 0 }
          .sort_by { |_, count| -count }
  end
  
  def self.current_objects_by_class
    counts = Hash.new(0)
    ObjectSpace.each_object { |obj| counts[obj.class] += 1 }
    counts
  end
end

# Usage
leaked_classes = HeapComparator.analyze do
  1000.times { process_request }
end

leaked_classes.each do |klass, count|
  puts "#{klass}: +#{count} objects"
end

The comparison reveals which classes accumulate during execution. Classes with large positive counts indicate retention problems. Investigating allocation sites for growing classes identifies the source of leaks.

Stress Testing with Memory Constraints

This approach runs applications under memory limits to force issues to surface quickly. Tests execute in containers or processes with restricted memory, causing OOM conditions when leaks exist. The strategy accelerates discovery of gradual leaks by making them fatal within minutes instead of hours.

# Memory-constrained test harness
class MemoryStressTest
  def run(memory_limit_mb:, iterations:)
    # Set process memory limit (Linux)
    Process.setrlimit(Process::RLIMIT_AS, 
                     memory_limit_mb * 1024 * 1024)
    
    begin
      iterations.times do |i|
        execute_workload
        
        current_mem = GetProcessMem.new.mb
        puts "Iteration #{i}: #{current_mem} MB"
        
        if current_mem > memory_limit_mb * 0.9
          puts "WARNING: Approaching memory limit"
          dump_heap_for_analysis
        end
      end
    rescue NoMemoryError
      puts "FAILURE: Memory limit exceeded"
      dump_heap_for_analysis
      raise
    end
  end
end

# Run test with 512MB limit
MemoryStressTest.new.run(memory_limit_mb: 512, iterations: 10000)

Stress testing validates fixes by confirming memory stays bounded under load. Tests that pass without memory limits but fail with constraints indicate genuine memory problems. The approach works particularly well in continuous integration, catching regressions before deployment.

Production Heap Dump Analysis

When memory issues occur in production, heap dumps provide comprehensive data for investigation. The approach involves triggering dumps during high memory conditions, downloading dumps from production servers, and analyzing them offline. Analysis identifies retained objects and reference chains preventing collection.

# Production-safe heap dump trigger
module ProductionMemoryDebug
  def self.dump_if_high_memory(threshold_mb:)
    mem = GetProcessMem.new
    
    return unless mem.mb > threshold_mb
    
    # Dump in separate thread to avoid blocking requests
    Thread.new do
      filename = "/tmp/heap_#{Process.pid}_#{Time.now.to_i}.json"
      
      ObjectSpace.trace_object_allocations_start
      File.open(filename, 'w') do |f|
        ObjectSpace.dump_all(output: f)
      end
      ObjectSpace.trace_object_allocations_stop
      
      Rails.logger.warn("Heap dump created: #{filename}")
      # Upload to S3 or log aggregation service
      upload_dump(filename)
    end
  end
end

# Trigger from monitoring
if memory_high?
  ProductionMemoryDebug.dump_if_high_memory(threshold_mb: 2048)
end

Common Pitfalls

Memory debugging encounters recurring problems that mislead investigations or mask underlying issues. Recognizing these pitfalls prevents wasted debugging effort and incorrect conclusions.

Confusing Allocated with Retained Memory

Memory profilers report both allocations (objects created) and retentions (objects surviving collection). High allocations indicate performance problems from garbage collection overhead, while high retentions indicate memory leaks from objects that persist. Developers often focus on total allocations while ignoring retention, missing actual memory leaks.

# High allocation but low retention - NOT a memory leak
def process_records(records)
  records.map do |record|
    # Allocates strings, but they're immediately discarded
    formatted = "#{record.id}: #{record.name}"
    formatted.upcase
  end
end

# Low allocation but high retention - ACTUAL memory leak  
class Cache
  @@data = []
  
  def add(record)
    # Few allocations, but objects retained forever
    @@data << record
  end
end

Allocation counts increase linearly with workload size. Processing 1000 records allocates more objects than processing 10 records, regardless of whether a leak exists. Retention counts reveal leaks because they should remain constant or grow sublinearly as caches fill. Debugging requires examining both metrics to identify the actual problem.

Triggering Garbage Collection During Measurements

Garbage collection during profiling distorts results by removing objects that would otherwise appear retained. Profilers capture object counts at specific points, but collections between measurements remove short-lived objects from the results. This makes allocations appear lower than reality and obscures allocation patterns.

# Incorrect measurement - GC runs automatically
report = MemoryProfiler.report do
  10_000.times { process_record }
  # GC may run here, removing allocated objects
end

# Correct measurement - disable GC
GC.disable
report = MemoryProfiler.report do
  10_000.times { process_record }
end
GC.enable

Disabling GC during measurements ensures accurate allocation counts. After measurement completes, re-enable GC to prevent memory exhaustion. Short measurements avoid memory pressure even with GC disabled. Long measurements may require periodic manual collections to prevent OOM, accepting measurement distortion as necessary.

Misinterpreting Heap Growth Patterns

Ruby's heap grows to accommodate peak memory usage but rarely shrinks. After processing a large dataset, the heap remains expanded even after objects are collected. This creates the appearance of a memory leak when memory actually stabilized at a higher level.

# Heap growth stabilizes, not a leak
def process_daily_batch
  # Loads 1GB of data
  records = load_full_dataset
  
  # Process data
  process(records)
  
  # Records eligible for collection
  records = nil
  GC.start
  
  # Heap remains ~1GB, RSS drops as pages are freed
  # This is normal, not a leak
end

Distinguishing growth from leaks requires monitoring memory over multiple cycles. Stabilization indicates normal heap behavior, while continuous growth indicates leaks. The pattern emerges over time: normal applications reach steady state after warmup, while leaking applications show unbounded growth.

Ignoring Shared Memory and COW

Ruby processes share memory pages through copy-on-write when forking. Applications running multiple worker processes share read-only pages, reducing actual memory usage below the sum of individual process RSS values. Counting total memory by summing RSS overestimates actual memory consumption.

# Forking reduces actual memory usage
before_rss = GetProcessMem.new.mb

# Fork creates copy-on-write child
pid = fork do
  # Child shares read-only pages with parent
  # Only modified pages are copied
  child_rss = GetProcessMem.new.mb
  puts "Child RSS: #{child_rss} MB"
end

parent_rss = GetProcessMem.new.mb
puts "Parent RSS: #{parent_rss} MB"
# Sum of parent + child RSS exceeds actual memory used

Memory analysis must account for sharing. Tools like ps_mem or smem calculate proportional set size (PSS), which divides shared pages among processes. PSS provides accurate total memory usage when multiple processes run. Without accounting for sharing, investigations blame applications for phantom memory consumption.

Assuming All Growth Is Leaks

Applications legitimately use more memory as they handle larger workloads or cache more data. Growth patterns that correlate with usage levels represent normal scaling behavior, not leaks. Memory leaks show growth independent of workload, continuing even under constant load.

# Normal growth - cache fills to capacity
class LRUCache
  def initialize(max_size)
    @max_size = max_size
    @cache = {}
  end
  
  def store(key, value)
    @cache[key] = value
    evict_oldest if @cache.size > @max_size
  end
  
  # Memory grows to max_size * value_size, then stabilizes
  # This is expected behavior, not a leak
end

Identifying leaks requires comparing memory growth against workload metrics. If memory grows proportionally to cache size limits, throughput, or dataset size, growth may be legitimate. If memory grows when these metrics remain constant, a leak exists. Context determines whether growth represents a problem.

Neglecting Reference Cycles in Closures

Closures capture variables from their defining scope, creating references that prevent garbage collection. Developers often forget closures retain entire scope chains, not just explicitly referenced variables. This causes unexpected retention of large objects referenced elsewhere in the scope.

# Closure retains more than expected
class RequestHandler
  def process(large_dataset)
    # Closure captures entire method scope, including large_dataset
    callback = -> { puts "Processing complete" }
    
    # Register callback in global listener
    EventBus.register(callback)
    
    # large_dataset retained by callback, even though not used
    # EventBus prevents garbage collection of callback and its scope
  end
end

Breaking reference cycles requires careful closure construction. Extract only necessary variables into closure scope, or clear references after closure registration. Understanding closure mechanics prevents retention surprises during debugging.

Reference

Memory Profiling Tools

Tool Purpose Use Case
memory_profiler Allocation and retention tracking Find high-allocation code paths and retention sources
derailed_benchmarks Rails application memory profiling Profile Rails endpoints and detect memory leaks
allocation_tracer Detailed allocation tracking Long-running allocation analysis with low overhead
get_process_mem Cross-platform memory measurement Monitor process memory in production
objspace Heap dumping and inspection Post-mortem memory analysis and object enumeration

GC Module Methods

Method Description Returns
GC.start Triggers garbage collection cycle nil
GC.disable Disables automatic garbage collection false or true
GC.enable Re-enables automatic garbage collection false or true
GC.stat Returns GC statistics hash Hash
GC.count Returns number of GC runs since boot Integer
GC.stress Gets or sets GC stress mode Boolean

GC Statistics Keys

Statistic Description
count Total number of GC runs
heap_allocated_pages Total heap pages allocated
heap_eden_pages Active heap pages
heap_tomb_pages Freed heap pages awaiting reuse
total_allocated_objects Cumulative objects allocated
total_freed_objects Cumulative objects freed
malloc_increase_bytes Memory allocated via malloc
oldmalloc_increase_bytes Old generation malloc memory

ObjectSpace Methods

Method Description
ObjectSpace.each_object Iterates over all live objects of class
ObjectSpace.count_objects Returns object count by type
ObjectSpace.dump_all Dumps heap to JSON format
ObjectSpace.trace_object_allocations_start Begins allocation tracking
ObjectSpace.trace_object_allocations_stop Stops allocation tracking

Memory Debugging Checklist

Phase Actions
Detection Monitor RSS, heap size, and object counts over time
Measurement Profile allocations with memory_profiler or allocation_tracer
Analysis Compare heap dumps or allocation traces to identify patterns
Hypothesis Identify suspected leak sources from profiling data
Verification Test hypothesis with targeted profiling of suspected code
Fix Implement reference cleanup, cache limits, or WeakRef usage
Validation Verify memory stabilizes under load with stress testing

Memory Leak Indicators

Indicator Significance
Linear RSS growth Strong leak indicator if growth continues under constant load
Increasing object counts Objects accumulating faster than collection rate
High minor GC frequency Excessive allocation creating GC pressure
Flat line object retention Specific classes not being collected
Growing heap pages VM expanding heap to accommodate retained objects

Heap Dump Analysis Commands

Command Purpose
ObjectSpace.dump_all Generate complete heap dump
heapy read Analyze single heap dump
heapy diff Compare two heap dumps to find new objects
ObjectSpace.each_object Enumerate live objects by class

Memory Optimization Strategies

Strategy Approach
Object pooling Reuse objects instead of allocating new ones
String freezing Share immutable string instances
Lazy initialization Defer allocation until needed
WeakRef caches Allow cache eviction via garbage collection
Batch processing Process data in chunks to limit peak memory
Explicit nil assignment Clear references to enable collection