JavaJVM & Memoryadvanced
Updated:

Java Memory Management: Heap, Stack and Metaspace

7 min read

Where do your objects, local variables and class metadata actually live? A practical map of JVM memory with runnable examples and the flags that control each region.

TL;DR – Quick Answer

Java memory management splits runtime memory into regions with different jobs: the heap stores all objects and is shared across threads, each thread gets its own stack for method frames and local variables, and metaspace holds class metadata in native memory. The garbage collector reclaims heap objects automatically once nothing references them, so you manage memory by managing references, not by freeing it manually.

On This Page

Java frees you from calling malloc and free, but "automatic" does not mean "invisible." When a service dies with OutOfMemoryError at 2 a.m., the developer who knows what lives on the heap, what lives on the stack and what metaspace even is fixes it in minutes. The one who doesn't restarts the pod and hopes.

This guide maps the JVM's memory regions, walks through what happens to your variables line by line, and finishes with the flags and tools you will actually use. It pairs with our deep dives on garbage collection and the overall JVM architecture, both linked below.

The big picture: five memory regions

The JVM specification defines several runtime data areas. In practice you reason about five:

Region Scope Stores Cleared by
Heap Shared, one per JVM All objects and arrays Garbage collector
Stack One per thread Method frames, locals, references Automatically on method return
Metaspace Shared, native memory Class metadata, method bytecode info GC when class loaders die
PC registers One per thread Address of current instruction Managed by JVM
Native method stacks One per thread Frames for JNI/native calls Automatically

The two you interact with daily are the heap and the stack, so let's make the distinction concrete.

Stack: one per thread, gone on return

Every thread gets its own stack (default size around 512 KB–1 MB depending on platform, tunable with -Xss). Each method call pushes a frame holding the method's local variables, operand stack and a reference to its class's runtime constant pool. When the method returns, the frame pops and every local in it vanishes — no GC involved.

Because stacks are per-thread, local variables are inherently thread-safe. This is why multithreading bugs almost always involve shared heap state, never locals.

Two things live in a frame: primitive values themselves (int, double, boolean...) and references to objects. The objects those references point to are never in the frame.

Heap: where every object lives

new always allocates on the heap, whether the variable holding the reference is local, static or a field. The heap is shared by all threads and is the only region the garbage collector manages continuously.

Trace this program and keep the two regions separate in your head:

public class StackVsHeap {

    static class Order {                 // metadata -> metaspace
        double amount;                   // field -> inside the object, on heap
        Order(double amount) { this.amount = amount; }
    }

    public static void main(String[] args) {
        int count = 3;                   // primitive local -> stack frame of main
        Order order = new Order(4999.0); // reference on stack, object on heap
        double total = totalWithTax(order.amount, count);
        System.out.println(total);       // 17646.47...
    }

    static double totalWithTax(double amount, int qty) {
        double gst = 0.18;               // new frame: amount, qty, gst on stack
        return amount * qty * (1 + gst);
    }   // frame popped here — gst, qty, amount (the copies) all gone
}

What to notice: order (the reference) sits in main's frame; the Order object with its amount field sits on the heap. When totalWithTax is called, a second frame is pushed with copies of the arguments — Java is pass-by-value, and this picture is the reason why. When main eventually ends, the reference dies with its frame and the Order object becomes garbage.

Common mistake: A common mistake beginners make is saying "local objects are stored on the stack." Only the reference is on the stack; the object is always on the heap. The JIT compiler can sometimes avoid a heap allocation through escape analysis, but that is an optimization detail, not the language rule — in an interview, lead with the rule.

Young and old generations

Internally the heap is split by object age: new objects start in the young generation (Eden plus two survivor spaces) and objects that survive several collections get promoted to the old generation. Most objects die young — a request-scoped DTO rarely outlives the request — so collecting the young generation is cheap and frequent. The full mechanics are covered in the garbage collection guide.

One heap detail worth knowing: since Java 7 the String pool lives on the heap too, which is why interned strings can be garbage collected.

Metaspace: class metadata in native memory

When a class is loaded, the JVM needs somewhere to keep its structure — field layouts, method bytecode, the runtime constant pool. Before Java 8 that lived in PermGen, a fixed-size heap region infamous for OutOfMemoryError: PermGen space after a few hot redeploys on an app server.

Java 8 replaced PermGen with metaspace, allocated from native (off-heap) memory and growing on demand. Class metadata is freed when its class loader becomes unreachable. You can still exhaust it — typically through class-loader leaks or runaway dynamic class generation by proxies and bytecode libraries — and the error becomes OutOfMemoryError: Metaspace.

Pro tip: Metaspace is unlimited by default, which means a class-loader leak eats native RAM until the OS kills the process. On containerized services, set -XX:MaxMetaspaceSize=256m (sized to your app) so a leak fails fast inside the JVM with a clear error instead of an opaque container OOM-kill.

Reference strength: how you influence GC

You never free memory in Java; you control reachability. The java.lang.ref package gives you four levels of reference strength, and frameworks use them heavily for caches:

import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;

public class ReferenceDemo {
    public static void main(String[] args) {
        byte[] strong = new byte[1024];              // strong: never collected
                                                     // while reachable

        WeakReference<byte[]> weak =
                new WeakReference<>(new byte[1024]); // weak: collected at next GC

        SoftReference<byte[]> soft =
                new SoftReference<>(new byte[1024]); // soft: collected only
                                                     // under memory pressure

        System.gc();                                 // hint only, but usually
                                                     // enough for the demo

        System.out.println("weak survived? " + (weak.get() != null)); // false
        System.out.println("soft survived? " + (soft.get() != null)); // true
        System.out.println("strong length: " + strong.length);        // 1024
    }
}

What to notice: after the GC hint, the weakly referenced array is typically gone while the softly referenced one survives because memory is not scarce. WeakHashMap builds on weak references — entries disappear once the key is unreachable elsewhere — which is exactly what you want for canonical-object caches.

Phantom references (the fourth level) are only useful for post-mortem cleanup hooks and rarely appear outside library code.

When memory runs out: the errors you will meet

Each region fails differently, and the error message tells you where to look:

  • OutOfMemoryError: Java heap space — allocation failed even after full GC. Suspects: static collections that only grow, unbounded caches, listeners never deregistered, or simply an -Xmx too small for the workload.
  • OutOfMemoryError: Metaspace — class metadata exhausted its cap. Suspects: redeploy loops, aggressive dynamic proxy generation, class-loader leaks.
  • OutOfMemoryError: GC overhead limit exceeded — the JVM is spending the vast majority of its time collecting and reclaiming almost nothing; effectively a slow-motion heap exhaustion.
  • StackOverflowError — one thread's stack filled up, almost always via unbounded recursion. Raising -Xss treats the symptom; fixing the recursion treats the cause.
  • OutOfMemoryError: unable to create new native thread — the OS refused another thread stack. Common in code that creates threads per task instead of using a pool.

A leak in Java is not "memory the program lost track of" — it is memory the program is still referencing but will never use again. GC cannot help, because reachability is its only signal.

Interview note: "Can a memory leak happen in Java even though it has GC?" is a favorite question at the 3–5 year level. Answer yes, define a leak as unwanted reachability, and give one concrete example — a static Map used as a cache with no eviction is the cleanest. Expect the follow-up covered in our experienced-level interview set: "how would you find it?" (heap dump, dominator tree).

Sizing flags and diagnostic tools

The flags you will actually set on a service:

-Xms2g                     # initial heap (set equal to -Xmx on servers)
-Xmx2g                     # maximum heap
-Xss512k                   # per-thread stack size
-XX:MaxMetaspaceSize=256m  # cap class metadata
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/

Setting -Xms equal to -Xmx avoids resize pauses on long-running services. The heap-dump pair costs nothing until the day it saves you, so treat it as mandatory on production JVMs.

For inspection, learn these in order: jcmd <pid> GC.heap_info for a quick snapshot, jstat -gcutil <pid> 1000 to watch generation occupancy live, jmap -dump:live,file=heap.hprof <pid> for a full dump, and Eclipse MAT or JDK Mission Control to analyze it. The dominator tree view in MAT answers "what is holding all this memory" faster than any amount of code reading.

Where to go from here

You now have the map: heap for objects, stack for frames, metaspace for class metadata, references as your only lever. The natural next step is understanding how the collector reclaims the heap — generational copying, G1's regions, ZGC's pauses — which we cover in Java Garbage Collection: How GC Really Works. To see where these regions sit inside the wider virtual machine, read the JVM architecture guide next.

Frequently Asked Questions

What is the difference between heap and stack memory in Java?
The heap is a single shared region where every object lives, managed by the garbage collector. The stack is per-thread and stores method call frames, local primitive values and references to heap objects. Stack memory is reclaimed instantly when a method returns; heap memory is reclaimed later by GC.
What replaced PermGen in Java 8?
Metaspace replaced PermGen in Java 8. Class metadata moved from a fixed-size region inside the JVM to native memory that grows on demand. This removed the frequent java.lang.OutOfMemoryError PermGen space failures, though metaspace can still be capped with MaxMetaspaceSize.
Are objects ever stored on the stack in Java?
By the language specification, all objects live on the heap; the stack holds only the reference. However, the JIT compiler can perform escape analysis and allocate an object's fields on the stack via scalar replacement when the object never escapes the method. That is an internal optimization you cannot control directly.
What causes java.lang.OutOfMemoryError: Java heap space?
It occurs when the JVM cannot allocate an object even after a full garbage collection. Typical causes are genuine memory leaks through static collections, listeners or caches that keep growing, an undersized -Xmx for the workload, or loading very large datasets into memory at once.
How do I see how much memory my Java application uses?
Programmatically, Runtime.getRuntime() exposes totalMemory, freeMemory and maxMemory. For real diagnostics use the JDK tools: jcmd for on-demand heap info, jmap for heap dumps, jstat for GC statistics, and JDK Flight Recorder with JDK Mission Control for continuous low-overhead profiling.
Where does the String pool live?
Since Java 7 the String pool lives on the heap, not in PermGen. That means pooled strings are garbage collected like any other object once unreachable, and the pool size can be tuned with -XX:StringTableSize.

Want to Build Your Career in Java Full Stack with AI?

Join CodeBegun and train with working industry engineers — Explore the Program

Apply for Demo Class →
Siva Prasad Galaba
Founder, CodeBegun · Staff Engineer

Founder of CodeBegun. 15+ years building Java systems at companies like Crunchyroll. Teaches Java, Spring Boot and system design the way the industry actually works, and mentors students through projects, mock interviews and placement preparation.

Technically reviewed by CodeBegun Technical TeamLast reviewed 14 July 2026 LinkedIn
Chat with us