> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Minor GC vs Full GC vs Major GC in Java, Explained
- URL: https://www.ggorantala.dev/minor-vs-major-vs-full-gc/
- Published: 2026-11-04T11:29:00.000Z
- Updated: 2026-11-06T11:29:44.000Z
- Description: Minor GC vs full GC is clear. 'Major GC' isn't, and it means different things per collector. What each pause collects, what triggers it, and how to read it.
- Author: Gopi Gorantala
- Tags: garbage-collection, jvm, gc-tuning

## I grepped a pile of GC logs for "major" and got nothing

Search "minor GC vs full GC" and page 1 gives you a tidy table. Minor GC cleans the young generation, major GC cleans the old generation, full GC cleans everything. Three words, three scopes, copy it into your interview notes.

Then you open a real GC log from a real JDK 21 process and look for the second row. Here's what I get from G1 under a churn program, and from a similar one on Serial:

```log
$ java -XX:+UseG1GC -Xlog:gc ... | grep -ic major
0
[0.615s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 44M->39M(256M) 6.388ms
[0.833s][info][gc] GC(20) Pause Young (Mixed) (G1 Evacuation Pause) 148M->124M(256M) 7.457ms
[12.541s][info][gc] GC(1384) Pause Full (G1 Compaction Pause) 255M->52M(256M) 17.973ms

$ java -XX:+UseSerialGC -Xlog:gc ...
GC(29) Pause Full (Allocation Failure) 234M->166M(247M) 3.773ms

```

(OpenJDK 21.0.12.1, 2 vCPUs, `-Xmx256m`, lab output from the program below. The `grep` count is from the G1 `mixed` run.)

Zero hits. Not on G1, not on Serial, not on Parallel. The JVM prints `Pause Young` and `Pause Full`, and that's the whole vocabulary for stop-the-world work in the classic collectors. The phrase "major GC" lives somewhere else, and it took me a while to find where. That's most of this article.

## The short version: minor and full are real, major is a label somebody else attached

A *minor* collection is a young-generation collection. The collector only looks at the young area, treats everything in old as live, and copies survivors out. Cheap, frequent, short.

A *full* collection is the one that gives up on being clever and looks at the whole heap, usually with compaction. It's the one that costs you a long pause, and it always has a stated reason in the log line.

"Major GC" has no fixed meaning. Some people use it for "old gen collection" and some for "full GC". The collectors' own logs avoid the term. But the JMX beans, Micrometer's `action` tag and a couple of other tools do use it, and they don't use it the way the table on page 1 does. In G1, for instance, a mixed collection (young plus some old regions) is reported as a *minor* GC by JMX. I'll show that with output below.

What actually helps you at 3 a.m. is a different pair of facts: which pause type it is, and the cause in parentheses. The cause is the part page 1 doesn't mention, and it's where all the diagnosis is.

## One program, a listener on the beans, and a JFR join

Everything here runs on a laptop in a couple of minutes per collector. The program is small on purpose. One `Lab.java`, five modes, and an optional listener that prints what the JVM's own management beans call each collection.

**What you need.** JDK 21\. I used the Ubuntu package, OpenJDK `21.0.12.1+1-1-24.04.4`, x86-64, 2 vCPUs, 8 GB. Any 21 build works; the Shenandoah and ZGC lines at the end need those collectors in your build. Compile with `javac` (not the source launcher), because the metaspace mode loads its own class file.

```java
import java.lang.management.*;
import javax.management.*;
import javax.management.openmbean.CompositeData;
import com.sun.management.GarbageCollectionNotificationInfo;
import java.util.*;

public class Lab {
    static final List<Object> keep = new ArrayList<>();
    static volatile Object sink;
    static int PIN = Integer.getInteger("pin", 90);               // how many chunks stay live

    public static void main(String[] a) throws Exception {
        if (a.length > 1 && a[1].equals("notify")) listen();
        switch (a[0]) {
            case "young" -> { for (int i = 0; i < 3000; i++) sink = new byte[64 * 1024]; }   // pure garbage
            case "fill" -> {                                       // live data builds up in old, churn goes on
                long end = System.currentTimeMillis() + 12000; int n = 0;
                while (System.currentTimeMillis() < end) {
                    if (n++ % 40 == 0 && keep.size() < PIN) keep.add(new byte[Integer.getInteger("chunk", 524288)]);
                    sink = new byte[128 * 1024];
                }
            }
            case "explicit" -> { for (int i = 0; i < 3; i++) { sink = new byte[1 << 20]; System.gc(); } }
            case "meta" -> {                                       // same class bytes, a fresh loader every time
                byte[] b = Lab.class.getResourceAsStream("Dummy.class").readAllBytes();
                List<ClassLoader> ls = new ArrayList<>();
                for (int i = 0; i < 6000; i++) ls.add(new L(b));
            }
            case "mixed" -> {                                      // data that dies after it was promoted
                ArrayDeque<byte[]> ring = new ArrayDeque<>();
                long end = System.currentTimeMillis() + 15000;
                while (System.currentTimeMillis() < end) {
                    ring.add(new byte[100 * 1024]);
                    if (ring.size() > 500) ring.poll();
                    sink = new byte[50 * 1024];
                }
            }
        }
        Thread.sleep(300);
        for (GarbageCollectorMXBean g : ManagementFactory.getGarbageCollectorMXBeans())
            System.out.println("MXBEAN " + g.getName() + " count=" + g.getCollectionCount());
    }
    static class L extends ClassLoader { L(byte[] b) { defineClass("Dummy", b, 0, b.length); } }

    static void listen() {                                         // what the JVM's own beans call each collection
        for (GarbageCollectorMXBean g : ManagementFactory.getGarbageCollectorMXBeans())
            ((NotificationEmitter) g).addNotificationListener((n, hb) -> {
                if (!n.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) return;
                var i = GarbageCollectionNotificationInfo.from((CompositeData) n.getUserData());
                System.out.println("NOTIF name=\"" + i.getGcName() + "\" action=\"" + i.getGcAction() + "\" cause=\"" + i.getGcCause() + "\"");
            }, null, null);
    }
}
class Dummy { }

```

```log
Step 1 — compile, then look at young-only behaviour
$ javac Lab.java
$ java -XX:+UseSerialGC -Xmx256m -Xms256m -Xlog:gc:stdout:none Lab young
Expected: two or three "Pause Young (Allocation Failure) 69M->1M(247M)" lines, no Full

Step 2 — make the old generation hit its limit, per collector
$ java -XX:+UseSerialGC -Xmx256m -Xms256m -Dpin=330 -Xlog:gc:stdout:none Lab fill notify
$ java -XX:+UseParallelGC -Xmx256m -Xms256m -Dpin=330 -Xlog:gc:stdout:none Lab fill notify
$ java -XX:+UseG1GC -Xmx256m -Xms256m -Dpin=2800 -Dchunk=65536 -Xlog:gc:stdout:none Lab fill notify
Expected: Serial "Pause Full (Allocation Failure) 234M->166M(247M)"; Parallel "Pause Full (Ergonomics)"; G1 no Full at all

Step 3 — one trigger at a time
$ java -XX:+UseSerialGC -Xmx256m Lab explicit notify                               # System.gc()
$ java -XX:+UseG1GC -Xmx256m -XX:MetaspaceSize=16m Lab meta notify                 # Metaspace threshold
$ java -XX:+UseG1GC -Xmx256m -Xms256m -XX:InitiatingHeapOccupancyPercent=30 \
       -XX:-G1UseAdaptiveIHOP -Xlog:gc:stdout:none Lab mixed notify               # mixed collections
Expected: the counts in the table further down

```

**Forcing levers.** `-Xmx256m` and a pinned 165 MB are here to make the old generation fill in seconds; production heaps are bigger and fill over hours. `-XX:MetaspaceSize=16m` lowers the first Metaspace GC threshold (the default on 21.0.12.1 is 21 MB; `PrintFlagsFinal` shows 22020096). `-XX:-G1UseAdaptiveIHOP` with a low IHOP makes G1 start concurrent cycles early so you get mixed collections in a short run.

**Confirmation signal.** The `NOTIF` lines. Serial and Parallel `fill` runs print `action="end of major GC"` next to the `Pause Full` lines. The G1 `mixed` run prints no `end of major GC` at all.

**Environment differences.** On a bigger box, Parallel's thread count and G1's region size change the numbers but not the shapes. If your Parallel `fill` run shows far fewer Full GCs than mine, your young gen is larger; shrink `-Xmx` or raise `-Dpin`.

**Cleanup.** Nothing is written to disk. Kill stray `java` processes.

**The first G1 run was a dead end, and I should have seen why.** I ran `fill` on G1 with the default 512 KB chunks and `-Dpin=330`. G1 Full-GC'd 29 times in a row (`254M->253M(256M)`) and then threw `OutOfMemoryError`. I spent a while thinking G1 handles a nearly-full old generation worse than Serial. It doesn't. With a 256 MB heap, G1's region size is 1 MB, so a 512 KB array is exactly half a region and counts as humongous, and the "old gen" was full of oversized objects G1 couldn't compact away. I wrote that mechanism up in [the humongous objects article](https://ggorantala.dev/g1-humongous-objects/?ref=ggorantala.dev). Switch to 64 KB chunks and G1 sails through with zero Full GCs. The lesson for this article is that "the same live data" isn't the same on every collector.

## What each kind of pause is allowed to touch

```log
 Serial / Parallel                     G1
 ┌───────────────┬──────────────┐      ┌─ regions, each tagged Eden / Survivor / Old / Humongous ─┐
 │ Eden  │ S0 S1 │   Old        │      │  Young pause:  Eden + Survivor regions only              │
 └───────────────┴──────────────┘      │  Mixed pause:  young + a few Old regions picked          │
  Young pause: left box only           │                by the concurrent cycle                   │
  Full pause : the whole heap          │  Full pause:   everything, single-threaded fallback      │
               (+ Metaspace unloading) └──────────────────────────────────────────────────────────┘

```

A young (minor) collection never walks old. It finds old-to-young references through the card table, which I covered in [the remembered sets article](https://ggorantala.dev/card-tables-remembered-sets/?ref=ggorantala.dev), and copies live young objects into survivor space or promotes them. Pause time scales with live young data and dirty cards, not with heap size. That's why one is cheap.

A G1 *mixed* collection is a young collection with extra old regions added to the collection set. The decision about which ones comes out of the concurrent marking cycle ([G1 GC explained](https://ggorantala.dev/g1-gc-explained/?ref=ggorantala.dev) has the details, and [SATB marking](https://ggorantala.dev/satb-tri-color-marking/?ref=ggorantala.dev) has the part that makes marking concurrent). It's incremental old collection, but it's still logged as `Pause Young (Mixed)`.

A full collection is a fallback. In Serial and Parallel it's what happens when old can't take any more promotion. In G1 it's the thing that is supposed to almost never happen; it's what the collector does when concurrent work didn't keep up or an allocation can't be satisfied.

## Where the names come from, and what moved in JDK 20

I didn't read HotSpot source for this one. The log strings and the bean names I could verify by running things; the history I took from JBS and JEPs. The three layers worth separating:

1. **The `-Xlog:gc` pause line.** `Pause Young (...)`, `Pause Full (...)`, plus `Pause Remark` and `Pause Cleanup` for G1's concurrent cycle. The cause is always in the parentheses. That's the layer to trust.
2. **JMX beans.** `GarbageCollectorMXBean` names and the `gcAction` string in the notification. This is where "minor" and "major" actually appear.
3. **JFR events.** `jdk.GarbageCollection` with a `name` field, plus `jdk.YoungGarbageCollection` and `jdk.OldGarbageCollection`.

Here's what the `mixed` run gave me across all three. The first column is the pause line, the others are what the JVM called it:

```log
log line (G1, 1 run, IHOP fixed at 30)       JFR name   JMX bean / action
Pause Young (Normal)           302 pauses     G1New      G1 Young Generation / end of minor GC
Pause Young (Concurrent Start) 289            G1New      G1 Young Generation / end of minor GC
Pause Young (Prepare Mixed)    289            G1New      G1 Young Generation / end of minor GC
Pause Young (Mixed)            288            G1New      G1 Young Generation / end of minor GC
Pause Remark                   289            G1Old      G1 Concurrent GC    / end of concurrent GC pause
Pause Cleanup                  289            G1Old      G1 Concurrent GC    / end of concurrent GC pause
(none in run 1, one in run 2)                 G1Full     G1 Old Generation   / end of major GC

```

(Counts are from the first run. The JFR column came from joining `gcId` in a recording to `GC(n)` in the log on a second identical run: same mapping, counts within a few, and that second run had one `G1Full` where the first had none.)

Two surprises sit in that table. "G1Old" in JFR is *not* Full GC and *not* mixed. It's remark and cleanup, the two stop-the-world pauses of the concurrent cycle. If you've been counting `G1Old` events as old-gen collections, you've been counting concurrent cycles, which is a perfectly useful number but isn't the one you think. And the JMX bean called "G1 Old Generation" stays at zero until a real Full GC happens. Before JDK 20, remark and cleanup time wasn't counted in any bean; [JDK-8297247](https://bugs.openjdk.org/browse/JDK-8297247?ref=ggorantala.dev) added the "G1 Concurrent GC" bean for them. In JDK 17 you won't see that third bean.

Serial reports `Copy` for young and `MarkSweepCompact` for full; Parallel reports `PS Scavenge` and `PS MarkSweep`. Same minor/major strings. JFR calls them `DefNew`/`SerialOld` (Serial: 1,288 and 20 in one `fill` run).

```log
JDK 8    Parallel default; CMS available; "major GC" folklore is Parallel/CMS folklore
JDK 9    G1 default; unified logging; the pause lines above start to look like this
JDK 14   CMS removed. A "major" collection no longer has a collector of its own
JDK 20   "G1 Concurrent GC" bean added (JDK-8297247)
JDK 21   Generational ZGC (opt-in); logs "Minor Collection" and "Major Collection"
JDK 23   Generational ZGC the default (JEP 474)
JDK 24   Non-generational ZGC removed (JEP 490)

```

And this is the one place "major" is a first-class word. On 21 with `-XX:+UseZGC -XX:+ZGenerational`, ZGC's log says it itself:

```log
GC(0) Major Collection (Warmup)
GC(98) Minor Collection (Allocation Rate)
MXBEAN ZGC Minor Cycles ...   MXBEAN ZGC Major Cycles ...

```

Here "major" means young and old collected together in one cycle, and "minor" means young only. It's a perfectly clean definition. It isn't the one G1 or Serial use, and I'd bet most of the "major GC" confusion on the internet comes from carrying ZGC's or CMS's meaning onto a collector that has no such concept. I ran generational ZGC on 21 only. The JEP numbers above come from the JEP pages and a search; I didn't run 23 or 24.

## How people misread the log, and the flags that come with it

**"Pause Full means the heap is full."** It means the collector escalated. The cause in parentheses says why, and half the time the heap isn't full at all. In my `explicit` runs, here's the same `System.gc()` call on four setups:

```log
Serial     Pause Full (System.gc()) 8M->2M(247M) 3.576ms
Parallel   Pause Young (System.gc()) 7M->2M(245M) 1.501ms
           Pause Full  (System.gc()) 2M->2M(245M) 4.113ms
G1         Pause Full (System.gc()) 4M->3M(256M) 5.651ms
G1 + -XX:+ExplicitGCInvokesConcurrent
           Pause Young (Concurrent Start) (System.gc()) 4M->3M(256M) 1.448ms
           Pause Remark 3M->3M(256M) 0.438ms
           Pause Cleanup 3M->3M(256M) 0.068ms

```

Look at Parallel. One `System.gc()` call, *two* stop-the-world pauses, a young one and then a full one. `-XX:ScavengeBeforeFullGC` is `false` on 21.0.12.1 (I checked `PrintFlagsFinal`), so that flag isn't the explanation. I haven't found why Parallel does it, and I'd like to. The same pair shows up for `Metadata GC Threshold` on Parallel, so it's systematic. If you count Full GCs and pauses per hour and you're on Parallel, remember each of those has a young twin.

**"Full GC count is a memory-pressure gauge."** Same program, same heap, same 165 MB pinned: Serial did 8 Full GCs in 12 seconds. Parallel did 769, each `Pause Full (Ergonomics)`, 231M->166M(246M). Parallel's ergonomics decided old was out of room and kept going back to it. I can't tell you why the two collectors diverged by two orders of magnitude on identical live data; adaptive sizing is my guess and I haven't read `adaptiveSizePolicy.cpp` for this. What I can say is that the count depends on the collector's policy at least as much as on your leak.

**"Metaspace full means Full GC."** Not on G1\. With `-XX:MetaspaceSize=16m`, Serial gave `Pause Full (Metadata GC Threshold) 10M->3M 12.7 ms`. G1 gave `Pause Young (Concurrent Start) (Metadata GC Threshold)`, no Full, with the concurrent cycle doing the class unloading. The threshold is a trigger, and what it triggers depends on the collector. There's a whole Metaspace piece on its way in [the Metaspace article](https://ggorantala.dev/metaspace-vs-permgen/?ref=ggorantala.dev).

**"A G1 mixed GC is a major GC."** The JMX action string says minor. A dashboard that charts "major GC count" from Micrometer's action tag will show G1 mixed activity as minor and stay at zero until the collector actually falls over. (I didn't read Micrometer's source; I'm assuming `jvm.gc.pause`'s `action` tag carries the notification string. Check your own `/actuator/prometheus` output.)

**The flags everyone copies.**

`-XX:+DisableExplicitGC` stops `System.gc()` from doing anything. It's a product flag, default `false`. It also stops it from doing the useful things: RMI's distributed GC calls it on purpose, and direct buffers rely on a GC running to free native memory (that story is in [the native memory article](https://ggorantala.dev/jvm-native-memory-tracking/?ref=ggorantala.dev)). `-XX:+ExplicitGCInvokesConcurrent` (also `false` by default on 21) is the safer way to defang an unwanted `System.gc()` on G1: the output above shows it turning a Full into a young pause plus a concurrent cycle. Note that Shenandoah ergonomically turns it on by itself; the log said so at startup.

`-XX:-UseGCOverheadLimit` and "give it more `-Xmx`" are both ways of hiding a high Full GC rate. I tested the first on all four classic collectors in [the premature promotion article](https://ggorantala.dev/premature-promotion-gc-overhead/?ref=ggorantala.dev); it's a crawl, not a fix.

## Triggers you can remove, and what each removal costs

A Full GC is a symptom with a cause printed in the line. Work from the cause:

| Cause in the log      | Collector       | Where it comes from                                     | Fix order                                                                                                      |
| --------------------- | --------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| System.gc()           | all             | Someone called it (RMI, a library, a health endpoint)   | Find the caller (JFR jdk.SystemGC events carry a stack trace); on G1 then consider ExplicitGCInvokesConcurrent |
| Allocation Failure    | Serial (Full)   | Old couldn't take promotion                             | Live set too big, or premature promotion: allocate less, then size                                             |
| Ergonomics            | Parallel (Full) | Policy decided old is out of room                       | Same; plus check whether the live set really grew                                                              |
| G1 Compaction Pause   | G1              | Evacuation or allocation failed, concurrent work behind | Humongous objects, too little headroom, IHOP too late                                                          |
| Metadata GC Threshold | all             | Class metadata grew past MetaspaceSize                  | Raise the initial threshold, or find the class leak                                                            |

One rule I'd keep: don't touch collector flags until you know which of those rows you're in. And if the cause is `Allocation Failure` on Serial or `Ergonomics` on Parallel with the post-GC floor climbing, that's [a leak](https://ggorantala.dev/java-memory-leak-heap-dump/?ref=ggorantala.dev) or [premature promotion](https://ggorantala.dev/premature-promotion-gc-overhead/?ref=ggorantala.dev), and neither is fixed by a flag.

If you want fewer pauses of the "I wasn't asked" kind, [\-XX:MetaspaceSize](https://ggorantala.dev/metaspace-vs-permgen/?ref=ggorantala.dev) is the one that costs nothing: set it near your steady-state size, and the early threshold GCs while the app warms up go away. It costs a bit of reserved address space and it can delay the first unload of a leaking class loader. That's the whole price.

## What to alert on, and the labels that will fool your dashboard

Alert on pause *type* plus cause, not on "major". Concretely:

- Count `Pause Full` per hour, grouped by the cause in parentheses. On G1, anything above zero in steady state is worth a ticket. On Parallel or Serial, watch the *post-Full* heap floor instead of the count, because the count depends on the policy.
- On G1, graph the "G1 Concurrent GC" bean separately from "G1 Young Generation". They mean different things and mixing them hides the cycle frequency.
- If you use JFR, filter `jdk.GarbageCollection` by `name`, and don't read `G1Old` as "old collection". Use `jdk.OldGarbageCollection` only if you know it fires for remark and cleanup on G1.
- Keep `-Xlog:gc` on with rotation. It's cheap at `info`, and the cause strings are the only record of why the pause happened; see [the -Xlog field guide](https://ggorantala.dev/java-gc-logs-xlog/?ref=ggorantala.dev).

**Staying safe across versions.** JDK 17 has no "G1 Concurrent GC" bean; remark and cleanup time vanish from the beans. JDK 21 adds it. JDK 21 generational ZGC renames its beans to `ZGC Minor/Major Cycles` and `Pauses`, and non-generational ZGC (`ZGC Cycles`) is on its way out per JEP 490\. When you move LTS to LTS, re-check every dashboard query that matches on a bean name or an `action` string. I ran only 21.0.12.1; the JDK 17 and 25 claims here come from JBS and the JEPs and not from my laptop.

I never got a clean answer on two things: why Parallel adds a young pause to `System.gc()`, and why it Full-GCs 769 times where Serial managed 8\. Everything else was settled by running it. Writing it down is the cheap part; getting to the bottom of it took well over a day, and you don't have to repeat that.

## Pin this to the wall

- Minor is a young collection. Full is the escalation. "Major" has no fixed meaning; it's a JMX label, and in generational ZGC it means "young and old together".
- A G1 Mixed pause is logged and counted as *minor*. G1's `Remark` and `Cleanup` pauses are what JFR calls `G1Old`. Only `Pause Full` is a real full collection.
- Read the cause in parentheses before you read anything else. `System.gc()`, `Allocation Failure`, `Ergonomics`, `Metadata GC Threshold` and `G1 Compaction Pause` have five different fixes.
- On Parallel, one `System.gc()` or one Metaspace-threshold GC is a young pause plus a full pause. Count accordingly.

## Short answers to what people search

**What's the difference between minor GC and full GC in Java?**  
A minor GC collects only the young generation and treats old as live. A full GC collects the whole heap, usually with compaction, and costs far more. In my JDK 21 lab, Serial's young pauses ran 0.2 to 7 ms and its Full pauses 3.4 to 19 ms on a 256 MB heap.

**Is there a "major GC" in G1?**  
Not as a pause type. G1 logs `Pause Young (Mixed)` for incremental old-gen collection, JMX calls that minor, and `Pause Full` is the only whole-heap collection. JFR's `G1Old` name covers remark and cleanup.

**What triggers a full GC in Java?**  
The cause is printed in the log line: `System.gc()`, `Allocation Failure`, `Ergonomics`, `Metadata GC Threshold`, or `G1 Compaction Pause`, depending on the collector. See [long GC pauses](https://ggorantala.dev/long-gc-pauses-java/?ref=ggorantala.dev) for triage when the pause is slow.

**Is a minor GC stop-the-world?**  
Yes, in Serial, Parallel and G1\. The pause is short because it only copies live young data, but it still needs a safepoint; [the safepoints article](https://ggorantala.dev/jvm-safepoints-stop-the-world/?ref=ggorantala.dev) explains why that part can dominate.