From Healthy GC to OutOfMemoryError: What Really Goes Wrong Inside the JVM
A Practical Walkthrough of OutOfMemoryError
In this blog, I intentionally break the JVM by creating an OutOfMemoryError and then analyze what actually goes wrong using GC logs and memory behavior.
This blog continues the discussion from my earlier post on JVM memory and Garbage Collection fundamentals where the foundational concepts required for this analysis are explained.
Table of Contents
- How I Crashed JVM
- From Object Allocation to OutOfMemoryError
- Understanding OutOfMemoryError Through GC Log Analysis and Graphs
- Effect of OutOfMemoryError on Throughput – A Key Performance Indicator
- OutOfMemoryError Is a Symptom, Not the Root Cause
- Causes and Recommendations for Memory-Inefficient Code
- Conclusion
How I Crashed JVM
Crashing the JVM typically means encountering an OutOfMemoryError (OOM), which occurs when the JVM can no longer allocate memory due to insufficient heap space. In this blog, I ran a large-scale test with a deliberately constrained heap to observe how memory pressure builds up and eventually leads to an OOM. This setup helps us understand how allocation patterns and Garbage Collection behavior contribute to memory exhaustion.
The above screenshot shows the OutOfMemoryError observed during the test run, clearly indicating that the JVM was unable to allocate additional heap memory.
From Object Allocation to OutOfMemoryError
As objects survive Minor GCs in the Young Generation, they are promoted to the Old Generation. If these promoted objects remain referenced, they cannot be reclaimed during Full GC. Over time, as more objects continue to be promoted from the Young Generation, the Old Generation keeps accumulating live data. Eventually, the Old Generation reaches its capacity, and even a Full GC is unable to free sufficient space. At this point, the JVM can no longer allocate memory, resulting in an OutOfMemoryError.
Understanding OutOfMemoryError Through GC Log Analysis and Graphs
To understand why the JVM failed, we need to look beyond the error itself. GC logs and memory graphs provide a timeline of events that show how memory usage, garbage collection, and allocation pressure changed leading up to the OutOfMemoryError.
• Analyzing Young Generation Memory Usage Until OutOfMemoryError
The steadily rising before GC line indicates a high object allocation rate. The application keeps creating short-lived objects, causing Eden to fill up quickly. The after GC line staying low for most of the timeline shows that the JVM is efficiently reclaiming short-lived objects in the Young Generation during Minor GCs.
Toward the end, allocation peaks close to the Young Gen capacity, and GC activity becomes more aggressive. Despite Young Gen still being cleaned, the JVM can no longer sustain promotions because the Old Generation is already under heavy pressure.
• Analyzing Old Generation Memory Usage Until OutOfMemoryError
This graph represents Old Generation memory usage from application startup until the OutOfMemoryError occurred.
-
Low usage during early runtime
Initially, Old Generation usage remains very low, indicating that most objects are short-lived and are successfully reclaimed in the Young Generation. -
Sudden increase due to promotions
Around the mid-point of the timeline, Old Generation usage rises sharply. This corresponds to an increase in object promotions from the Young Generation as more objects begin surviving Minor GCs. -
Temporary reclaim during Full GC
Around 08:32:00 AM, a noticeable downward slope is visible in the graph. During this time, multiple Full GCs ran and were able to reclaim some Old Generation memory. However, the amount of reclaimed space was limited, indicating that many objects were still live and referenced. -
Growing memory retention
After this window, Old Generation usage trends upward again, showing that the rate of object promotion exceeded the rate at which memory could be reclaimed by Full GC. -
Final Full GCs with no recovery
Near the end of the timeline, repeated Full GCs occur, but the after GC line remains close to the maximum heap size. This indicates that Full GC is no longer effective in freeing memory. -
OutOfMemoryError
Once the Old Generation reaches its capacity and Full GC fails to reclaim sufficient space, the JVM is unable to allocate memory, resulting in an OutOfMemoryError.
Key takeaway: OutOfMemoryError occurs not because Garbage Collection stops running, but because Full GC can no longer free enough memory in the Old Generation to satisfy allocation and promotion demands.
• Reclaimed Bytes During Young GC and Full GC
This graph shows the amount of memory reclaimed during Young GC and Full GC events over time. It provides direct insight into how effective Garbage Collection was as memory pressure increased.
-
Young GC continues to reclaim memory
Throughout most of the runtime, Young GCs consistently reclaim memory. This confirms that short-lived objects are being cleaned up efficiently in the Young Generation and that Minor GC behavior itself is not the root cause of the problem. -
Full GC initially succeeds in reclaiming space
In the highlighted time window, Full GC events are able to reclaim a significant amount of memory (for example, around 2.4 GB at 08:32:40 AM). This aligns with the temporary drop in Old Generation usage seen in earlier graphs. -
Reclaimed memory steadily declines
As execution continues, the amount of memory reclaimed by Full GC begins to decrease. This indicates that an increasing number of Old Generation objects remain live and cannot be reclaimed. -
Full GC runs but reclaims no memory
Toward the end of the timeline, multiple Full GC events occur where the reclaimed bytes drop close to zero. This is a critical warning sign that Garbage Collection is running but is no longer effective. -
Clear signal before OutOfMemoryError
The highlighted area where Full GC reclaims little to no memory is a classic pre-OutOfMemoryError pattern. At this point, the heap is saturated with live objects, leaving the JVM with no space to satisfy new allocations or promotions.
Key takeaway: Reclaimed bytes clearly show that OutOfMemoryError occurs not because Garbage Collection stops running, but because Full GC can no longer free meaningful memory.
Effect of OutOfMemoryError on Throughput – A Key Performance Indicator
This graph shows heap usage after GC, with the small red triangles representing Full GC events. As the application approaches an OutOfMemoryError, Full GCs begin to run repeatedly. In this test, the JVM was configured with a maximum heap size of 4 GB, and the graph clearly shows the heap reaching and sustaining this limit. At this stage, the JVM attempts to reclaim space from the Old Generation, but since most objects are still live and referenced, Full GC is unable to free meaningful memory.
The increasing frequency of Full GCs indicates severe heap saturation. Each Full GC is a stop-the-world event, meaning all application threads are paused while garbage collection is in progress. As these pauses become more frequent and prolonged, the JVM spends a growing proportion of time performing GC rather than executing application logic.
This behavior directly impacts application throughput, which measures how much useful work is completed over time. In this scenario, repeated Full GCs significantly reduce effective throughput because more time is spent in GC activity than in request processing. In this particular test, the observed throughput was 91.085%, clearly showing the performance degradation caused by excessive Full GC activity even before the OutOfMemoryError was thrown.
Here I have explained the concept of throughput in detail.
OutOfMemoryError Is a Symptom, Not the Root Cause
OutOfMemoryError is not just a memory size problem but a symptom of how an application uses memory. While GC logs show when memory is exhausted, heap dumps reveal what is consuming memory and why it is not being released.
• Enabling Heap Dumps on OutOfMemoryError
Enabling heap dumps on OutOfMemoryError ensures that the exact memory state at the time of failure is preserved. This makes it possible to identify which objects are consuming memory, how they are referenced, and whether the issue is caused by configuration, workload, or inefficient application code.-XX:HeapDumpPath=/path/to/heapdump/heapdump.hprof
-XX:+ExitOnOutOfMemoryError
Note: The heap dump configured using
-XX:+HeapDumpOnOutOfMemoryError is generated
only when the JVM throws an OutOfMemoryError.
If the application continues to run without encountering an OOM error,
no heap dump file is created.
This behavior ensures that the heap dump captures the exact memory state at the point of failure, making it highly useful for diagnosing memory leaks and inefficient memory usage.
Causes and Recommendations for Memory-Inefficient Code
By analyzing heap dumps, we can identify which objects are consuming the most memory, understand how they are being referenced, and pinpoint areas in the code where memory usage can be optimized or reduced.
• Inefficient Use of Collections – HashMap
Heap dump analysis revealed that a significant portion of memory was consumed by
collection objects that were either underutilized or completely unused.
One striking observation was that 58% of java.util.HashMap instances
contained no elements at all.
This indicates that HashMap objects were being created but never populated.
By default, creating a HashMap using:
allocates an internal capacity for 16 entries. If no elements are added, this reserved space remains unused, resulting in wasted heap memory. While a single instance may appear harmless, at scale this pattern can significantly contribute to Old Generation memory pressure.
Hidden Cost of HashMap Resizing
The impact becomes even more pronounced when elements slightly exceed the default capacity. For example:
- Inserting 17 elements causes the HashMap to resize from 16 to 32, leaving 15 unused slots.
- Inserting 33 elements triggers another resize from 32 to 64, wasting 31 slots.
This automatic resizing behavior, combined with a high object creation rate, can lead to unnecessary memory consumption and increased GC pressure, especially in high-throughput applications.
Recommendation
Always initialize collections with an appropriate initial capacity when the expected size is known, or avoid creating them altogether when they are not required. This small optimization can significantly reduce memory waste and help prevent Old Generation exhaustion.
• Other Common Causes of Memory Inefficiency
Beyond inefficient collections, heap dump analysis often reveals a few recurring patterns that contribute to excessive memory usage and Old Generation pressure. Below are some of the most common ones observed in real-world applications.
-
Duplicate Strings:
Creating multiple identical
Stringobjects increases memory usage and promotion to Old Gen. Reuse constants and avoid unnecessary string creation. - Duplicate Objects: Repeated creation of logically identical objects causes faster heap growth. Reuse immutable objects and cache only where it makes sense.
- Large or Duplicate Arrays: Holding large arrays or buffers longer than required leads to rapid Old Gen expansion. Reuse buffers and release references when no longer needed.
-
Objects Waiting for Finalization:
Objects relying on finalization remain reachable longer, delaying memory
reclamation. Prefer explicit cleanup mechanisms over
finalize().
While GC logs indicate when memory pressure occurs, heap dumps clearly show why it happens, making them essential for identifying and fixing inefficient memory usage patterns.
Conclusion
In this post, we moved beyond theory and explored how a JVM transitions from healthy GC behavior into a full-blown OutOfMemoryError scenario. We saw how GC logs and memory graphs provide visibility into allocation patterns, promotion behavior, and how the Old Generation ultimately becomes saturated. By correlating GC activity with throughput and heap utilization, we gained insight into why the JVM struggles before throwing an OOM.
Importantly, OutOfMemoryError is a symptom—not the root cause. While GC logs help us understand when memory is exhausted, heap dumps reveal what is consuming that memory and why it is being retained. Examining heap dumps allows us to pinpoint inefficient usage patterns such as underutilized collections, duplicate objects, and other common memory inefficiencies.
Armed with the right observability tools and a structured analysis approach, you can diagnose and address memory-related issues more confidently and accurately. Whether through tuning GC configurations, optimizing application code, or both, understanding JVM memory behavior empowers you to improve stability and performance in production systems.
As always, observations backed by data will help you write more reliable, memory-efficient Java applications.
Comments
Post a Comment