Skip to content

Memory, pass by value and garbage collection

Stack vs heap, why Java is always pass by value, boxing traps like the Integer cache, how garbage collection decides what to free, and how Java apps still leak memory.

Stage 1core11 min read

Stack and heap

Think of Java memory as two main areas: the stack, which holds the method calls running right now, and the heap, a shared area that holds objects. The real JVM may store things differently, but this model explains scope, reachability and garbage collection well.

The stack tracks work happening right now. The heap holds objects that may outlive one method call.

  • Stack. Every thread gets its own. Each method call pushes a frame, which is a small block of memory holding that call's local variables and parameters. When the method returns, the frame is popped and everything in it is gone. No cleanup work is needed, which is why the stack is so cheap.
  • Heap. One big shared area for all threads. Every object you create with new lives here, along with arrays and the string pool. Objects stay until the garbage collector decides nobody can reach them.
public static void main(String[] args) {
    int userId = 42;                         // stack, in main's frame
    Cart cart = new Cart();                  // 'cart' reference on the stack, Cart object on the heap
    placeOrder(cart, 2);
}

static void placeOrder(Cart cart, int qty) {
    Order order = new Order(cart, qty);      // 'order' reference on the stack, Order on the heap
}
Loading diagram…

When placeOrder returns, its frame is popped. Nothing on the stack points to the Order any more, so it becomes garbage. The Cart is still reachable from main, so it stays.

What lives where

ThingWhere
Local primitive (int qty)Stack
Local reference (Order order)The reference on the stack, the object on the heap
Any object, any arrayHeap
Fields of an object, even int onesHeap, inside the object
String literalsHeap, in the string pool
Static fieldsHeap, stored with the class. They live as long as the class is loaded, which in most apps means forever
Class metadata (method code)Metaspace, outside the heap (Java 8+)

A common wrong answer is "primitives live on the stack". Only local primitives do. An int field inside an Order lives on the heap with the Order.

One caveat for senior interviews: the JIT compiler (the part of the JVM that speeds up running code) can sometimes prove that an object never leaves its method, and then it skips the heap. This is called escape analysis. You cannot control or rely on it. Reason with the simple model above.

Java is always pass by value

This is the most asked Java trick question. Java has exactly one rule: the method gets a copy of whatever is in the variable.

  • For a primitive, the variable holds the value, so the method gets a copy of the value.
  • For an object, the variable holds a reference (an address), so the method gets a copy of the reference.

It never passes the variable itself. That is what "pass by reference" would mean, and Java does not have it.

The swap that does not work

static void swap(Integer a, Integer b) {
    Integer temp = a;
    a = b;
    b = temp;
}

Integer x = 10, y = 20;
swap(x, y);
System.out.println(x + " " + y);     // 10 20, nothing swapped

a and b are copies of x and y. Swapping the copies does nothing to the originals. In C++ with references this would work. In Java it cannot be written at all.

Reassign vs mutate

This is the example that makes it click.

static void reassign(Order o) {
    o = new Order();            // point the local copy at a new object
    o.status = "CANCELLED";
}

static void mutate(Order o) {
    o.status = "DELIVERED";     // follow the reference and change the object
}

Order order = new Order();      // status = "PLACED"
reassign(order);
System.out.println(order.status);   // PLACED

mutate(order);
System.out.println(order.status);   // DELIVERED
Before the call:
  main:      order --> [Order: PLACED]

Inside reassign(), after o = new Order():
  main:      order --> [Order: PLACED]
  reassign:  o     --> [Order: CANCELLED]     (dropped when the method returns)

Inside mutate():
  main:      order --+
                     +--> [Order: DELIVERED]  (same object, both see the change)
  mutate:    o     --+

Both copies point to the same object, so changes made through either one are visible. But pointing your copy somewhere else does not move the caller's variable.

The same thing with a list:

static void addItem(List<String> cart) {
    cart.add("Paneer Tikka");     // caller sees this
    cart = new ArrayList<>();     // caller does not see this
    cart.add("Gone");
}
// caller's list ends up as [Paneer Tikka]

Primitives vs wrapper classes

PrimitiveWrapperDefault as a field
intInteger0 vs null
longLong0L vs null
doubleDouble0.0 vs null
booleanBooleanfalse vs null
charCharacter'\u0000' vs null

Collections and generics only take objects, so List<int> is not allowed. You write List<Integer>.

Autoboxing is Java turning int into Integer for you. Unboxing is the reverse.

List<Integer> prices = new ArrayList<>();
prices.add(349);              // autoboxing: Integer.valueOf(349)
int first = prices.get(0);    // unboxing: prices.get(0).intValue()

It is convenient, but it hides four traps.

Trap 1: the Integer cache

Integer a = 127, b = 127;
System.out.println(a == b);       // true

Integer c = 128, d = 128;
System.out.println(c == d);       // false
System.out.println(c.equals(d));  // true

Integer.valueOf keeps a cache of objects for -128 to 127. Inside that range you get the same object back, so == happens to work. Outside it, you get two different objects. Long, Short and Byte cache the same range, and Character caches 0 to 127. The upper limit for Integer can be raised with a JVM flag, which is one more reason never to depend on it.

Code that compares order quantities with == passes every test with small numbers and breaks on the first bulk order of 200. Always use equals on wrappers, or unbox to int first.

Trap 2: NullPointerException from unboxing

Map<String, Integer> walletBalance = new HashMap<>();
int balance = walletBalance.get("user-42");   // NullPointerException

get returns null for a missing key, and Java then tries to call null.intValue(), which cannot work. Use getOrDefault("user-42", 0) instead.

A sneakier version, with a ternary (a ? b : c is a one line if/else):

Integer cashback = null;
Integer result = isPrime ? cashback : 0;     // NullPointerException when isPrime is true

When one side of a ternary is Integer and the other is a plain int, the whole expression has type int. So Java unboxes cashback to fit, and unboxing null throws. Writing Integer.valueOf(0) on the right keeps both sides as Integer and avoids it.

Trap 3: remove(int) vs remove(Object)

List<Integer> ids = new ArrayList<>(List.of(10, 20, 30));
ids.remove(1);                     // removes index 1, list is [10, 30]
ids.remove(Integer.valueOf(10));   // removes the value 10, list is [30]

remove(1) picks the int index version. To remove by value, box the number so Java picks remove(Object).

Trap 4: boxing in a hot loop

Long totalPaise = 0L;
for (Order o : orders) {
    totalPaise += o.amountInPaise();   // unbox, add, box a brand new Long, every time
}

Once the total passes 127, every iteration allocates a new Long that is garbage one line later. Over a few million orders that is a few million objects for the GC to clean up, for no reason. Use long. In real services this shows up in metrics counters, map values like Map<String, Long> that get incremented constantly, and streams that use map where mapToLong would avoid the boxing.

Garbage collection basics

You never free memory yourself in Java. The garbage collector (GC) does it.

How it decides what is garbage

A GC root is a starting point the JVM already knows is in use, such as a local variable. An object is alive if you can reach it from a GC root by following references. Everything else is garbage.

GC roots include:

  • Local variables in any active stack frame
  • Static fields of loaded classes
  • Running threads
GC root (main's 'cart')  -->  Cart  -->  List  -->  MenuItem
GC root (static CACHE)   -->  HashMap -->  ...

Order (nobody points to it)    <-- garbage, will be collected

Two objects pointing at each other but not reachable from any root are still garbage. Java checks reachability instead of counting references, so cycles are not a problem.

Young and old generation

Most objects die young. A request comes in, a few hundred small objects are created, the response goes out, and all of them are garbage. The heap is split to take advantage of that.

  • Young generation. New objects start here. It is collected often and quickly, because most of it is garbage. This is a minor GC.
  • Old generation. Objects that survive several young collections get promoted here. It is collected less often and each collection costs more. This is a major or full GC.

You do not need more detail than that for most interviews. If asked about collectors, G1 has been the default since Java 9. It cleans the old generation a piece at a time alongside young collections, and a true full GC is a fallback when it falls behind. ZGC (-XX:+UseZGC, production ready since Java 15) keeps pauses under a millisecond even on very large heaps, at the cost of some extra CPU.

What GC looks like in production

Most collectors have to freeze your application threads for part of their work. This is a stop the world pause. Nobody notices a 5 ms pause. A 2 second full GC shows up as a spike in p99 latency (the time the slowest 1 in 100 requests takes), timeouts in the services calling you, and sometimes a failed health check that gets the pod restarted.

The usual causes, roughly in order of how often they happen:

  • A leak. The old generation keeps filling up, full GCs come closer and closer together, and each one frees less. The service gets slower for hours and then dies with OutOfMemoryError.
  • A heap that is too small for the real working set, so the GC runs constantly.
  • A very high allocation rate, from things like boxing in loops, building big strings, or loading whole result sets into memory.

Two flags worth having on every production JVM: -Xlog:gc* to write GC logs, and -XX:+HeapDumpOnOutOfMemoryError so that when it does die, you get a heap dump to open in a tool like Eclipse MAT and see exactly what was holding the memory. Without the dump you are guessing.

You cannot force GC

System.gc() is a hint. The JVM may ignore it. Calling it in application code is almost always a mistake. Also, finalize() is deprecated, so do not rely on it for cleanup. Use try-with-resources.

Memory leaks in Java

Java has a GC, but you can still leak. A leak in Java means objects you no longer need that are still reachable, so the GC is not allowed to remove them.

1. Static collections that only grow

public class OrderAudit {
    private static final List<Order> ALL_ORDERS = new ArrayList<>();

    public static void record(Order order) {
        ALL_ORDERS.add(order);      // never removed, lives until the JVM stops
    }
}

A static field is a GC root. Everything in that list is held forever.

2. Listeners that are never removed

orderService.addListener(smsNotifier);
// smsNotifier is no longer needed, but orderService still holds it

If you subscribe, you must unsubscribe. A short lived screen or session registering itself with a long lived service is the classic case.

3. Unbounded caches

A HashMap used as a cache with no size limit or expiry grows until the heap is full. Put a cap on it, add expiry, or use a cache library like Caffeine.

4. Keys that stop matching. If a key's fields change after it is stored in a HashMap, its hash changes and the map can no longer find it. It cannot be removed either. See 15-EqualsAndHashCode.md.

5. ThreadLocal in a thread pool. A ThreadLocal (a variable that gives each thread its own copy) lives as long as its thread. Web servers reuse the same pool threads for every request, so a value set in one request and never cleared with remove() stays around for the life of the server, and can leak one user's data into the next request.

OutOfMemoryError vs StackOverflowError

OutOfMemoryErrorStackOverflowError
What ran outHeap (or Metaspace)One thread's stack
Usual causeLeak, unbounded cache, loading a huge file into memoryRecursion with no base case, or very deep recursion
Typical messageJava heap spaceLong stack trace of the same method
Flag to change-Xmx-Xss
int countDown(int n) {
    return countDown(n - 1);    // no base case: StackOverflowError
}

Both are Error, not Exception. Do not catch them and carry on. Fix the cause.

JVM memory flags

java -Xms512m -Xmx2g -Xss1m -jar order-service.jar
  • -Xms sets the starting heap size.
  • -Xmx sets the maximum heap size. Go past it and you get OutOfMemoryError.
  • -Xss sets the stack size for each thread.

Setting -Xms equal to -Xmx in production avoids the heap resizing at runtime. In containers, -XX:MaxRAMPercentage=75 sizes the heap from the container's memory limit instead of a fixed number. Without any flag, the JVM only takes a quarter of the container's memory for the heap, so a 4 GB pod runs with a 1 GB heap and hits OutOfMemoryError while most of the memory sits unused. Do not go to 100% either. Thread stacks, Metaspace and native buffers live outside the heap and need room too.

Common mistakes

  • Saying "Java passes objects by reference". It passes references by value. The swap example proves the difference.
  • Comparing Integer with ==. Works up to 127, then silently fails.
  • Using Long or Integer as a loop counter or running total. Every update allocates a new object.
  • Unboxing a null from a map. Use getOrDefault.
  • Calling System.gc() to "fix" memory. It is a hint, and the leak is still there.
  • Static maps used as caches with no limit. The most common real Java leak.
  • Registering a listener and never removing it.
  • Catching OutOfMemoryError. The JVM is in a bad state by then.

Interview questions

"Is Java pass by value or pass by reference?"

Always pass by value. For objects, the value being copied is the reference, so a method can change the object it points to but cannot make the caller's variable point to a different object.

"Why does Integer a = 128, b = 128; a == b print false?"

Integer.valueOf caches objects only for -128 to 127. 128 gives two different objects, and == compares references. Use equals for wrappers.

"What is stored on the stack and what on the heap?"

The stack holds method frames with local variables and parameters, including local references. All objects and arrays live on the heap, including the fields inside them, and the heap is shared by all threads.

"How does the garbage collector decide an object can be removed?"

It starts from GC roots such as local variables in active frames, static fields and live threads, and follows references. Anything it cannot reach is garbage, even if it is part of a cycle.

"Can a Java program have a memory leak?"

Yes. If unneeded objects stay reachable, the GC cannot remove them. Common causes are static collections that only grow, listeners that are never removed, and caches with no size limit.

"What is the difference between OutOfMemoryError and StackOverflowError?"

OutOfMemoryError means the heap is full, usually from a leak or loading too much data. StackOverflowError means one thread's stack is full, almost always from recursion with no base case.

Where you see it in this repo

  • Wrapper unboxing: ScoreTracker in OOOD/07-tic-tac-toe stores HashMap<Player, Integer> and does playerRatings.get(winner) + 1. It calls putIfAbsent(winner, 0) first, which is what stops that line from unboxing a null.
  • A static map that is fine: FlyweightDesignPattern/After/Main.java keeps a static treeTypeMap, and PrototypeDesignPattern/After/Main.java keeps a static template registry. Both are bounded by the number of tree types or templates, so they do not grow forever.
  • Removing listeners: the Observer example in ObserverPattern/After/Main.java has an unsubscribe method, and LockerManagerChange in OOOD/09-shipping-locker-system has removeObserver. That is the fix for leak number 2.
  • A slow leak worth spotting: SeatLockManager in OOOD/02-movie-ticket-booking removes an expired lock only when someone checks that same seat again. Locks on seats nobody touches after a show stay in the map forever. A periodic cleanup, or clearing all locks for a screening once it ends, would fix it.
  • Avoiding deep recursion: FileSearch in OOOD/03-unix-file-search-system walks the directory tree with an explicit ArrayDeque instead of recursion, so a very deep folder tree cannot cause a StackOverflowError.

Related: 15-EqualsAndHashCode.md for == vs equals, 18-Concurrency.md for why the shared heap needs locking, and 19-StringsAndImmutability.md for the string pool.

In this topic

Done with this topic?

How well do you know it?

PreviousStrings and immutability
Practise 6 questions