Skip to Content
Course content

139: JVM Tuning for Scala Applications

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

I remember a project a few years ago where we had a Scala service processing millions of telemetry events per minute. On paper, the logic was beautiful—purely functional, plenty of map and filter calls, and immutable case classes everywhere. But in production, the service would randomly freeze for three seconds, then crash with a java.lang.OutOfMemoryError: GC overhead limit exceeded.

The team's first instinct was to just throw more RAM at it. They bumped the heap from 4GB to 8GB. It helped for a day, then the crashes came back, only the "Stop-the-World" pauses were now longer because the garbage collector had a larger pile of trash to sift through. This is the classic "Scala Heap Trap."

The "GC Overhead Limit Exceeded" Crash

case class Event(id: Long, payload: String, timestamp: Long)

def processEvents(events: List[Event]): List[String] = {
  events
    .filter(_.payload.nonEmpty)
    .map(e => s"Event ${e.id} at ${e.timestamp}: ${e.payload.toUpperCase}")
    .filter(_.contains("ERROR"))
}

// Imagine this being called in a tight loop with lists of 100,000+ items
val results = processEvents(massiveEventList)

At first glance, this code is fine. But look at what's happening under the hood. Every time you call .filter or .map on a List, Scala creates a brand new collection. If your list has 100,000 elements, you aren't just creating one result list; you're creating multiple intermediate lists and thousands of temporary String objects. These are "short-lived" objects.

The JVM handles short-lived objects in the Young Generation. When the Young Gen fills up, a "Minor GC" happens. If those objects survive a few cycles, they get promoted to the Old Generation. The GC overhead limit exceeded error happens when the JVM spends more than 98% of its time doing GC and recovers less than 2% of the heap. Basically, the JVM is screaming that it's working too hard for almost no gain.

Balancing the Young and Old Generations

If you're writing idiomatic Scala, you're likely creating a massive amount of short-lived garbage. The default JVM heap proportions often don't account for this. If your Young Generation is too small, objects get promoted to the Old Gen prematurely (this is called "premature promotion"). Once they hit the Old Gen, they require a "Major GC" to be cleared, which is what causes those dreaded multi-second freezes.

I usually start by increasing the Young Generation size. You can do this with -Xmn (which sets the size directly) or -XX:NewRatio. For a Scala app with high allocation rates, I've found that giving the Young Gen more breathing room reduces the frequency of Major GCs significantly.

Try adding this to your JVM options: -Xms4g -Xmx4g -Xmn2g. By setting the Young Gen (-Xmn) to 2GB out of a 4GB heap, you're giving the GC more space to collect those intermediate List and String objects before they ever touch the Old Gen.

Switching to G1GC for Lower Latency

If you're still seeing pauses, the default Parallel GC (which focuses on throughput) might be the wrong tool. For most Scala services, I recommend the G1 (Garbage First) GC. Unlike the Parallel collector, G1 divides the heap into smaller regions and cleans them independently, which keeps pause times more predictable.

To enable it, use: -XX:+UseG1GC.

One specific tweak I always suggest for G1 in Scala apps is -XX:MaxGCPauseMillis=200. This tells the JVM: "Try your best to keep any single pause under 200ms." The JVM will then dynamically adjust the young generation size to meet that goal. It's not a guarantee, but it's a massive improvement over letting the JVM decide to freeze your entire app for five seconds to clean up a few gigabytes of stale Option wrappers.

So, the "fix" isn't usually in the Scala code—though using Views or Iterators can help reduce allocations—it's in how you tell the JVM to handle the debris your code leaves behind.




📋 Practical Task

Exercise: Optimizing a High-Allocation Data Pipeline

You have been given a Scala application that processes a large set of synthetic log entries. Currently, the application is running with default JVM settings and is experiencing significant latency spikes and occasional OutOfMemoryError crashes due to GC pressure.

The Scenario: The app loads 1 million LogEntry objects, performs three consecutive filter and map operations, and aggregates the result. You can observe the GC behavior by adding the flag -XX:+PrintGCDetails (or -Xlog:gc* on Java 9+).

Your Task: 1. Run the provided LogProcessor application with the default settings and note the frequency and duration of the GC pauses.
2. Apply a combination of the following JVM flags to stabilize the application:

  • Set a fixed heap size (-Xms and -Xmx) to avoid heap resizing overhead.
  • Switch the garbage collector to G1GC.
  • Set a maximum GC pause target (MaxGCPauseMillis).
  • Experiment with the Young Generation size (-Xmn) to see if you can move the "promotion" of objects to the Old Gen further back.
3. Compare the GC logs. Your goal is to eliminate the GC overhead limit exceeded error and ensure that no single GC pause exceeds 500ms.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.