Overview
Ruby allocates objects through a generational garbage collection system that divides memory into segments based on object lifetime expectations. The virtual machine maintains separate heaps for different object types and sizes, with automatic memory management through mark-and-sweep collection cycles.
Object allocation occurs during instance creation, literal definitions, and method calls that return new objects. Ruby's allocator tracks object references through a tri-color marking system, categorizing objects as white (unreachable), gray (reachable but unscanned), or black (reachable and scanned).
The allocation process involves several key components:
# Object allocation during instantiation
user = User.new # Allocates User object in heap
name = "Alice" # Allocates String object
ages = [25, 30] # Allocates Array object and Integer objects
Ruby's generational hypothesis assumes most objects die young. New objects start in the young generation heap, and long-lived objects promote to older generations. This strategy optimizes collection frequency, running minor collections more often on young objects while major collections scan the entire heap less frequently.
# Allocation tracking example
GC.stat[:total_allocated_objects] # => 150234
obj = Object.new
GC.stat[:total_allocated_objects] # => 150235
The ObjectSpace
module provides introspection into allocated objects, enabling analysis of allocation patterns and memory usage. The GC
module controls garbage collection behavior and provides allocation statistics.
# Object counting by class
ObjectSpace.count_objects
# => {:TOTAL=>45123, :FREE=>1234, T_OBJECT=>567, T_CLASS=>89, ...}
# Memory profiling
ObjectSpace.trace_object_allocations_start
String.new("test")
ObjectSpace.trace_object_allocations_stop
Basic Usage
Object allocation happens automatically during Ruby program execution, but developers can monitor and influence the process through built-in tools and configuration options.
The GC
module provides the primary interface for allocation control and monitoring:
# Force garbage collection
GC.start
# Get allocation statistics
stats = GC.stat
puts stats[:total_allocated_objects] # Total objects allocated
puts stats[:heap_allocated_pages] # Memory pages allocated
puts stats[:heap_live_slots] # Currently live object slots
The ObjectSpace
module enables object inspection and allocation tracking:
# Count objects by type
counts = ObjectSpace.count_objects
puts "Strings: #{counts[:T_STRING]}"
puts "Arrays: #{counts[:T_ARRAY]}"
puts "Hashes: #{counts[:T_HASH]}"
# Find objects by class
strings = ObjectSpace.each_object(String).to_a
puts "#{strings.count} String objects in memory"
Allocation tracing captures the source location where objects are created, useful for identifying allocation hotspots:
# Enable allocation tracking
ObjectSpace.trace_object_allocations_start
# Create objects
users = ["Alice", "Bob", "Charlie"].map { |name| User.new(name) }
# Inspect allocation sites
ObjectSpace.trace_object_allocations_stop
ObjectSpace.each_object(User) do |user|
file = ObjectSpace.allocation_sourcefile(user)
line = ObjectSpace.allocation_sourceline(user)
puts "User allocated at #{file}:#{line}"
end
Ruby provides several mechanisms to reduce allocation pressure. Object reuse patterns avoid creating temporary objects:
# High allocation - creates new string each iteration
(1..1000).each do |i|
message = "Processing item #{i}" # New string allocation
process(message)
end
# Lower allocation - reuse string buffer
buffer = String.new
(1..1000).each do |i|
buffer.clear << "Processing item #{i}" # Reuse existing string
process(buffer)
end
The freeze
method prevents object modification, enabling string interning and reducing duplicate allocations:
# String literals with freeze for interning
STATES = ["active", "inactive", "pending"].map(&:freeze)
# Symbol allocation for repeated string values
status = :active # Symbols are not garbage collected
Performance & Memory
Object allocation performance directly impacts application throughput and memory consumption. Ruby's allocation patterns influence garbage collection frequency, pause times, and overall memory efficiency.
Allocation rate measurement reveals program hotspots. High allocation rates trigger frequent garbage collection, reducing application performance:
require 'benchmark'
# Measure allocation rate
def measure_allocations
before = GC.stat[:total_allocated_objects]
yield
after = GC.stat[:total_allocated_objects]
after - before
end
# Compare allocation strategies
allocations = measure_allocations do
1000.times { |i| "string_#{i}" } # String interpolation
end
puts "String interpolation: #{allocations} allocations"
allocations = measure_allocations do
1000.times { |i| ['string_', i.to_s].join } # Array join
end
puts "Array join: #{allocations} allocations"
Memory profiling identifies allocation sources and object lifetimes. The memory_profiler
gem provides detailed allocation analysis:
require 'memory_profiler'
report = MemoryProfiler.report do
users = 1000.times.map do |i|
User.new(
name: "User #{i}",
email: "user#{i}@example.com",
created_at: Time.now
)
end
# Process users
users.select(&:active?).map(&:to_json)
end
puts report.pretty_print
Object pooling reduces allocation overhead for frequently created and destroyed objects:
class ConnectionPool
def initialize(size = 10)
@pool = Array.new(size) { create_connection }
@mutex = Mutex.new
end
def with_connection
conn = @mutex.synchronize { @pool.pop }
conn ||= create_connection # Allocate if pool empty
yield conn
ensure
@mutex.synchronize { @pool.push(conn) if @pool.size < 10 }
end
private
def create_connection
# Expensive allocation
Database::Connection.new
end
end
String allocation optimization techniques reduce memory pressure in text-heavy applications:
# Inefficient string building
def build_sql_inefficient(conditions)
sql = "SELECT * FROM users"
sql += " WHERE active = 1"
conditions.each do |key, value|
sql += " AND #{key} = '#{value}'" # Multiple string allocations
end
sql
end
# Efficient string building with single allocation
def build_sql_efficient(conditions)
parts = ["SELECT * FROM users", "WHERE active = 1"]
conditions.each { |k, v| parts << "AND #{k} = '#{v}'" }
parts.join(" ") # Single final allocation
end
Lazy evaluation patterns defer object allocation until values are actually needed:
class LazyCollection
def initialize(data_source)
@data_source = data_source
@loaded = false
@items = nil
end
def each(&block)
load_items unless @loaded
@items.each(&block)
end
private
def load_items
@items = @data_source.call # Defer allocation until access
@loaded = true
end
end
# Usage - no allocation until iteration
collection = LazyCollection.new(-> { expensive_database_query })
collection.each { |item| process(item) } # Allocates here
Thread Safety & Concurrency
Object allocation in concurrent Ruby programs requires consideration of thread safety, memory visibility, and race conditions. The garbage collector runs concurrently with application threads, creating coordination challenges.
Ruby's GIL (Global Interpreter Lock) serializes thread execution, but native extensions and I/O operations can release the GIL, allowing true parallelism during allocation-heavy operations:
require 'concurrent'
# Thread-safe allocation pattern
class ThreadSafeCounter
def initialize
@count = Concurrent::AtomicFixnum.new(0)
@objects = Concurrent::Array.new
end
def create_object(data)
# Atomic allocation tracking
id = @count.increment
obj = DataObject.new(id, data)
@objects << obj # Thread-safe append
obj
end
def stats
{ count: @count.value, objects: @objects.size }
end
end
Shared mutable state requires synchronization to prevent race conditions during allocation and modification:
class ObjectRegistry
def initialize
@objects = {}
@mutex = Mutex.new
end
def register(key, &factory)
@mutex.synchronize do
@objects[key] ||= factory.call # Single allocation per key
end
end
def get(key)
@mutex.synchronize { @objects[key] }
end
def clear
@mutex.synchronize do
@objects.clear # All objects become eligible for GC
end
end
end
# Usage across threads
registry = ObjectRegistry.new
threads = 10.times.map do
Thread.new do
registry.register(:shared_config) { load_configuration }
end
end
threads.each(&:join)
Memory barriers ensure proper visibility of allocated objects across threads:
class MessageQueue
def initialize
@queue = Queue.new # Thread-safe, provides memory barriers
@processors = []
end
def start_processors(count = 4)
count.times do
@processors << Thread.new do
while message = @queue.pop
# Process allocated message objects
handle_message(message)
end
end
end
end
def enqueue(data)
message = Message.new(data) # Allocate message
@queue.push(message) # Ensures visibility to processors
end
end
Garbage collection coordination in multi-threaded environments requires careful timing:
class BatchProcessor
def initialize(batch_size = 1000)
@batch_size = batch_size
@processed = 0
@mutex = Mutex.new
end
def process_items(items)
items.each_slice(@batch_size) do |batch|
results = batch.map { |item| transform(item) } # Allocate results
@mutex.synchronize do
persist_results(results)
@processed += batch.size
# Trigger GC periodically to prevent memory pressure
GC.start if (@processed % 10000) == 0
end
end
end
end
Error Handling & Debugging
Memory allocation errors in Ruby typically manifest as performance degradation, excessive memory usage, or NoMemoryError
exceptions. Debugging allocation issues requires systematic analysis of object creation patterns and memory consumption.
The ObjectSpace
module provides debugging capabilities for tracking object allocation and identifying memory leaks:
# Track allocation locations for specific objects
def debug_string_allocations
ObjectSpace.trace_object_allocations_start
# Code under investigation
strings = []
1000.times { |i| strings << "temporary_#{i}" }
ObjectSpace.trace_object_allocations_stop
# Analyze allocation sources
strings.each_with_index do |str, idx|
if idx < 5 # Show first few for brevity
file = ObjectSpace.allocation_sourcefile(str)
line = ObjectSpace.allocation_sourceline(str)
puts "String allocated at #{file}:#{line}"
end
end
end
Memory leak detection involves comparing object counts before and after operations:
class MemoryLeakDetector
def initialize
@snapshots = {}
end
def snapshot(name)
@snapshots[name] = ObjectSpace.count_objects.dup
end
def compare(before, after)
before_counts = @snapshots[before]
after_counts = @snapshots[after]
return unless before_counts && after_counts
increases = {}
after_counts.each do |type, count|
before_count = before_counts[type] || 0
increase = count - before_count
increases[type] = increase if increase > 0
end
increases.sort_by(&:last).reverse
end
end
# Usage
detector = MemoryLeakDetector.new
detector.snapshot(:before)
run_suspected_code
detector.snapshot(:after)
leaks = detector.compare(:before, :after)
puts "Potential leaks: #{leaks.inspect}"
Handling NoMemoryError
exceptions requires graceful degradation and resource cleanup:
class SafeProcessor
def process_large_dataset(data)
begin
# Attempt memory-intensive operation
results = data.map { |item| expensive_transform(item) }
results.each_slice(1000) { |batch| persist_batch(batch) }
rescue NoMemoryError
# Fallback to streaming approach
warn "Insufficient memory, switching to streaming mode"
stream_process(data)
ensure
# Force cleanup
GC.start
end
end
private
def stream_process(data)
data.each_slice(100) do |chunk|
results = chunk.map { |item| expensive_transform(item) }
persist_batch(results)
results.clear # Explicit cleanup
GC.start if rand < 0.1 # Periodic GC
end
end
end
Debugging memory growth patterns using allocation profiling:
require 'objspace'
class AllocationProfiler
def profile(description, &block)
puts "=== #{description} ==="
# Baseline measurements
before_objects = ObjectSpace.count_objects[:TOTAL]
before_memory = GC.stat[:heap_allocated_pages] * 65536 # Approximate
# Enable detailed tracking
ObjectSpace.trace_object_allocations_start
begin
result = yield
ensure
ObjectSpace.trace_object_allocations_stop
end
# Post-execution measurements
after_objects = ObjectSpace.count_objects[:TOTAL]
after_memory = GC.stat[:heap_allocated_pages] * 65536
puts "Object delta: #{after_objects - before_objects}"
puts "Memory delta: #{(after_memory - before_memory) / 1024}KB"
puts "GC runs: #{GC.stat[:count]}"
result
end
end
# Usage
profiler = AllocationProfiler.new
profiler.profile("String processing") do
text = "sample text"
1000.times { text = text.upcase.reverse.downcase }
end
Common Pitfalls
Object allocation in Ruby contains several subtle behaviors that can lead to unexpected memory usage, performance issues, and hard-to-debug problems.
String literal allocation varies based on mutability and interpolation usage. Frozen string literals reduce allocations but can cause confusion:
# Frozen string literals (Ruby 2.3+)
# frozen_string_literal: true
def process_messages
# Each call reuses the same string object
default_message = "No message" # Frozen, no allocation
# But interpolation always allocates
messages = []
10.times do |i|
messages << "Message #{i}" # New allocation each time
end
# String modification fails on frozen literals
begin
default_message << " available" # FrozenError
rescue FrozenError
default_message = default_message + " available" # New allocation
end
end
Block and proc allocation behaviors differ significantly, impacting performance in iterative code:
# High allocation - creates new proc each iteration
def inefficient_filter(items)
items.select { |item| item.active? } # Block to proc conversion
end
# Better allocation pattern - reuse proc
ACTIVE_FILTER = proc { |item| item.active? }
def efficient_filter(items)
items.select(&ACTIVE_FILTER) # Reuses existing proc
end
# Symbol to proc allocation
def symbol_to_proc_gotcha
items = [1, 2, 3, 4, 5]
# Creates new proc object each time
items.map { |n| n.to_s }
# Reuses singleton proc for symbol
items.map(&:to_s) # More efficient
end
Array and hash allocation through literals can be misleading during method calls:
class DataProcessor
# Allocates new array every method call
def process_with_defaults(data, options = [])
# Default array is allocated on each call
options << :default_option
data.process(options)
end
# Better approach - use frozen default
DEFAULT_OPTIONS = [].freeze
def process_efficiently(data, options = DEFAULT_OPTIONS)
# Copy only when modification needed
local_options = options.dup
local_options << :default_option
data.process(local_options)
end
# Hash default gotcha
def configure(settings = {})
# Default hash allocated every call
settings[:timeout] ||= 30
settings[:retries] ||= 3
end
end
Exception handling can trigger unexpected allocations during error processing:
def allocation_in_rescue
users = load_users
users.each do |user|
begin
process_user(user)
rescue StandardError => e
# String interpolation allocates during exception handling
message = "Failed to process user #{user.id}: #{e.message}"
logger.error(message) # Additional string allocation
end
end
end
# More efficient error handling
def efficient_error_handling
users = load_users
error_template = "Failed to process user %d: %s".freeze
users.each do |user|
begin
process_user(user)
rescue StandardError => e
# Sprintf avoids string interpolation allocation
message = sprintf(error_template, user.id, e.message)
logger.error(message)
end
end
end
Method chaining creates intermediate objects that may not be necessary:
# Inefficient chaining with intermediate allocations
def process_text_chain(text)
text.strip # New string
.downcase # New string
.gsub(/\s+/, ' ') # New string
.squeeze(' ') # New string
end
# More efficient single-pass processing
def process_text_efficient(text)
result = text.dup
result.strip!
result.downcase!
result.gsub!(/\s+/, ' ')
result.squeeze!(' ')
result
end
# Consider using StringIO for complex transformations
require 'stringio'
def process_text_stringio(text)
output = StringIO.new
text.each_char do |char|
# Character-by-character processing without intermediate strings
output << transform_char(char)
end
output.string
end
Closure allocation in nested scopes captures more variables than expected:
def closure_allocation_trap
large_data = Array.new(100000) { rand } # Large allocation
processors = []
10.times do |i|
# Closure captures entire large_data array
processors << proc { |x| x + i } # Keeps large_data alive
end
# large_data cannot be garbage collected while processors exist
processors
end
# Better approach - minimize closure capture
def efficient_closure_creation
large_data = Array.new(100000) { rand }
processed_data = large_data.sum # Extract needed value
large_data = nil # Explicit dereferencing
processors = []
10.times do |i|
# Closure only captures i and processed_data
processors << proc { |x| x + i + processed_data }
end
processors
end
Reference
Core Allocation Methods
Method | Parameters | Returns | Description |
---|---|---|---|
GC.start |
full_mark: true, immediate_sweep: true |
nil |
Initiates garbage collection cycle |
GC.stat |
hash: {} |
Hash |
Returns allocation and GC statistics |
GC.count |
None | Integer |
Number of GC cycles since start |
ObjectSpace.count_objects |
result_hash: {} |
Hash |
Object counts by type |
ObjectSpace.each_object |
klass |
Enumerator |
Iterates over objects of specified class |
Allocation Tracking Methods
Method | Parameters | Returns | Description |
---|---|---|---|
ObjectSpace.trace_object_allocations_start |
None | nil |
Enables allocation site tracking |
ObjectSpace.trace_object_allocations_stop |
None | nil |
Disables allocation site tracking |
ObjectSpace.allocation_sourcefile |
object |
String or nil |
File where object was allocated |
ObjectSpace.allocation_sourceline |
object |
Integer or nil |
Line where object was allocated |
ObjectSpace.allocation_class_path |
object |
String or nil |
Class context during allocation |
GC Statistics Keys
Statistic | Type | Description |
---|---|---|
:total_allocated_objects |
Integer |
Total objects allocated since start |
:heap_allocated_pages |
Integer |
Memory pages allocated for heap |
:heap_live_slots |
Integer |
Object slots currently in use |
:heap_free_slots |
Integer |
Available object slots |
:major_gc_count |
Integer |
Number of major GC cycles |
:minor_gc_count |
Integer |
Number of minor GC cycles |
Object Type Constants
Constant | Description |
---|---|
:T_OBJECT |
Generic objects |
:T_CLASS |
Class objects |
:T_MODULE |
Module objects |
:T_STRING |
String objects |
:T_ARRAY |
Array objects |
:T_HASH |
Hash objects |
:T_REGEXP |
Regular expression objects |
Environment Variables
Variable | Default | Description |
---|---|---|
RUBY_GC_HEAP_INIT_SLOTS |
10000 | Initial object slot count |
RUBY_GC_HEAP_FREE_SLOTS |
4096 | Minimum free slots after GC |
RUBY_GC_HEAP_GROWTH_FACTOR |
1.8 | Heap growth multiplier |
RUBY_GC_HEAP_GROWTH_MAX_SLOTS |
0 | Maximum slots per growth |
RUBY_GC_MALLOC_LIMIT |
16777216 | Malloc threshold for GC trigger |
Memory Profiling Tools
Tool | Installation | Purpose |
---|---|---|
memory_profiler |
gem install memory_profiler |
Detailed allocation analysis |
allocation_tracer |
gem install allocation_tracer |
Real-time allocation tracking |
heapy |
gem install heapy |
Heap dump analysis |
derailed_benchmarks |
gem install derailed_benchmarks |
Memory benchmarking |
Common Allocation Patterns
# String allocation patterns
"literal" # May be interned/frozen
+"literal" # Forces new allocation
String.new("content") # Explicit allocation
"template #{var}" # Always allocates
# Array allocation patterns
[] # Empty array allocation
Array.new(size) # Pre-allocated array
[*range] # Range to array conversion
Array(object) # Coercion allocation
# Hash allocation patterns
{} # Empty hash allocation
Hash.new # Explicit allocation
Hash[pairs] # Construction from pairs
{}.tap { |h| ... } # Builder pattern