Overview
Memory allocation strategies determine how programs request, receive, and release memory during execution. These strategies balance competing requirements: speed of allocation, efficient memory usage, fragmentation prevention, and deterministic behavior. The choice of allocation strategy affects application performance, memory footprint, and system scalability.
Operating systems and runtime environments implement various allocation strategies, each optimized for different usage patterns. Static allocation assigns memory at compile time, stack allocation manages automatic variables through function calls, and heap allocation provides dynamic memory during runtime. Modern systems combine multiple strategies, selecting the appropriate mechanism based on object lifetime, size, and access patterns.
Memory allocation involves several key operations: requesting memory blocks, tracking allocated regions, managing free space, and reclaiming unused memory. Each strategy implements these operations differently, trading performance for flexibility or determinism for dynamic behavior.
# Memory allocation in Ruby - all allocations go through object creation
string = "allocated on heap" # String object allocated
array = [1, 2, 3] # Array and elements allocated
hash = {key: "value"} # Hash structure allocated
# Ruby hides allocation details but manages memory automatically
class User
def initialize(name)
@name = name # Instance variable storage allocated
end
end
user = User.new("Alice") # Object allocated by Ruby VM
The allocation strategy impacts application behavior in multiple dimensions. Allocation speed determines how quickly programs can create objects. Memory fragmentation affects long-running process stability. Overhead from metadata and alignment affects memory efficiency. Thread safety determines concurrent allocation capability.
Key Principles
Memory allocation strategies operate on fundamental principles that govern their behavior and implementation. Understanding these principles clarifies how different strategies make trade-offs between competing requirements.
Memory Lifetime Management
Objects have three distinct lifetime patterns: automatic, dynamic, and static. Automatic lifetime objects exist within a defined scope and deallocate when that scope exits. Dynamic lifetime objects persist until explicitly freed or garbage collected. Static lifetime objects exist for the entire program duration. Allocation strategies specialize for these patterns, optimizing common cases.
Address Space Organization
Programs divide their address space into regions with different allocation characteristics. The text segment contains executable code. The data segment stores global and static variables. The stack grows and shrinks automatically with function calls. The heap provides dynamically allocated memory. This separation enables specialized allocation strategies for each region.
Allocation Metadata
Allocators maintain metadata to track memory usage. Block headers store size information, allocation status, and links to adjacent blocks. Free lists connect available memory blocks. Boundary tags enable coalescing adjacent free blocks. The metadata overhead trades memory space for allocation speed and flexibility.
# Ruby's allocation metadata (simplified conceptual model)
# Actual implementation in C, hidden from Ruby code
class ObjectSpace
def self.allocation_info
# Ruby tracks object allocation internally
objects = []
ObjectSpace.each_object do |obj|
# Metadata includes: class, size, references
objects << {
class: obj.class,
object_id: obj.object_id,
size: ObjectSpace.memsize_of(obj)
}
end
objects
end
end
Fragmentation Types
External fragmentation occurs when free memory exists but not in contiguous blocks large enough for requests. Internal fragmentation happens when allocated blocks contain unused space due to rounding or minimum sizes. Allocation strategies combat fragmentation through coalescing, compaction, or avoiding it entirely through fixed-size allocations.
Allocation Speed vs Memory Efficiency
Fast allocation often requires maintaining complex data structures or accepting higher memory overhead. Simple allocators minimize metadata but perform slower searches. Bump allocation provides constant-time allocation by incrementing a pointer but cannot reuse freed memory. Free list allocators reuse memory but require traversing lists.
Thread Safety Models
Single-threaded allocators avoid synchronization overhead. Per-thread allocators give each thread independent memory pools. Global allocators use locks to serialize access. Lock-free allocators employ atomic operations and careful ordering. The choice affects both performance and implementation complexity.
Allocation Granularity
Block size affects both fragmentation and allocation speed. Small blocks reduce internal fragmentation but increase metadata overhead. Large blocks minimize allocation count but increase external fragmentation. Variable-sized allocators adapt to requests but require complex management. Fixed-size allocators simplify management but may waste memory.
Implementation Approaches
Memory allocation strategies fall into several categories, each optimized for different usage patterns and requirements. Real systems often combine multiple approaches, selecting the strategy based on allocation characteristics.
Static Allocation
Static allocation assigns memory at compile time, storing data in the program's data segment. The compiler determines memory layout, and addresses remain fixed throughout execution. This approach eliminates runtime allocation overhead and provides deterministic memory usage.
// Static allocation (conceptual)
static_variable = reserved_at_compile_time
global_array[1000] = fixed_size_known_ahead
allocation_time: 0 (done at compile time)
deallocation_time: 0 (reclaimed when program exits)
memory_overhead: 0 (no metadata needed)
Static allocation works well for global variables, constant data, and embedded systems with known memory requirements. The strategy cannot accommodate variable-sized data or dynamic object creation, limiting its applicability in general-purpose programming.
Stack Allocation
Stack allocation manages automatic variables through a last-in-first-out discipline. Function calls push activation records containing local variables, and returns pop them off. Stack allocation provides constant-time operations by adjusting a single stack pointer.
function_call():
stack_pointer -= frame_size
store local variables at [stack_pointer]
execute function body
function_return():
stack_pointer += frame_size
locals automatically deallocated
Stack allocation suits temporary variables with scope-bound lifetimes. The fixed stack size limits recursive depth and prevents allocating large objects. Stack discipline prevents flexible object lifetimes, requiring heap allocation for objects that outlive their creating function.
Heap Allocation with Free Lists
Free list allocators maintain linked lists of available memory blocks. Allocation searches the list for suitable blocks using first-fit, best-fit, or other policies. Deallocation returns blocks to the free list, potentially coalescing with adjacent free blocks.
# Conceptual free list implementation
class FreeListAllocator
def initialize(memory_size)
@free_list = [Block.new(0, memory_size)]
end
def allocate(size)
# First-fit: find first block large enough
@free_list.each_with_index do |block, index|
if block.size >= size
# Split block if larger than needed
if block.size > size + MIN_BLOCK_SIZE
new_block = Block.new(block.address + size, block.size - size)
@free_list.insert(index + 1, new_block)
block.size = size
end
return @free_list.delete_at(index)
end
end
nil # Out of memory
end
def free(block)
# Insert block back into free list
@free_list << block
@free_list.sort_by!(&:address)
coalesce_adjacent_blocks
end
def coalesce_adjacent_blocks
i = 0
while i < @free_list.length - 1
current = @free_list[i]
next_block = @free_list[i + 1]
if current.address + current.size == next_block.address
current.size += next_block.size
@free_list.delete_at(i + 1)
else
i += 1
end
end
end
end
Free list allocation balances flexibility and performance. First-fit searches quickly but may fragment memory. Best-fit minimizes wasted space but requires full list traversal. Worst-fit leaves larger remaining blocks but increases fragmentation. The strategy adapts to variable-sized allocations but requires coalescing to combat fragmentation.
Buddy System Allocation
Buddy allocators partition memory into power-of-two sized blocks. Allocation rounds requests up to the next power of two and splits larger blocks recursively. Deallocation merges freed blocks with their buddies if both are free.
# Buddy allocator (simplified)
class BuddyAllocator
def initialize(total_size)
@order = Math.log2(total_size).to_i
@free_lists = Array.new(@order + 1) { [] }
@free_lists[@order] << Block.new(0, total_size)
end
def allocate(size)
# Round up to next power of 2
order = (Math.log2(size).ceil)
order = 0 if order < 0
# Find smallest available block
current_order = order
while current_order <= @order && @free_lists[current_order].empty?
current_order += 1
end
return nil if current_order > @order
# Split blocks until reaching desired size
while current_order > order
block = @free_lists[current_order].pop
current_order -= 1
buddy_size = 2 ** current_order
@free_lists[current_order] << Block.new(block.address, buddy_size)
@free_lists[current_order] << Block.new(block.address + buddy_size, buddy_size)
end
@free_lists[order].pop
end
def free(block)
order = Math.log2(block.size).to_i
# Try to merge with buddy
while order < @order
buddy_address = block.address ^ (2 ** order)
buddy = @free_lists[order].find { |b| b.address == buddy_address }
break unless buddy
@free_lists[order].delete(buddy)
block = Block.new([block.address, buddy_address].min, 2 ** (order + 1))
order += 1
end
@free_lists[order] << block
end
end
Buddy allocation simplifies coalescing by constraining block addresses. The power-of-two sizes cause internal fragmentation but enable fast buddy calculation. The strategy suits systems with varied allocation sizes and long-running processes requiring fragmentation resistance.
Slab Allocation
Slab allocators pre-allocate memory for fixed-size objects, eliminating allocation overhead for frequently created types. Each slab contains multiple objects of identical size, and allocators maintain lists of slabs with available objects.
# Slab allocator for fixed-size objects
class SlabAllocator
def initialize(object_size, slab_size = 4096)
@object_size = object_size
@objects_per_slab = slab_size / object_size
@slabs = []
@free_objects = []
end
def allocate
if @free_objects.empty?
allocate_new_slab
end
@free_objects.pop
end
def free(object)
@free_objects << object
end
private
def allocate_new_slab
slab = Slab.new(@object_size, @objects_per_slab)
@slabs << slab
@free_objects.concat(slab.objects)
end
end
# Used internally by many systems for common sizes
small_object_allocator = SlabAllocator.new(32)
medium_object_allocator = SlabAllocator.new(128)
Slab allocation excels for kernel object caches and memory pools. The fixed-size constraint limits applicability but enables constant-time allocation without fragmentation. Modern allocators combine slabs for common sizes with general allocators for unusual requests.
Region-Based Allocation
Region allocators (arena allocators) allocate memory in bulk and free entire regions at once. Individual objects within a region cannot be freed separately, simplifying allocation and eliminating per-object metadata.
class RegionAllocator
def initialize
@regions = []
@current_region = nil
@region_offset = 0
end
def allocate(size)
if @current_region.nil? || @region_offset + size > REGION_SIZE
@current_region = allocate_region(REGION_SIZE)
@regions << @current_region
@region_offset = 0
end
object_address = @current_region + @region_offset
@region_offset += size
object_address
end
def free_all
@regions.each { |region| free_region(region) }
@regions.clear
@current_region = nil
@region_offset = 0
end
end
Region allocation suits request-response systems, compilers, and batch processing where many objects share a common lifetime. The inability to free individual objects wastes memory for long-lived regions with mixed lifetimes.
Ruby Implementation
Ruby implements automatic memory management through a garbage collector, abstracting allocation details from developers. The Ruby VM handles object allocation, tracks references, and reclaims unreachable objects. Understanding Ruby's allocation strategy helps optimize memory usage and diagnose performance issues.
Object Allocation Model
Ruby allocates all objects on the heap, managed by the VM. Object creation triggers allocation through the VM's internal allocator, which maintains memory pools organized by object size. Small objects use slab-like allocators, while large objects use direct system allocations.
# All Ruby objects allocated on heap
string = "text" # String object allocated
array = Array.new(100) # Array structure + element storage
hash = Hash.new # Hash table allocated
# Even seemingly "simple" values create objects
number = 42 # Fixnum: small integers (optimized, not always allocated)
float = 3.14 # Float: always allocated
symbol = :name # Symbol: allocated once, reused
# Object allocation visible through ObjectSpace
require 'objspace'
obj = "allocated string"
puts ObjectSpace.memsize_of(obj) # Size in bytes
# => 40
# Allocation tracking
ObjectSpace.trace_object_allocations_start
1000.times { "string #{rand}" }
allocations = []
ObjectSpace.each_object(String) do |str|
if ObjectSpace.allocation_sourcefile(str)
allocations << {
file: ObjectSpace.allocation_sourcefile(str),
line: ObjectSpace.allocation_sourceline(str),
class: ObjectSpace.allocation_class_path(str)
}
end
end
ObjectSpace.trace_object_allocations_stop
Ruby optimizes small integer allocation through immediate values, storing Fixnums directly in pointers rather than allocating objects. Symbols receive similar treatment after initial allocation, reusing the same object for repeated symbol references. These optimizations reduce allocation overhead for common cases.
Generational Garbage Collection
Ruby uses generational garbage collection, dividing objects into generations based on age. New objects start in the young generation, and survivors promote to old generations after surviving multiple collections. This strategy optimizes for the observation that most objects die young.
# GC.stat shows allocation and collection statistics
require 'objspace'
before = GC.stat
# Allocate many temporary objects
10_000.times do
temp = "temporary string #{rand}"
temp.upcase
end
after = GC.stat
puts "Minor GC runs: #{after[:minor_gc_count] - before[:minor_gc_count]}"
puts "Major GC runs: #{after[:major_gc_count] - before[:major_gc_count]}"
puts "Objects allocated: #{after[:total_allocated_objects] - before[:total_allocated_objects]}"
puts "Heap pages: #{after[:heap_allocated_pages]}"
# Control GC behavior
GC.start # Force immediate collection
GC.disable # Disable automatic GC
# ... allocate objects ...
GC.enable # Re-enable GC
GC.compact # Compact heap (Ruby 2.7+)
Minor collections scan only the young generation, running frequently with low pause times. Major collections scan all generations, running less frequently with longer pauses. The generational hypothesis—most objects die young—makes this strategy effective for typical Ruby programs.
Heap Slot Management
Ruby's heap consists of pages containing fixed-size slots. Objects smaller than the slot size fit within a single slot, while larger objects span multiple slots or use separate large object allocation. The VM maintains free lists of available slots, organized by size class.
# Heap slot information through GC module
heap_info = GC.stat_heap
heap_info.each do |slot_size, stats|
puts "Slot size: #{slot_size}"
puts " Total slots: #{stats[:heap_allocatable_slots]}"
puts " Used slots: #{stats[:heap_eden_slots]}"
puts " Free slots: #{stats[:heap_free_slots]}"
end
# Observe slot allocation over time
class SlotMonitor
def self.monitor
loop do
stats = GC.stat
puts "Heap pages: #{stats[:heap_allocated_pages]}"
puts "Live objects: #{stats[:heap_live_slots]}"
puts "Free objects: #{stats[:heap_free_slots]}"
puts "Heap fragmentation: #{calculate_fragmentation(stats)}"
sleep 1
end
end
def self.calculate_fragmentation(stats)
total_slots = stats[:heap_live_slots] + stats[:heap_free_slots]
return 0 if total_slots == 0
(stats[:heap_free_slots].to_f / total_slots * 100).round(2)
end
end
Object size classes determine slot assignment. Small objects (strings, arrays with few elements) use standard slots. Medium objects may require multiple slots. Large objects (big arrays, long strings) bypass the slot system, using direct system allocations. This multi-tier approach balances allocation speed and memory efficiency.
Memory Pool Management
Ruby maintains separate memory pools for different object types, reducing contention and improving cache locality. Each pool uses appropriate allocation strategies for its object characteristics.
# Monitor memory pools through allocation tracing
require 'objspace'
ObjectSpace.trace_object_allocations_start
# Generate various object types
strings = 1000.times.map { |i| "string #{i}" }
arrays = 1000.times.map { |i| [i, i*2, i*3] }
hashes = 1000.times.map { |i| {key: i, value: i*2} }
# Analyze allocation patterns
allocation_stats = Hash.new(0)
ObjectSpace.each_object do |obj|
next unless ObjectSpace.allocation_sourcefile(obj)
class_name = obj.class.name
size = ObjectSpace.memsize_of(obj)
allocation_stats["#{class_name}:#{size}"] += 1
end
ObjectSpace.trace_object_allocations_stop
allocation_stats.sort_by { |k, v| -v }.first(10).each do |type, count|
puts "#{type}: #{count} allocations"
end
Memory pools reduce fragmentation by grouping similar objects. Strings allocate from string pools, arrays from array pools, and so on. This organization improves allocation speed by avoiding searches across differently-typed objects.
Allocation Optimization Strategies
Ruby developers can optimize allocation patterns by reusing objects, preallocating structures, and avoiding unnecessary copies. These strategies reduce garbage collection pressure and improve performance.
# Inefficient: creates many temporary strings
def build_message_slow(items)
message = ""
items.each do |item|
message += item.to_s # Creates new string each iteration
end
message
end
# Efficient: mutates single string
def build_message_fast(items)
message = ""
items.each do |item|
message << item.to_s # Mutates existing string
end
message
end
# Even better: preallocate capacity
def build_message_optimized(items)
message = String.new(capacity: items.sum { |i| i.to_s.size })
items.each do |item|
message << item.to_s
end
message
end
# Array preallocation
def process_data_slow(count)
results = []
count.times { |i| results << compute(i) }
results
end
def process_data_fast(count)
results = Array.new(count)
count.times { |i| results[i] = compute(i) }
results
end
# Object pooling for frequently created objects
class ConnectionPool
def initialize(size)
@pool = Array.new(size) { create_connection }
@available = @pool.dup
end
def acquire
@available.pop || create_connection
end
def release(connection)
@available << connection if @available.size < @pool.size
end
end
These optimizations reduce allocation rate, decreasing GC frequency and improving application throughput. Profiling tools identify allocation hotspots, guiding optimization efforts.
Performance Considerations
Memory allocation performance affects application speed, scalability, and predictability. Allocation strategy choices create performance trade-offs across multiple dimensions: allocation speed, deallocation speed, memory overhead, fragmentation, and thread scalability.
Allocation Latency
Allocation time varies significantly across strategies. Bump allocators provide constant-time allocation by incrementing a pointer. Free list allocators require searching for suitable blocks, with time proportional to list length. Segregated fits reduce search time by maintaining separate lists for size classes.
# Benchmark allocation strategies
require 'benchmark'
ITERATIONS = 100_000
# Measure Ruby's built-in allocation
Benchmark.bmbm do |x|
x.report("String allocation") do
ITERATIONS.times { "test string" }
end
x.report("Array allocation") do
ITERATIONS.times { [1, 2, 3, 4, 5] }
end
x.report("Hash allocation") do
ITERATIONS.times { {a: 1, b: 2, c: 3} }
end
x.report("Object allocation") do
ITERATIONS.times { Object.new }
end
end
# Compare reuse vs allocation
Benchmark.bmbm do |x|
x.report("New strings") do
ITERATIONS.times { |i| "string #{i}" }
end
x.report("Reused string") do
str = ""
ITERATIONS.times { |i| str.replace("string #{i}") }
end
x.report("String mutation") do
str = ""
ITERATIONS.times { |i| str.clear << "string" << i.to_s }
end
end
Allocation speed impacts high-frequency operations. Web servers handling thousands of requests per second spend significant time allocating request objects. Game engines allocating entities each frame require fast allocation. Database systems allocating query results need efficient memory management.
Deallocation Overhead
Explicit deallocation requires traversing data structures, coalescing adjacent blocks, and updating free lists. Garbage collection defers deallocation costs, paying them in batches during collection cycles. The trade-off between immediate overhead and batched pauses depends on application requirements.
# Measure GC impact on performance
def measure_gc_impact
GC.disable
no_gc_time = Benchmark.measure do
10_000.times do
array = Array.new(1000) { |i| "element #{i}" }
process_array(array)
end
end
GC.enable
GC.start # Start fresh
with_gc_time = Benchmark.measure do
10_000.times do
array = Array.new(1000) { |i| "element #{i}" }
process_array(array)
end
end
puts "Without GC: #{no_gc_time.real}s"
puts "With GC: #{with_gc_time.real}s"
puts "GC overhead: #{((with_gc_time.real / no_gc_time.real - 1) * 100).round(2)}%"
end
Deallocation speed matters for short-lived applications and batch jobs. Region-based allocation eliminates individual deallocation by freeing entire regions. Generational GC reduces pause times by collecting only young objects frequently. Manual memory management provides predictable deallocation timing but requires careful programming.
Memory Fragmentation Impact
Fragmentation reduces effective memory capacity and increases allocation failures. External fragmentation leaves memory unusable despite sufficient total free space. Internal fragmentation wastes allocated space through rounding and minimum block sizes.
# Demonstrate fragmentation effects
class FragmentationDemo
def self.demonstrate
# Allocate mixed sizes
large_objects = 100.times.map { "x" * 10_000 }
small_objects = 1000.times.map { "x" * 100 }
before_stats = GC.stat
# Free alternating objects (creates fragmentation)
large_objects.each_with_index { |obj, i| obj.clear if i.even? }
small_objects.each_with_index { |obj, i| obj.clear if i.odd? }
GC.start
after_stats = GC.stat
puts "Heap pages before: #{before_stats[:heap_allocated_pages]}"
puts "Heap pages after: #{after_stats[:heap_allocated_pages]}"
puts "Free slots: #{after_stats[:heap_free_slots]}"
puts "Live slots: #{after_stats[:heap_live_slots]}"
# Fragmentation prevents page reclamation
if after_stats[:heap_allocated_pages] == before_stats[:heap_allocated_pages]
puts "Fragmentation prevented page release"
end
end
end
Long-running processes accumulate fragmentation over time. Buddy systems limit fragmentation through constrained block sizes. Compacting collectors move objects to eliminate external fragmentation. Slab allocation avoids fragmentation entirely for fixed-size objects.
Cache Effects
Memory allocation patterns affect CPU cache performance. Sequential allocations improve cache hit rates through spatial locality. Scattered allocations cause cache misses. Object pooling and arena allocation improve locality by grouping related objects.
# Compare cache-friendly vs cache-unfriendly allocation
class CacheEffectsDemo
def self.sequential_processing
# Allocate in sequence
data = Array.new(10_000) { |i| {id: i, value: rand} }
# Process sequentially (cache-friendly)
sum = 0
data.each { |item| sum += item[:value] }
sum
end
def self.random_processing
# Allocate in sequence
data = Array.new(10_000) { |i| {id: i, value: rand} }
indices = (0...10_000).to_a.shuffle
# Process randomly (cache-unfriendly)
sum = 0
indices.each { |i| sum += data[i][:value] }
sum
end
end
# Sequential access benefits from cache lines loading adjacent data
# Random access causes more cache misses
Allocation strategy affects cache behavior indirectly through object placement. Generational collectors group young objects, improving locality for frequently accessed data. Segregated fits separate different size classes, potentially hurting locality. Thread-local allocation improves cache affinity by keeping thread data on the same CPU.
Scalability with Thread Count
Memory allocation scalability determines multi-threaded application performance. Global allocators serialize all allocation through locks, limiting scalability. Per-thread allocators eliminate contention but may waste memory. Lock-free allocators balance these concerns through atomic operations.
# Demonstrate allocation contention effects
require 'concurrent'
def measure_allocation_scaling
[1, 2, 4, 8].each do |thread_count|
time = Benchmark.measure do
threads = thread_count.times.map do
Thread.new do
10_000.times do
array = Array.new(100) { rand }
array.sum
end
end
end
threads.each(&:join)
end
allocations_per_second = (thread_count * 10_000 / time.real).to_i
puts "#{thread_count} threads: #{allocations_per_second} allocs/sec"
end
end
Thread-local pools reduce contention by giving each thread independent memory. Object stealing allows load balancing when threads have imbalanced allocation patterns. The trade-off between contention and memory waste depends on allocation frequency and thread count.
Common Patterns
Memory allocation patterns emerge from common requirements and constraints. These patterns codify effective approaches to allocation problems, providing reusable solutions for typical scenarios.
Object Pooling
Object pools maintain collections of reusable objects, reducing allocation frequency for expensive or frequently created objects. Pools trade memory for allocation speed, keeping objects alive between uses.
class ObjectPool
def initialize(size, &factory)
@factory = factory
@pool = Array.new(size) { @factory.call }
@available = @pool.dup
@mutex = Mutex.new
end
def acquire
@mutex.synchronize do
@available.pop || @factory.call
end
end
def release(obj)
@mutex.synchronize do
@available << obj if @available.size < @pool.size
end
end
def with_object
obj = acquire
begin
yield obj
ensure
release(obj)
end
end
end
# Database connection pool
connection_pool = ObjectPool.new(10) { Database.new_connection }
connection_pool.with_object do |conn|
conn.execute("SELECT * FROM users")
end
# Thread pool with preallocated threads
thread_pool = ObjectPool.new(4) { Thread.new { |work| work.call } }
Object pools suit resource handles (database connections, file handles), complex objects with expensive initialization, and high-frequency temporary objects. The pool size balances memory usage against allocation savings.
Arena Allocation for Request Handling
Arena allocation simplifies memory management for request-response systems by allocating all request data from a single region and freeing it after response completion.
class RequestArena
def initialize
@objects = []
end
def allocate_string(content)
str = String.new(content)
@objects << str
str
end
def allocate_array(size)
arr = Array.new(size)
@objects << arr
arr
end
def clear
@objects.clear
end
end
class RequestHandler
def handle_request(request)
arena = RequestArena.new
# All allocations use arena
parsed_data = arena.allocate_array(request.params.size)
request.params.each do |key, value|
processed = arena.allocate_string(process_value(value))
parsed_data << processed
end
response = generate_response(parsed_data)
# Free all request allocations at once
arena.clear
response
end
end
Arena allocation suits web servers, RPC handlers, and batch processors where many objects share a common lifetime. The pattern eliminates individual deallocation calls and improves locality by grouping related allocations.
Copy-on-Write Optimization
Copy-on-write defers copying until modification, sharing memory between readers and creating copies only when necessary. This pattern reduces allocation for immutable or rarely modified data.
class CopyOnWriteArray
def initialize(array = [])
@data = array
@copied = false
end
def [](index)
@data[index]
end
def []=(index, value)
ensure_writable_copy
@data[index] = value
end
def <<(value)
ensure_writable_copy
@data << value
end
def dup
# Share underlying array until write
copy = CopyOnWriteArray.new(@data)
copy
end
private
def ensure_writable_copy
unless @copied
@data = @data.dup
@copied = true
end
end
end
# Ruby strings use copy-on-write in some implementations
original = "shared string"
copy = original.dup # Shares memory initially
copy << " modified" # Triggers actual copy
Copy-on-write applies to process forking, immutable data structures, and snapshot isolation. Operating systems use copy-on-write for process memory after fork, copying pages only when written. The pattern trades page fault overhead for reduced memory usage.
Size-Class Segregation
Segregated allocation maintains separate pools for different size classes, reducing fragmentation and improving allocation speed through specialized allocators for common sizes.
class SegregatedAllocator
SIZE_CLASSES = [16, 32, 64, 128, 256, 512, 1024, 2048]
def initialize
@allocators = {}
SIZE_CLASSES.each do |size|
@allocators[size] = SlabAllocator.new(size)
end
@large_allocator = LargeObjectAllocator.new
end
def allocate(size)
size_class = SIZE_CLASSES.find { |s| s >= size }
if size_class
@allocators[size_class].allocate
else
@large_allocator.allocate(size)
end
end
def free(ptr, size)
size_class = SIZE_CLASSES.find { |s| s >= size }
if size_class
@allocators[size_class].free(ptr)
else
@large_allocator.free(ptr)
end
end
end
Segregated allocation enables fast allocation by eliminating size searches. Small object allocators use simple free lists or bitmaps. Large object allocators handle unusual sizes with general-purpose strategies. The pattern underlies many modern allocators including jemalloc and tcmalloc.
Lazy Allocation
Lazy allocation defers memory allocation until actual use, reducing memory footprint for sparse or rarely-used data structures. Virtual memory systems implement lazy allocation through demand paging.
class LazyArray
def initialize(size, &factory)
@size = size
@factory = factory
@storage = {}
end
def [](index)
return nil if index < 0 || index >= @size
@storage[index] ||= @factory.call(index)
end
def []=(index, value)
return if index < 0 || index >= @size
@storage[index] = value
end
end
# Allocate only accessed elements
sparse_data = LazyArray.new(1_000_000) { |i| expensive_computation(i) }
sparse_data[100] # Only allocates element 100
# Lazy hash with default factory
lazy_cache = Hash.new { |h, k| h[k] = fetch_from_database(k) }
lazy_cache[:user_123] # Allocates only when accessed
Lazy allocation applies to sparse matrices, caches, and configuration systems where not all data gets accessed. The pattern trades lookup overhead for reduced memory usage.
Reference
Allocation Strategy Comparison
| Strategy | Allocation Time | Deallocation Time | Fragmentation | Memory Overhead | Use Case |
|---|---|---|---|---|---|
| Static | Compile-time | Program exit | None | None | Global data, constants |
| Stack | O(1) | O(1) | None | Minimal | Local variables, call frames |
| Bump/Linear | O(1) | All-at-once | None | Minimal | Arena allocation, temporary data |
| Free List | O(n) | O(1) | High | Medium | General-purpose heap |
| Buddy System | O(log n) | O(log n) | Medium | Low | Kernel memory, page allocation |
| Slab | O(1) | O(1) | None | Medium | Fixed-size objects, kernel caches |
| Segregated Fits | O(1) | O(1) | Low | Medium | Modern general allocators |
Ruby Memory Management Methods
| Method | Purpose | Example |
|---|---|---|
| GC.start | Force garbage collection | GC.start |
| GC.disable | Disable automatic GC | GC.disable |
| GC.enable | Enable automatic GC | GC.enable |
| GC.stat | Get GC statistics | GC.stat[:heap_live_slots] |
| GC.compact | Compact heap (Ruby 2.7+) | GC.compact |
| ObjectSpace.memsize_of | Get object size in bytes | ObjectSpace.memsize_of(obj) |
| ObjectSpace.each_object | Iterate all objects | ObjectSpace.each_object(String) |
| ObjectSpace.trace_object_allocations_start | Track allocations | ObjectSpace.trace_object_allocations_start |
Fragmentation Types
| Type | Description | Cause | Mitigation |
|---|---|---|---|
| External | Free memory exists but not in large enough contiguous blocks | Scattered deallocations | Coalescing, compaction |
| Internal | Allocated blocks contain unused space | Rounding, minimum block sizes | Better size classes, exact-fit allocation |
| Heap | Heap grows but cannot shrink | Mixed object lifetimes | Generational GC, compaction |
Performance Characteristics
| Factor | Impact | Fast Approach | Slow Approach |
|---|---|---|---|
| Allocation Speed | Throughput | Bump pointer | Free list search |
| Deallocation Speed | Pause time | Deferred (GC) | Immediate with coalescing |
| Fragmentation | Long-term stability | Fixed sizes, compaction | Variable sizes, no coalescing |
| Memory Overhead | Memory efficiency | Minimal metadata | Per-object headers |
| Thread Scalability | Multi-core performance | Per-thread pools | Global lock |
| Locality | Cache performance | Sequential allocation | Scattered allocation |
Ruby GC Statistics
| Stat Key | Meaning | Interpretation |
|---|---|---|
| heap_allocated_pages | Total heap pages allocated | Memory committed to Ruby |
| heap_live_slots | Slots containing live objects | Current object count |
| heap_free_slots | Empty slots available | Allocation capacity |
| minor_gc_count | Young generation collections | Frequency of minor GC |
| major_gc_count | Full heap collections | Frequency of major GC |
| total_allocated_objects | Cumulative object creations | Allocation pressure |
| malloc_increase_bytes | External memory growth | C extension memory |
Allocation Pattern Selection
| Requirement | Recommended Strategy | Rationale |
|---|---|---|
| Deterministic timing | Static or stack | Zero runtime overhead |
| High allocation rate | Bump pointer with periodic reset | Constant-time allocation |
| Mixed object lifetimes | Generational GC | Optimizes common case |
| Fixed-size objects | Slab allocation | Eliminates fragmentation |
| Large temporary data | Arena allocation | Batch deallocation |
| Long-lived process | Compacting GC | Prevents fragmentation |
| Multi-threaded | Per-thread pools | Reduces contention |
| Memory-constrained | Best-fit or segregated fits | Minimizes waste |
Common Allocation Sizes (Ruby)
| Object Type | Typical Size | Allocation Strategy |
|---|---|---|
| Empty object | 40 bytes | Standard slot |
| Short string | 40-80 bytes | String pool |
| Small array | 40 + (8 × elements) bytes | Array pool |
| Hash | 200+ bytes (varies with size) | Hash pool |
| Symbol | Allocated once | Symbol table |
| Fixnum | No allocation (immediate value) | Immediate value |
| Float | 24 bytes | Float pool |