Java Module 3 – Control flow, methods and recursion
Recursion and the call stack
Write recursive Java methods with clear base cases, see each call's stack frame, measure when StackOverflowError strikes, and turn deep recursion into loops.
What you will learn
- Write recursive methods with a base case that every call moves towards
- Trace the frames of a recursion on the call stack and explain StackOverflowError
- Estimate how many frames a stack holds, and why the count changes with the JIT
- Apply memoisation and halving to cut the number of calls and the depth
- Convert a tail recursion or a deep recursion into a loop
Before you start
What you’ll learn · Before you start
What you will learn
- Write recursive methods with a base case that every call moves towards
- Trace the frames of a recursion on the call stack and explain StackOverflowError
- Estimate how many frames a stack holds, and why the count changes with the JIT
- Apply memoisation and halving to cut the number of calls and the depth
- Convert a tail recursion or a deep recursion into a loop
Before you start
On this page
factorial(4) is 4 × factorial(3), and so on down to factorial(0), which is 1: five calls, each waiting for the
next. A recursion: A method that calls itself on a smaller version of its problem, until it reaches a case it answers directly, the base case. Each call waits in its own frame until the call it made returns. method calls itself on a smaller piece of its job,
until the piece is small enough to answer directly, the base case: The input a recursive method answers directly, without calling itself, such as factorial(0) = 1. Every other call must move closer to it, or the calls never end and the stack overflows.. Every call gets its own
frame on the thread’s stack, holding its parameters and local variables, and the frame stays there until that call
returns. The stack has a fixed size, so a recursion that goes too deep, or never reaches its base case, fills it, and
the JVM throws StackOverflowError.
You will step through the frames of a small recursion, measure how many frames fit on a thread’s stack, and use the three tools that make recursion practical: memoisation, halving the problem, and turning deep recursion into a loop. Java gives every call a frame, even a call in tail position, so the safe rule is: recursion when the depth stays small, a loop when it grows with the input.
A recursive call waits in its own frame for a smaller call
Every recursive method needs two things: a base case that returns without calling itself, and a recursive case that calls itself on a problem that is strictly closer to the base case. The program below prints each call on the way in and each result on the way out, indented by its depth:
// factorial(n) = n * factorial(n - 1), and factorial(0) = 1 ends the chain.
// The depth parameter only indents the trace, one step per frame on the stack.
long factorial(int n, int depth) {
String indent = " ".repeat(depth);
IO.println(indent + "call factorial(" + n + ")");
if (n == 0) {
IO.println(indent + "base case: return 1");
return 1;
}
long result = n * factorial(n - 1, depth + 1);
IO.println(indent + "return " + n + " * " + (result / n) + " = " + result);
return result;
}
void main() {
IO.println("factorial(4) = " + factorial(4, 0));
} Output
call factorial(4)
call factorial(3)
call factorial(2)
call factorial(1)
call factorial(0)
base case: return 1
return 1 * 1 = 1
return 2 * 1 = 2
return 3 * 2 = 6
return 4 * 6 = 24
factorial(4) = 24
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Factorial.java
Step through the same run frame by frame. Each call pushes a frame: The room a method call gets on its thread's stack: its parameters, its local variables and its own operand stack. The JVM creates it when the call starts and removes it when the call returns. onto the call stack: The frames of a thread's calls that have started and not yet returned, the most recent on top. Its size is fixed when the thread starts; a call that needs more room than is left throws StackOverflowError., and each return removes one:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The first frame. main calls factorial(4, 0), and a frame for that call is pushed onto the stack with its own n, 4, and its own depth, 0. The call prints call factorial(4).
- Three more frames. factorial(4) cannot finish before factorial(3) returns, so it calls it and waits. factorial(3), factorial(2) and factorial(1) do the same, each with its own n, each printed one step further in.
- The base case. factorial(0) finds n == 0, prints base case: return 1 and returns 1 without calling itself. Five frames of factorial are on the stack now, the deepest point of the run.
- 1 * 1. factorial(0)'s frame is removed. factorial(1) gets 1, prints return 1 * 1 = 1 and returns 1, and its own frame goes too.
- 2 * 1. factorial(2) gets 1 and returns 2 * 1 = 2. The multiplications run on the way back out, from the newest frame to the oldest.
- 3 * 2. factorial(3) gets 2 and returns 3 * 2 = 6, and its frame is removed in turn.
- 4 * 6. factorial(4) returns 4 * 6 = 24 to main, which prints factorial(4) = 24. Only main's frame is left, as before the first call.
The trace is the call stack drawn sideways. factorial(4) cannot finish until factorial(3) returns, which waits for
factorial(2), and so on: five frames are on the stack at the deepest point, each with its own n. The base case
returns first, and the multiplications happen on the way back out, from the innermost frame to the outermost.
Checkpoint
Check: A frame for every call
Without a base case, frames pile up until the stack overflows
Remove the base case and nothing stops the chain:
// The base case is missing, so every call makes another one until the stack is full.
long factorial(int n) {
return n * factorial(n - 1);
}
void main() {
IO.println(factorial(4));
} The session runs it twice: the first time head keeps the first four lines of what it printed, and the second time
grep -c counts the lines of the trace that name the method. After each $ comes the command:
from session import run
# head keeps the first four lines of the error; grep -c counts the lines of the whole trace that name factorial.
run("""
java NoBase.java 2>&1 | head -n 4
java NoBase.java 2>&1 | grep -c "at NoBase.factorial"
""") Output
# openjdk version "25.0.4.1" LTS $ java NoBase.java 2>&1 | head -n 4 Exception in thread "main" java.lang.StackOverflowError at NoBase.factorial(NoBase.java:3) at NoBase.factorial(NoBase.java:3) at NoBase.factorial(NoBase.java:3) $ java NoBase.java 2>&1 | grep -c "at NoBase.factorial" 1019
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nobase_session.py
Each call waits in its own frame until the call it made returns
Text description of the diagram
The figure shows the frames on the main thread's stack while factorial(4) runs, from the oldest frame at the top to the newest at the bottom.
- main called factorial(4) and waits for its result.
- factorial(4), with n = 4, called factorial(3) and waits; when that returns 6, it returns 4 * 6 = 24.
- factorial(3), with n = 3, called factorial(2) and waits; when that returns 2, it returns 3 * 2 = 6.
- factorial(2), with n = 2, called factorial(1) and waits; when that returns 1, it returns 2 * 1 = 2.
- factorial(1), with n = 1, called factorial(0) and waits; when that returns 1, it returns 1 * 1 = 1.
- factorial(0) is the base case and returns 1 at once, so it is the first frame to finish.
Each frame holds its own n. A dashed arrow leads from the last frame to a note: without the base case, calls would keep adding frames until the stack is full, and the JVM throws StackOverflowError.
Each call makes another one and none ever returns, so the frames keep coming, as the dashed path of the diagram
shows, until the JVM throws StackOverflowError. Its stack trace: What an uncaught exception prints: its class and message, then the calls that were open when it was thrown, the most recent first, each with its file and line, and any Caused by sections. More in “IDEs, compiler errors and your first debug” shows the same frame,
NoBase.factorial at line 3, over and over: 1,019 times, as the second command counted. The stack held many more
frames than that, as the next section measures, but the Throwable documentation allows a JVM to leave frames out of
a trace, and this one keeps only the newest. When you see one line repeated like that, look for a base case that is
missing or never reached.
A base case the calls can step over
if (n == 0) return; with a recursive call on n - 2 works for even numbers only: countdown(5) goes 5, 3, 1, -1
and never meets 0, and a negative argument never meets it either. Test n <= 0, so that every way past the base
case still ends there.
The result has its own limit. A long holds at most 9,223,372,036,854,775,807, so factorial(20), about
2.4 × 10¹⁸, is the largest factorial that fits; factorial(21) overflows silently, an
integer overflow: What happens when the result of +, -, * or ++ does not fit its integer type: Java keeps the lowest 32 or 64 bits of the true result, so the value wraps round without an error, and Integer.MAX_VALUE + 1 is Integer.MIN_VALUE. More in “Integer arithmetic, division and overflow”. For bigger values, java.math.BigInteger computes exactly.
Checkpoint
Check: The base case
The stack’s size limits the depth, and the JIT changes it
The Java Virtual Machine Specification gives each thread its own stack, created with the thread, and requires a
StackOverflowError when a computation needs a larger stack than is permitted. How many frames fit depends on the
stack’s size and on the size of each frame. This program counts:
Java (StackDepth.java) · StackDepth.java
// How many nested calls fit on the main thread's stack before StackOverflowError?
int depth = 0;
void dive() {
depth++;
dive();
}
void main() {
try {
dive();
} catch (StackOverflowError e) {
// Caught here only to count the frames. Real code should never try to carry on after this error.
}
IO.println("main thread: " + "%,d".formatted(depth) + " frames");
} Java (BigStack.java) · BigStack.java
// The same dive on a thread created with a 64 MiB stack instead of the default.
int depth = 0;
void dive() {
depth++;
dive();
}
void main() throws InterruptedException {
Thread big = Thread.ofPlatform().stackSize(64L << 20).start(() -> {
try {
dive();
} catch (StackOverflowError e) {
// counting only
}
});
big.join();
IO.println("64 MiB thread: " + "%,d".formatted(depth) + " frames");
} StackDepth.java catches the error only to count the frames, and BigStack.java starts a thread only to give it a
bigger stack; exceptions and threads have modules of their own later in this track. The session runs them three
ways:
from session import run
# -Xint runs every method in the interpreter, whose frames always have the same size, so the counts repeat exactly.
run("""
java -Xint StackDepth.java
java -Xint -Xss8m StackDepth.java
java -Xint BigStack.java
""") Output
# openjdk version "25.0.4.1" LTS $ java -Xint StackDepth.java main thread: 17,640 frames $ java -Xint -Xss8m StackDepth.java main thread: 73,814 frames $ java -Xint BigStack.java 64 MiB thread: 598,108 frames
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 depth_session.py
These runs use -Xint, which makes the JVM interpret every method instead of compiling the busy ones, so each frame
of dive() has the same size and the counts repeat exactly. The main thread’s stack is 2 MiB by default on macOS on
Apple silicon, as the java command’s documentation lists, -Xss8m sets an 8 MiB stack, and a thread built with
Thread.ofPlatform().stackSize(64L << 20) gets 64 MiB. Dividing each size by its count gives the room a frame takes:
| Stack | Frames that fit | Bytes per frame |
|---|---|---|
| 2 MiB, the default | 17,640 | about 119 |
8 MiB, with -Xss8m |
73,814 | about 114 |
| 64 MiB, a platform thread | 598,108 | about 112 |
The step from 2 MiB to 8 MiB added 56,174 frames for 6 MiB, 112 bytes each. The rest of each stack, about 120,000 bytes in
all three runs (2 MiB less 17,640 frames of 112 bytes), holds something other than the frames of dive(), which is
why the share per frame is highest in the smallest stack. A method with more parameters and local variables has
bigger frames, and fits fewer. With the JIT compilation: Just-in-time compilation: while a program runs, the JVM compiles the methods that run often from bytecode to native machine code, so a Java program gets faster after a short warm-up. The option -Xint switches it off. More in “What Java is, how it runs and where it is used” on, which is how Java
normally runs, the same program gives a different count each time:
from session import run
# With the JIT compiler on (the default), the count changes from one run to the next.
run("""
java StackDepth.java
java StackDepth.java
java StackDepth.java
""") Output
# openjdk version "25.0.4.1" LTS $ java StackDepth.java main thread: 36,140 frames $ java StackDepth.java main thread: 40,352 frames $ java StackDepth.java main thread: 36,072 frames
This output changes from run to run: the JIT compiler turns dive() into machine code partway through the dive, at a moment that varies from run to run, and compiled frames are smaller than interpreted ones
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 jit_session.py
The JIT compiles dive() while the dive is under way, and from then on each frame is smaller, so the count depends on
when compilation finished. The documentation of stackSize warns that its effect is “highly platform dependent”, and
the JVM may treat it as a suggestion. So never write code that needs a particular depth to work: if the input decides
how deep a recursion goes, a big enough input will overflow some stack somewhere.
Key idea
The depth a recursion may reach depends on the stack’s size, each frame’s size and the JIT, so code that works only up to some depth fails on some input, on some machine.
Checkpoint
Check: How deep the stack goes
Tail calls still use the stack
A call is in tail position when it is the last thing a method does, as in return sumTo(n - 1, total + n);. Some
languages reuse the caller’s frame for such a tail call: A call that is the last thing a method does, its result returned as it is, as in return sumTo(n - 1, total + n);. HotSpot still gives it a frame of its own, so tail recursion can overflow the stack.. HotSpot, the JVM in this JDK,
does not:
// The recursive call is the last thing sumTo does (a tail call), yet each call still needs a frame of its own.
long sumTo(long n, long total) {
if (n == 0) {
return total;
}
return sumTo(n - 1, total + n);
}
// The same computation as a loop: one frame, whatever n is.
long sumToLoop(long n) {
long total = 0;
while (n > 0) {
total += n;
n--;
}
return total;
}
void main() {
IO.println("sumTo(3, 0) = " + sumTo(3, 0));
IO.println("sumToLoop(3) = " + sumToLoop(3));
IO.println("sumTo(1000, 0) = " + sumTo(1000, 0));
IO.println("sumToLoop(1000000) = " + sumToLoop(1_000_000));
try {
IO.println("sumTo(1000000, 0) = " + sumTo(1_000_000, 0));
} catch (StackOverflowError e) {
IO.println("sumTo(1000000, 0): StackOverflowError");
}
} Output
sumTo(3, 0) = 6 sumToLoop(3) = 6 sumTo(1000, 0) = 500500 sumToLoop(1000000) = 500000500000 sumTo(1000000, 0): StackOverflowError
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java TailSum.java
Step through sumTo(3, 0) beside the loop that computes the same total:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A tail call. sumTo(3, 0) returns sumTo(2, 3) as the very last thing it does, with no work left after it. The JVM still gives the new call a frame of its own, below the first one.
- Four frames. sumTo(1, 5) and sumTo(0, 6) follow the same way, so four frames of sumTo are on the stack for n = 3, each holding an n and a total.
- Passing 6 back. sumTo(0, 6) returns 6, and each frame hands the same 6 to the one above it unchanged, so the run printed sumTo(3, 0) = 6.
- The loop. sumToLoop(3) keeps n and total as two variables of one frame and changes them: 3 and 0, then 2 and 3, 1 and 5, 0 and 6, the same values the four recursive calls received. It also returns 6.
- A million. For n = 1,000,000 the recursion needs a million frames, and the run ended in StackOverflowError. The loop stayed in its one frame and printed 500000500000.
Three nested calls, or a thousand, are fine, but a million need a million frames, and the stack overflows although no frame has any work left to do after its call. The loop computes the same total in one frame. A tail-recursive method turns into a loop almost mechanically: the parameters become variables, and the recursive call becomes an update of those variables followed by another pass.
Checkpoint
Check: Tail calls
Memoisation computes each smaller problem once
Some recursions solve the same small problem many times. The Fibonacci numbers are the classic case: fib(n) calls
fib(n - 1) and fib(n - 2), and fib(n - 1) calls fib(n - 2) again.
long calls = 0;
// Plain recursion: fib(n - 1) and fib(n - 2) solve the same smaller problems again and again.
long fib(int n) {
calls++;
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
// Memoised: each answer is worked out once and kept in memo[n]; 0 means "not known yet".
long[] memo = new long[93];
long fibMemo(int n) {
calls++;
if (n < 2) {
return n;
}
if (memo[n] != 0) {
return memo[n];
}
memo[n] = fibMemo(n - 1) + fibMemo(n - 2);
return memo[n];
}
void main() {
for (int n : new int[] {5, 10, 20, 30, 40}) {
calls = 0;
long value = fib(n);
IO.println("fib(" + n + ") = " + value + " after " + "%,d".formatted(calls) + " calls");
}
for (int n : new int[] {5, 40, 90}) {
calls = 0;
memo = new long[93];
long value = fibMemo(n);
IO.println("fibMemo(" + n + ") = " + value + " after " + calls + " calls");
}
} Output
fib(5) = 5 after 15 calls fib(10) = 55 after 177 calls fib(20) = 6765 after 21,891 calls fib(30) = 832040 after 2,692,537 calls fib(40) = 102334155 after 331,160,281 calls fibMemo(5) = 5 after 9 calls fibMemo(40) = 102334155 after 79 calls fibMemo(90) = 2880067194370816120 after 179 calls
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Fibonacci.java
The plain version made 15 calls for fib(5) and 331,160,281 for fib(40), and the count grows by a factor of about
1.6 with each step of n, the same rate as the Fibonacci numbers themselves. Its depth, though, is only 40: the
problem is the number of calls, not the stack. memoisation: Keeping each answer of a function once it is worked out, in an array or a map, so that a later call with the same argument returns it at once instead of working it out again. keeps each answer once it is
known, here in an array, so every value from 2 to n is worked out only once. Step through fibMemo(5) and watch the
array fill:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Down one level at a time. fibMemo(5) finds memo[5] empty and calls fibMemo(4), which calls fibMemo(3), which calls fibMemo(2). Each first call goes one level down: 4 calls so far.
- The base cases. fibMemo(2) calls fibMemo(1) and fibMemo(0), which return 1 and 0 at once. fibMemo(2) keeps 1 + 0 = 1 in memo[2]: 6 calls so far.
- memo[3]. Back in fibMemo(3), the second call, fibMemo(1), returns 1, so memo[3] = 1 + 1 = 2: 7 calls.
- A known answer. fibMemo(4) needs fibMemo(2) again. memo[2] is already 1, so this call returns at once without calling anything, and memo[4] = 2 + 1 = 3: 8 calls.
- memo[5]. fibMemo(5) needs fibMemo(3), and memo[3] is known, so memo[5] = 3 + 2 = 5, after 9 calls in all, where the plain fib(5) made 15.
- 2n - 1 calls. The first calls go down from fibMemo(n) to fibMemo(0), n + 1 calls, and each level from 3 up makes one more call that returns at once, n - 2 calls: 2n - 1 in all, 79 for fib(40) and 179 for fib(90), as the run printed.
fibMemo(5) made 9 calls where fib(5) made 15, and for any n of 1 or more the memoised version makes 2n − 1: 79
calls for fib(40) and 179 for fib(90). It cuts the calls, not the depth: the first calls still go down one level
at a time, so fibMemo(40) is 40 frames deep at its deepest. A HashMap does the same job when the arguments are not
small numbers. The array has 93 places because fib(92) is the largest Fibonacci number that fits in a long.
Checkpoint
Check: Memoisation
Halving the problem keeps the depth tiny
A recursion is safe when each call makes the problem much smaller, not just one step smaller. Raising a number to a power shows the difference:
int calls = 0;
// base^exp the slow way: one call, and one multiplication, for each unit of exp.
long powerSlow(long base, int exp) {
calls++;
if (exp == 0) {
return 1;
}
return base * powerSlow(base, exp - 1);
}
// Halving: base^exp = (base^(exp / 2))^2, times one more base when exp is odd.
long power(long base, int exp) {
calls++;
if (exp == 0) {
return 1;
}
long half = power(base, exp / 2);
long square = half * half;
return exp % 2 == 0 ? square : square * base;
}
void main() {
calls = 0;
IO.println("powerSlow(3, 39) = " + powerSlow(3, 39) + " after " + calls + " calls");
calls = 0;
IO.println("power(3, 39) = " + power(3, 39) + " after " + calls + " calls");
calls = 0;
IO.println("power(1, 1000000000) = " + power(1, 1_000_000_000) + " after " + calls + " calls");
calls = 0;
IO.println("power(2, 62) = " + power(2, 62) + " after " + calls + " calls");
calls = 0;
IO.println("power(2, 64) = " + power(2, 64) + " after " + calls + " calls");
} Output
powerSlow(3, 39) = 4052555153018976267 after 40 calls power(3, 39) = 4052555153018976267 after 7 calls power(1, 1000000000) = 1 after 31 calls power(2, 62) = 4611686018427387904 after 7 calls power(2, 64) = 0 after 8 calls
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java FastPower.java
Step through power(3, 39): down through the halved exponents, then back up through the squares:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Halving on the way in. power(3, 39) calls power(3, 19): 39 / 2 is 19, because integer division drops the remainder. The odd 39 will need one more factor of 3 on the way out.
- Down to 0. 19 halves to 9, then 4, 2, 1 and 0: seven calls in all, one for each binary digit of 39 and one for the base case, where powerSlow needs 40.
- The base case. power(3, 0) returns 1 without calling itself, and the squaring starts on the way back up.
- 3, 9 and 81. exp 1 is odd: 1 squared, times 3, is 3. exp 2 is even: 3 squared is 9. exp 4: 9 squared is 81.
- 19,683. exp 9 is odd: 81 squared is 6,561, and times 3 that is 19,683, which is 3 to the 9th.
- 3 to the 39th. exp 19: 19,683 squared, times 3, is 1,162,261,467. exp 39: that squared, times 3, is 4,052,555,153,018,976,267, the number both methods printed.
powerSlow makes one call per unit of the exponent, 40 calls for 3³⁹, and an exponent of a billion would overflow the
stack. power uses base^exp = (base^(exp / 2))², times one more base when the exponent is odd, so each call halves
the exponent: 7 calls for 3³⁹, and only 31 for an exponent of a billion, as the third line shows. The depth grows with the number of binary
digits of the exponent, which keeps it tiny. The last line is a reminder that recursion does not protect you from
overflow: 2⁶⁴ does not fit in a long, and the result wraps to 0. Computing powers modulo a number, as the exercise
below does, keeps every intermediate value small.
Checkpoint
Check: Halving the problem
When the depth grows with the input, use a loop
When the depth of a recursion grows with the size of the input (one call per character, per list element or per unit of a count), rewrite it as a loop before a large input reaches it:
- Tail recursion: replace the call with a loop that updates the parameters, as
sumToLoopdoes. - A running count or total: keep the state in local variables, and visit the input once with
fororwhile. - Branching recursion over trees or grids: keep your own stack of work still to do (an
ArrayDeque, from the collections module) and loop until it is empty, so the size of the input lives on the heap instead of the thread’s stack.
Raising the stack size with -Xss or a thread with a larger stack only moves the limit, and catching
StackOverflowError is not a fix: the error can strike in the middle of any method on the stack and leave its work
half done. Answer the questions below for a recursion of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Shape | Depth on the stack | Repeated work | Reads like the problem |
|---|---|---|---|
| plain recursion Suits: a small, bounded depth | Poor one frame per step: sumTo(1000000, 0) overflowed | Poor fib(40) made 331,160,281 calls | Good factorial(n) = n * factorial(n - 1), as the definition reads |
| recursion that halves Suits: powers, balanced splits | Good one frame per binary digit: 31 calls for an exponent of a billion | Good one call per halving, nothing worked out twice | Good base^exp is (base^(exp / 2))², times base when exp is odd |
| memoised recursion Suits: the same subproblems again | Mixed fibMemo(40) still goes 40 frames deep | Good fib(40) in 79 calls instead of 331,160,281 | Good the same recursion, plus one array or map of answers |
| a loop with variables Suits: one step at a time, no branching | Good one frame: sumToLoop(1000000) printed 500000500000 | Mixed each step runs once, but only for a problem that does not branch | Mixed the parameters become variables and the call an update |
| a loop with your own stack Suits: deep trees and grids | Good the work still to do sits on the heap, in an ArrayDeque | Mixed as much work as the recursion it replaces | Poor more code: push the work, then pop it until the stack is empty |
How the answers decide
- “How deep can the calls go for the largest input you expect?” One level per item, character or unit of a count: rules out plain recursion and memoised recursion and adds a caveat to recursion that halves. When the depth grows with the input, a big enough input overflows the stack, as sumTo(1000000, 0) did. Memoisation saves calls, not frames, and halving helps only where the problem splits in halves.
- “How deep can the calls go for the largest input you expect?” A few dozen levels, such as the binary digits of a number: adds a caveat to a loop with your own stack. A depth bounded by something small never comes near the 17,640 frames the default stack held, so the recursion can stay as it is, and a stack of your own is only more code.
- “Does the recursion work out the same smaller problem more than once?” Yes, as fib does: rules out plain recursion. When the same smaller problems come back, the calls multiply: fib(40) made 331,160,281 of them. Keep each answer once it is known, as fibMemo does.
- “Does one call make several recursive calls, as a walk over a tree or a grid does?” Yes, it branches and “How deep can the calls go for the largest input you expect?” One level per item, character or unit of a count: rules out a loop with variables. A branching recursion leaves work to come back to, which plain variables cannot hold. Keep it on a stack of your own, such as an ArrayDeque, and loop until that stack is empty.
- “Does one call make several recursive calls, as a walk over a tree or a grid does?” No, one call at most and “How deep can the calls go for the largest input you expect?” One level per item, character or unit of a count: adds a caveat to a loop with your own stack. With one call at most per step there is nothing to come back to, so the variables of a loop are enough, as in sumToLoop.
In short: recursion stays the clearer choice when the depth is bounded by something small, such as the height of a balanced tree or the number of binary digits of an exponent; memoise when the same smaller problems come back; and write a loop, with a stack of your own where the problem branches, when the depth grows with the input.
Checkpoint
Check: Recursion or a loop
Practise: modular power and bracket depth
RecursionCheck.java has two methods that work on small inputs and overflow the stack on large ones, so three of its
eleven checks fail and the program ends with exit status 1:
// Try it yourself: rewrite both methods so that every check passes, then run java RecursionCheck.java.
//
// power(base, exp, mod) is base to the power exp, modulo mod, for base >= 0, exp >= 0 and 1 <= mod <= 2,000,000,000
// (so that the product of two remainders still fits in a long). maxDepth(parens) is the deepest nesting of a balanced
// string of '(' and ')': 0 for "", 1 for "()()", 3 for "((()))".
long power(long base, int exp, long mod) {
if (exp == 0) {
return 1 % mod;
}
return base % mod * power(base, exp - 1, mod) % mod; // one call for each unit of exp
}
int maxDepth(String parens) {
return deepest(parens, 0, 0);
}
// The deepest level from position i on, when the level before position i is depth: one call per character.
int deepest(String s, int i, int depth) {
if (i == s.length()) {
return 0;
}
int here = s.charAt(i) == '(' ? depth + 1 : depth - 1;
return Math.max(here, deepest(s, i + 1, here));
}
int passed = 0;
int total = 0;
void report(String call, String got, String expected) {
total++;
if (got.equals(expected)) {
passed++;
IO.println("PASS " + call + " is " + got);
} else {
IO.println("FAIL " + call + " is " + got + ", expected " + expected);
}
}
void checkPower(long base, int exp, long mod, long expected) {
String call = "power(" + base + ", " + exp + ", " + mod + ")";
try {
report(call, String.valueOf(power(base, exp, mod)), String.valueOf(expected));
} catch (StackOverflowError e) {
report(call, "a StackOverflowError", String.valueOf(expected));
}
}
void checkDepth(String name, String parens, int expected) {
String call = "maxDepth(" + name + ")";
try {
report(call, String.valueOf(maxDepth(parens)), String.valueOf(expected));
} catch (StackOverflowError e) {
report(call, "a StackOverflowError", String.valueOf(expected));
}
}
void main() {
checkPower(2, 10, 1000, 24);
checkPower(3, 0, 7, 1);
checkPower(5, 3, 1, 0);
checkPower(10, 18, 1_000_000_007, 49);
checkPower(7, 1_000_000_000, 1_000_000_007, 312_556_845);
checkPower(123_456_789, 987_654_321, 1_000_000_007, 652_541_198);
checkDepth("\"\"", "", 0);
checkDepth("\"()()\"", "()()", 1);
checkDepth("\"(())()\"", "(())()", 2);
checkDepth("\"((()))\"", "((()))", 3);
checkDepth("a million nested pairs", "(".repeat(1_000_000) + ")".repeat(1_000_000), 1_000_000);
IO.println(passed + " of " + total + " checks pass");
if (passed < total) {
System.exit(1);
}
} Output (exit status 1)
PASS power(2, 10, 1000) is 24
PASS power(3, 0, 7) is 1
PASS power(5, 3, 1) is 0
PASS power(10, 18, 1000000007) is 49
FAIL power(7, 1000000000, 1000000007) is a StackOverflowError, expected 312556845
FAIL power(123456789, 987654321, 1000000007) is a StackOverflowError, expected 652541198
PASS maxDepth("") is 0
PASS maxDepth("()()") is 1
PASS maxDepth("(())()") is 2
PASS maxDepth("((()))") is 3
FAIL maxDepth(a million nested pairs) is a StackOverflowError, expected 1000000
8 of 11 checks pass
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java RecursionCheck.java
- Copy
RecursionCheck.javaand rewritepowerandmaxDepth. - Run it with
java RecursionCheck.java. - You are done when the last line reads “11 of 11 checks pass”.
A first hint: power can stay recursive if each call halves the exponent, as FastPower.java does; take the
remainder after every multiplication. maxDepth needs one counter that goes up at ( and down at ), and a loop
that remembers the highest value it reached.
Then work through the practice below. It moves from frames to loops, begins with the recursion idea you answer worst, and explains every option as soon as you choose:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a base case in recursion, and what happens if a recursive method has none?
The base case is the input a recursive method answers directly, without calling itself: factorial(0) returns 1,
and a search of an empty range returns “not found”. Every other call must move closer to a base case, by making the
problem smaller. Without a base case, or when the calls never reach it, each call starts another, every call adds a
frame to the thread’s stack, and none of them returns. The compiler does not detect this; at run time the stack fills
up after some tens of thousands of frames on the default stack, and the JVM throws StackOverflowError, whose trace
repeats the same method line over and over. Check the smallest inputs, such as 0, 1 and an empty string, first.
The next question is usually about a deeper input or a repeated subproblem. Here is one of each; choose, then see why each option is right or wrong:
Checkpoint
Interview round: two follow-up questions
Two longer questions of this module build on this lesson: a folder walk that meets an archive nested 50,000 levels
deep, and how long a plain recursive fib(50) takes compared with the faster ways to compute it.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A recursive method walks the nested folders of an uploaded archive. It passes every test, but one customer's upload is nested 50,000 levels deep and the method throws StackOverflowError. What are your options, what does each one cost, and which would you ship?
-
Fresher to mid
A plain recursive fib(40) takes about a fifth of a second on a laptop. Estimate how long fib(50) takes, explain why, and compare memoisation, a bottom-up table and a loop that keeps only two values. What changes once n passes 92?
Key takeaways
- A recursive method needs a base case that returns directly and that every call is sure to reach, and a recursive case that makes the problem smaller.
- Each call has its own frame on the thread’s stack until it returns, so a missing base case, or a depth that grows with
the input, ends in
StackOverflowError. - On this JDK the default 2 MiB stack held 17,640 frames of a trivial method in the interpreter, about 112 bytes each; with the JIT on the count changes from run to run, so never rely on an exact depth.
- Java does not remove tail calls, so tail recursion overflows like any other; rewrite it as a loop whose variables take the parameters’ place.
- Memoisation cut
fib(40)from 331,160,281 calls to 79 but not its depth, and halving the problem kept the depth at 31 calls for an exponent of a billion. - When the depth grows with the input, write a loop, with a stack of your own on the heap where the recursion branches.
Terms in this lesson
- base case
- The input a recursive method answers directly, without calling itself, such as factorial(0) = 1. Every other call must move closer to it, or the calls never end and the stack overflows.
- call stack
- The frames of a thread's calls that have started and not yet returned, the most recent on top. Its size is fixed when the thread starts; a call that needs more room than is left throws StackOverflowError.
- frame
- The room a method call gets on its thread's stack: its parameters, its local variables and its own operand stack. The JVM creates it when the call starts and removes it when the call returns.
- memoisation
- Keeping each answer of a function once it is worked out, in an array or a map, so that a later call with the same argument returns it at once instead of working it out again.
- recursion
- A method that calls itself on a smaller version of its problem, until it reaches a case it answers directly, the base case. Each call waits in its own frame until the call it made returns.
- tail call
- A call that is the last thing a method does, its result returned as it is, as in return sumTo(n - 1, total + n);. HotSpot still gives it a frame of its own, so tail recursion can overflow the stack.
References
- The Java Virtual Machine Specification, Java SE 25: 2.5.2 Java Virtual Machine Stacks (Oracle)
- The Java Virtual Machine Specification, Java SE 25: 2.6 Frames (Oracle)
- StackOverflowError (Java SE 25 and JDK 25) (Oracle)
- Throwable (Java SE 25 and JDK 25): getStackTrace (Oracle)
- Thread.Builder.OfPlatform (Java SE 25 and JDK 25) (Oracle)
- The java Command (JDK 25) (Oracle)
- The Java Language Specification, Java SE 25: 4.2.1 Integral Types and Values (Oracle)
- ArrayDeque (Java SE 25 and JDK 25) (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress