Java garbage collection algorithms are the methods the JVM uses to find objects that the program can no longer reach and to reclaim their memory, by sweeping the free space, compacting the live objects or copying them to another area. Each HotSpot garbage collector, such as G1, ZGC or Parallel, combines these algorithms in its own way.
We choose and tune a collector when an app has long pauses, uses too much memory or runs slower than expected, and we read GC logs to find out which of the three it is. On JDK 25, G1 is the default collector, and CMS no longer exists.
The following example asks the running JVM which collector it uses and how often it has run.
List<String> collectors = ManagementFactory.getGarbageCollectorMXBeans().stream().map(GarbageCollectorMXBean::getName).toList(); // [G1 Young Generation, G1 Concurrent GC, G1 Old Generation]
long youngCollections = ManagementFactory.getGarbageCollectorMXBeans().get(0).getCollectionCount(); // collections so far, 0 right after startup
Notice that G1 reports separate young and old collections, which comes from its generational design. We start with how the JVM finds garbage and the three basic algorithms, look at generations, and compare the six collectors of JDK 25 with their flags and real log output. The last sections cover choosing a collector with the help of GC logs, and the code habits that keep the collector’s work small.
1. How the JVM Finds Garbage
An object is garbage when no chain of references leads to it from a GC root. GC roots are the references the JVM always knows about, and they are the starting points of every collection.
- Local variables and parameters in the stack frames of running threads.
- Static fields of loaded classes.
- Active threads, and objects used as locks by synchronized blocks.
- References held by native code through JNI.
The collector starts at the roots, follows every reference and marks each object it reaches as live. Every object that is not marked is garbage. This method is called tracing, and all HotSpot collectors use it. The JVM does not count references to an object, because reference counting cannot free objects that refer to each other.
public class Node {
Node next;
}
Node first = new Node();
Node second = new Node();
first.next = second;
second.next = first;
first = null;
second = null;
After the last two lines, the two Node objects still refer to each other, but no GC root leads to them. A tracing collector never reaches them during marking, so it frees both. With reference counting, each object would keep a count of 1 forever.
Marking a heap that changes while the app runs is hard, so collectors stop the application threads for some or all of their work. Such a pause is called a stop-the-world pause. The collectors differ mostly in how much of their work they do during those pauses and how much they do concurrently, while the app keeps running.
2. Java Garbage Collection Algorithms (Mark-Sweep, Compact, Copy)
After marking, the collector has to turn the memory of dead objects into free space. There are three basic ways to do it, and real collectors combine them.

| Algorithm | What it does | Advantage | Cost |
|---|---|---|---|
| Mark-sweep | Adds the memory of dead objects to free lists | Does not move objects | Fragmentation, a large array may not fit although enough memory is free |
| Mark-sweep-compact | Slides live objects to one end of the space | No fragmentation, allocation by bumping a pointer | Moving objects and updating every reference to them takes time |
| Mark-copy | Copies live objects into an empty space and frees the old space as a whole | Work depends only on the number of live objects | Needs a second, empty space |
Copying is cheap when few objects survive, because the collector never touches the dead ones. That makes it the right algorithm for new objects, most of which die young, and it is the reason for the generational heap in the next section.
3. Generational Garbage Collection
The weak generational hypothesis says that most objects die young. A web app creates request objects, DTOs and temporary strings for every HTTP call, and almost all of them are unreachable once the response is sent. A few objects, such as caches and connection pools, live as long as the app.
A generational collector splits the heap into a young generation and an old generation and collects them separately. The layout of the heap areas is covered in the Java memory model article.
- A minor (young) collection copies the live objects of eden and one survivor space into the other survivor space, or into the old generation once they are old enough. It runs often and is short.
- A major (old) collection cleans the old generation. In G1, mixed collections clean the young generation and a selection of old regions together.
- A full collection cleans the whole heap in one stop-the-world pause. It is the slow case that tuning tries to avoid.
The following GC log comes from a small program that allocates 100 KB arrays in a loop and keeps every tenth one, running with the Serial collector and a 64 MB heap. Each line shows the heap use before and after the collection, the heap size in parentheses and the pause time.
$ java -XX:+UseSerialGC -Xmx64m -Xlog:gc GcDemo
[0.004s][info][gc] Using Serial
[0.034s][info][gc] GC(0) Pause Young (Allocation Failure) 18M->2M(61M) 1.703ms
[0.038s][info][gc] GC(1) Pause Young (Allocation Failure) 19M->4M(61M) 2.502ms
[0.040s][info][gc] GC(2) Pause Young (Allocation Failure) 21M->6M(61M) 1.432ms
Every young collection frees about 15 MB, and the heap use after the collection grows by 2 MB each time, because the kept arrays move to the old generation. When the old generation is full, the log shows a full collection, as in this run with a heap of 24 MB.
[0.093s][info][gc] GC(42) Pause Full (Allocation Failure) 23M->20M(23M) 5.599ms
4. Garbage Collectors in Java 25
JDK 25 has six garbage collectors. Each one is selected with a -XX flag, and only one runs in a JVM. The table sums them up and links the JEPs that changed ZGC and Shenandoah most recently.
| Collector | Flag | Algorithms | Pauses | Best for |
|---|---|---|---|---|
| Serial | -XX:+UseSerialGC | Copy (young), mark-sweep-compact (old), one thread | Stop-the-world | Small heaps, one CPU, small containers |
| Parallel | -XX:+UseParallelGC | Same as Serial, with many threads | Stop-the-world | Batch jobs that want maximum throughput |
| G1 (default) | -XX:+UseG1GC | Region-based copying, concurrent marking | Short, with a pause-time goal | Most server apps |
| ZGC | -XX:+UseZGC | Concurrent copying, generational (JEP 490) | Under 1 ms | Large heaps with strict latency needs |
| Shenandoah | -XX:+UseShenandoahGC | Concurrent compaction, optional generational mode (JEP 521) | Under a few ms | Low latency on OpenJDK builds that include it |
| Epsilon | -XX:+UseEpsilonGC (experimental) | Allocation only, no reclamation | None until the heap is full | Performance tests and very short-lived jobs |
4.1. Serial Collector
The Serial collector does all its work in one thread and stops the app while it runs. It uses copying in the young generation and mark-sweep-compact in the old generation. Because it needs no coordination between threads, it has the lowest overhead, and it is the right choice for heaps up to about 100 MB and for containers with one CPU.
4.2. Parallel Collector
The Parallel collector uses the same algorithms as Serial but runs them with many threads (-XX:ParallelGCThreads). It was the default from Java 5 to Java 8 on server machines. It gives the highest throughput, which means the largest share of CPU time for the app, but its pauses grow with the heap and can reach a second or more on large heaps. A nightly report job that only cares about the total run time fits it well.
4.3. G1 Collector
G1 (Garbage-First) is the default since Java 9 (JEP 248). It splits the heap into equal-sized regions, between 1 MB and 32 MB by default depending on the heap size, and each region is eden, survivor, old or humongous at any time. G1 marks the old generation concurrently with the app and, during pauses, copies live objects out of the regions with the most garbage first, which gave it its name.
G1 tries to keep pauses below a goal set with -XX:MaxGCPauseMillis, 200 ms by default. It adjusts the size of the young generation to meet the goal, so we set a pause goal and a maximum heap and leave the generation sizes to G1.
$ java -Xmx64m -Xlog:gc GcDemo
[0.004s][info][gc] Using G1
[0.036s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->2M(64M) 2.014ms
[0.046s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 28M->5M(64M) 1.746ms
[0.060s][info][gc] GC(17) Concurrent Mark Cycle
[0.076s][info][gc] GC(17) Concurrent Mark Cycle 15.990ms
4.4. ZGC
ZGC does marking, compaction and reference updates concurrently with the app, so its pauses stay under a millisecond regardless of the heap size, at the cost of some throughput and extra CPU. It became a product feature in JDK 15 (JEP 377), gained a generational mode in JDK 21 (JEP 439) and has been generational only since JDK 24, when JEP 490 removed the non-generational mode. On JDK 25, the old option to switch back is ignored with a warning.
$ java -XX:+UseZGC -XX:-ZGenerational -Xlog:gc -version
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
[0.021s][info][gc] Using The Z Garbage Collector
The ZGC log reports minor (young) and major (whole heap) collections with the heap use in percent.
[0.044s][info][gc] GC(0) Major Collection (Warmup)
[0.052s][info][gc] GC(0) Major Collection (Warmup) 8M(12%)->26M(41%) 0.008s
[0.059s][info][gc] GC(2) Minor Collection (Allocation Rate)
4.5. Shenandoah
Shenandoah also compacts the heap concurrently and keeps pauses short, independent of the heap size. It became a product feature in JDK 15 (JEP 379). Its generational mode was experimental in JDK 24 and is a product feature in JDK 25, so it no longer needs -XX:+UnlockExperimentalVMOptions. The default mode is still single-generation.
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar
Shenandoah is part of OpenJDK builds such as Eclipse Temurin, Amazon Corretto and Red Hat builds, but Oracle JDK builds do not include it. On a JDK without it, the JVM fails at startup with an error about the unknown flag.
4.6. Epsilon
Epsilon (JEP 318, JDK 11) allocates memory and never frees it. When the heap is full, the JVM throws OutOfMemoryError. We use it to measure the allocation rate of code without GC noise, or for a short CLI job that ends before it fills the heap. It is still experimental in JDK 25.
$ java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx64m -Xlog:gc GcDemo
[0.004s][info][gc] Using Epsilon
[0.022s][info][gc] Heap: 65536K reserved, 65536K (100.00%) committed, 3315K (5.06%) used
[0.024s][info][gc] Heap: 65536K reserved, 65536K (100.00%) committed, 6635K (10.13%) used
4.7. What Happened to CMS?
The Concurrent Mark Sweep (CMS) collector was deprecated in JDK 9 and removed in JDK 14 (JEP 363). It never compacted the old generation, so long-running apps ended up with a fragmented heap and slow full collections. On JDK 25, the CMS flags stop the JVM at startup, and the replacement is G1, or ZGC when pauses must stay very short.
$ java -XX:+UseConcMarkSweepGC -jar app.jar
Unrecognized VM option 'UseConcMarkSweepGC'
Error: Could not create the Java Virtual Machine.
5. Choosing a Garbage Collector
Without a GC flag, JDK 25 picks G1 on most machines. On a machine or container where the JVM sees only one CPU or less than about 1792 MB of memory, it picks Serial, which surprises teams that run small containers. The GC log shows the choice in its first line.
$ java -XX:ActiveProcessorCount=1 -Xlog:gc -version
[0.003s][info][gc] Using Serial
We start with the default G1 and switch collectors only when GC logs show a problem that another collector solves. The HotSpot GC tuning guide gives the same advice, and the table maps typical needs to a collector.
| Situation | Collector | Example |
|---|---|---|
| Heap up to about 100 MB, or one CPU | Serial | A CLI tool or a small sidecar service |
| Highest throughput, pauses of a second are fine | Parallel | A nightly batch import |
| Balanced throughput and pauses of tens of milliseconds | G1 | A Spring Boot REST service |
| Pauses under a millisecond on any heap size | ZGC or Shenandoah | A trading API or an ad-bidding service with a 50 ms response budget |
| Measure allocations without GC | Epsilon | A JMH benchmark run |
For example, a payment API on G1 with an 8 GB heap shows pauses of 150 ms in its p99 response times. Lowering -XX:MaxGCPauseMillis helps up to a point. If the pauses stay above the budget, we switch to -XX:+UseZGC, give the JVM some more CPU and heap, and compare the latency again.
6. GC Logging with -Xlog
Since JDK 9, GC logging uses the unified logging framework (JEP 271), configured with one option, -Xlog. The option takes the tags to log, the output and the decorations of each line. The flag -Xlog:gc prints one line per collection, and -Xlog:gc* prints all GC details.
# one line per collection on the console
java -Xlog:gc -jar app.jar
# all GC details in a file, five rotating files of 10 MB
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=10m -jar app.jar
Old JDK 8 GC logging flags either print a deprecation warning or stop the JVM on JDK 25, so old startup scripts need an update.
| JDK 8 flag | JDK 25 behavior | Replacement |
|---|---|---|
| -XX:+PrintGC, -verbose:gc | -verbose:gc works, PrintGC warns | -Xlog:gc |
| -XX:+PrintGCDetails | Warning, uses -Xlog:gc* | -Xlog:gc* |
| -Xloggc:gc.log | Warning, uses -Xlog:gc:gc.log | -Xlog:gc:file=gc.log |
| -XX:+PrintGCTimeStamps, -XX:+PrintGCDateStamps | Unrecognized VM option, JVM does not start | Decorations uptime and time |
| -XX:+PrintTenuringDistribution | Unrecognized VM option, JVM does not start | -Xlog:gc+age=trace |
The flags that control heap size and pauses are the same for all collectors, except where the table says otherwise.
| Flag | Purpose |
|---|---|
| -Xms, -Xmx | Initial and maximum heap size |
| -XX:MaxRAMPercentage=75 | Maximum heap as a share of the container memory |
| -XX:MaxGCPauseMillis=100 | Pause-time goal for G1 (default 200) and Parallel |
| -XX:ParallelGCThreads, -XX:ConcGCThreads | Threads for stop-the-world and concurrent GC work |
| -XX:+HeapDumpOnOutOfMemoryError, -XX:HeapDumpPath | Write a heap dump when the heap runs out |
| -XX:+DisableExplicitGC | Ignore calls to System.gc() |
7. Reducing Garbage Collection Work in Our Code
Most GC problems in real apps come from the code, not from the collector settings. A memory leak in Java is an object that the app no longer needs but still reaches from a GC root, very often through a static collection. The collector cannot free it, and the old generation grows until the app fails with OutOfMemoryError.
For example, a search service keeps the recent searches of each user in a static HashMap and never removes entries. After a week in production, the map holds millions of entries and full collections run every few seconds. A size limit fixes the leak. A WeakHashMap is another option when the keys are objects that other code holds.
public class RecentSearches<K, V> extends LinkedHashMap<K, V> {
private final int maxEntries;
public RecentSearches(int maxEntries) {
super(16, 0.75f, true);
this.maxEntries = maxEntries;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxEntries;
}
}
RecentSearches<String, Integer> searches = new RecentSearches<>(2);
searches.put("apple", 5);
searches.put("banana", 3);
searches.put("cherry", 7);
int size = searches.size(); // 2
boolean hasApple = searches.containsKey("apple"); // false
A few other habits help as well.
- Do not call System.gc(). It is only a request, and with G1 it starts a full collection, so it makes pauses longer. Frameworks that call it can be silenced with -XX:+DisableExplicitGC.
- Do not use finalize() to release resources. Finalization is deprecated for removal since JDK 18 (JEP 421). Close resources with try-with-resources and use a Cleaner as a safety net.
- Do not set local variables to null when a method ends. The JIT compiler knows when a local variable is no longer used, so the extra line changes nothing.
- Remove listeners and callbacks when a component is closed, because a listener list keeps every registered object reachable.
8. Java Garbage Collection FAQs
A first GC log or a JDK upgrade raises questions about the default collector, forced collections and the kinds of GC events.
8.1. Which Garbage Collector Does Java 25 Use by Default?
G1. JDK 25 selects G1 unless the JVM sees only one CPU or less than about 1792 MB of memory, in which case it selects Serial. The first line of -Xlog:gc output, such as Using G1, shows the collector, and -XX:+UseG1GC forces G1 in small containers.
8.2. Can We Force Garbage Collection in Java?
No. System.gc() and Runtime.getRuntime().gc() only ask the JVM to collect, and the JVM may ignore the request, for example with -XX:+DisableExplicitGC. For a heap analysis, jcmd <pid> GC.run triggers a collection from outside, and jcmd <pid> GC.heap_dump writes a heap dump.
8.3. Does Java Use Reference Counting?
No. All HotSpot collectors use tracing from GC roots, as described in section 1. Tracing frees cycles of objects that refer to each other, which reference counting cannot do without an extra cycle detector.
8.4. Is ZGC Better Than G1?
Not for every app. ZGC keeps pauses under a millisecond, but it uses more CPU and memory for the same work, so the throughput is often a bit lower than with G1. For services with strict latency targets, ZGC is the better choice, and for most other server apps, G1 gives a better balance. We measure both with the real workload before switching.
8.5. What Is the Difference Between Minor, Major and Full GC?
A minor GC collects only the young generation and is short. A major GC collects the old generation, often concurrently in G1, ZGC and Shenandoah. A full GC collects the whole heap in one stop-the-world pause, and frequent full collections in the log point to a heap that is too small or a memory leak.
9. Conclusion
Java garbage collection finds live objects by tracing references from GC roots, and reclaims the rest by sweeping, compacting or copying. Generational collectors copy the many short-lived objects out of the young generation and clean the old generation less often.
JDK 25 has Serial, Parallel, G1, ZGC, Shenandoah and Epsilon. G1 is the default, ZGC is generational only, Shenandoah’s generational mode is a product feature, and CMS was removed in JDK 14. We keep the default until the logs from -Xlog:gc show a pause or throughput problem, and we fix leaks in the code before we change any GC flag.
10. References
- HotSpot Virtual Machine Garbage Collection Tuning Guide (JDK 25)
- The java Command (JDK 25)
- JEP 248 Make G1 the Default Garbage Collector
- JEP 363 Remove the Concurrent Mark Sweep (CMS) Garbage Collector
- JEP 490 ZGC Remove the Non-Generational Mode
- JEP 521 Generational Shenandoah
- JEP 318 Epsilon, A No-Op Garbage Collector
- JEP 271 Unified GC Logging
- GarbageCollectorMXBean (Java SE 25 API)
Happy Learning !!
Awesome work, appreciate all the effort and knowledge.
Good, Simple and to the point.
Hi Lokesh,
Can you explain about G1 collector and also further classification of Generational Collector like serial, parallel and cms collector.
Regards,
Himansu
Hi Lokesh,
You had provided in-depth knowledge about GC and it’s functionality.
Awesome insights has been shared by you on this topic.
Thanks Lokesh, It was helpful.