Methods and why Java is always pass-by-value
What the JVM builds on every method call, why Java copies references instead of passing variables, and how deep recursion turns into a StackOverflowError.
On this page
📮 What does "Java is always pass-by-value" mean?
When you call a method, Java copies each argument's value into a brand-new parameter variable that lives in the callee's stack frame. For an int, the copy is the number. For an object, the copy is the reference (the address-like handle), not the object. So a method can change the object you point at, but it can never make your variable point somewhere else.
For this lesson, we'll build the mental model from the ground up: what a method is made of, what the JVM creates on every call, and why the "objects are passed by reference" claim you'll hear in interviews is wrong in a precise, provable way. We'll finish with recursion, StackOverflowError and the -Xss flag, because they come from the same picture.
Below the line (out of scope)
- Overloading and varargs get their own lesson: Overloading and varargs
- Arrays as parameters are covered here briefly and in depth in Arrays and multi-dimensional arrays
- Lambdas, method references and virtual dispatch belong to later modules
The 30-second answer
Java is always pass-by-value. Each call pushes a new stack frame and copies argument values into fresh parameter slots. For primitives that's the number; for objects it's a copy of the reference. That's why p.x = 99 inside a method is visible to the caller (both references point at the same heap object) but p = new Point() is not (you only re-pointed the local copy). The proof: you can't write a swap(a, b) that swaps the caller's variables.
What you’ll learn
- What a stack frame holds: the local variable array, the operand stack and a constant pool link
- Why Java is always pass-by-value, and why objects pass a copy of the reference
- The difference between mutating an argument and reassigning a parameter, proved with
swap() - How recursion grows the stack, what triggers
StackOverflowErrorand how-Xsschanges the limit
01Method anatomy in one minute
A method declaration has a small, fixed shape. Interviewers rarely ask you to recite it, but the vocabulary matters later when we talk about signatures.
public static int clamp(int value, int min, int max) throws IllegalArgumentException {
if (min > max) throw new IllegalArgumentException("min > max");
return Math.max(min, Math.min(max, value));
}
Reading left to right: modifiers (public static), the return type (int, or void), the name, the parameter list, an optional throws clause, then the body. The name plus the parameter types form the method's signature (JLS §8.4.2). The return type and throws clause are not part of it, which is why you can't overload on return type alone.
The variables in the parentheses are parameters; the expressions you pass at the call site, like clamp(x + 1, 0, 10), are arguments.
02Picture it first: one frame per call
Think of each method call as a sticky note the thread slaps on top of a pile. The note holds that call's private scratch space. When the method returns, the note is torn off and thrown away. That pile is the thread's Java stack, and each note is a frame (JVMS §2.6).
A frame has three parts:
- A local variable array. Slot 0 is
thisfor instance methods, then the parameters in order, then the method's own locals.longanddoubletake two slots each. - An operand stack, the scratch area bytecode instructions push to and pop from.
- A link to the class's runtime constant pool, so instructions can resolve method and field references.
The compiler works out the exact sizes ahead of time (max_locals and max_stack in the class file), so pushing a frame is cheap: bump a pointer, no heap allocation.
Objects never live in a frame. A frame slot of reference type holds a pointer to an object on the heap. The figure below shows what that means for a call like replace(p): before the reassignment both slots point at the same object; after it, only the callee's copy moves.
Caller and callee frames each hold their own copy of the reference; reassigning the parameter re-points only the callee's copy, while the caller still sees the original heap object
If you remember one sentence, make it this: the method receives a copy of the arrow, not the box it points at.
03Under the hood: proving it with code
Try it yourself first
Before reading on, predict the single line this program prints. Write it down, then check.
public class PassByValue {
static final class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public String toString() { return "(" + x + "," + y + ")"; }
}
static void bump(int n) { n = n + 1; }
static void move(Point p) { p.x = 99; }
static void replace(Point p) { p = new Point(-1, -1); p.x = 42; }
static void swap(Point a, Point b) { Point t = a; a = b; b = t; }
static void shout(String s) { s = s.toUpperCase(); }
public static void main(String[] args) {
int n = 1;
bump(n);
Point p = new Point(1, 2);
move(p);
replace(p);
Point a = new Point(0, 0), b = new Point(5, 5);
swap(a, b);
String s = "hi";
shout(s);
System.out.println(n + " " + p + " " + a + " " + b + " " + s);
}
}
It prints 1 (99,2) (0,0) (5,5) hi. Notice what each call teaches:
bump(n): the callee changed its own copy of1. The caller'snis untouched.move(p): mutation through the reference. Both copies point at the same object, so the caller seesx = 99.replace(p): reassignment of the parameter. The callee's slot now points at a new object; the42lands on that new object, which becomes garbage on return.swap(a, b): the swap proof. If Java had pass-by-reference,aandbwould trade places inmain. They don't, because only the callee's two slots were swapped.shout(s):Stringis immutable, so there is no mutating method to call. Every "change" builds a newStringand reassigns the local copy. That's why strings look like they're passed differently. They aren't; you can only ever do thereplacecase with them.
You can see the copying in the bytecode. Compile a tiny method and run javap -c:
class Add {
static int add(int a, int b) { int sum = a + b; return sum; }
}
// javap -c Add (the add method):
// iload_0 push local slot 0 (a) onto the operand stack
// iload_1 push local slot 1 (b)
// iadd pop two ints, push their sum
// istore_2 pop into local slot 2 (sum)
// iload_2 push sum
// ireturn pop it and hand it to the caller's operand stack
The caller pushes argument values onto its operand stack; the invokestatic instruction pops them into the new frame's local slots 0 and 1. That's the copy, at the instruction level. For an object argument the instructions are aload/areturn instead, and what gets copied is a reference, never the object's fields.
04Return values and getting results out
Because you can't re-point the caller's variable, the idiomatic way to hand back a new value is the return value. On ireturn or areturn the JVM pops the value from the callee's operand stack, discards the frame and pushes the value onto the caller's operand stack.
When you need to return more than one thing, return a small object. Since Java 16, records make this cheap to write:
record MinMax(int min, int max) {}
static MinMax minMax(int[] values) {
int lo = Integer.MAX_VALUE, hi = Integer.MIN_VALUE;
for (int v : values) { lo = Math.min(lo, v); hi = Math.max(hi, v); }
return new MinMax(lo, hi);
}
The older trick is a mutable holder (an int[1] passed in and mutated). It works because of the move(p) rule, but a return value is easier to read and test.
A classic production bug: a helper "normalises" a request with req = req.withDefaults(); and returns void. Every caller keeps using the un-normalised object, and the bug only shows up when a default actually matters. The fix is always the same: return the new object and assign it at the call site. Code review rule of thumb: reassigning a parameter is a smell. Some teams mark parameters final or enable a checkstyle rule for exactly this reason.
05Recursion, StackOverflowError and -Xss
Every call pushes a frame, and frames are only popped on return. Recursion therefore grows the stack by one frame per level until the base case starts unwinding it. The next figure shows f(3), where f(n) is n <= 1 ? 1 : n * f(n - 1), growing the stack, then the frames being popped as each return hands its value down.
Recursive calls push one frame each on the thread's stack until the base case returns, then frames pop in reverse order; past the -Xss limit the JVM throws StackOverflowError
Each thread's stack has a fixed maximum size. On 64-bit Linux the HotSpot default is 1 MB (-XX:ThreadStackSize=1024); on Apple Silicon macOS it is 2 MB. When a push would exceed it, the JVM throws StackOverflowError, a VirtualMachineError, so it is an Error, not an Exception.
public class Depth {
static int depth = 0;
static void dive() { depth++; dive(); } // no base case
public static void main(String[] args) {
try {
dive();
} catch (StackOverflowError e) {
System.out.println("overflowed at depth " + depth);
}
}
}
On my machine this overflows at about 19,500 frames with -Xss1m and about 46,000 with the 2 MB default. The number moves between runs because interpreted frames are larger than JIT-compiled ones, and the JIT kicks in partway through.
You change the limit with -Xss, for example java -Xss4m App. It applies to every new platform thread, so raising it on a server with 2,000 threads reserves more virtual address space per thread (the memory is committed lazily, page by page, but the reservation is real). For a single deep task, prefer new Thread(group, task, "deep", 64L << 20): the fourth constructor argument requests a stack size for that one thread (the javadoc allows the JVM to treat it as a hint).
Many candidates say "catch StackOverflowError and carry on". That's frequently a mistake: the error can fire inside any method, including in the middle of a library updating its own state, leaving objects half-modified. Treat it as a bug signal. Fix the base case, or convert deep recursion to a loop with an explicit ArrayDeque as the stack.
HotSpot does not do tail-call elimination, even for a call in tail position. A recursive walk over a 100,000-node linked list will overflow a 1 MB stack; the iterative version uses constant stack and is typically faster because it avoids call overhead per element. Recursion is fine when depth is bounded by log n (balanced trees, divide-and-conquer sorts).
06Advanced: the edge cases interviewers probe
Arrays are objects, so passing one copies the reference. arr[0] = 7 inside a method is visible to the caller; arr = new int[5] is not.
final parameters only stop you reassigning the local copy. final List<String> list can still be list.add(...)ed. final is about the variable, never the object.
Wrappers behave like strings. Integer is immutable, so count++ on an Integer parameter unboxes, adds, boxes a new object and re-points the local copy. The caller sees nothing.
| You do this inside the method | Caller sees it? | Why |
|---|---|---|
| Assign a primitive parameter | No | Callee changed its own copy |
| Call a mutator / set a field via the reference | Yes | Both copies point at the same heap object |
| Assign a new object to the parameter | No | Only the callee's copy is re-pointed |
"Modify" a String or Integer |
No | Immutable: every change is a new object plus a reassignment |
| Write to an array element | Yes | The array is a heap object shared by both references |
07How to say it in the interview
When I ask this, I'm listening for three things: do you say "always pass-by-value" without hedging, can you explain that the value of an object variable is a reference, and can you back it up with the swap proof or the mutate-versus-reassign distinction. Bonus points if you can connect it to frames and the stack, because that's how you then explain StackOverflowError without memorising it.
In the interview
"Java is always pass-by-value. On every call the JVM pushes a new frame and copies each argument into a fresh parameter slot. For objects, the value being copied is the reference, so the method can mutate the object both references point at, but if it reassigns the parameter only its own copy moves. That's why you can't write a swap method for two variables in Java. Strings look different only because they're immutable, so all you can do is reassign. And since each call is a frame, unbounded recursion runs out of stack, which is the StackOverflowError you tune with -Xss."
Here's how this might look if you sketch it on the whiteboard:
On the whiteboard
Pass-by-value
- call = new frame: locals + operand stack
- args copied into param slots
- primitive: copy of number
- object: copy of reference (same heap object)
- mutate via ref: visible / reassign param: not visible
- swap(a, b) can't work: proof
- String/Integer immutable: only reassign
- deep recursion: StackOverflowError, -Xss (1 MB Linux x64)