Java Memory Management: Understanding the JVM Heap, GC, and Tuning Your Application

Most Java developers know what a garbage collector is. Far fewer understand what it's actually doing, why it sometimes tanks application performance, and how to tell the JVM to knock it off.

Here's a question that sounds basic until you actually try to answer it precisely: how does memory work in Java?

Not the hand-wavy "oh, the garbage collector handles it for you" answer. The real answer. Where does an object live the moment you instantiate it? What triggers a garbage collection? What's the difference between what G1GC does and what ZGC does, and why does it matter for your application? What's the actual JVM flag that prevents OutOfMemoryError from silently crashing your process?

I'll be honest — I didn't understand any of this until I was deep in the weeds trying to optimize Minecraft servers and kept running into java.lang.OutOfMemoryError with no idea what was actually happening. If you want the full story on that particular adventure, and the specific configurations that came out of it, go check out Java Version Selection for Minecraft Servers — I wrote up that whole experience there. But the short version is: trying to keep a Java server alive under load is one of the fastest ways to actually learn how the JVM manages memory, because if you get it wrong, the error message is immediate and brutal.

This article is everything I learned from that — and everything I learned afterward building production Java applications. Let's go deep.

The Big Picture: Why Java Memory Management Exists

Java made a deliberate choice when it was designed in 1995: no manual memory allocation. No malloc(). No free(). The Java Virtual Machine (JVM) handles memory for you, automatically allocating objects and cleaning up the ones nobody needs anymore.

This is garbage collection. And the reason you need to understand it — even though it's "automatic" — is that automatic doesn't mean free. GC takes CPU time. GC can pause your application. GC can fail entirely if you push it too hard. And if you don't understand how the heap is structured, you can write perfectly reasonable-looking code that makes the garbage collector work ten times harder than it needs to.

The JVM divides managed memory into two main regions: the Heap and the Stack.

The Stack holds the current execution context — local variables, method call frames, references to objects. It's per-thread, LIFO (Last In First Out), and typically much smaller than the heap. A StackOverflowError means you've recursed so deep (or have so many local variables) that you've run out of stack space. The fix is usually fixing your recursion, not throwing more memory at it.

The Heap is where objects live. Every time you write new SomeObject(), that object goes on the heap. The garbage collector watches the heap, finds objects that nobody holds a reference to anymore, and reclaims their memory. The rest of this article is about how that actually works.

The Heap Structure: Generations

The Java heap isn't a flat pool of memory. It's divided into generations, and this design is deliberate — it's based on decades of empirical observation that most objects die young. You create a StringBuilder to build a string, use it once, and it becomes garbage almost immediately. A few objects — database connections, caches, static configuration — live forever. The generational model optimizes for this pattern.

The Young Generation

Every new object starts here. The Young Generation is itself divided into three spaces:

Eden Space — This is where freshly allocated objects land. When you call new, this is where your object goes. Eden fills up fast.

Survivor Space 0 (S0) and Survivor Space 1 (S1) — Two identically-sized spaces. Objects that survive a GC cycle get moved here. Only one survivor space is "active" at any given time; the JVM alternates between them.

Here's the flow:

  1. Objects are allocated in Eden. Eden fills up.
  2. A Minor GC (also called Young GC) is triggered. The JVM scans Eden and the active Survivor space.
  3. Objects that are still referenced — still alive — are moved to the inactive Survivor space. Everything else is deleted.
  4. The two Survivor spaces swap roles. The newly populated one is now "active."
  5. Objects that have survived a certain number of Minor GCs (the "tenuring threshold," configurable via -XX:MaxTenuringThreshold) get promoted to the Old Generation.

Minor GC is designed to be fast. It only touches the Young Generation, which is small relative to the total heap. Pause times are typically in the single-digit milliseconds range for well-configured applications.

Here's what this looks like in JVM object flow:

[new Object()] ──→ Eden Space
                      │
              (Eden fills up)
                      │
              [Minor GC triggered]
                      │
              ┌───────┴───────┐
              │               │
        still alive?    no reference?
              │               │
        → Survivor        → Deleted
          Space S1           (freed)
              │
    (survived N GC cycles?)
              │
        → Old Generation

The Old Generation (Tenured Generation)

Long-lived objects end up here. This is the larger portion of the heap — typically 2/3 of the total heap size by default with many GC configurations.

When the Old Generation fills up, a Major GC (also called Full GC in many contexts) is triggered. This is the expensive one. Major GC has to scan the entire heap — young and old generations — to figure out what's alive and what isn't. This takes significantly longer than Minor GC, and during the pause, your application may be partially or fully stopped.

This is what causes the latency spikes in production. If you've ever seen an API go from sub-100ms response times to 2-3 seconds for a brief window and then recover, there's a good chance a Major GC fired. For high-traffic systems like Amazon or Walmart's checkout flows, a Major GC at peak load means timeout errors and abandoned carts.

PermGen vs. Metaspace

If you're running Java 7 or earlier, you've heard of PermGen (Permanent Generation). This was a separate area of the heap that stored:

  • Class definitions (bytecode)
  • Method metadata
  • Static variables
  • String pool (moved to the heap in Java 7 via JEP 193)

PermGen had a fixed maximum size. If you deployed enough classes — or had a classloader leak — you'd hit java.lang.OutOfMemoryError: PermGen space.

Java 8 removed PermGen entirely and replaced it with Metaspace, which is backed by native memory rather than heap memory. The key difference: Metaspace grows dynamically by default, limited only by available native memory. You still can (and should) cap it with -XX:MaxMetaspaceSize to prevent runaway class loading from consuming all available memory, but you no longer need to guess a static PermGen size at startup.

# Old world (Java 7 and earlier) - guess wrong and you're dead
-XX:MaxPermSize=256m

# New world (Java 8+) - set a ceiling on native memory growth
-XX:MaxMetaspaceSize=256m

The Garbage Collectors: Choose Your Weapon

The JVM ships with multiple garbage collectors, and they make different tradeoffs. Understanding which one to pick for your workload matters a lot.

G1GC (Garbage-First) — The Default Since Java 9

G1GC divides the heap into roughly 2,048 equal-sized regions rather than using fixed young/old areas. It predicts which regions have the most "garbage" (hence Garbage-First) and collects those first to maximize reclaimed memory per pause.

G1 is designed to balance throughput and latency. It targets a configurable maximum GC pause time (-XX:MaxGCPauseMillis) and does a lot of its work concurrently with the application. It's the right default for most applications.

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200   # target 200ms max pauses (soft goal)

Note that MaxGCPauseMillis is a goal, not a guarantee. G1 will try to stay under it but will exceed it if necessary.

ZGC — Sub-Millisecond Pauses at Scale

ZGC is designed for applications that need extremely low latency — think real-time bidding systems, financial trading, anything where a 200ms GC pause is unacceptable. ZGC does almost all of its work concurrently, keeping pause times consistently below 1ms even on heaps in the hundreds of gigabytes.

The tradeoff: ZGC uses more CPU and memory overhead for its concurrent work. It's the right choice when latency is the primary concern and you have the hardware headroom.

-XX:+UseZGC

Available without flags since Java 15. Became production-ready in Java 15 and continues to improve. Notably, Java 26 improved G1 throughput via reduced synchronization, and ZGC is also getting ahead-of-time startup optimizations in upcoming releases — the GC landscape keeps moving.

Shenandoah GC — ZGC's Sibling

Shenandoah shares ZGC's goal of ultra-low pause times via concurrent garbage collection. It was developed by Red Hat rather than Oracle, which makes it the preferred option in Red Hat/IBM enterprise environments. The two collectors have converged in capability over the years; the choice often comes down to which one benchmarks better for your specific workload.

-XX:+UseShenandoahGC

G1 vs ZGC: When to Choose What

Application Profile              Recommended GC
────────────────────────────────────────────────────────
Most web apps, microservices     G1GC (default, balanced)
Large heaps, batch processing    G1GC or Parallel GC
Sub-millisecond latency required ZGC or Shenandoah
CPU-bound, throughput first      Parallel GC (-XX:+UseParallelGC)
Tiny app, minimal footprint      Serial GC (-XX:+UseSerialGC)

Memory Errors: What They Actually Mean

Every Java memory error is telling you something specific. Here's the full list, what each one means, and what you do about it.

java.lang.StackOverflowError

Stack memory is full. Usually infinite or excessively deep recursion. Sometimes caused by deeply nested method calls or very large numbers of local variables in a call chain.

// This will StackOverflow immediately
public int infiniteRecursion(int n) {
    return infiniteRecursion(n + 1); // never terminates
}

Fix: find and fix the recursion bug. Alternatively, increase the thread stack size with -Xss:

-Xss2m   # 2MB stack per thread (default is usually 512KB-1MB)

java.lang.OutOfMemoryError

The general one. Heap memory is exhausted and GC couldn't reclaim enough to satisfy the allocation request. This means either:

  • The heap is genuinely too small for your workload (increase -Xmx)
  • You have a memory leak — objects are being retained when they shouldn't be

Always take a heap dump when this happens. -XX:+HeapDumpOnOutOfMemoryError does this automatically:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof

Analyze the dump with Eclipse MAT, VisualVM, or JProfiler to find what's holding references.

java.lang.OutOfMemoryError: GC Overhead Limit Exceeded

The JVM is spending more than 98% of its time doing GC and recovering less than 2% of the heap each cycle. This is a death spiral — the heap is nearly full, GC keeps firing, but can't get ahead. The JVM throws this error rather than letting you spin forever.

This almost always means a memory leak or wildly undersized heap. Fix the leak, then size the heap appropriately.

java.lang.OutOfMemoryError: PermGen Space

Java 7 and earlier only. PermGen is full. You've loaded too many classes or have a classloader leak (common in application servers that redeploy apps). On Java 8+, this error doesn't exist — but OutOfMemoryError: Metaspace is its replacement.

java.lang.OutOfMemoryError: Metaspace

Java 8+. Metaspace (native memory for class metadata) is full. Usually a classloader leak — classes being loaded repeatedly without the old ones being unloaded. Set a cap to make failures explicit:

-XX:MaxMetaspaceSize=256m

java.lang.OutOfMemoryError: Unable to Create New Native Thread

The OS can't create another native thread. You've hit either the OS process limit (ulimit -u on Linux) or exhausted virtual address space. With virtual threads (Java 21+), this is much harder to hit for I/O-bound work — but CPU-bound workloads with thousands of threads can still trigger it. Check our vtstream article for how virtual threads change this calculus.

java.lang.OutOfMemoryError: Requested Array Size Exceeds VM Limit

You tried to allocate an array larger than Integer.MAX_VALUE - 2 elements (roughly 2.1 billion). Not a configuration problem — it's a code problem. You're trying to allocate something the JVM fundamentally can't create as a single array.

java.lang.OutOfMemoryError: request <N> bytes for <reason>. Out of swap space?

Swap space is exhausted. The JVM tried to allocate native memory and failed. Either the machine is genuinely out of memory and swap, or another process is consuming it. Add RAM, add swap, or figure out what else is consuming memory on the host.

Configuring the Heap: The Flags That Actually Matter

Initial Heap Size: -Xms

This is the heap size at JVM startup. Before any application code runs, the JVM allocates this much memory.

The traditional rule of thumb is to set it to 1/64th of available RAM. My personal default for most applications is 256MB, though for long-running services I often set -Xms equal to -Xmx — more on that shortly.

-Xms256m    # start with 256MB

Maximum Heap Size: -Xmx

The ceiling. The JVM will never allocate more heap than this. Set it too small and you get OOMEs. Set it too large and you're wasting memory that could serve other processes or that GC has to traverse unnecessarily.

The old rule was "half of available RAM." More nuanced reality: set it to what your application actually needs with comfortable headroom, verified by profiling under realistic load — not by guessing.

-Xmx4g    # max 4GB heap

Setting Xms == Xmx in Production

For long-running production services, setting -Xms equal to -Xmx is often the right call:

-Xms4g -Xmx4g

When they differ, the JVM starts small and grows the heap as needed, which triggers GC more frequently at startup and during load spikes (because the JVM resizes the heap). Setting them equal means the JVM takes the full heap at startup, stabilizes faster, and avoids the overhead of heap resizing. The tradeoff is that the memory is reserved immediately rather than on demand.

For containerized environments (Docker, Kubernetes), this also prevents the container from reporting incorrect memory usage that could trigger OOM kills. Set your heap to leave headroom for non-heap memory: Metaspace, thread stacks, code cache, and native libraries typically add another 256MB–512MB on top of heap usage.

Real-World Startup Example

java \
  -Xms2g \
  -Xmx2g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/app/ \
  -XX:+UseStringDeduplication \
  -XX:MaxMetaspaceSize=256m \
  -jar myapp.jar

This is a sane baseline for a typical web service: fixed 2GB heap, G1GC targeting 200ms max pauses, automatic heap dump on OOM, string deduplication to reduce memory usage from identical string objects, and a Metaspace cap.

Advanced JVM Tuning Flags

Here's the complete reference for the flags you'll actually reach for:

GC Selection

-XX:+UseG1GC           # G1 (default Java 9+, balanced)
-XX:+UseZGC            # ZGC (Java 15+, ultra-low latency)
-XX:+UseShenandoahGC   # Shenandoah (low latency, Red Hat)
-XX:+UseParallelGC     # Parallel GC (throughput-first)
-XX:+UseSerialGC       # Serial GC (single-threaded, small apps)

Heap Region Sizing

-XX:NewRatio=2
# Ratio of old:young generation. NewRatio=2 means old is 2x young.
# Default is 2 for most GCs. Increase if you have many long-lived objects.

-XX:SurvivorRatio=8
# Ratio of eden:survivor space within young generation.
# SurvivorRatio=8 means each survivor space is 1/10 of young gen.
# Smaller value = larger survivor spaces = more room for objects before promotion.

-XX:MaxTenuringThreshold=15
# How many GC cycles an object must survive before promotion to old gen.
# Default is 15 for G1. Lower this if many objects are surviving unnecessarily.

GC Pause Tuning

-XX:MaxGCPauseMillis=200
# G1GC: target max pause time in ms. Soft goal — G1 tries but doesn't guarantee.

-XX:GCPauseIntervalMillis=1000
# G1GC: target interval between GC pauses. Helps maintain throughput.

-XX:InitiatingHeapOccupancyPercent=45
# G1GC: percentage of heap occupancy that triggers a concurrent GC cycle.
# Default is 45. Lower it to start GC earlier, reducing the chance of Full GC.

Thread Configuration

-XX:ParallelGCThreads=4
# Threads used during stop-the-world GC phases.
# Default: number of CPUs (or a fraction for many-core machines).

-XX:ConcGCThreads=2
# Threads used during concurrent GC phases (G1, ZGC, Shenandoah).
# Setting to 1/4 of ParallelGCThreads is a common starting point.

Diagnostic and Monitoring Flags

-XX:+PrintGCDetails
# Verbose GC log output. Essential for understanding what GC is doing.

-XX:+PrintGCDateStamps
# Adds timestamps to GC log entries. Makes correlation with app logs possible.

-XX:+PrintGCApplicationStoppedTime
# Logs total time spent in GC pauses. Critical for measuring pause impact.

-XX:+PrintGCApplicationConcurrentTime
# Logs time between GC pauses (actual application runtime).

-XX:+PrintHeapAtGC
# Dumps heap state before and after each GC event. Noisy but informative.

# Modern approach (Java 9+): use -Xlog instead of individual -XX:+Print flags
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

The -Xlog unified logging flag in Java 9+ supersedes the older PrintGC* flags. It's more flexible and allows you to write GC logs to a rotating file, which the PrintGC* flags couldn't do cleanly.

Code Cache and Optimization

-XX:+UseCodeCacheFlushing
# Allows JVM to flush the JIT-compiled code cache when it fills up.
# Without this, the JVM may disable JIT compilation entirely when the
# code cache is full, causing severe performance degradation.

-XX:ReservedCodeCacheSize=256m
# Sets the maximum code cache size. Default is 240MB in recent JDKs.
# Increase if you see "CodeCache is full" warnings in logs.

String Deduplication

-XX:+UseStringDeduplication
# G1GC only. Identifies String objects with identical content and makes
# them share a single char[]. Can meaningfully reduce heap usage for
# applications with lots of duplicate string data (e.g., JSON keys,
# database column names, repeated log messages).

Practical Code: Writing Memory-Efficient Java

JVM flags tune the runtime. But the most important performance work happens in your code. Here are the patterns that matter most.

Avoid Unnecessary Object Creation in Hot Paths

// Bad: creates a new String every iteration
public void processItems(List<String> items) {
    for (String item : items) {
        String key = "prefix_" + item; // new String allocation every iteration
        cache.put(key, compute(item));
    }
}

// Better: use StringBuilder or reuse where possible
public void processItems(List<String> items) {
    StringBuilder sb = new StringBuilder("prefix_");
    int prefixLength = sb.length();
    
    for (String item : items) {
        sb.setLength(prefixLength); // reset to just "prefix_"
        sb.append(item);
        cache.put(sb.toString(), compute(item));
    }
}

Use Object Pools for Expensive Resources

Creating and destroying expensive objects (database connections, thread instances, large byte buffers) is a major GC pressure source. Pool them:

import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

// Connection pool example using Apache Commons Pool
GenericObjectPoolConfig<Connection> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(20);      // max 20 connections
config.setMinIdle(5);        // keep 5 ready
config.setMaxIdle(10);       // max 10 idle
config.setMaxWaitMillis(3000); // wait up to 3s before failing

GenericObjectPool<Connection> pool = new GenericObjectPool<>(
    new ConnectionFactory(dataSource), config
);

// Use and return - object stays in pool, not GC'd
Connection conn = pool.borrowObject();
try {
    // do database work
} finally {
    pool.returnObject(conn); // returns to pool, not garbage
}

Watch Your Collection Sizing

Java's ArrayList and HashMap have default initial capacities that cause repeated resizing (and object allocation) as they grow. When you know the size upfront, set it:

// Bad: ArrayList starts with capacity 10, resizes (and copies) repeatedly
List<String> results = new ArrayList<>();

// Better: pre-size if you know the approximate count
List<String> results = new ArrayList<>(expectedSize);

// Bad: HashMap with default capacity 16 and load factor 0.75 resizes at 12 entries
Map<String, Value> lookup = new HashMap<>();

// Better: size to avoid resizing (capacity = expected entries / load factor)
// For 1000 entries: 1000 / 0.75 ≈ 1334, round up to next power of 2
Map<String, Value> lookup = new HashMap<>(2048);

Soft References for Memory-Sensitive Caches

When you want a cache that the GC can evict under memory pressure, use SoftReference:

import java.lang.ref.SoftReference;
import java.util.concurrent.ConcurrentHashMap;

public class MemorySensitiveCache<K, V> {
    private final ConcurrentHashMap<K, SoftReference<V>> cache 
        = new ConcurrentHashMap<>();
    
    public void put(K key, V value) {
        cache.put(key, new SoftReference<>(value));
    }
    
    public V get(K key) {
        SoftReference<V> ref = cache.get(key);
        if (ref == null) return null;
        
        V value = ref.get(); // returns null if GC has collected it
        if (value == null) {
            cache.remove(key); // clean up the dead reference
        }
        return value;
    }
}

SoftReferences are guaranteed to be collected before the JVM throws OutOfMemoryError, making them safe for caches that should yield memory when the heap is under pressure.

Detecting Memory Problems: The Diagnostic Workflow

When something goes wrong with memory, here's the sequence of steps that actually finds the problem:

Step 1: Enable GC logging from the start. You can't diagnose problems you didn't capture. Always run production services with -Xlog:gc* logging to a file.

Step 2: Watch for GC frequency and pause duration. If Minor GC is firing every 100ms instead of every few seconds, your Eden space is too small or you're allocating objects too fast. If Full GC is firing regularly, your heap is too small or you have a memory leak.

Step 3: Take a heap dump. Either configure -XX:+HeapDumpOnOutOfMemoryError to get one automatically, or trigger one manually with:

jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>
# or with jcmd (preferred for modern JDKs):
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof

Step 4: Analyze the dump. Load it into Eclipse MAT or VisualVM. Look for:

  • Objects with unexpectedly high retention — large object graphs keeping objects alive
  • Classloader leaks — thousands of class instances
  • Collection objects holding far more entries than expected

Step 5: Use jstat for live monitoring:

# Show GC stats every 1 second for process <pid>
jstat -gcutil <pid> 1000

# Output columns:
# S0  S1  E    O     M     CCS   YGC  YGCT  FGC  FGCT  GCT
# 0   45  72   33    95    89    142  1.23   3   0.87   2.1
#
# E = Eden usage %, O = Old gen usage %
# YGC = Young GC count, FGC = Full GC count
# YGCT/FGCT = time spent in each

If O (Old generation usage) is consistently above 85%, you're heading toward a Full GC. If FGC is climbing steadily, you have a leak or an undersized heap.

The Conclusion

Java memory management is one of those topics where the surface looks simple ("GC just cleans up unused objects") and the depth is enormous. The generational heap design, the different GC algorithms, the tradeoffs between pause time and throughput, the differences between PermGen and Metaspace, the dozen ways an OOM can manifest with different root causes — each of these is a real problem that costs real engineering hours when you encounter it without the mental model.

The thing is, once you have the mental model, these problems stop being mysterious. A spike in Full GC frequency + growing Old Generation usage = something retaining objects too long. A GC Overhead Limit Exceeded = your heap is way too small for your workload. OutOfMemoryError: Metaspace = classloader leak. Pattern recognition becomes possible.

If you want to go deeper on specific pieces of this, there's a lot more in the CoderOasis Java library. For understanding how to build custom memory management systems on top of the JVM, the custom garbage collector walkthrough is worth reading — it takes the generational model and shows you what implementing it actually looks like. For async workloads specifically and how virtual threads change memory pressure from thread stacks, the vtstream article covers that in depth. And if you want to stay current on where the JVM is going — the G1 throughput improvements in Java 26, the ZGC startup optimizations coming in future releases — the Java 26 features article has the rundown.

The JVM has over 500 tunable arguments. We covered the ones that actually move the needle. Start there, measure your application, and go from there.