Fix: Java StackOverflowError – Infinite Recursion, Circular References, and Stack Size
Quick Answer
How to fix java.lang.StackOverflowError caused by infinite recursion, circular references in toString or equals, JPA bidirectional relationships, and Spring circular dependencies.
The Error
Your Java application crashes with this stack trace:
Exception in thread "main" java.lang.StackOverflowError
at com.example.util.TreeNode.traverse(TreeNode.java:28)
at com.example.util.TreeNode.traverse(TreeNode.java:29)
at com.example.util.TreeNode.traverse(TreeNode.java:29)
at com.example.util.TreeNode.traverse(TreeNode.java:29)
... (thousands of repeated lines)The stack trace repeats the same method call over and over. The JVM ran out of stack space and killed your thread.
This error is different from an OutOfMemoryError. OutOfMemoryError means the heap is full. StackOverflowError means the call stack for a single thread is full. Every method call pushes a new frame onto the stack. When there are too many nested calls, the stack overflows.
Why This Happens
Each thread in Java gets its own call stack. The default stack size is typically 512 KB to 1 MB, depending on the JVM and operating system. Every time you call a method, the JVM pushes a stack frame onto the stack. That frame holds local variables, the return address, and operand data.
When a method calls itself recursively without a proper base case, or when two methods call each other in an infinite loop, stack frames pile up until the stack is full. At that point, the JVM throws java.lang.StackOverflowError.
The most common causes:
- Infinite recursion — a recursive method with a missing or incorrect base case.
- Deep recursion on large data — the base case exists, but the input is so large that the recursion depth exceeds the stack size.
- Circular
toString(),hashCode(), orequals()calls — two objects reference each other, and callingtoString()on one triggerstoString()on the other, which calls back to the first. - JPA/Hibernate bidirectional relationships — entity A references entity B, and entity B references entity A. Serializing or logging either entity triggers an infinite loop.
- Spring circular dependencies — bean A depends on bean B, and bean B depends on bean A, causing infinite construction loops in certain configurations.
Fix 1: Add or Fix the Base Case in Recursive Methods
The most common cause of StackOverflowError is a recursive method that never stops calling itself.
Here is the broken code:
public int factorial(int n) {
return n * factorial(n - 1); // No base case — runs forever
}The fix is straightforward. Add a base case that stops the recursion:
public int factorial(int n) {
if (n <= 1) {
return 1; // Base case stops recursion
}
return n * factorial(n - 1);
}Check for these common mistakes in recursive methods:
- Missing base case entirely. The method always calls itself.
- Base case that is never reached. For example, checking
n == 0but passing negative numbers. - Wrong recursive step. The method calls itself with the same arguments instead of moving toward the base case.
// Bug: wrong direction — n grows instead of shrinking
public int brokenSum(int n) {
if (n >= 100) return 0;
return n + brokenSum(n + 1); // Only works if n < 100
}Pro Tip: When writing recursive methods, always start by defining the base case first. Write the termination condition before the recursive call. This habit prevents most
StackOverflowErrorbugs before they happen.
Fix 2: Convert Recursion to Iteration
Some problems involve input so large that recursion will overflow the stack even with a correct base case. The default stack can handle roughly 5,000 to 20,000 frames depending on how much memory each frame uses. If your data structure has millions of nodes, recursion is not the right approach.
Convert the recursive method to an iterative one using an explicit stack or queue:
Recursive version (can overflow on deep trees):
public void traverse(TreeNode node) {
if (node == null) return;
process(node);
traverse(node.left);
traverse(node.right);
}Iterative version (safe for any depth):
public void traverse(TreeNode root) {
if (root == null) return;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
process(node);
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
}The iterative version uses the heap instead of the call stack. The heap is typically hundreds of megabytes or gigabytes, so it can handle millions of nodes without issue.
Other common conversions:
Recursive linked list traversal:
// Recursive — can overflow on long lists
public void printAll(Node node) {
if (node == null) return;
System.out.println(node.value);
printAll(node.next);
}
// Iterative — safe
public void printAll(Node node) {
while (node != null) {
System.out.println(node.value);
node = node.next;
}
}Tail recursion note: Java does not optimize tail-recursive calls. Unlike Scala or Kotlin on the JVM, the standard Java compiler does not eliminate tail calls. Do not rely on tail recursion to prevent stack overflows in Java.
Fix 3: Break Circular References in toString(), hashCode(), and equals()
When two objects reference each other, calling toString() on one can trigger an infinite loop:
public class Order {
private Customer customer;
@Override
public String toString() {
return "Order{customer=" + customer + "}"; // Calls customer.toString()
}
}
public class Customer {
private List<Order> orders;
@Override
public String toString() {
return "Customer{orders=" + orders + "}"; // Calls each order.toString()
}
}Calling order.toString() calls customer.toString(), which iterates over orders and calls order.toString() again. Stack overflow.
Fix: exclude the back-reference from toString():
public class Order {
private Customer customer;
@Override
public String toString() {
return "Order{customerId=" + customer.getId() + "}"; // Use ID, not the full object
}
}The same problem happens with hashCode() and equals(). If Order.hashCode() includes the full Customer object, and Customer.hashCode() includes the list of Order objects, you get infinite recursion.
Rule of thumb: In bidirectional relationships, only one side should include the other in toString(), hashCode(), and equals(). The other side should use an identifier (like an ID field) instead.
Common Mistake: Using Lombok’s
@Dataor@ToStringannotations on both sides of a bidirectional relationship. Lombok generatestoString()andhashCode()methods that include all fields by default. You must explicitly exclude the back-reference:@Data @ToString(exclude = "orders") // Prevent circular toString @EqualsAndHashCode(exclude = "orders") // Prevent circular hashCode/equals public class Customer { private List<Order> orders; }
Fix 4: Handle JPA/Hibernate Bidirectional Relationships
This is one of the most common causes of StackOverflowError in Spring Boot applications. JPA entities with @OneToMany and @ManyToOne relationships create bidirectional references that trigger infinite loops during serialization.
@Entity
public class Author {
@Id
private Long id;
@OneToMany(mappedBy = "author")
private List<Book> books; // Author → Book
}
@Entity
public class Book {
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author; // Book → Author
}When Jackson (the JSON serializer in Spring Boot) tries to serialize an Author, it serializes books. Each Book has an author field, so Jackson serializes the Author again. Infinite loop. StackOverflowError.
Fix option 1: Use @JsonIgnore
@Entity
public class Book {
@ManyToOne
@JoinColumn(name = "author_id")
@JsonIgnore // Jackson will skip this field during serialization
private Author author;
}Fix option 2: Use @JsonManagedReference and @JsonBackReference
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@JsonManagedReference // This side gets serialized
private List<Book> books;
}
@Entity
public class Book {
@ManyToOne
@JoinColumn(name = "author_id")
@JsonBackReference // This side is skipped during serialization
private Author author;
}Fix option 3: Use DTOs
The cleanest solution for REST APIs is to use Data Transfer Objects instead of returning entities directly:
public class AuthorDTO {
private Long id;
private String name;
private List<Long> bookIds; // Just IDs, not full objects
public static AuthorDTO from(Author author) {
AuthorDTO dto = new AuthorDTO();
dto.id = author.getId();
dto.name = author.getName();
dto.bookIds = author.getBooks().stream()
.map(Book::getId)
.collect(Collectors.toList());
return dto;
}
}DTOs avoid the problem entirely because you control exactly what gets serialized. This also prevents accidentally leaking internal fields to your API consumers.
If your integration tests fail with StackOverflowError and you see Hibernate or Jackson in the stack trace, entity relationships are almost certainly the cause. This is a frequent issue in projects using Gradle or Maven where serialization runs during test assertions or logging.
Fix 5: Resolve Spring Circular Dependencies
Spring throws a StackOverflowError (or BeanCurrentlyInCreationException) when two beans depend on each other through constructor injection:
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(ServiceB serviceB) { // Needs ServiceB
this.serviceB = serviceB;
}
}
@Service
public class ServiceB {
private final ServiceA serviceA;
public ServiceB(ServiceA serviceA) { // Needs ServiceA
this.serviceA = serviceA;
}
}Spring tries to create ServiceA, which needs ServiceB, which needs ServiceA — infinite loop.
Fix option 1: Redesign to break the cycle
This is the best solution. Extract the shared logic into a third service:
@Service
public class SharedService {
// Move the common logic here
}
@Service
public class ServiceA {
private final SharedService sharedService;
public ServiceA(SharedService sharedService) {
this.sharedService = sharedService;
}
}
@Service
public class ServiceB {
private final SharedService sharedService;
public ServiceB(SharedService sharedService) {
this.sharedService = sharedService;
}
}Fix option 2: Use @Lazy on one dependency
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) { // Lazy proxy breaks the cycle
this.serviceB = serviceB;
}
}The @Lazy annotation tells Spring to inject a proxy instead of the real bean. The real bean is created only when a method on it is first called.
Fix option 3: Use setter injection on one side
@Service
public class ServiceA {
private ServiceB serviceB;
@Autowired
public void setServiceB(ServiceB serviceB) {
this.serviceB = serviceB;
}
}Setter injection allows Spring to create the bean first and inject the dependency later, breaking the cycle. However, constructor injection is generally preferred in modern Spring applications, so redesigning the dependency graph (option 1) is the recommended approach.
Note: Starting with Spring Boot 3.x, circular dependencies cause an error by default instead of being silently resolved. You can temporarily allow them with spring.main.allow-circular-references=true in application.properties, but this is a workaround, not a fix.
Fix 6: Increase the Thread Stack Size with -Xss
If your recursion is intentional and you know the maximum depth, you can increase the stack size. The -Xss JVM flag sets the stack size for all threads:
java -Xss2m -jar myapp.jarCommon values:
| Flag | Stack Size | Approximate Max Depth |
|---|---|---|
-Xss512k | 512 KB (default on many JVMs) | ~5,000–10,000 frames |
-Xss1m | 1 MB | ~10,000–20,000 frames |
-Xss2m | 2 MB | ~20,000–40,000 frames |
-Xss4m | 4 MB | ~40,000–80,000 frames |
-Xss8m | 8 MB | ~80,000–160,000 frames |
The exact depth depends on the size of each stack frame (number and type of local variables).
Warning: Increasing the stack size applies to every thread. If your application uses hundreds of threads (common in web servers), -Xss8m means each thread uses 8 MB of stack memory. With 200 threads, that is 1.6 GB just for stacks. This can cause OutOfMemoryError instead.
For build tool configuration, add the flag to your JVM arguments. In Gradle:
// build.gradle
test {
jvmArgs '-Xss2m'
}In Maven:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Xss2m</argLine>
</configuration>
</plugin>Fix 7: Set Stack Size for Specific Threads
If only one thread needs a larger stack, do not increase the stack size globally. Use the Thread constructor that accepts a stack size parameter:
Runnable task = () -> {
deepRecursiveMethod(100_000);
};
Thread thread = new Thread(null, task, "deep-recursion-thread", 4 * 1024 * 1024); // 4 MB stack
thread.start();
thread.join();The four-argument Thread constructor takes a ThreadGroup, a Runnable, a thread name, and the stack size in bytes. This only affects the single thread, leaving all other threads at the default stack size.
With ExecutorService, you can use a custom ThreadFactory:
ThreadFactory factory = r -> {
Thread t = new Thread(null, r, "big-stack-thread", 4 * 1024 * 1024);
t.setDaemon(true);
return t;
};
ExecutorService executor = Executors.newSingleThreadExecutor(factory);
executor.submit(() -> deepRecursiveMethod(100_000));This approach keeps memory usage predictable. Only the threads that actually need deep stacks get them.
Fix 8: Debug the Stack Overflow
When the cause is not obvious, you need to inspect the stack trace carefully.
Step 1: Find the repeating pattern
The stack trace of a StackOverflowError is long, but it contains a repeating cycle. Look for the pattern:
at com.example.A.methodX(A.java:15)
at com.example.B.methodY(B.java:32)
at com.example.A.methodX(A.java:15)
at com.example.B.methodY(B.java:32)This tells you methodX calls methodY, and methodY calls methodX.
Step 2: Check the full stack trace
By default, the JVM may truncate the stack trace. To see the full trace, add this flag:
java -XX:MaxJavaStackTraceDepth=0 -jar myapp.jarSetting it to 0 means unlimited depth. This helps when you need to see the bottom of the stack to understand how the recursion started.
Step 3: Use a debugger
Set a conditional breakpoint in your IDE at the start of the suspected recursive method. Set the condition to trigger after a certain number of hits (e.g., hit count > 100). When it triggers, inspect the call stack and local variables to understand why the recursion is not terminating.
In IntelliJ IDEA:
- Right-click the breakpoint.
- Select More (or Properties).
- Enable Pass count and set it to a high number like 100.
This lets the recursion run for a while before pausing, so you can inspect the state near the overflow point.
If you are debugging an error that involves a class not being found during the recursive calls, check for ClassNotFoundException issues separately. Missing classes can cause unexpected behavior in recursive frameworks.
Fix 9: Handle StackOverflowError Gracefully
You can catch StackOverflowError, but you should rarely do so. It extends Error, not Exception, meaning the JVM is in a potentially unstable state.
That said, in some cases it makes sense to catch it — for example, in a parser that processes user-provided input:
public Result parseUserInput(String input) {
try {
return recursiveParse(input, 0);
} catch (StackOverflowError e) {
throw new IllegalArgumentException("Input too deeply nested. Max nesting depth exceeded.", e);
}
}Warning: After catching StackOverflowError, do not continue with complex operations. The thread’s stack is nearly full. Log the error, return a safe response, and let the thread complete. Do not catch it in a retry loop — you will just overflow again.
This pattern is similar to handling deeply nested structures in other languages. Python, for example, raises a RecursionError when the recursion limit is hit, and the same principle applies: catch it, report it, and stop recursing.
Still Not Working?
If none of the fixes above solve your problem, try these:
Check for indirect recursion. The cycle might span many methods:
A → B → C → D → A. Search your stack trace for the repeating pattern, not just direct self-calls.Check event listeners and observers. A listener that modifies the object it is observing can trigger another event, which calls the listener again. This is especially common in UI frameworks and reactive programming.
Check proxy and AOP interceptors. Spring AOP, Hibernate interceptors, and Java dynamic proxies can add unexpected method calls to the stack. If the stack trace includes proxy classes like
$Proxy,$$EnhancerBySpringCGLIB, or_$$_jvst, the issue may be in your interceptor logic.Check your serialization framework. Jackson, Gson, and other JSON serializers will follow all object references by default. Use
@JsonIgnore,@Expose, or custom serializers to break cycles. If the serialization error produces malformed JSON instead of a stack overflow, see fixing JSON parse unexpected token for the client-side symptoms.Reduce the number of local variables in recursive methods. Each local variable increases the stack frame size. Move large arrays or objects to instance fields or pass them as parameters to a wrapper method.
Monitor stack usage in production. Use
-Xsstogether with-XX:+PrintFlagsFinalto verify the actual stack size your JVM is using:java -XX:+PrintFlagsFinal -version 2>&1 | grep ThreadStackSizeCheck if you are running in a container. Docker and Kubernetes containers may have memory limits that affect available stack space. If the container’s total memory is low, even default stack sizes can cause problems with many threads. Review your container memory limits alongside your JVM memory settings.
Try a different JVM. GraalVM, OpenJ9, and Oracle HotSpot use different default stack sizes and may handle deep recursion differently. If you are locked into deep recursion for algorithmic reasons, test on multiple JVMs to find the best fit.
If the error only appears in production but not locally, your production environment likely has different JVM flags, a different OS (which affects default stack size), or more concurrent threads consuming memory. Compare your local and production JVM configurations carefully.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Java ClassCastException: class X cannot be cast to class Y
How to fix Java ClassCastException by using instanceof checks, fixing generic type erasure, resolving ClassLoader conflicts, correcting raw types, and using pattern matching in Java 16+.
Fix: Java ConcurrentModificationException
How to fix Java ConcurrentModificationException caused by modifying a collection while iterating, HashMap concurrent access, stream operations, and multi-threaded collection usage.
Fix: Java NoSuchMethodError
How to fix Java NoSuchMethodError caused by classpath conflicts, incompatible library versions, wrong dependency scope, shaded JARs, and compile vs runtime version mismatches.
Fix: Java java.lang.IllegalArgumentException
How to fix Java IllegalArgumentException caused by null arguments, invalid enum values, negative numbers, wrong format strings, and Spring/Hibernate validation failures.