Demystifying Java Garbage Collection: Logs, KPIs, and Memory Behavior
GC Analysis Made Simple: Understanding JVM Memory, KPIs & Log Analysis
In this blog, I’ll walk you through how I personally analyze and optimize Java Garbage Collection using easy-to-understand examples and real-world insights.
Table of Contents
- What is Garbage Collection (GC)
- JVM Memory Structure
- Why JVM Uses Multiple Generations
- Enabling GC Logs
- GC KPIs (Latency & Throughput)
- GC Graph Analysis & Takeaways
What is Garbage Collection (GC)
To understand Garbage Collection, we first need to understand what garbage actually means. In simple terms, garbage refers to objects that are no longer useful or are no longer needed by the application.
Garbage Collection (GC) in Java is the process of automatically identifying and removing these unused objects from memory, allowing the JVM to reuse memory efficiently.
Inside the JVM: Young Gen, Old Gen and Metaspace Explained
Image credit: Memory Management in JAVA
Before understanding how Garbage Collection works, it is important to understand how the JVM organizes memory. The JVM divides heap memory into different regions, each designed for a specific purpose and object lifecycle. This separation helps the Garbage Collector efficiently manage memory, reduce pause times, and improve application performance.
In this section, we will explore the three key memory areas involved in GC behavior:
- Young Generation – where most objects are created.
- Old Generation – where long-lived objects reside.
- Metaspace – which stores class-level metadata.
• Young Generation
All the objects that the application creates are initially placed in the Young Generation
• Old Generation
When Garbage Collection runs, the JVM identifies long-living objects in the Young Generation. These objects are then promoted from the Young Generation to the Old Generation.By long-living objects, we mean objects that are still in use or are expected to be used for a longer period of time.
• Metaspace
Metaspace stores information about classes, not actual objects. This includes class names, method definitions, field definitions, variables, bytecode, and other metadata required by the JVM to execute the class.
Apart from the regions discussed above, the JVM contains several other memory areas such as the Code Cache, Thread Stack, and Native Memory. However, from a Garbage Collection perspective, the Young Generation, Old Generation, and Metaspace are the most critical regions to understand.
To keep this blog focused and easy to follow, I have explained the most important memory areas related to GC. For readers who want to explore other JVM memory regions in more detail, you can refer to the official documentation below.
Managing Memory and Garbage Collection
Why Does the JVM Use Multiple Generations Instead of a Single One?
One of the very first questions that came to my mind when I started learning about JVM memory regions was: Why do we need Young and Old Generations? Initially, I couldn’t find a clear or satisfying answer to this. However, after multiple rounds of research, I was able to understand the real reason behind this memory bifurcation.
The key reason for dividing the heap into generations is that, in most applications, nearly 80% of objects are short-lived. These objects perform very small or temporary tasks and remain in memory only for a short duration. Keeping such objects alive for a long time does not make sense and would lead to inefficient memory usage.
To handle this efficiently, the JVM introduced the Young Generation, which is specifically designed to store short-lived objects. These objects are quickly allocated, used, and reclaimed. The responsibility of cleaning up these short-lived objects lies with Minor GC.
Minor GC runs frequently and operates only on the Young Generation. During each Minor GC cycle, the JVM identifies objects that are no longer in use and removes them from memory, promotes the long lived objects to Old Generation ensuring that memory is reclaimed quickly and efficiently without impacting long-lived objects.
How to Enable & Read Garbage Collection (GC) Logs in a Java Application
Now that we understand how the JVM organizes memory and how objects move across different memory regions, the next step is to observe how Garbage Collection behaves at runtime. This is where GC logs become extremely important, as they provide visibility into what is happening inside the JVM.
Without GC logs, GC behavior remains a black box, making it extremely difficult to analyze performance issues such as high latency, frequent pauses, or memory leaks. By enabling GC logs, we can observe important details such as when GC events occur, how long they last, how much memory is reclaimed, and how the heap is being utilized over time. These logs form the foundation for effective GC analysis and tuning.
For Java 9 and later versions, GC logging is enabled using following JVM parameter:
For Java 8 and earlier versions, GC logging can be enabled using the following JVM options:
-XX:+PrintGCTimeStamps
-Xloggc:/your_destination_path/gc.log
This configuration logs all GC-related events to a file named gc.log, along with timestamps and useful contextual information. I strongly recommend enabling GC logs in your application. GC logging consumes very little disk space and has a negligible impact on application performance, while providing extremely valuable insights for troubleshooting and performance tuning.
Reading GC Log File for both Minor GC and Full GC
Below is an image showing a sample GC log generated by the JVM during one of my tests (I have used G1 GC in my application). All the details required to understand this log are explained directly within the image, and I have also covered above how to enable and locate GC logs.
Note: The structure and content of GC log files may vary depending on the Garbage Collector in use (G1, ZGC, Parallel GC, etc.) and the JVM version. The above sample GC log file is for G1 GC.
Key Performance Indicators
Once GC logs are enabled, the next challenge is making sense of the data they produce. Instead of analyzing every GC event individually, it is more practical to focus on a few key performance indicators (KPIs) that give a high- level view of GC health.
Since multiple GC events occur during the lifetime of an application, analyzing each GC event individually can be time-consuming and impractical. That is why I focus on a set of key performance indicators (KPIs) that provide a high-level view of whether Garbage Collection is keeping the JVM healthy and performing as expected.
Below are the two KPIs that I use most widely to understand GC performance:
- Latency
- Throughput
• Latency
Latency, in simple terms, is the total time the JVM pauses the application to complete a GC activity. To evaluate GC latency, I primarily focus on two metrics: the average GC pause time and the maximum GC pause time.
In my case, the observed average GC pause time is 20.7 ms, which is well within an acceptable range and indicates healthy GC behavior. The maximum GC pause time is 264 ms, which is slightly higher but still acceptable for most backend applications, as long as it does not occur frequently.
As a general guideline, an average GC pause time below 100 ms is considered healthy for most applications. For the maximum GC pause time, values below 200–300 ms are usually acceptable. If pause times consistently exceed these limits, it may indicate GC pressure and potential performance impact on the application.
• Throughput
Throughput, in simple terms, represents the percentage of time the JVM spends doing useful application work (such as executing methods and handling requests) versus the time spent performing Garbage Collection. A higher throughput percentage indicates that the JVM is spending more time on application execution and less time on GC, which generally translates to better overall performance.
In my case, the JVM throughput is 99.493%, which indicates that the JVM is spending the vast majority of its time executing application logic rather than performing Garbage Collection. This is a strong indicator of a healthy GC behavior, as GC overhead is minimal and unlikely to impact application performance.
GC Graph Analysis and Takeaways
While KPIs provide a summarized view of GC behavior, visual graphs make it much easier to identify trends and patterns over time. In the following section, I analyze key GC graphs to understand memory allocation, reclamation, and overall JVM health.
These visualizations help identify GC behavior patterns, validate whether collections are effective, and highlight potential memory pressure or tuning opportunities.Rather than interpreting individual GC events in isolation, these graphs provide a holistic view of JVM health and garbage collection efficiency.
• Young Gen Graph
Figure: Young Generation allocation and reclamation.
This graph represents the behavior of the Young Generation over time and clearly shows how frequently short-lived objects are created and reclaimed by the JVM. The allocated space line indicates the total memory reserved for the Young Generation, while the Before GC line shows how much of that space is occupied just before a GC cycle runs. The greater the space between Before GC and After GC, the larger the amount of memory reclaimed.
• Old Gen Graph
Figure: Old Generation memory usage over time.
This graph illustrates the behavior of the Old Generation over time and highlights how long-lived objects accumulate in the heap. A key observation here is that the Before GC and After GC lines closely follow each other, showing minimal difference between them. This indicates that very few objects in the Old Generation were reclaimed during GC cycles, as these objects were still actively in use by the application. At the same time, the gradual upward trend in memory usage suggests that additional objects were continuously promoted from the Young Generation. This pattern is a classic example where no Full GC was executed and only object promotion occurred.
• Reclaimed Bytes After GC
Figure: Memory reclaimed after GC cycles.
This graph represents the amount of memory reclaimed after each GC cycle. A closer look shows that no memory was reclaimed during a Full GC (there are no red triangles), and all reclamation activity is driven by Minor GCs. This behavior aligns with what we observed in the Old Generation graph, where memory usage continued to grow over time without any noticeable drop, indicating that long-lived objects were not eligible for reclamation.
If we now correlate this with the Young Generation graph, the allocated space remains around ~11 GB. Since no Full GC was performed, we can safely conclude that memory cleanup was limited to the Young Generation. The reclaimed memory averages around ~10 GB, which clearly demonstrates that most short-lived objects in the Young Generation were successfully collected during Minor GC cycles. This is a good example of healthy GC behavior, where short-lived objects are efficiently reclaimed without impacting the Old Generation.
• GC Pause Duration
Figure: GC pause durations with low average impact.
The GC Pause Duration graph shows how long the JVM pauses during Garbage Collection events. In this case, the average pause time remains around ~20 ms, which is considered very good. Such low pause durations indicate efficient GC behavior, minimal impact on application execution, and improved JVM throughput.
• Allocation & Promotion
Figure: Allocation vs promotion behavior.
This graph highlights the relationship between object allocation and object promotion within the JVM. In this case, the allocation rate is around 1808, while the promoted object size is only about 103. This clearly indicates that most objects are short-lived and are being collected in the Young Generation itself, with only a small fraction surviving long enough to be promoted to the Old Generation. Such behavior is healthy and expected for well-performing applications, as it reduces Old Generation pressure and minimizes the need for expensive Full GC cycles.
Conclusion
Garbage Collection plays a crucial role in maintaining JVM performance and application stability. However, without proper visibility, GC behavior often remains a blind spot. By enabling GC logs and analyzing them using meaningful KPIs and visual graphs, we can shift from assumptions to data-driven insights.
In this blog, I explained how the JVM organizes memory, why generational garbage collection exists, and how Minor and Full GCs influence application performance. By focusing on key metrics such as GC latency, throughput, allocation, promotion, and reclaimed memory, it becomes much easier to determine whether GC behavior is healthy or requires attention.
The examples and graphs discussed here demonstrate a well-performing JVM, where short-lived objects are efficiently reclaimed in the Young Generation, pause times remain low, and Old Generation pressure is minimal. This is the kind of GC behavior most backend applications should aim for.
In upcoming blogs, I will intentionally create OutOfMemoryError scenarios to show how memory issues arise in real-world applications. I will then deep-dive into GC tuning techniques, configuration options, and practical tips and tricks to resolve these issues and improve JVM performance. The goal is to move beyond theory and focus on hands-on, practical GC tuning that can be applied directly in production environments.
Garbage Collection tuning is not about eliminating pauses entirely, but about keeping them predictable, short, and aligned with application requirements. With the right observability and a structured analysis approach, GC becomes far less intimidating and much easier to optimize.
👉 Stay tuned for the next part, where we break the JVM on purpose and learn how to fix it the right way.
Keep it up Ranveer
ReplyDeleteThank you!
Delete