Java Garbage Collection Algorithms and Collectors (Java 25)

Java garbage collection algorithms explained for JDK 25 with mark-sweep, compact and copy, plus G1, ZGC, Shenandoah, Serial, Parallel, flags and GC logs.

Heap cells after marking with live objects A, B, C and D between garbage, shown after mark-sweep with free gaps, after mark-sweep-compact with live objects moved to the start, and after mark-copy with live objects copied to an empty to-space

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.

Heap cells after marking with live objects A, B, C and D between garbage, shown after mark-sweep with free gaps, after mark-sweep-compact with live objects moved to the start, and after mark-copy with live objects copied to an empty to-space
Sweeping leaves gaps, compacting moves live objects together, and copying moves them into a second space
AlgorithmWhat it doesAdvantageCost
Mark-sweepAdds the memory of dead objects to free listsDoes not move objectsFragmentation, a large array may not fit although enough memory is free
Mark-sweep-compactSlides live objects to one end of the spaceNo fragmentation, allocation by bumping a pointerMoving objects and updating every reference to them takes time
Mark-copyCopies live objects into an empty space and frees the old space as a wholeWork depends only on the number of live objectsNeeds 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.

CollectorFlagAlgorithmsPausesBest for
Serial-XX:+UseSerialGCCopy (young), mark-sweep-compact (old), one threadStop-the-worldSmall heaps, one CPU, small containers
Parallel-XX:+UseParallelGCSame as Serial, with many threadsStop-the-worldBatch jobs that want maximum throughput
G1 (default)-XX:+UseG1GCRegion-based copying, concurrent markingShort, with a pause-time goalMost server apps
ZGC-XX:+UseZGCConcurrent copying, generational (JEP 490)Under 1 msLarge heaps with strict latency needs
Shenandoah-XX:+UseShenandoahGCConcurrent compaction, optional generational mode (JEP 521)Under a few msLow latency on OpenJDK builds that include it
Epsilon-XX:+UseEpsilonGC (experimental)Allocation only, no reclamationNone until the heap is fullPerformance 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.

SituationCollectorExample
Heap up to about 100 MB, or one CPUSerialA CLI tool or a small sidecar service
Highest throughput, pauses of a second are fineParallelA nightly batch import
Balanced throughput and pauses of tens of millisecondsG1A Spring Boot REST service
Pauses under a millisecond on any heap sizeZGC or ShenandoahA trading API or an ad-bidding service with a 50 ms response budget
Measure allocations without GCEpsilonA 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 flagJDK 25 behaviorReplacement
-XX:+PrintGC, -verbose:gc-verbose:gc works, PrintGC warns-Xlog:gc
-XX:+PrintGCDetailsWarning, uses -Xlog:gc*-Xlog:gc*
-Xloggc:gc.logWarning, uses -Xlog:gc:gc.log-Xlog:gc:file=gc.log
-XX:+PrintGCTimeStamps, -XX:+PrintGCDateStampsUnrecognized VM option, JVM does not startDecorations uptime and time
-XX:+PrintTenuringDistributionUnrecognized 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.

FlagPurpose
-Xms, -XmxInitial and maximum heap size
-XX:MaxRAMPercentage=75Maximum heap as a share of the container memory
-XX:MaxGCPauseMillis=100Pause-time goal for G1 (default 200) and Parallel
-XX:ParallelGCThreads, -XX:ConcGCThreadsThreads for stop-the-world and concurrent GC work
-XX:+HeapDumpOnOutOfMemoryError, -XX:HeapDumpPathWrite a heap dump when the heap runs out
-XX:+DisableExplicitGCIgnore 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

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Hi Lokesh,
    Can you explain about G1 collector and also further classification of Generational Collector like serial, parallel and cms collector.

    Regards,
    Himansu

  2. Hi Lokesh,

    You had provided in-depth knowledge about GC and it’s functionality.

    Awesome insights has been shared by you on this topic.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.