GC Fundamentals: Mark-Compact, Copying, and Mark-Sweep
Understanding the three core garbage collection algorithms - Mark-Sweep, Mark-Compact, and Copying - their mechanics, trade-offs, and when to use each.
GC Fundamentals: Mark-Compact, Copying, and Mark-Sweep
Garbage collection sounds like magic until you hit a 10-second pause and suddenly you want to know exactly what the JVM is doing under the hood. The three foundational GC algorithms - Mark-Sweep, Mark-Compact, and Copying - each take a different approach to the same problem: finding dead objects and reclaiming their memory. Once these click, every garbage collector in the JVM starts making sense - including why your application just froze for 10 seconds.
Introduction
Garbage collection sounds like magic until you hit a 10-second pause and suddenly you need to know exactly what the JVM is doing under the hood. The three foundational GC algorithms — Mark-Sweep, Mark-Compact, and Copying — each take a different approach to solving the same problem: finding dead objects and reclaiming their memory without leaving dangling pointers or fragmenting the heap. Once you understand how each algorithm works and what trade-offs it makes, every garbage collector in the JVM starts making sense — including why your application just froze for 10 seconds.
The JVM uses different algorithms for different heap generations based on object lifetimes. Most objects die young — allocated and collected before they ever need promoting to old gen. A fast copying collector on the young generation handles the majority of garbage cheaply. Objects that survive long enough get promoted to old generation, which uses mark-compact collection because those objects stick around and need to be collected infrequently. This is the generational hypothesis in practice, and it is why understanding the individual algorithms matters.
This guide walks through Mark-Sweep, Mark-Compact, and Copying in detail: their phases, their performance characteristics, and when each algorithm is the right choice. You will learn why fragmentation is the key tradeoff — Mark-Sweep is fast but fragmented, Mark-Compact eliminates fragmentation but has the longest pauses, and Copying has the fastest allocation but wastes 50% of heap space. Understanding these fundamentals gives you the vocabulary and mental model for reading GC logs, choosing a GC algorithm, and diagnosing GC-related performance problems.
When to Use This Knowledge
Use when:
- Debugging GC pause issues or throughput problems
- Choosing or tuning a garbage collector for a specific workload
- Reading GC logs and trying to make sense of the phases
- Understanding why certain objects are collected while others are not
Do not use when:
- Writing application code that does not deal with memory directly
- Using a managed runtime that abstracts GC entirely (some languages do this well)
When NOT to Use
Understanding GC algorithm internals is overkill for most developers. If you are building web applications, REST APIs, or business logic that does not involve custom memory management, Mark-Compact versus Copying details rarely matter. Most GC languages like Python, JavaScript, Go, or Java with a properly tuned collector handle this for you.
Managed cloud runtimes and serverless environments abstract GC entirely. AWS Lambda, Google Cloud Functions, and similar platforms manage memory for you and do not expose JVM tuning flags. In these environments, knowing that Mark-Sweep leaves fragmentation behind does not help you write better code. The platform configures GC, and you cannot change it.
Serverless functions with tight memory limits also rarely benefit from GC knowledge. The platform may kill your function before it accumulates enough garbage to trigger a significant pause. Algorithm trade-offs do not change the constraints you are operating under.
How Garbage Collection Works: The Big Picture
Every GC algorithm has to solve three problems:
- Find live objects - trace from GC roots (stack, static fields, JNI references)
- Reclaim dead objects - free memory that is no longer reachable
- Keep memory usable - avoid fragmentation so future allocations succeed
Different algorithms solve these in different ways, with different trade-offs around speed, memory overhead, and pause times.
Mark-Sweep Algorithm
Mark-Sweep is the simplest algorithm. It operates in two phases:
graph LR
A["Phase 1: Mark"] --> B["Phase 2: Sweep"]
A --> C["Traverse from GC roots"]
A --> D["Mark all reachable objects"]
B --> E["Sweep unreachable objects"]
B --> F["Add to free list"]
Phase 1: Mark
The collector starts from GC roots (thread stacks, static fields, JNI handles) and traverses the object graph. Every object it reaches gets marked. At the end of this phase, every reachable object has a mark bit set.
Phase 2: Sweep
The collector scans the entire heap. Any object without a mark bit is unreachable and its memory gets reclaimed. The free memory is added to a free list (or similar structure) for future allocations.
Key Characteristics
| Aspect | Behavior |
|---|---|
| Memory overhead | Low - just one mark bit per object |
| Pause time | Long - must scan entire heap during mark AND sweep |
| Fragmentation | High - free memory is scattered |
| Allocation speed | Slower over time - free list lookup can be costly |
| Cache behavior | Poor - objects far apart in memory |
Why Mark-Sweep Alone Is Rarely Used in Production
Mark-Sweep leaves the heap fragmented. After many cycles, free memory exists as scattered islands. Allocating a large object may fail even though total free memory is plenty, just because nothing is big enough to hold it contiguously.
The JVM uses Mark-Sweep internally (as part of Mark-Compact), but no major GC runs pure Mark-Sweep on the old generation.
Mark-Compact Algorithm
Mark-Compact extends Mark-Sweep with a compaction phase that eliminates fragmentation.
graph LR
A["Mark"] --> B["Sweep"] --> C["Compact"]
C --> D["Slide live objects to one end"]
C --> E["Update all object references"]
C --> F["Single contiguous free block"]
Phase 1: Mark
Same as Mark-Sweep - trace reachable objects and mark them.
Phase 2: Sweep
Same as Mark-Sweep - identify unreachable objects.
Phase 3: Compact
This is where Mark-Compact differs from Mark-Sweep. The collector slides all live objects toward one end of the heap, eliminating gaps. All references to moved objects are updated. The result is a single contiguous block of free memory at the far end.
Compact Phase Details
Compacting is expensive because:
- Every live object’s location changes
- Every reference to those objects must be updated (including stack frames, other object fields, and static fields)
- The collector must update the object table that maps old addresses to new addresses
graph TB
subgraph Before["Before Compact"]
O1["Object A"]
O2["Object B"]
F1["Free"]
O3["Object C"]
F2["Free"]
F3["Free"]
end
subgraph After["After Compact"]
O1A["Object A"]
O2A["Object B"]
O3A["Object C"]
F4["Free (contiguous)"]
end
Before --> After
Key Characteristics
| Aspect | Behavior |
|---|---|
| Memory overhead | Low - same as Mark-Sweep |
| Pause time | Longest - adds compaction on top of mark and sweep |
| Fragmentation | None - heap is defragmented |
| Allocation speed | Fast after compact - big contiguous block |
| Throughput | Lower than Copying due to compaction cost |
Copying Algorithm
Copying (also called evacuation or scavenge) avoids fragmentation entirely by using two equal-sized spaces called from-space and to-space.
graph TB
subgraph Semispace["Semispace Copying"]
direction LR
From["From-Space"] -->|"Live objects copied"| To["To-Space"]
To -->|"Roles swap"| From2["From-Space (next GC)"]
end
How It Works
- Objects start in from-space
- When from-space is full (or on every minor GC), the collector copies all reachable objects to to-space, one by one
- Objects are copied densely, filling to-space with no gaps
- The roles of from-space and to-space swap for the next collection
Hot Spots in Copying
Copying collectors optimize by tracking where the next object should go (a “free pointer”). New allocations simply advance this pointer. This makes allocation essentially free - no free list lookup, no fragmentation.
// Allocation with semispace copying - simplified
Object allocate(int size) {
if (freePointer + size > toSpaceEnd) {
triggerGC(); // not enough room
}
Object result = freePointer;
freePointer += size;
return result;
}
Key Characteristics
| Aspect | Behavior |
|---|---|
| Memory overhead | High - needs two equal spaces (only 50% usable) |
| Pause time | Short - copying is faster than scanning + compacting |
| Fragmentation | None |
| Allocation speed | Very fast - bump-the-pointer allocation |
| Throughput | High for young gen where most objects die |
| Old gen suitability | Poor - copying old objects is expensive |
The Generational Twist
Copying collectors shine when paired with generations. Most objects die young - allocated and collected before they ever need promoting. A fast copying collector on the young generation handles the majority of garbage cheaply. Objects that survive long enough get promoted to old generation, which uses Mark-Compact.
Trade-off Table
| Aspect | Mark-Sweep | Mark-Compact | Copying |
|---|---|---|---|
| Memory overhead | Low | Low | High (50%) |
| Pause time | Moderate | Longest | Shortest |
| Throughput | Moderate | Lower | Highest |
| Fragmentation | High | None | None |
| Allocation speed | Moderate | Fast (after compact) | Very fast |
| Old gen suitability | Some | Best | Poor |
| Young gen suitability | Some | Some | Best |
| Complexity | Low | High | Moderate |
Production Failure Scenarios
1. Premature Old Generation Fill
Symptom: Frequent Full GC events even with plenty of young generation space.
Cause: Objects being promoted too quickly (premature promotion) fill the old generation faster than Full GC can reclaim space.
Solution: Increase Survivor spaces with -XX:SurvivorRatio or reduce the tenuring threshold. Monitor promotion rates with jstat -gc.
2. Fragmentation-Induced Allocation Failure
Symptom: OutOfMemoryError: Heap Space with heap dump showing significant free memory.
Cause: Pure Mark-Sweep or poor compaction strategy leaving fragmented free memory.
Solution: Switch to a collector with better compaction (G1, ZGC, Shenandoah). Increase heap if possible.
3. Long GC Pauses
Symptom: Application freezes for seconds during Old Generation collection.
Cause: Mark-Compact running on a large heap with many live objects. Compaction requires updating references everywhere.
Solution: Use a low-pause collector (G1, ZGC, Shenandoah) or reduce heap size and optimize object retention.
Implementation Snippets
Reading GC Logs
Enable verbose GC logging to see which algorithm phases are running:
-Xlog:gc*:file=gc.log
A typical Serial GC log entry (old generation collection) shows mark, sweep, and compaction phases:
[Full GC (Allocation Failure) 16384K->8192K(16384K), 0.1234567 secs]
The Allocation Failure means the old generation could not accommodate a promotion, triggering a Full GC.
Forcing a GC with jcmd
# Force a full GC and print result
jcmd <pid> GC.run
Checking GC Algorithm in Use
import java.lang.management.*;
import java.util.*;
public class GCInfo {
public static void main(String[] args) {
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gc : gcBeans) {
System.out.println("GC Name: " + gc.getName());
System.out.println(" Collection count: " + gc.getCollectionCount());
System.out.println(" Collection time: " + gc.getCollectionTime() + "ms");
System.out.println(" Memory pools: " + Arrays.toString(gc.getMemoryPoolNames()));
}
}
}
Observability Checklist
- Enable GC logging with
-Xlog:gc*:file=gc.log - Use
jstat -gc <pid>to monitor heap usage per space - Track
jstat -gccolumnsFGCT(Full GC time) andGCT(Total GC time) - Generate heap dump before and after GC:
jmap -dump:file=before.hprof <pid> - Use Java Flight Recorder to capture GC events with
-XX:StartFlightRecording - Monitor promotion rate from Young to Old with generational sizing tools
- Watch for compaction overhead by comparing
GCC(GC count) vsFGCC(Full GC count)
Security Notes
- GC Logging can reveal application behavior patterns - protect gc.log files
- Heap Dumps from production can expose sensitive data - sanitize before sharing
- JMX Access to GC MBeans should be restricted to authorized monitoring systems
- Memory Access via JVMTI can inspect/modify heap - secure agent attachments
Common Pitfalls / Anti-Patterns
| Pitfall | What happens | Fix |
|---|---|---|
| Assuming Full GC means compaction | Serial/Parallel GC may do mark-sweep without compacting | Use -XX:+UseG1GC or -XX:+UseZGC |
| Ignoring young gen sizing | Frequent minor GC or premature promotion | Tune -Xmn or -XX:NewRatio |
| Using Copying on old gen | Objects copied unnecessarily, high memory churn | Use Mark-Compact based GC for old gen |
| Setting heap too small | Frequent GC of any type | Profile and size correctly |
| Assuming GC solves memory leaks | Leaked objects accumulate in old gen | Use heap dumps and profiling to find leaks |
Quick Recap Checklist
- Mark-Sweep = Mark + Sweep (fast but fragmented)
- Mark-Compact = Mark + Sweep + Compact (no fragmentation but longest pause)
- Copying = Copy between two spaces (fast allocation, 50% memory overhead)
- Copying works best on young gen where most objects die
- Mark-Compact works best on old gen where objects stick around
- Fragmentation causes allocation failures even with free memory available
- GC algorithm choice directly impacts pause times and throughput
- No single algorithm is best for all generations - JVM uses different ones for each
Interview Questions
Mark, Sweep, and Compact. Mark traces reachable objects from GC roots. Sweep identifies what is not reachable. Compact slides everything together, updating every reference along the way. Compaction is what separates it from Mark-Sweep and eliminates fragmentation - but it is the most expensive phase.
Object lifetimes differ between generations. Most objects die before they leave the young generation, so copying works well there - fast, no fragmentation, and the 50% memory overhead is fine when most objects get reclaimed. Old generation objects stick around, so copying them would just burn CPU and memory for no gain. Mark-Compact handles that space better: no overhead, no fragmentation, longer pauses but fewer of them.
Mark-Sweep leaves free memory as scattered islands. After enough cycles, you could have 500MB free spread across dozens of tiny blocks. A 100MB allocation fails not because memory is exhausted, but because nothing is big enough to hold it contiguously. Compacting collectors solve this by packing live objects together.
Bump-the-pointer. A free pointer tracks the end of used space. Allocating size N means checking if N bytes remain, writing the object there, and advancing the pointer. No searching, no metadata. Free-list allocators must hunt through a list of blocks to find one that fits - which gets slow under contention.
More throughput usually means longer individual pauses. Shorter pauses usually mean less work done per collection, which adds up. Copying collectors give you high throughput with short pauses but waste half your memory. Mark-Compact is efficient with memory but can freeze your app during compaction. G1 and ZGC try to split the difference with incremental work that runs alongside your application.
The generational hypothesis (also called the weak generational hypothesis) states that most objects die young. This observation justifies separating heap into young and old generations - most objects die in young gen where a fast copying collector handles them cheaply, while survivors age into old gen where they persist with less frequent, more thorough collection. Without this hypothesis, a single unified GC approach would be slower and less efficient.
GC roots are objects that are guaranteed to be reachable without going through any other object. They include: thread stack frames (local variables), static fields from loaded classes, JNI references, and internal JVM structures. The marking phase starts from these roots and traverses the object graph transitively - every object reached is marked live. Everything else is garbage. GC roots are never collected and serve as the starting point for all GC algorithms.
A safe point is a point during program execution where all heap references are known and stable - the GC can run safely. JIT compiled code inserts safe point checks at loop back edges and method returns. Stop-the-world (STW) means all application threads are paused (at safe points) while GC runs. Not all GC work requires STW - concurrent collectors like G1 and ZGC do most work while threads run, stopping only briefly for specific phases like root scanning.
Semispace copying uses two equal halves of memory (from-space and to-space). At any time, only one half is active for allocation. When GC runs, live objects copy to the other half, leaving the filled half empty. This means half your heap is always idle. The trade-off is acceptable in young generation because most objects die there - copying is fast and fragmentation-free, which beats the fragmentation and allocation overhead of Mark-Sweep for short-lived objects.
Floating garbage is objects that become unreachable during concurrent marking but are not reclaimed until the next GC cycle. This happens because concurrent marking cannot stop the application completely - objects modified during marking may become unreachable but are not caught. CMS and G1 both produce floating garbage; ZGC and Shenandoah produce less because they use more sophisticated concurrent techniques. Floating garbage is a fundamental trade-off of doing GC work concurrently with the application.
The card table is a data structure (typically 512 bytes per card) that tracks which memory regions might contain references from old generation to young generation. When old gen references young gen change, the card is marked "dirty." This lets minor GC scan only cards with potential cross-generational references rather than the entire old generation. This optimization dramatically reduces the scanning overhead for minor GC.
Adaptive sizing (-XX:+UseAdaptiveSizePolicy, enabled by default with G1 and Parallel GC) monitors allocation rates and survival rates, then automatically adjusts heap region sizes to match workload patterns. It can resize Eden, Survivor, and old generation spaces dynamically. The risk in production is that resizing events can trigger GC pauses you cannot predict. For deterministic performance, disable adaptive sizing and set explicit ratios.
Minor GC (also called young GC) collects the young generation only - Eden and Survivor spaces. It is fast (typically milliseconds) because young gen is small and most objects die there. Full GC collects the entire heap including old generation. It is much slower (can be seconds on large heaps) and is the pause time culprit in most GC-related performance issues. Full GC can be triggered by old gen filling up, explicit System.gc(), or allocation failures during minor GC.
Premature promotion. If Survivor spaces are too small, objects get promoted to old gen before they should. Even though the total heap usage is low, old gen fills with short-lived objects that should still be in young gen. This triggers Full GC when old gen eventually fills. The fix is usually to increase Survivor spaces via -XX:SurvivorRatio or -XX:MaxTenuringThreshold tuning.
Each G1 region has a Remembered Set (RS) that tracks which other regions have references to objects in this region. This lets G1 collect regions independently without scanning the entire heap. During GC, G1 uses the remembered sets to update references to evacuated objects. Remembered sets add memory overhead (roughly 10% of heap) but enable G1's incremental compaction and parallel collection.
Mark traces reachable objects from GC roots and takes time proportional to live objects. Sweep reclaims unmarked objects by scanning the heap and adding them to a free list, taking time proportional to heap size. Compact slides live objects together and updates all references, taking time proportional to both heap size and live object count. Compact is the most expensive because it must move every live object, update every reference to moved objects (including stack frames and other heap objects), andtouch the most memory. This is why compaction is the primary cause of long GC pauses in Mark-Compact collectors.
The generational hypothesis states most objects die young. Copying collectors work by evacuating live objects from one space to another, discarding the entire source space at once. In young gen where most objects die before each minor GC, only a small fraction of objects survive and need to be copied. This makes copying very fast despite the 50% memory overhead. Because most objects die young, the copying work per minor GC is small compared to marking and compacting the entire old generation. Copying would be prohibitively expensive in old gen where most objects survive each collection.
A stop-the-world pause halts all application threads while GC runs. The pause duration scales with the number of live objects because all live objects must be visited during marking, potentially moved during compaction, and all references updated. Larger heaps contain more live objects and require more memory to process. Under memory pressure, compaction must touch a larger portion of the heap. This is why pause times grow with heap size for stop-the-world collectors. Concurrent collectors like G1, ZGC, and Shenandoah reduce pause time by doing work concurrently with the application.
Full GC is triggered when old generation cannot accommodate a promotion from young gen, when System.gc() is called, when Metaspace is exhausted, or when allocation fails during minor GC. It is more expensive because it collects the entire heap including old generation, which typically holds the most live objects. Full GC uses Mark-Compact which is the most expensive GC algorithm. It also stopsすべての application threads, causing latency spikes. Frequent Full GC indicates promotion failure or memory leaks in old gen.
GC roots are objects reachable without going through any other object: thread stack frames (local variables and operand stack references), static fields from loaded classes, JNI handles, and internal JVM structures. Every GC algorithm starts marking from GC roots. The number and nature of GC roots affects pause time because ALL roots must be scanned at the start of every stop-the-world pause. Thread stacks are particularly expensive because they must be scanned completely if not at a safepoint, even if most stack slots contain only primitive types that are not heap references.
Further Reading
- HotSpot Virtual Machine Garbage Collection Tuning Guide - Oracle’s official comprehensive GC tuning guide
- Java GC Handbook - Preventing GC Pauses - Practical strategies for minimizing GC pauses in production
- Understanding Java Garbage Collection Logging - How to read and interpret GC logs effectively
- Generational GC and Object Aging - Deep dive into how objects age across generations
Conclusion
Mark-Sweep, Mark-Compact, and Copying are the three foundational GC algorithms, each solving the same problem (finding dead objects, reclaiming memory) with different trade-offs: Mark-Sweep is fast but fragmented, Mark-Compact eliminates fragmentation but has longest pauses, Copying is fastest for young gen but wastes 50% memory. The JVM uses Copying for young generation (where most objects die) and Mark-Compact for old generation (where objects stick around).
Category
Related Posts
CMS and G1 Collectors: Low-Latency Garbage Collection
How CMS and G1 garbage collectors reduce pause times through concurrent marking, region-based heap layout, and incremental compaction.
JVM GC Tuning: Heap Sizing and Threshold Optimization
Practical strategies for sizing JVM heap, tuning generation ratios, and optimizing GC thresholds to reduce pause times and improve throughput.
JVM Heap Memory: Young Gen, Old Gen, Metaspace, and Object Headers
A deep dive into JVM heap memory organization including Young Generation, Old Generation, Metaspace, and object header internals for performance optimization.