Skip to Content
Course content

114: Garbage Collection Tuning

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was helping a teammate debug a production issue on a Rails API that handled massive JSON payloads for a fintech client. Everything looked great in staging, but in production, we saw these random, jagged spikes in response times. Some requests took 50ms, others suddenly jumped to 1.2 seconds for no apparent reason. We spent two days obsessing over database indexes and Redis latency, only to realize the culprit was the Ruby Garbage Collector (GC). The app was creating so many short-lived objects during the JSON parsing phase that it was triggering "stop-the-world" GC cycles right in the middle of critical requests. We weren't hitting a memory limit; we were just spending too much time cleaning up.

By default, Ruby's GC is designed to be a generalist. It tries to balance memory usage and performance for the average app. But when you're dealing with high-throughput systems or memory-intensive processing, the defaults can actually become a bottleneck. You'll find yourself in a cycle where Ruby allocates a bit of memory, decides it's time to clean up, pauses your entire application to do so, and then repeats the process a millisecond later. It's a death by a thousand cuts.

Balancing the Heap and the Pause

To fix this, you have to understand the trade-off: you can either use more RAM to reduce the frequency of GC pauses, or you can keep your memory footprint tight and accept that your CPU will spend more time pausing your code. Most of the time in modern cloud environments, RAM is cheaper than CPU cycles and latency. If you have 4GB of RAM available but Ruby is aggressively cleaning up every time it hits 200MB, you're wasting resources.

Ruby uses a generational garbage collector (RGenGC). It separates objects into "young" and "old" generations. Most objects die young, so Ruby cleans the young generation frequently and the old generation rarely. The problem arises when you have "bloated" allocations that force the GC to promote objects to the old generation too quickly or trigger a full mark-and-sweep of the entire heap. This is where tuning the environment variables comes in.

Tuning the GC Environment Variables

You don't change these settings in your Ruby code; you set them as environment variables before the process starts. If you're using Docker or Heroku, these go in your config. The most impactful ones are usually the malloc limits.

# Increase the threshold for when the GC is triggered by memory allocation
export RUBY_GC_MALLOC_LIMIT=40000000
export RUBY_GC_MALLOC_LIMIT_MAX=160000000
export RUBY_GC_OLDMALLOC_LIMIT=16000000
export RUBY_GC_OLDMALLOC_LIMIT_MAX=64000000

The RUBY_GC_MALLOC_LIMIT tells Ruby: "Don't worry about running a GC cycle until you've allocated this much memory since the last run." By raising this limit, you're telling Ruby to be less anxious. You'll see fewer pauses, but your process's RSS (Resident Set Size) will climb higher. I usually suggest starting by doubling the defaults and monitoring your memory usage. If you have head-room in your RAM, keep pushing it up until the latency spikes disappear.

Observing the GC with GC.stat

Never tune your GC based on a "hunch." It's a dangerous game that can lead to OOM (Out of Memory) kills in production. Instead, use GC.stat. This method returns a hash of the current GC state, which is a goldmine for debugging. I specifically look at minor_gc_count and major_gc_count.

# Check how many times the GC has run
stats = GC.stat
puts "Minor GCs: #{stats[:minor_gc_count]}"
puts "Major GCs: #{stats[:major_gc_count]}"
puts "Heap live slots: #{stats[:heap_live_slots]}"

If you see the major_gc_count climbing rapidly during a specific task, you know you're hitting a wall. A major GC is the expensive one—it's the "stop-the-world" event we're trying to avoid. If your minor GC count is astronomical, you're likely creating too many temporary objects (strings, hashes) in a tight loop. In that case, tuning variables might help, but you should also look at your code—maybe use String#freeze or map! to reduce allocation pressure in the first place.




📋 Practical Task

Exercise: Reducing GC Cycles for a Large-Scale Object Generator

You have been given a script that simulates a heavy data processing task. Currently, the script triggers a high number of GC cycles because it creates millions of small, short-lived strings. Your goal is to reduce the number of Major GC cycles by tuning the environment variables.

The Setup: Create a file named gc_test.rb with the following code:


# gc_test.rb
def process_data
  1_000_000.times do |i|
    # Simulating heavy allocation
    "user_data_point_#{i}_temp_string_#{rand(1000)}"
  end
end

puts "Starting processing..."
GC.start # Start clean
before_stats = GC.stat

process_data

after_stats = GC.stat
puts "--- GC Stats ---"
puts "Minor GC count: #{after_stats[:minor_gc_count] - before_stats[:minor_gc_count]}"
puts "Major GC count: #{after_stats[:major_gc_count] - before_stats[:major_gc_count]}"

Your Task:

  1. Run the script normally: ruby gc_test.rb. Note the number of Minor and Major GC cycles.
  2. Run the script again, but this time prepend the execution with an increased RUBY_GC_MALLOC_LIMIT. For example: RUBY_GC_MALLOC_LIMIT=100000000 ruby gc_test.rb.
  3. Experiment with RUBY_GC_OLDMALLOC_LIMIT as well.
  4. Find a combination of environment variables that reduces the Major GC count to zero (or the lowest possible number) while keeping the script's execution time stable.
  5. Document which variables you changed and how they affected the Major GC count compared to the baseline.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.