In high-level languages like Ruby, garbage collection (GC) is the process for managing memory automatically. At its core, the GC performs three main tasks: allocating memory for new objects, identifying objects that are no longer in use, and reclaiming memory from unused objects.
By handling memory allocation and cleanup, GC allows developers to focus on writing code without worrying about manually releasing memory. However, sometimes we feel a little curious about how things work behind the scenes, especially in languages like Ruby where many processes it often feels like “magic”.
Understanding Ruby's GC system not only helps demystify this process but also enables developers to optimize performance and troubleshoot memory-related issues. So in this article, we'll explore Ruby's garbage collection algorithm, understanding how it performs these tasks and how we can see it working in practice.
Mark and Sweep
Ruby's traditional garbage collection (GC) algorithm is based on the Mark and Sweep technique.
The Mark and Sweep algorithm allocates memory for new objects until the heap runs out of space. When this happens, the GC identifies objects still in use, referred to as live objects, by marking them. Remaining objects, known as garbage objects, are swept away to free up memory for reuse. Once this process is complete, the program resumes execution.
Free List
Ruby employs the Free List as its allocation solution. The Free List can be visualized as a linked list of memory chunks. Whenever a new object needs to be allocated, Ruby takes a chunk of memory from the head of the list and uses it to create the new object.
Marking
When all memory chunks are consumed and the Free List is depleted, the GC pauses the program. At this stage, its task is to identify and mark memory spaces containing unused objects, known as garbage objects, which can be reclaimed. If no unused objects are found, Ruby requests additional memory from the operating system. If no more memory is available, Ruby raises an Out of Memory exception and terminates the program.
To identify garbage objects, the GC traverses the object reference graph, which begins at root objects. Root objects may include global variables or objects that Ruby knows are currently in use. Each object in this graph contains pointers to other objects it references. By traversing this graph and marking all objects with references, the GC determines which objects are still active. Unmarked objects, having no references, are considered garbage and can be removed from memory.
Sweeping
Once marking is complete, the GC moves to the sweeping phase, where it removes unmarked objects from memory. This is achieved by simply moving the memory pointers of these objects to create a new Free List.
Starting with version 1.9.3, an optimization called lazy sweeping was introduced in Ruby MRI to reduce the time the program remains paused for garbage collection. Instead of marking and sweeping all garbage objects at once, the GC marks just enough objects to free up a portion of memory. This results in shorter, more frequent pauses instead of a single long one. However, it’s worth noting that this doesn’t reduce the total amount of work for garbage collection; it merely distributes it across smaller intervals.
Disadvantages
One of the main disadvantages of the Mark and Sweep algorithm is that the time required for memory cleanup is proportional to the size of the heap. As your program grows and more objects are used, the garbage collection process takes longer. This issue has become more manageable with the introduction of the Compacting GC in version 2.7. This feature reduces heap fragmentation by reorganizing objects in memory, which improves collection efficiency and minimizes the need for continuous heap growth.
Another disadvantage is the program's pause time, but its impact has been mitigated with the implementation of lazy sweeping and the Incremental GC method, which we will explore in the next section.
Incremental GC
While lazy sweeping focuses on performing sweeping gradually, the role of the Incremental GC technique is to achieve this during the marking phase.
The first pause in the program is used to identify only the root objects. The marking of other objects is done incrementally, interleaving program execution with the marking process, until all objects have been traversed.
To prevent potential modifications to object references from altering the result of the marking phase, a control structure called a write barrier is used. The write barrier is responsible for tracking all reference modifications, ensuring the garbage collector can properly handle these changes and preventing it from missing new references while the marking process is ongoing.
Generational GC
Starting from version 2.1, MRI introduced the generational garbage collection algorithm as an extension of Mark and Sweep. This technique treats young objects differently from old objects, based on the weak generational hypothesis, which posits that older objects are more likely to remain in use, while newer objects are more likely to become garbage quickly.
The lifetime of an object is determined by how many GC cycles it has survived. When an object becomes "old" by surviving a set number of GC cycles, it is promoted. Promoted objects are then moved to a separate heap designated for mature objects.
This separation allows MRI to focus less on the heap containing old objects, scanning them less frequently and reducing the overall workload of the garbage collector. This approach improves efficiency, particularly in applications with a large number of short-lived objects.
See It in Action
Now that we understand how the MRI GC works, let’s explore it in practice by diving into some hands-on examples!
We’ll start by creating a few new objects. To inspect memory usage and object statistics, we’ll use the ObjectSpace
module, which provides insights into object and memory management. You can learn more about it in the official documentation.
def display_count
data = ObjectSpace.count_objects
puts "Total: #{data[:TOTAL]} Free: #{data[:FREE]} Object: #{data[:T_OBJECT]}"
end
5.times do
obj = Object.new
display_count
end
In the code above, we create a new object and then call the display_count method in a loop, which uses ObjectSpace.count_objects to display some memory statistics, giving us a snapshot of the current state of object allocation:
Total: 988930 Free: 148904 Object: 38928
Total: 988930 Free: 148896 Object: 38929
Total: 988930 Free: 148889 Object: 38930
Total: 988930 Free: 148882 Object: 38931
Total: 988930 Free: 148875 Object: 38932
The Total
value refers to the total number of objects managed by MRI, which includes objects we create, Ruby's internal objects, and objects in the free list. We can see that this number doesn't change because the objects we create were already included in the free list.
The Free
field shows the size of the free list. Notice that this value decreases by about 7 with each iteration: 1 for the object we created, and the remaining are objects created by Ruby.
The Object
field shows the count of RObject
structures that Ruby is currently tracking, including both active and garbage objects. This value increases by 1 with each iteration, even though we aren’t saving references to the objects we create, and they are considered garbage. Our objects are being counted because they haven't been collected by the GC yet.
Lazy sweeping
Let's change our code from 5 to 1000 iterations. We now have the following output:
...
Total: 1004481 Free: 112541 Object: 47260
Total: 1004481 Free: 112534 Object: 47261
Total: 1004481 Free: 112527 Object: 47262
Total: 1004481 Free: 112520 Object: 47263
Total: 1004481 Free: 112513 Object: 47264
Total: 1004481 Free: 114898 Object: 47252 # 'Free' increased 2,385 here
Total: 1004481 Free: 114891 Object: 47253
Total: 1004481 Free: 114884 Object: 47254
Total: 1004481 Free: 114877 Object: 47255
Total: 1004481 Free: 114870 Object: 47256
Total: 1004481 Free: 114863 Object: 47257
...
Part of the execution followed the same behavior as in the previous test until, at the indicated line, the Free
count increased by 2,385 while the Object
count decreased by 12. This was due to a lazy sweep: the MRI marked garbage objects, moved some of them to the free list, and completely removed certain specific objects. Most objects are still included in the Object
count because they continue to exist, although now as part of the free list.
If you weren’t able to observe this behavior while running the code locally, try increasing the number of iterations until a lazy sweep is triggered.
Full Collection
To perform a full garbage collection, we can use the GC
module, which provides an interface to Ruby's mark-and-sweep garbage collection mechanism.
The GC.start
method manually initiates a full GC cycle. It accepts some optional parameters, such as full_mark
, which determines whether all objects are marked (true
) or only younger objects are marked (false
). Additionally, you can disable incremental marking and lazy sweeping by setting the immediate_mark
and immediate_sweep
parameters to false
.
For simplicity, we’ll use the method with its default values, where all these features are enabled. However, feel free to experiment with these parameters to observe the differences in practice. You can find more details in the official documentation.
5.times do
...
end
GC.start
display_count
With the manual activation of a full garbage collection, we get the following result:
Total: 1000290 Free: 96790 Object: 44242
Total: 1000290 Free: 96782 Object: 44243
Total: 1000290 Free: 96775 Object: 44244
Total: 1000290 Free: 96768 Object: 44245
Total: 1000290 Free: 96761 Object: 44246
Total: 1000290 Free: 217265 Object: 37866
We can see that the full garbage collection freed up over 120,000 free slots and more than 6,000 objects—a significant difference compared to the lazy sweeping we performed earlier.
GC Profiler
The GC::Profiler
module provides more detailed information about garbage collection cycles and can be used in daily tasks to analyze performance, optimize memory usage, and debug performance issues.
require 'rdoc/rdoc'
GC::Profiler.enable
1_000_000.times do
obj = Object.new
end
GC::Profiler.report
GC::Profiler.disable
The report generated looks like this:
Index Invoke Time(sec) Use Size(byte) Total Size(byte) Total Object GC Time(ms)
1 0.337 31130440 71351280 1783782 17.02700000000006852474
2 0.377 31130160 71351280 1783782 7.44499999999992390087
3 0.408 31130120 71351280 1783782 7.11099999999997844924
4 0.439 0 0 0 3.93199999999999105782
Each line represents a garbage collection cycle performed since the profiler was enabled. From the report, we can see that our code triggered 4 collections, and the report provided the following details:
- Invoke Time: The time in seconds when the garbage collection occurred after the script started running.
- Use Size: The amount of heap memory used by all live objects after the collection was completed.
- Total Size: The total size of the heap after the collection, including memory used by live objects and the size of the free list.
- Total Objects: The number of live objects plus those in the free list.
- GC Time: The time, in milliseconds, taken to complete the garbage collection.
Note that there were no changes in the Total Size or Total Objects in the previous example since the created objects were not being actively used. Let’s modify our code to save these objects and observe how the garbage collector behaves:
require 'rdoc/rdoc'
GC::Profiler.enable
arr = []
1_000_000.times do
arr << Object.new
end
GC::Profiler.report
GC::Profiler.disable
Now, our report looks like this:
Index Invoke Time(sec) Use Size(byte) Total Size(byte) Total Object GC Time(ms)
1 0.446 40629280 70499520 1762488 25.07700000000007278800
2 0.472 40712200 70499520 1762488 2.42200000000025728042
3 0.633 62310760 91662480 2291562 19.87700000000014455281
4 0.837 97384120 127239840 3180996 89.03400000000006286882
Since we are saving the objects in an array, the GC cannot remove them because they remain active, causing Ruby to allocate more memory. Notice that as the cycles progress, the Total Size grows significantly, which also leads to an increase in GC Time.
Another interesting point is the cycle 2, which took only 2ms and did not change the Total Size. This can be explained by the incremental process, where some cycles are much faster because they perform less work.
This tool can be especially useful for performance-critical applications, where the code needs to be optimized to reduce GC cycles. With the GC::Profiler, it’s possible to understand how the GC behaves and how much work it’s doing in each part of the code.
Final Thoughts
Understanding how these GC mechanisms work and affect memory allocation enables us to optimize our applications more effectively, minimize GC pauses, and create more efficient Ruby programs. Now that we’ve peeled back the curtain, we know how this “magic” works, and we can use that knowledge to improve our code and performance - or simply appreciate the elegance of Ruby and let it handle everything for us! After all, details like these won’t always be relevant for everyday tasks, but it’s still fascinating to understand how Ruby works under the hood. Having this knowledge allows us to make more informed decisions when performance becomes a priority or when we encounter memory-related issues.
Refs:
https://blog.heroku.com/incremental-gc
https://bugs.ruby-lang.org/issues/8339
https://blog.peterzhu.ca/notes-on-ruby-gc/