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
newlives 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
}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
| Thing | Where |
|---|---|
Local primitive (int qty) | Stack |
Local reference (Order order) | The reference on the stack, the object on the heap |
| Any object, any array | Heap |
Fields of an object, even int ones | Heap, inside the object |
| String literals | Heap, in the string pool |
| Static fields | Heap, 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 swappeda 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); // DELIVEREDBefore 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
| Primitive | Wrapper | Default as a field |
|---|---|---|
int | Integer | 0 vs null |
long | Long | 0L vs null |
double | Double | 0.0 vs null |
boolean | Boolean | false vs null |
char | Character | '\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)); // trueInteger.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"); // NullPointerExceptionget 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 trueWhen 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 itIf 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
| OutOfMemoryError | StackOverflowError | |
|---|---|---|
| What ran out | Heap (or Metaspace) | One thread's stack |
| Usual cause | Leak, unbounded cache, loading a huge file into memory | Recursion with no base case, or very deep recursion |
| Typical message | Java heap space | Long 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
-Xmssets the starting heap size.-Xmxsets the maximum heap size. Go past it and you getOutOfMemoryError.-Xsssets 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
Integerwith==. Works up to 127, then silently fails. - Using
LongorIntegeras a loop counter or running total. Every update allocates a new object. - Unboxing a
nullfrom a map. UsegetOrDefault. - 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.valueOfcaches objects only for -128 to 127. 128 gives two different objects, and==compares references. Useequalsfor 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:
ScoreTrackerinOOOD/07-tic-tac-toestoresHashMap<Player, Integer>and doesplayerRatings.get(winner) + 1. It callsputIfAbsent(winner, 0)first, which is what stops that line from unboxing anull. - A static map that is fine:
FlyweightDesignPattern/After/Main.javakeeps a statictreeTypeMap, andPrototypeDesignPattern/After/Main.javakeeps 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.javahas an unsubscribe method, andLockerManagerChangeinOOOD/09-shipping-locker-systemhasremoveObserver. That is the fix for leak number 2. - A slow leak worth spotting:
SeatLockManagerinOOOD/02-movie-ticket-bookingremoves 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:
FileSearchinOOOD/03-unix-file-search-systemwalks the directory tree with an explicitArrayDequeinstead of recursion, so a very deep folder tree cannot cause aStackOverflowError.
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.