Heap, Stack and Objects

See where locals, references and objects live, what an object really costs in memory, and how a long-lived service leaks memory.

Syntaxint count = 1; // value in the stack frame Order order = new Order(...); // reference in the frame, object on the heap Order same = order; // copies the reference, not the object

A running JVM keeps data in two main places. Each thread has its own stack, a pile of frames, one per method call in progress. A frame holds the method's parameters and local variables. Every object and array lives on the shared heap, which the garbage collector manages. (The JIT can sometimes skip an allocation that never leaves a method, but you cannot rely on it.)

Values and references

A local int or long sits directly in the frame. A local of an object type holds a reference: a pointer to the object on the heap. Assigning one variable to another copies the reference, not the object, so both names see the same changes. Java passes the reference by value, which is why a method can change an object's fields but cannot make the caller's variable point at a new object.

When a method returns, its frame is gone. The objects it created stay on the heap until nothing reachable refers to them any more.

What an object costs

On a 64-bit HotSpot JVM with default settings an object starts with a 12-byte header, and sizes are rounded up to a multiple of 8 bytes. An Integer therefore takes 16 bytes to hold 4 bytes of data, plus a 4-byte reference wherever it is stored. A List<Integer> of a million entries costs several times more than an int[] of the same length. References are 4 bytes while the heap is under 32 GB, thanks to compressed references. Java 25 also offers compact object headers as a product option, -XX:+UseCompactObjectHeaders, which cut the header to 8 bytes; it is off by default.

Stack limits

Platform thread stacks are a fixed size, 1 MB by default on 64-bit Linux, set with -Xss. Recursion that goes too deep throws StackOverflowError. Recursing over user-controlled input, such as deeply nested JSON, lets a client trigger that error, so parsers cap the nesting depth. Virtual threads keep their frames on the heap while they are parked, which is why a server can hold a million of them.

How a service leaks memory

Java has no dangling pointers, but it can still leak: anything reachable from a static field, a long-lived singleton or a running thread stays alive forever. The usual culprits in back-end code are:

  • A static Map used as a cache with no size limit or expiry.
  • Listeners or callbacks registered and never removed.
  • ThreadLocal values set on pooled threads and never cleared.
  • Whole request bodies or result sets held in memory when they could be streamed.

Each of these grows with traffic, so the service looks fine in testing and fails after days in production.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Replace a static HashMap cache of user profiles with a size-limited cache so a busy service does not run out of heap after a few days.
  • Cap the nesting depth when parsing client JSON so a deeply nested payload cannot crash the handler with StackOverflowError.
  • Store millions of numeric metrics in primitive arrays instead of lists of boxed values to cut the heap size of an analytics job.

More examples

Boxed values are heap objects

Autoboxing reuses cached Integer objects only from -128 to 127, so == compares identities and breaks at 128. The int array holds 100,000 values in about 400 KB, while the list needs a separate 16-byte Integer for most entries plus a reference to each.

Example · java
import java.util.ArrayList;
import java.util.List;

void main() {
    Integer a = 127, b = 127;
    Integer c = 128, d = 128;
    IO.println(a == b);
    IO.println(c == d);
    IO.println(c.equals(d));

    int[] primitives = new int[100_000];
    List<Integer> boxed = new ArrayList<>(100_000);
    for (int i = 0; i < 100_000; i++) {
        primitives[i] = i;
        boxed.add(i);
    }
    IO.println(primitives[99_999] + " " + boxed.get(99_999));
}
// Output:
// true
// false
// true
// 99999 99999

An unbounded cache versus a bounded one

The static map keeps every session's 1 KB forever because a static field is always reachable, so it grows with traffic until the heap runs out. The access-ordered LinkedHashMap drops its least recently used entry once it passes 100, so its memory use stays flat.

Example · java
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

static final Map<String, byte[]> UNBOUNDED = new HashMap<>();

static <K, V> Map<K, V> lru(int maxEntries) {
    return new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return size() > maxEntries;
        }
    };
}

void main() {
    Map<String, byte[]> bounded = lru(100);
    for (int i = 0; i < 10_000; i++) {
        String sessionId = "session-" + i;
        UNBOUNDED.put(sessionId, new byte[1024]);
        bounded.put(sessionId, new byte[1024]);
    }
    IO.println("unbounded entries: " + UNBOUNDED.size());
    IO.println("bounded entries: " + bounded.size());
    IO.println("oldest kept: " + bounded.keySet().iterator().next());
}
// Output:
// unbounded entries: 10000
// bounded entries: 100
// oldest kept: session-9900

Locals belong to one thread, the heap is shared

Each request handler's local variables live in its own stack frame, so they need no locking. Anything on the heap that several threads touch, like the total here, needs an atomic or a lock.

Example · java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

void main() throws InterruptedException {
    AtomicLong sharedTotal = new AtomicLong();
    long[] perRequest = new long[4];
    List<Thread> threads = new ArrayList<>();

    for (int r = 0; r < 4; r++) {
        int request = r;
        threads.add(Thread.ofVirtual().start(() -> {
            long sum = 0; // a local: each thread has its own copy
            for (int i = 1; i <= 1_000; i++) {
                sum += i * (request + 1);
            }
            perRequest[request] = sum;
            sharedTotal.addAndGet(sum); // a heap object: needs a thread-safe type
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println(java.util.Arrays.toString(perRequest));
    IO.println("total " + sharedTotal.get());
}
// Output:
// [500500, 1001000, 1501500, 2002000]
// total 5005000

Discussion

  • Be the first to comment on this lesson.