Overview
Memory management controls how programs allocate, use, and release memory during execution. Every running program requires memory to store variables, data structures, execution context, and machine instructions. The method used to manage this memory directly impacts application performance, reliability, and resource consumption.
Memory management operates at multiple levels. At the hardware level, physical RAM stores program data. The operating system manages virtual memory, mapping physical addresses to virtual address spaces for each process. Programming languages implement memory management through various strategies ranging from manual allocation to automatic garbage collection. Each approach presents different trade-offs between performance, developer complexity, and safety.
The memory lifecycle follows a consistent pattern: allocation, usage, and deallocation. During allocation, the system reserves memory for program data. The program then reads and writes to this memory during execution. Finally, deallocation releases the memory back to the system for reuse. Failures in this cycle lead to memory leaks, dangling pointers, and crashes.
Two primary memory regions exist in most programs: the stack and the heap. The stack stores local variables, function parameters, and return addresses in a last-in-first-out structure. Stack allocation and deallocation occur automatically when functions are called and return. The heap provides dynamic memory for data structures with unpredictable lifetimes. Programs explicitly request heap memory during execution, and different languages handle heap deallocation through manual or automatic mechanisms.
# Stack allocation - automatic and fast
def calculate_sum(a, b)
result = a + b # 'result' allocated on stack
result # memory freed when function returns
end
# Heap allocation - dynamic and persistent
class DataProcessor
def initialize
@large_dataset = Array.new(1_000_000) # Allocated on heap
@cache = {} # Hash also on heap
end
end
Memory management complexity increases with program scale. Small programs may function adequately with inefficient memory handling, but production systems processing millions of requests require careful memory optimization. Memory issues often manifest as gradual performance degradation, making them difficult to diagnose without proper monitoring.
Key Principles
Memory management builds on fundamental principles that apply across programming languages and platforms. Understanding these principles provides the foundation for writing efficient, reliable code regardless of the specific memory management approach used.
Memory Allocation Strategies
Programs allocate memory through two primary mechanisms: static allocation and dynamic allocation. Static allocation occurs at compile time for variables with known size and lifetime. The compiler determines exact memory requirements and reserves space in the program binary. Dynamic allocation happens at runtime when memory requirements are not known in advance. The program requests memory from the system during execution, and the allocation size can vary based on runtime conditions.
Stack allocation provides the fastest memory access pattern. The stack pointer moves up and down as functions call and return, making allocation and deallocation single instruction operations. Stack memory is inherently limited, typically measured in megabytes, making it suitable only for small, short-lived data. Stack overflow occurs when nested function calls or large local variables exceed the available stack space.
Heap allocation offers flexibility at the cost of performance. Programs request variable amounts of heap memory during execution, and the allocator finds suitable memory blocks from the available heap space. Heap memory persists until explicitly freed or garbage collected, making it suitable for long-lived data structures. The heap can grow to fill available system memory, but fragmentation and allocation overhead impact performance.
Memory Layout and Addressing
Programs organize memory into distinct segments, each serving specific purposes. The text segment contains executable machine code and remains read-only during execution. The data segment stores global and static variables initialized at program start. The BSS segment holds uninitialized global variables, allocated but not stored in the executable file. The heap grows upward in memory as allocations occur, while the stack grows downward from high addresses.
Virtual memory abstracts physical memory addresses, providing each process with an isolated address space. The operating system maps virtual addresses to physical RAM locations, enabling memory protection and overcommitment. Page tables store these mappings, and the memory management unit (MMU) performs address translation during memory access. Virtual memory allows processes to use more memory than physically available through swapping to disk.
Ownership and Lifetime Management
Memory ownership determines which code component is responsible for deallocating memory. In manual memory management, the allocating code typically owns and must free the memory. Ownership transfer occurs when functions return allocated memory to callers. Shared ownership arises when multiple components reference the same memory, requiring coordination for safe deallocation.
Object lifetime spans from allocation to deallocation. Local variables have automatic lifetime tied to function scope. Dynamic allocations persist until explicitly freed or garbage collected. Static variables live for the entire program execution. Mismatched lifetimes cause memory leaks when allocations outlive their usefulness, and use-after-free bugs when code accesses deallocated memory.
Garbage Collection Fundamentals
Garbage collection automates memory deallocation by identifying unreachable objects. The garbage collector traces references from root objects (globals, stack variables) to find all reachable memory. Objects not reached during tracing are considered garbage and can be deallocated. This automatic approach eliminates manual deallocation bugs but introduces runtime overhead.
Reference counting tracks how many references point to each object. When the count reaches zero, the object becomes garbage. Reference counting provides deterministic deallocation timing but cannot handle reference cycles where objects reference each other circularly. Tracing collectors handle cycles but run periodically, causing unpredictable pauses.
Memory Safety and Correctness
Memory safety ensures programs cannot access invalid memory addresses or interpret memory as the wrong type. Buffer overflows occur when code writes beyond allocated memory boundaries, corrupting adjacent data. Dangling pointers reference deallocated memory, leading to crashes or security vulnerabilities. Double-free errors attempt to deallocate the same memory twice, corrupting allocator metadata.
Type safety prevents interpreting memory as incorrect types. Type systems enforce that operations match data types, preventing undefined behavior. Memory-safe languages eliminate entire classes of vulnerabilities by preventing unsafe memory access at compile time or runtime.
# Ruby prevents manual memory access
array = [1, 2, 3]
# No pointer arithmetic or manual addressing
# Bounds checking prevents overflows
value = array[10] # Returns nil, doesn't crash
# References are managed automatically
obj1 = Object.new
obj2 = obj1 # Reference counted
obj1 = nil # Decrements reference
# obj2 still valid - memory not freed until last reference gone
Memory Pooling and Allocation Patterns
Memory pools pre-allocate blocks of memory for efficient repeated allocations of same-sized objects. Instead of requesting memory from the system for each allocation, programs draw from the pool. This reduces allocation overhead and fragmentation for frequently created and destroyed objects. Object pools reuse allocated objects rather than repeatedly allocating and freeing memory.
Allocation patterns significantly impact performance. Many small allocations cause overhead from repeated system calls. Allocating large contiguous blocks and subdividing them improves efficiency. Arena allocators batch allocate memory that gets freed together, simplifying memory management for request-scoped data. Stack-like allocation patterns allocate and free in reverse order, enabling efficient bump-pointer allocation.
Implementation Approaches
Memory management strategies span a spectrum from fully manual to fully automatic, each presenting distinct characteristics for reliability, performance, and developer experience.
Manual Memory Management
Manual memory management places allocation and deallocation responsibility entirely on the programmer. Languages like C and C++ provide explicit functions for memory operations. Programs call malloc or new to allocate memory, receiving a pointer to the allocated space. When finished, programs must call free or delete to return memory to the system.
This approach offers maximum control over memory usage and timing. Programmers decide exactly when to allocate and free memory, enabling fine-tuned optimization. Memory footprint remains predictable since allocations are explicit. No runtime overhead from garbage collection exists, making manual management suitable for real-time systems requiring deterministic performance.
Manual management demands careful attention to ownership semantics. Each allocation must have a corresponding deallocation, and programs must track which code owns each memory region. Documentation and conventions establish ownership rules, but the compiler cannot enforce them. Complex data structures with shared references require sophisticated coordination to avoid double-free errors or leaks.
# Ruby simulates manual patterns through explicit cleanup
class ResourceManager
def initialize
@resources = []
end
def allocate_resource
resource = ExpensiveResource.new
@resources << resource
resource
end
def free_resource(resource)
@resources.delete(resource)
resource.cleanup # Explicit cleanup method
end
def free_all
@resources.each(&:cleanup)
@resources.clear
end
end
Automatic Reference Counting
Automatic reference counting (ARC) tracks how many references point to each object. The system maintains a reference count metadata field for each allocation. When code creates a reference, the count increments. When a reference goes out of scope or gets reassigned, the count decrements. When the count reaches zero, the object is immediately deallocated.
Reference counting provides deterministic deallocation timing. Objects are freed as soon as the last reference disappears, making resource release predictable. This characteristic benefits objects managing external resources like file handles or network connections. Destructors run immediately when references drop to zero, ensuring timely resource cleanup.
Reference counting introduces overhead for every reference operation. Each assignment requires incrementing one count and decrementing another. Atomic operations are necessary in multithreaded programs to prevent race conditions in count updates. Cyclic references create memory leaks when objects reference each other, preventing counts from ever reaching zero.
# Ruby uses reference counting internally
class Node
attr_accessor :next, :data
def initialize(data)
@data = data
@next = nil
end
end
# Circular reference - both nodes reference each other
node1 = Node.new("A")
node2 = Node.new("B")
node1.next = node2
node2.next = node1
# Ruby's GC handles this cycle through tracing
Tracing Garbage Collection
Tracing garbage collectors periodically scan memory to identify reachable objects. Starting from root references (stack variables, globals, registers), the collector traces through all reachable objects, marking them as live. After tracing completes, any unmarked objects are garbage and can be reclaimed.
Mark-and-sweep collection traverses the object graph in two phases. The mark phase traces references and sets mark bits on reachable objects. The sweep phase scans all allocated memory, freeing unmarked objects. This approach handles reference cycles correctly but requires stopping program execution during collection.
Generational collection optimizes tracing based on the observation that most objects die young. The heap is divided into generations, with newly allocated objects in the young generation. Minor collections frequently scan only the young generation, quickly reclaiming short-lived objects. Objects surviving multiple collections promote to older generations scanned less frequently. This reduces collection overhead since most allocations are reclaimed in fast minor collections.
Copying collection moves live objects to compact them and eliminate fragmentation. The heap is divided into two semi-spaces. Allocation occurs in the from-space until full. Collection copies live objects to the to-space, leaving garbage behind. The roles of spaces swap, and allocation continues. Copying automatically compacts memory but requires twice the heap space.
Hybrid Approaches
Modern systems often combine multiple strategies to balance trade-offs. Languages may use reference counting for immediate cleanup supplemented by cycle-detecting tracing collection. This provides predictable destructor timing while handling cyclic structures.
Region-based memory management groups allocations into regions freed together. Programs allocate memory in the current region, then free the entire region at once. This provides efficient bulk deallocation for request-scoped or phase-based allocation patterns. Arena allocators implement this pattern for temporary data structures.
Escape analysis determines whether objects remain local to a function or escape to the heap. When the compiler proves an object does not escape, it can allocate it on the stack instead of the heap. This eliminates garbage collection overhead for objects with local lifetime, combining manual allocation efficiency with automatic safety.
Ruby Implementation
Ruby implements automatic memory management through a sophisticated garbage collector that has evolved significantly over time. Understanding Ruby's memory model enables developers to write efficient code and diagnose performance issues.
Ruby Memory Model
Ruby allocates objects on the heap and manages them through the garbage collector. Each object requires memory for its data and metadata. The Ruby VM stores objects in allocated slots, with each slot sized for the object's type. Small objects like integers and symbols are allocated in compact slots, while larger objects like arrays and hashes require more space.
Ruby uses object references throughout. Variables store references to objects rather than the objects themselves. When assigning one variable to another, Ruby copies the reference, not the object data. This reference model enables efficient passing of large objects without copying but requires understanding reference semantics.
# Variables hold references, not values
array1 = [1, 2, 3]
array2 = array1 # Copy reference, not data
array2 << 4
puts array1.inspect # => [1, 2, 3, 4] - same object
# Primitive values are immediate, not references
x = 5
y = x
y += 1
puts x # => 5 - integers are immutable values
Ruby symbols are interned strings stored in a global symbol table. The first time Ruby encounters a symbol, it allocates memory and adds it to the table. Subsequent uses of that symbol reference the existing allocation. Symbols persist for the program lifetime and are never garbage collected in older Ruby versions, though modern Ruby can collect unreferenced symbols.
Garbage Collection Implementation
Ruby's garbage collector uses a mark-and-sweep algorithm with generational collection. The collector identifies live objects by tracing references from root objects: global variables, constants, local variables on the stack, and objects referenced by C extensions.
The marking phase traverses the object graph recursively, setting mark bits on reachable objects. Ruby uses a tri-color marking scheme: white objects are candidates for collection, gray objects are reached but not yet scanned, and black objects are fully processed. This scheme enables incremental marking where collection work spreads across multiple phases.
The sweeping phase scans allocated object slots, freeing unmarked objects. Ruby maintains free lists of available slots organized by size. When sweeping frees an object, it returns the slot to the appropriate free list. Future allocations draw from free lists before requesting new memory from the system.
# Examining GC statistics
GC.stat.each do |key, value|
puts "#{key}: #{value}"
end
# Typical output includes:
# count: number of GC runs
# heap_allocated_pages: total allocated pages
# heap_live_slots: slots with live objects
# heap_free_slots: available slots
# total_allocated_objects: cumulative allocations
Generational Garbage Collection
Ruby divides the heap into generations to optimize collection performance. Newly allocated objects start in the young generation. Objects surviving multiple collections promote to the old generation. Minor collections scan only the young generation, while major collections scan all generations.
The write barrier tracks references from old objects to young objects. When code assigns a young object to a field in an old object, the write barrier records this reference. Minor collections must scan these recorded references to avoid missing reachable young objects. This mechanism allows minor collections to avoid scanning the old generation.
Ruby uses a three-generation model in modern versions: young, old, and remembered sets. The young generation is collected frequently and quickly. Objects surviving several minor collections promote to the old generation. Remembered sets track cross-generational references detected by write barriers.
# Forcing garbage collection
GC.start # Triggers full collection
# Disabling/enabling GC
GC.disable
# ... allocate many objects without collection
GC.enable
GC.start
# Checking GC statistics before and after
before = GC.stat
# ... code to profile
after = GC.stat
puts "Collections: #{after[:count] - before[:count]}"
puts "Allocated: #{after[:total_allocated_objects] - before[:total_allocated_objects]}"
Memory Allocation Patterns
Ruby allocates memory in pages, with each page divided into slots for objects. The page size and slot size vary by Ruby version and configuration. When the free list empties, Ruby allocates new pages from the operating system. These pages remain allocated for the program lifetime, even if all objects within them are freed.
Ruby optimizes allocation for common object types. Small strings, arrays, and hashes get slots of specific sizes. Large objects that exceed slot sizes are allocated separately. Ruby may store small arrays inline within the array object rather than allocating separate memory for elements.
String optimization includes interning and copy-on-write semantics. Frozen strings are interned, reusing existing allocations. Substring operations may share memory with the original string through shared buffers. Modifications trigger copy-on-write, allocating new memory only when necessary.
# String interning reduces memory
str1 = -"constant_string" # Frozen and interned
str2 = -"constant_string" # Reuses same memory
puts str1.object_id == str2.object_id # => true
# Hash optimization
hash = {}
1000.times { |i| hash[i] = i * 2 }
# Ruby may optimize hash storage as it grows
# Array optimization for small arrays
small = [1, 2, 3] # Elements stored inline
large = Array.new(1000) # Separate allocation for elements
Memory Profiling and Debugging
Ruby provides several tools for memory profiling and debugging. The ObjectSpace module enables enumerating all live objects, counting objects by type, and tracking allocations. The GC module exposes statistics and controls for the garbage collector.
The memory_profiler gem provides detailed allocation tracking, showing where objects are allocated and how long they live. This gem helps identify memory leaks and excessive allocations. The allocation tracer shows allocation patterns over time, helping optimize hot paths.
require 'objspace'
# Count objects by type
ObjectSpace.count_objects.each do |type, count|
puts "#{type}: #{count}" if count > 1000
end
# Find where objects are allocated
ObjectSpace.trace_object_allocations_start
array = Array.new(1000)
file = ObjectSpace.allocation_sourcefile(array)
line = ObjectSpace.allocation_sourceline(array)
puts "Allocated at #{file}:#{line}"
ObjectSpace.trace_object_allocations_stop
# Memory usage by class
counts = Hash.new(0)
ObjectSpace.each_object do |obj|
counts[obj.class.name] += 1
end
counts.sort_by { |k, v| -v }.first(10).each do |klass, count|
puts "#{klass}: #{count}"
end
Tuning Ruby Garbage Collection
Ruby exposes environment variables and runtime settings to tune garbage collection behavior. These settings adjust heap growth, collection frequency, and generation thresholds. Tuning requires understanding application allocation patterns and performance requirements.
The RUBY_GC environment variables control various collector parameters. RUBY_GC_HEAP_INIT_SLOTS sets the initial heap size. RUBY_GC_HEAP_GROWTH_FACTOR controls how aggressively the heap grows. RUBY_GC_HEAP_GROWTH_MAX_SLOTS limits heap growth. These settings balance memory usage against collection frequency.
# Programmatic GC tuning
GC.start(full_mark: true, immediate_sweep: true)
# Adjusting GC parameters at runtime
GC::Profiler.enable
# ... run code
GC::Profiler.report
GC::Profiler.disable
# Application-specific tuning example
if ENV['RAILS_ENV'] == 'production'
# Tune for throughput over latency
GC.stat[:heap_allocated_pages] * 0.3
end
Practical Examples
Memory management concepts become clearer through concrete examples demonstrating allocation patterns, lifecycle management, and optimization techniques.
Managing Object Lifecycles
Applications often create temporary objects during request processing that should be released after the request completes. Proper lifecycle management prevents memory leaks in long-running processes.
class RequestProcessor
def process_request(data)
# Temporary objects created during processing
parser = DataParser.new(data)
result = parser.parse
# Transform result
transformed = transform(result)
# Heavy object no longer needed
parser = nil
result = nil
# Return transformed data
# parser and result eligible for collection
transformed
end
def transform(data)
# Create temporary structures
temp_buffer = []
data.each do |item|
processed = expensive_operation(item)
temp_buffer << processed
end
# Compact result
temp_buffer.compact
# temp_buffer eligible for collection after return
end
def expensive_operation(item)
# Simulate expensive computation
result = item.transform
result.validate? ? result : nil
end
end
Optimizing Memory Allocation in Loops
Allocating objects inside loops can cause excessive garbage collection. Moving allocations outside loops or reusing objects reduces memory pressure.
# Inefficient: allocates string on each iteration
def process_inefficient(items)
items.each do |item|
formatted = "Item: #{item}" # New string each iteration
log(formatted)
end
end
# Efficient: reuse string buffer
def process_efficient(items)
buffer = String.new
items.each do |item|
buffer.clear
buffer << "Item: " << item.to_s
log(buffer)
end
end
# Alternative: use frozen string optimization
def process_frozen(items)
prefix = "Item: ".freeze # Single allocation, shared
items.each do |item|
log(prefix + item.to_s) # Only item.to_s allocates
end
end
Managing Large Collections
Large collections consume significant memory and require careful handling to prevent out-of-memory errors. Streaming and batching techniques process data without loading everything into memory.
class LargeDataProcessor
def process_file_streaming(filepath)
# Stream file line by line instead of reading all at once
File.foreach(filepath) do |line|
process_line(line)
# Previous lines eligible for collection
end
end
def process_database_batched(records)
# Process records in batches
records.find_each(batch_size: 1000) do |record|
process_record(record)
# Batches collected after processing
end
end
def build_large_result
# Use enumerator for lazy evaluation
(1..1_000_000).lazy
.map { |n| expensive_computation(n) }
.select { |result| result.valid? }
.first(100) # Only computes 100 results
end
def expensive_computation(n)
# Simulate expensive operation
OpenStruct.new(value: n * 2, valid?: n.even?)
end
def process_line(line)
# Process individual line
line.strip.split(',').each { |field| validate(field) }
end
def process_record(record)
# Process individual record
record.update(processed: true)
end
def validate(field)
!field.empty?
end
end
Object Pool Implementation
Object pooling reuses expensive objects instead of repeatedly allocating and deallocating them. This pattern reduces garbage collection overhead for frequently created objects.
class ConnectionPool
def initialize(size: 5)
@size = size
@pool = []
@available = []
@mutex = Mutex.new
# Pre-allocate connections
size.times do
conn = create_connection
@pool << conn
@available << conn
end
end
def acquire
@mutex.synchronize do
if @available.empty?
raise "No connections available"
end
conn = @available.pop
conn.reset # Reset state for reuse
conn
end
end
def release(connection)
@mutex.synchronize do
connection.cleanup # Cleanup but don't close
@available << connection
end
end
def with_connection
conn = acquire
begin
yield conn
ensure
release(conn)
end
end
private
def create_connection
# Simulate expensive connection creation
ExpensiveConnection.new
end
end
class ExpensiveConnection
def reset
@state = nil
end
def cleanup
@buffer = nil
end
def query(sql)
# Simulate query
[{ id: 1, name: "test" }]
end
end
# Usage
pool = ConnectionPool.new(size: 10)
pool.with_connection do |conn|
results = conn.query("SELECT * FROM users")
process_results(results)
end # Connection automatically returned to pool
Memory-Efficient Data Structures
Choosing appropriate data structures impacts memory consumption. Different structures have different memory characteristics and access patterns.
class MemoryEfficientProcessor
# Use array for ordered, indexed access
def process_sequential(count)
data = Array.new(count) { |i| compute_value(i) }
data.each { |value| process(value) }
end
# Use hash for key-value lookups
def process_keyed(items)
lookup = items.each_with_object({}) do |item, hash|
hash[item.key] = item.value
end
lookup.each { |key, value| process_pair(key, value) }
end
# Use set for membership testing
def process_unique(items)
require 'set'
seen = Set.new
items.each do |item|
next if seen.include?(item.id)
seen.add(item.id)
process(item)
end
end
# Avoid intermediate arrays
def process_streaming(large_array)
# Inefficient: creates intermediate arrays
# large_array.map { |x| x * 2 }.select { |x| x > 10 }.first(5)
# Efficient: lazy evaluation
large_array.lazy
.map { |x| x * 2 }
.select { |x| x > 10 }
.first(5)
end
private
def compute_value(i)
i * 2
end
def process(value)
# Process value
end
def process_pair(key, value)
# Process key-value pair
end
end
Performance Considerations
Memory management performance affects application throughput, latency, and resource utilization. Understanding performance characteristics enables optimizing for specific requirements.
Allocation Performance
Memory allocation speed varies by size and allocator strategy. Small allocations from pre-allocated pools complete in nanoseconds. Large allocations require system calls taking microseconds. Allocation patterns significantly impact performance.
Ruby's object allocation involves finding a free slot, initializing object metadata, and running initialization code. Minor allocations from free lists complete quickly. Allocations triggering page allocations or garbage collection cause significant latency spikes.
require 'benchmark'
# Measure allocation patterns
Benchmark.bm do |x|
x.report("small objects") do
100_000.times { Object.new }
end
x.report("medium arrays") do
10_000.times { Array.new(1000) }
end
x.report("large arrays") do
1_000.times { Array.new(100_000) }
end
x.report("string interpolation") do
100_000.times { |i| "iteration #{i}" }
end
x.report("frozen strings") do
frozen = "iteration ".freeze
100_000.times { |i| frozen + i.to_s }
end
end
Garbage Collection Overhead
Garbage collection pauses application execution during collection cycles. The pause duration depends on the heap size, object count, and collection type. Minor collections complete in milliseconds, while major collections may take hundreds of milliseconds.
Allocation rate determines collection frequency. Applications allocating many short-lived objects trigger frequent minor collections. High allocation rates increase time spent in collection, reducing throughput. Reducing allocations or increasing heap size decreases collection frequency.
# Measure GC impact
GC::Profiler.enable
before_gc = GC.stat
before_time = Time.now
# Allocate many objects
1_000_000.times { Object.new }
after_time = Time.now
after_gc = GC.stat
puts "Time elapsed: #{after_time - before_time}s"
puts "GC count: #{after_gc[:count] - before_gc[:count]}"
puts "Total allocated: #{after_gc[:total_allocated_objects] - before_gc[:total_allocated_objects]}"
# Show GC profile
GC::Profiler.report
GC::Profiler.disable
Memory Fragmentation
Memory fragmentation occurs when free memory exists in non-contiguous blocks. External fragmentation leaves gaps between allocated objects. Internal fragmentation wastes space within allocated blocks. Fragmentation reduces effective memory capacity and slows allocation.
Ruby's generational collector handles fragmentation through compaction during major collections. Copying live objects eliminates gaps, producing contiguous free space. However, compaction requires moving objects and updating references, increasing collection time.
Cache Performance
Memory access patterns affect CPU cache efficiency. Sequential access to contiguous memory achieves high cache hit rates. Random access to scattered objects causes cache misses, slowing execution by an order of magnitude. Data structure layout impacts cache performance.
Locality of reference describes accessing nearby memory locations in time or space. Temporal locality reuses recently accessed data. Spatial locality accesses nearby addresses. High locality improves cache performance through prefetching and reduced cache misses.
# Cache-friendly: sequential array access
def sum_array(array)
total = 0
array.each { |value| total += value } # Sequential access
total
end
# Cache-unfriendly: scattered hash access
def sum_hash_values(hash, keys)
total = 0
keys.each { |key| total += hash[key] } # Random access
total
end
Memory Bandwidth
Memory bandwidth limits the rate at which data transfers between RAM and CPU. Applications processing large datasets become memory-bound when computation waits for memory access. Reducing memory traffic through smaller data structures and fewer allocations improves bandwidth utilization.
Copying large objects consumes memory bandwidth. Ruby copies references rather than objects, minimizing bandwidth usage. However, operations like array slicing or string duplication copy data, consuming bandwidth proportional to data size.
Optimization Strategies
Reducing allocations provides the most significant performance improvement. Reusing objects, using object pools, and allocating outside loops minimize allocation overhead. Frozen strings and symbols reduce duplicate allocations for repeated values.
Sizing collections appropriately avoids repeated resizing. Creating arrays or hashes with estimated final size prevents growth-related allocations. Pre-allocation completes in one operation rather than many small allocations.
# Inefficient: repeated resizing
def build_array_inefficient(count)
array = []
count.times { |i| array << i } # Grows and reallocates
array
end
# Efficient: pre-sized
def build_array_efficient(count)
array = Array.new(count)
count.times { |i| array[i] = i } # No resizing
array
end
# Efficient: exact size known
def build_hash_efficient(items)
hash = Hash.new(items.size) # Pre-sized
items.each { |item| hash[item.key] = item.value }
hash
end
Reducing object retention decreases live heap size and collection time. Nulling references to large objects makes them eligible for collection. Avoiding global variables and class variables prevents unintended retention. Weak references allow references without preventing collection.
Profiling and Measurement
Memory profiling identifies allocation hotspots and excessive retention. Ruby's allocation tracking shows where objects are created and which code paths allocate the most. The memory_profiler gem provides detailed reports of allocations, retentions, and memory usage by location.
require 'memory_profiler'
report = MemoryProfiler.report do
# Code to profile
1000.times do
data = Array.new(100)
process(data)
end
end
# Show top allocation locations
report.pretty_print(scale_bytes: true)
Common Pitfalls
Memory management complexity creates opportunities for subtle bugs that impact reliability and performance. Recognizing common pitfalls enables avoiding them during development.
Unintended Object Retention
Objects remain in memory longer than necessary when references persist after the object's purpose is fulfilled. Closures capture variables from enclosing scopes, preventing collection even when the captured data is no longer needed. Global variables and class variables retain objects for the application lifetime.
class Processor
# Pitfall: class variable retains all instances
@@instances = []
def initialize(data)
@data = data
@@instances << self # Retains forever
end
end
# Better: use instance variable with cleanup
class BetterProcessor
@instances = []
class << self
attr_accessor :instances
end
def initialize(data)
@data = data
self.class.instances << self
end
def cleanup
self.class.instances.delete(self)
end
end
Callbacks and event handlers commonly cause retention issues. Registering an object as a listener creates a reference preventing collection. Applications must unregister listeners when objects should be collected.
class EventPublisher
def initialize
@listeners = []
end
def subscribe(listener)
@listeners << listener
end
def unsubscribe(listener)
@listeners.delete(listener) # Important: allow GC
end
def notify
@listeners.each { |listener| listener.handle_event }
end
end
# Usage must unsubscribe
publisher = EventPublisher.new
listener = EventListener.new
publisher.subscribe(listener)
# ... use listener
publisher.unsubscribe(listener) # Required for GC
Excessive Temporary Allocations
Creating many short-lived objects in hot code paths increases garbage collection overhead. String concatenation in loops allocates intermediate strings. Mapping and filtering collections creates intermediate arrays.
# Pitfall: allocates many intermediate strings
def build_message_inefficient(items)
message = ""
items.each do |item|
message += "#{item}," # New string each iteration
end
message
end
# Better: use string buffer
def build_message_efficient(items)
message = String.new
items.each do |item|
message << item.to_s << ","
end
message
end
# Pitfall: multiple intermediate arrays
def process_inefficient(items)
items.map { |x| x * 2 }
.select { |x| x > 10 }
.take(5)
# Creates 3 intermediate arrays
end
# Better: lazy evaluation
def process_efficient(items)
items.lazy
.map { |x| x * 2 }
.select { |x| x > 10 }
.take(5)
.to_a
# Single final array
end
Memory Leaks Through Caches
Unbounded caches grow indefinitely, consuming increasing memory over time. Applications must limit cache size through eviction policies. Least-recently-used (LRU) caches evict old entries when reaching size limits.
# Pitfall: unbounded cache grows forever
class UnboundedCache
def initialize
@cache = {}
end
def get(key)
@cache[key] ||= compute(key)
end
def compute(key)
# Expensive computation
key.to_s.upcase
end
end
# Better: bounded LRU cache
class BoundedCache
def initialize(max_size: 1000)
@cache = {}
@max_size = max_size
@access_order = []
end
def get(key)
if @cache.key?(key)
@access_order.delete(key)
@access_order << key
return @cache[key]
end
value = compute(key)
set(key, value)
value
end
private
def set(key, value)
if @cache.size >= @max_size
oldest = @access_order.shift
@cache.delete(oldest)
end
@cache[key] = value
@access_order << key
end
def compute(key)
key.to_s.upcase
end
end
Circular References in Data Structures
Circular references occur when objects reference each other directly or indirectly. While Ruby's garbage collector handles cycles, they increase collection time and complicate debugging.
# Circular reference example
class Node
attr_accessor :value, :next, :prev
def initialize(value)
@value = value
@next = nil
@prev = nil
end
end
# Creating circular list
node1 = Node.new(1)
node2 = Node.new(2)
node1.next = node2
node2.prev = node1
node2.next = node1 # Circular
node1.prev = node2 # Circular
# Breaking cycles for cleanup
def break_cycle(node)
node.next = nil
node.prev = nil
end
Forgetting to Close Resources
Objects managing external resources like files or connections must explicitly close them. Relying on garbage collection for resource cleanup causes resource exhaustion before collection occurs.
# Pitfall: relying on GC to close files
def process_file_unsafe(filename)
file = File.open(filename)
data = file.read
process_data(data)
# File remains open until GC runs
end
# Better: explicit close
def process_file_safe(filename)
file = File.open(filename)
begin
data = file.read
process_data(data)
ensure
file.close
end
end
# Best: block form auto-closes
def process_file_best(filename)
File.open(filename) do |file|
data = file.read
process_data(data)
end # Automatically closed
end
def process_data(data)
# Process the data
data.length
end
Performance Degradation From Large Live Sets
Applications maintaining large numbers of live objects experience slow garbage collection. Collection time increases with heap size since the collector must trace all reachable objects. Reducing live set size through aggressive cleanup improves performance.
# Pitfall: retaining large intermediate results
class BatchProcessor
def initialize
@results = [] # Grows without bound
end
def process_batches(batches)
batches.each do |batch|
result = process_batch(batch)
@results << result # Retains everything
end
@results
end
def process_batch(batch)
batch.map { |item| transform(item) }
end
def transform(item)
item * 2
end
end
# Better: stream results, don't accumulate
class StreamingProcessor
def process_batches(batches)
batches.flat_map do |batch|
result = process_batch(batch)
yield result # Stream results
result = nil # Allow GC
end
end
def process_batch(batch)
batch.map { |item| transform(item) }
end
def transform(item)
item * 2
end
end
Reference
Memory Management Terminology
| Term | Definition |
|---|---|
| Stack | Memory region for local variables and call frames, automatically managed with LIFO allocation |
| Heap | Memory region for dynamic allocations with explicit or automatic lifetime management |
| Allocation | Reserving memory for program use from available system memory |
| Deallocation | Returning allocated memory to the system for reuse |
| Garbage Collection | Automatic memory management that reclaims unreachable objects |
| Reference Counting | Tracking number of references to each object for automatic deallocation |
| Mark-and-Sweep | Garbage collection algorithm that traces reachable objects and frees unreachable ones |
| Generational GC | Collection strategy dividing objects by age, collecting young objects frequently |
| Memory Leak | Failure to release unused memory, causing gradual memory exhaustion |
| Dangling Pointer | Reference to deallocated memory causing undefined behavior when accessed |
| Fragmentation | Scattered free memory reducing effective capacity and allocation efficiency |
| Virtual Memory | Abstraction mapping virtual addresses to physical memory locations |
| Page | Fixed-size memory block used as unit of memory management |
| Object Pool | Pre-allocated set of objects reused to reduce allocation overhead |
| Arena Allocator | Batch allocator for memory freed together as a group |
| Write Barrier | Mechanism tracking references between objects in generational collection |
Ruby Memory Management API
| Method | Purpose |
|---|---|
| GC.start | Triggers garbage collection cycle |
| GC.disable | Disables automatic garbage collection |
| GC.enable | Re-enables automatic garbage collection |
| GC.stat | Returns hash of garbage collector statistics |
| GC.count | Returns number of GC runs since program start |
| GC::Profiler.enable | Enables GC profiling |
| GC::Profiler.report | Prints GC profiling report |
| ObjectSpace.count_objects | Returns count of objects by type |
| ObjectSpace.each_object | Iterates over all live objects |
| ObjectSpace.trace_object_allocations_start | Begins tracking object allocation locations |
| ObjectSpace.allocation_sourcefile | Returns file where object was allocated |
| ObjectSpace.allocation_sourceline | Returns line number where object was allocated |
GC Statistics Keys
| Statistic | Description |
|---|---|
| count | Total number of garbage collection runs |
| heap_allocated_pages | Total pages allocated from operating system |
| heap_sorted_length | Size of heap page array |
| heap_allocatable_pages | Pages available for allocation without growing heap |
| heap_available_slots | Total slots available across all pages |
| heap_live_slots | Slots containing live objects |
| heap_free_slots | Empty slots available for allocation |
| heap_final_slots | Slots for objects with finalizers |
| heap_marked_slots | Slots marked during most recent collection |
| total_allocated_objects | Cumulative count of allocated objects |
| total_freed_objects | Cumulative count of freed objects |
| malloc_increase_bytes | Bytes allocated through malloc since last GC |
| oldmalloc_increase_bytes | Malloc bytes for old generation objects |
| minor_gc_count | Count of minor garbage collections |
| major_gc_count | Count of major garbage collections |
GC Environment Variables
| Variable | Effect |
|---|---|
| RUBY_GC_HEAP_INIT_SLOTS | Initial number of heap slots |
| RUBY_GC_HEAP_FREE_SLOTS | Minimum free slots maintained after collection |
| RUBY_GC_HEAP_GROWTH_FACTOR | Heap growth multiplier when allocating pages |
| RUBY_GC_HEAP_GROWTH_MAX_SLOTS | Maximum slots to add when growing heap |
| RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR | Factor for old generation growth |
| RUBY_GC_MALLOC_LIMIT | Malloc bytes triggering collection |
| RUBY_GC_OLDMALLOC_LIMIT | Old generation malloc bytes triggering major GC |
Memory Optimization Techniques
| Technique | When to Use |
|---|---|
| Object pooling | Frequently created/destroyed expensive objects |
| String freezing | Repeated use of same string values |
| Lazy evaluation | Processing large collections with early termination |
| Streaming | Processing data too large for memory |
| Batching | Handling large datasets in manageable chunks |
| Pre-allocation | Known collection sizes before population |
| Reference clearing | Releasing large objects after use |
| Weak references | Caches that should not prevent collection |
| Arena allocation | Request-scoped or phase-based allocations |
| Copy-on-write | Sharing read-only data between processes |
Common Memory Issues
| Issue | Symptoms | Detection |
|---|---|---|
| Memory leak | Gradual memory growth, eventual OOM | Monitor heap size over time |
| Excessive GC | High CPU usage, throughput degradation | GC profiling, allocation tracking |
| Fragmentation | Available memory but allocation failures | Heap statistics, page utilization |
| Retention | Objects not collected as expected | ObjectSpace enumeration, object tracking |
| Cache growth | Unbounded memory increase | Monitor cache sizes |
| Resource leak | File descriptor or socket exhaustion | System resource monitoring |
| Large live set | Slow GC pauses | Heap object counts, GC pause times |
| Allocation storm | Rapid memory allocation/deallocation | Allocation profiling, GC statistics |