Our applications produce a lot of waste while in operation. Diligent Java garbage collectors remove the waste automatically, so we don’t have to bother with manual memory cleaning. But when a collector cannot handle their job, we immediately see that in the form of performance degradation. For instance, improper garbage collection settings may lead to noticeable pauses in your application’s work or increased memory usage.
The first step in making garbage collection more efficient is understanding how the process is organized in Java, which GC implementations are there, and how to choose the right one for your app.
Table of Contents
How automatic Garbage Collection works
What is Garbage Collection?
Garbage collection is a process of freeing up memory by deleting unused objects from the heap. An object is considered eligible for GC when it becomes unreachable, meaning there are no references to it. Unlike C/C++, where the developer is in charge of destroying objects, GC in Java is automatic. There are ways of invoking the GC manually by calling System.gc(), but it is not the best practice. System.gc() may induce Full GC when the whole heap gets cleaned, and the method will wait until it is possible, which will have disastrous impact on performance.
Before delving deep into GC implementations in Java, we should understand its mechanisms first.
One of the functions of Java GC is managing objects in generations. Newly created objects belong to the young generation. As a rule, most objects “die young,” but some can move to the old or tenured generation. These generations occupy different spaces in the JVM heap:
- Metaspace replaces PermGen utilized in versions before Java 8. It stores metadata
- Eden space and two Survivor spaces (0 and 1) are where the young generation resides
- Tenured space preserves the old generation
Java memory model
When any of these spaces fill up, garbage collection happens. A minor collection occurs when the young space tops up. Most young objects don’t live long and are swept away, but if they are still used at the moment of garbage collection, they are moved to a Survivor space 0 or 1, and then to the tenured space. At some point, tenured space gets filled up, which causes a major collection. Major collections usually take more time because more objects are involved.
How does Garbage Collection work?
Now that we’ve discussed some essential concepts, how does GC work?
When the application starts, the Eden space is empty. While the threads are working, the heap is filling up, and then an event triggers garbage collection. In the case of non-concurrent GCs, threads have to be stopped for a garbage collector to do its job. The GC marks unused objects and removes them. Other events that trigger the GC activation are:
- Insufficient memory for creating new objects;
- Explicit invocation of GC.
After the memory gets cleaned, the threads are restarted. The pauses happening during the garbage collection are called Stop-the-World pauses. The key to optimal app performance is to keep the pauses minimal.
Advantages of automatic Garbage Collection
- No need for manual memory allocation/deallocation, which saves the developer’s time and minimizes bugs related to human error;
- Efficient memory usage as the heap gets cleaned as soon as it fills up;
- Reduced risk of memory leaks — most of these cases are successfully handled by the collector.
Disadvantages of automatic Garbage Collection
- Lack of control over memory management leaves little space for improving the resource usage;
- While some applications may benefit from automatic GC, larger enterprise apps may experience unpredictable performance deterioration;
- A memory leak that escaped the attention of a garbage collector is difficult to debug.
Types of Garbage Collectors in Java
Java provides numerous opportunities for GC tuning with various GC implementations with their own strengths. Primarily, the company establishes the most important performance indicators: throughput, latency, or footprint. The choice of a garbage collector depends on the defined goals.
For instance, Parallel GC is the best option when throughput matters. On the other hand, G1 GC is aimed at low latency. After selecting the collector, you can start the tuning process based on GC capabilities. Garbage collection adjustment is a balancing act: large heap means longer pauses, short pauses lead to a more frequent GC activation and so on.
Below you will find the summary of key garbage collectors available in JVM with distinguishing features.
Serial Garbage Collector
Serial GC is the oldest and simplest GC implementation in Java. It is utilized in single-threaded environments as it freezes all threads while it performs the collection and works in one thread itself. Serial GC is suitable for client-side applications without low pause requirements. Note that this GC will be used automatically if the RAM limit is set to less than 1792 MB or there are less than 2 CPUs. Otherwise, to enable Serial GC, use
java -XX:+UseSerialGC -jar yourApp.java
Parallel Garbage Collector
Parallel GC or throughput collector is the default GC implementation suitable for multiprocessor environments. It also freezes all threads for garbage collection. But unlike Serial GC, it uses multiple threads to speed up the process. The developer can set the maximum thread number. For example, the following command
java -XX:+UseParallelGC -XX:ParallelGCThreads=10 -jar yourApp.java
will enable the Parallel GC with ten threads. But in a real-world scenario, the number of threads is calculated based on the processor number. Parallel GC uses multiple threads to sweep the young generation, but does with one thread when removing objects from the old generation.
Starting with Parallel GC, it is possible to target the pause time (-XX:MaxGCPauseMillis
) to maintain optimal throughput. Be careful not to set the value too small, though, or else the JVM will use a smaller heap to perform rapid garbage collection leading to the increased number of pauses. Another flag, -XX:GCTimePercentage
, enables the developers to set the time the application spends on garbage collection.
Concurrent Mark Sweep Garbage Collector
CMS GC has two distinguishing features:
- It uses multiple threads to perform the collection;
- It shares processor resources with the application, i.e., it doesn’t freeze all the threads but utilizes some of them to do its job.
This GC implementation is suitable for applications that benefit from short pauses and can afford to share the resources with the GC. CMS GC performs slower than Parallel GC or Serial GC, but it doesn’t stop the application. Since it performs garbage collection in concurrent mode, calling System.gc()
will lead to concurrent mode failure. To enable CMS GC, run
java -XX:+UseConcMarkSweepGC -jar yourApp.java
Note that CMS GC has been deprecated since Java 9 to “accelerate the development of other garbage collectors in HotSpot” according to JEP 291, so you will get the following warning when trying to run it with Java 11:
java -XX:+UseConcMarkSweepGC --version
OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
It has already been removed from the code base in newer versions, and the following message with Java 17 will pop up:
Unrecognized VM option 'UseConcMarkSweepGC'
G1 Garbage Collector
G1 GC was designed to replace CMS GC with low latency in mind. It is fit for any application, but shows all its glory with server-style applications running a multiprocessor environment with a large heap (6+ GB). It splits the heap into multiple regions and performs the global marking phase to determine the liveliness of objects. After learning which heap regions are mostly filled with garbage, it first collects garbage in those regions to free up a lot of memory. This approach is called Garbage-First.
G1 GC copies objects from one or several memory regions into a single region, which enables it to compact memory. The compaction is performed in parallel on multiprocessor machines, thus reducing pause times and increasing throughput. In addition, the developers can adjust the maximum pause time and pause time intervals.
To enable G1 GC, run
java -XX:+UseG1GC -jar yourApp.java
Z Garbage Collector
Z GC was introduced in Java 11 as an experimental feature and obtained production status starting with Java 15. It is a scalable low-latency collector that performs the expensive work concurrently and doesn’t stop the application threads for more than 10 ms. The most important parameter is the max. heap size (-Xmx
): it should be able to accommodate the live-set of the app and provide enough room for allocations. To use Z GC, run
java -XX:+UseZGC -jar yourApp.java
For versions up to Java 15, the command will be slightly different
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar yourApp.java
Shenandoah Garbage Collector
Another low pause garbage collector is Shenandoah GC. It performs garbage collection concurrently with the running Java application thus reducing pause times which are not directly proportional to the heap size. Oracle Java doesn’t ship Shenandoah with any of its releases, but this GC is part of major OpenJDK distributions including Liberica JDK. Shenandoah works with all LTS releases and a current Java release and supports a wide range of platforms. The OpenJDK community continuously backports improvements and bug fixes to previous supported JDK versions.
To enable Shenandoah GC, run
java -XX:+UseShenandoahGC -jar yourApp.java
Epsilon GC
Epsilon GC is the most peculiar of all Java garbage collectors because it doesn’t collect any garbage. Its primary purpose is to allocate memory. Once the available heap is exhausted and the application tries to allocate more memory than allowed (i.e., set by -Xmx
), the JVM shuts down with an OutOfMemoryError.
This no-ops GC was introduced in Java 11 as an experimental feature, but it was decided to keep it experimental to avoid accidental Epsilon GC enabling in production. So, to use Epsilon GC, you need to explicitly enable experimental features first:
java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -jar yourApp.java
Despite the fact that Epsilon GC doesn’t collect any garbage, there are several use cases where it can be useful:
- Performance testing with Epsilon GC may reveal how fast the application runs without garbage collection and whether there are performance bottlenecks, which can be clearly seen without GC-induced performance artifacts.
- Applications that create all necessary objects at start and don’t produce any garbage as well as short-lived applications that don’t have time to spend all available resources may run faster without garbage collection.
Otherwise, it is not recommended to use Epsilon GC to avoid unexpected application behavior and crashes.
A comparative table of Java garbage collectors
Characteristic |
Serial GC |
Parallel GC |
CMS GC |
G1 GC |
Z GC |
Shenandoah GC |
Epsilon GC |
Heap size |
Small |
Medium to Large |
Medium to Large |
Medium to Large |
Very large |
Very large |
N/A |
Latency |
High |
Moderate |
Moderate |
Moderate |
Low |
Low |
N/A |
Throughput |
Low |
High |
Moderate |
High |
High |
High |
N/A |
Use cases |
Client-sized application in the single processor environment |
Server-side application in the multiprocessor environment with focus on throughput |
Server-side application in the multiprocessor environment that can afford sharing the resources with GC |
Server-side applications in the multiprocessor environment with a large heap (~6 GB) |
Latency-sensitive applications, Applications with a very large heap (terabytes) |
Latency-sensitive applications, Applications with a very large heap (terabytes) |
Performance testing, Short-lived applications, Apps not producing garbage |
Garbage Collection in GraalVM Native Image
What about the Native Image technology? How is memory managed there?
Native images do not run on HotSpot but rather on GraalVM. Although the mechanism of garbage collection is the same, the range of GC implementations offered by the Native Image is slightly different:
- Serial GC is a default collector both in GraalVM Community and Enterprise Edition. It is aimed at delivering low footprint and minimal overhead at the expense of increased latency. The maximum heap size with Serial GC will be automatically set to 80% of available physical memory if the heap size is not explicitly specified with -Xmx or -XX:MaximumHeapSizePercent flags
- G1 GC is available in GraalVM EE only. It is optimized for optimal correlation between latency and throughput. By default, the maximum heap size is set to 25% of physical memory, but you can adjust this parameter. Other options of G1 GC tuning such as pause time, thread number, etc., are also available.
- Epsilon GC is available with GraalVM version 21.2 and higher. It doesn’t collect any garbage and is intended to be used with short running apps that allocate a small amount of memory.
Find more information on GC tuning in native images in the official GraalVM documentation.
Liberica Native Image Kit (NIK), a GraalVM-based utility for turning JVM-based applications into native executables, provides all these GC implementations. In addition, Liberica NIK is always based on the latest versions of GraalVM and Liberica JDK 11 or 17 with fixed bugs and improvements.
Garbage Collection and Java versions
The choice of a garbage collector relies, among other things, on the Java version you use.
First of all, a certain collector may not be available with your version:
- CMS GC was removed from Java 14, so it is absent from newer JDK releases;
- Z GC appeared in Java 11 as an experimental feature and became ready for production-use in Java 15. However, it is absent in Java 8;
- Shenandoah GC was introduced in Java 12 as an experimental feature and became ready for production-use in Java 15, so it is absent from older Java versions;
- Epsilon GC was introduced in Java 11 and is not available in previous versions.
Secondly, the performance of a chosen GC implementation also depends on the JDK version because there have been many enhancements introduced to Java garbage collection up until now, and the improvement process is on-going. Here are only a few examples of numerous GC changes in the latest Java versions:
- JEP 307: Parallel Full GC for G1 improves the latency of G1 GC during the full collection.
- JEP 439: Generational ZGC reduces the GC CPU overhead by making Z GC maintain young and old objects separately, thus collecting young objects more frequently.
- JEP 423: Region Pinning for G1 reduces latency by not disabling garbage collection in the presence of JNI critical regions.
Just to get a taste how small this drop in the ocean is: as of August 22, 2024, Java Bug System contained 2,061 resolved and integrated fixes and enhancements to G1 GC alone!
Therefore, upgrading the JDK version is key to using the full potential of Java garbage collectors. But if your enterprise workloads are based on JDK 8 or 11 and migration is off the table for now, you can use Liberica JDK Performance Edition that couples JDK 8 or 11 and JVM 17. So technically, you stay on JDK 8 or 11, but enjoy the performance of version 17, including new and improved GC implementation.
The graph below shows the results of latency study with improved G1 GC and new Z GC for Spring Petclinic application based on Liberica JDK 8 Standard and Liberica JDK 8 Performance Edition:
GC latency study with a standard JDK and Liberica JDK 8 Performance Edition
More performance studies of Liberica JDK Performance Edition with improved garbage collectors can be found here.
Garbage Collection best practices
Garbage collection tuning is arduous and doesn’t have a “one-size-fits-all” solution, as everything depends on your application, the environment, and end goals. You can read more about JVM memory tuning options here.
Here, we would like to give general recommendations on taking care of garbage collection:
- Don’t start tuning the GC when it is unnecessary. Small applications that don’t handle large amounts of data do well with automatic garbage collection. But even in the case of large enterprise apps, make sure that default GC settings are to blame before making changes. But even in this case, try switching to another GC, whose default setting may be more optimal for your application.
- There are three performance goals of GC tuning: throughput, latency, and footprint. Pick two and work towards them, as these goals usually compete. For instance, the more memory you allocate to your app, the better the throughput, but the longer the pause times. On the other hand, the smaller the heap, the lower the latency, but frequent pauses lead to deteriorated throughput.
- To adjust the GC parameters, you must understand how your JVM behaves and what is happening in the heap. Use Java monitoring tools such as JFR, Mission Control, jstats, or other profilers to gather the metrics on footprint, garbage collection, and performance in general.
- Learn to understand the GC logs that provide exhaustive information on garbage collector behavior, memory reclamation and allocation, pause length, and so on. To enable GC logs, run
-Xlog:gc*:<gc.log file path>:time
- It is recommended to store the logs in a dedicated file so that they don’t mix up with application logs.
- Calling
System.gc()
should be avoided, but you can help your GC differently by making the objects unreachable and thus eligible for GC. For instance, you can nullify or reassign the reference variable. - Sometimes setting the min. (
-Xms
) and max. (-Xmx
) heap size is enough to solve the issue. In some cases, more fine-grained tuning with extensive monitoring and benchmarking is required. But the deeper you dig into GC settings, the more fragile the solution will be. If the environment changes due to hardware upgrade, application scaling, etc., you may have to repeat the whole GC tuning process. - GC tuning won’t magically enhance your app’s performance. If, after all the adjustments, you haven’t reached the goals, consider alternatives such as upgrading your hardware, OS, or refactoring the code.
Conclusion
This article described how automatic garbage collection is set up in Java. We summarized different GC implementations and their capabilities, so now you can choose an appropriate collector and start adjusting its settings.