People produce 2 billion tons of trash every year. Without a waste management system, the world would be a dreadful post-apocalyptic nightmare, wouldn’t it? But we usually take garbage collection for granted, even though a single interruption in the process can feel like a disaster.
The same goes for software. 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, increased energy consumption, and hardware utilization. So the garbage in digital reality turns into garbage in the real world!
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.
- How automatic Garbage Collection works
- Types of Garbage Collectors in Java
- Garbage Collection and native images
- Garbage Collection best practices
How automatic Garbage Collection works
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
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.
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
- 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. In newer Java versions, you can also enable Parallel Old GC (
-XX:+UseParallelOldGC) that utilizes multiple threads both for the young and old generations.
In addition, 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 varying in size from 1 MB to 32 MB and performs the global marking phase to determine the liveliness of objects. After learning which heap regions are mostly empty, 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<size>): it should be able to accommodate the live-set of the app and provide enough room for allocations. To use X 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
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
Garbage Collection and native images
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
- 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. Try it out and see how it accelerates the startup of your application!
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. We have already brushed upon the importance of GC tuning in our article on HotSpot configuration. We will also elaborate on adjusting the GC parameters in Kubernetes cluster in the following articles, with metrics and hands-on examples.
But for now, 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
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
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. In the next article, we will move to action and explore how different GC parameters can impact the app’s performance in Kubernetes cluster. Stay tuned — subscribe to our newsletter!