The service had been up for nine days. At 03:41 a scheduled job started a catalogue export, and at 03:41:02 a single worker thread died with java.lang.StackOverflowError and 1024 frames of trace, of which four were mine and the other 1020 were the same four repeated 255 times. The export retried at 04:41 and died again. Same second, same depth, same four frames.
What made it interesting is that the code had not changed in seven months. The data had. Somebody had added a category whose parent chain looped back on itself — category 8841's parent was 9002, and 9002's parent was 8841 — and my tree walker had been perfectly correct for every catalogue that was actually a tree.
That is the shape of most of these. The recursion is not obviously wrong; it is wrong for one input, and the input arrived nine days after the deploy. The trace tells you exactly which cycle it was, if you know that the useful information is the repetition rather than any individual frame.
This post is about reading that repetition, about what a thread stack actually is so that "how deep can I recurse" stops being a question with a number for an answer, and about the fixes in the order they deserve — which puts -Xss last rather than first.
Table of Contents
- What the Error Actually Means
- Why It Happens: What a Thread Stack Is
- Cause 1: A Base Case That Never Fires
- Cause 2: Mutual Recursion You Did Not Know You Wrote
- Cause 3: Honest Depth on a Stack That Is Too Small
- Reading the Trace
- How to Fix It, and What Each Fix Costs
- How to Prevent It
- Frequently Asked Questions
What the Error Actually Means
Look at where it sits, because the placement is the whole argument.
java.lang.Throwable
├── java.lang.Exception
│ └── java.lang.RuntimeException
│ └── java.lang.IllegalStateException // a condition
└── java.lang.Error
├── java.lang.LinkageError
│ └── java.lang.NoClassDefFoundError
└── java.lang.VirtualMachineError
├── java.lang.StackOverflowError // no stack left
├── java.lang.OutOfMemoryError
├── java.lang.InternalError
└── java.lang.UnknownError
StackOverflowError is a VirtualMachineError. Its siblings are OutOfMemoryError and InternalError. That family means the virtual machine itself has run out of a resource it needs in order to keep working, or has found itself in a state it cannot describe. It is not a condition in the world that your code was supposed to anticipate.
The specific reason the JVM cannot promise you much after throwing it is worth stating plainly, because it is the reason every "just catch it" suggestion is weaker than it sounds. Throwing an exception in Java requires running code: constructing the throwable, capturing the stack trace, walking the frames to find a handler, executing any finally blocks along the way. All of that runs on the stack. And the stack is the thing that just ran out.
In practice implementations reserve a small margin — a red zone — specifically so that the error can be thrown and delivered at all, which is why you usually get a full readable trace instead of a process that just dies. But that margin is a courtesy, not a contract. Your catch block runs with whatever is left after the unwinding, and the first method it calls that needs a moderately large frame can overflow again, this time inside your handler.
So the honest reading is: a thread asked for a frame and there was no room. Not "recursion went too deep", which is only the most common cause. Not "the JVM ran out of memory", which is a different error with a different fix. One thread, one stack, one allocation that did not fit.
That per-thread framing matters more than people expect. Nothing global has failed. The other 200 threads in the process are unaffected and the heap is untouched. A StackOverflowError on a request thread kills that request; it does not kill the service, which is exactly why these can go on quietly for days as an elevated error rate rather than an outage.
Why It Happens: What a Thread Stack Is
Every thread gets its own stack when it is created, and that stack is a fixed-size region reserved up front. Each method invocation pushes a frame onto it and each return pops one. The frame holds three things:
The local variable array. One slot per parameter and local, plus this for instance methods. A slot is four bytes conceptually, and long and double take two slots each. The size is computed by javac and baked into the class file as max_locals; it does not vary at runtime.
The operand stack. The scratch space the bytecode actually computes on — push two ints, iadd, store the result. Its maximum depth is max_stack, also fixed at compile time. A method with one long arithmetic expression or a call taking eight arguments needs a deeper operand stack than a method that just returns a field.
Frame data. The return address, a reference to the constant pool of the class, whatever bookkeeping the implementation needs for exception handling and for linking. This part is entirely implementation-specific and you cannot compute it from the class file.
Which gives the fact that makes "how deep can I recurse" unanswerable in general: frame size varies per method. Two locals and a shallow operand stack might be forty-odd bytes on a 64-bit HotSpot build; a method with fifteen locals, a wide expression and a long argument list can be several times that. Change the method and the depth you can reach changes with it. Add a local variable during a refactor and you can lose a few percent of your headroom without touching a line of the recursion itself.
And JIT compilation moves the number again. Interpreted frames and compiled frames are not laid out the same way, and inlining removes callee frames entirely, so a recursion that survives the first thousand iterations while interpreted can behave differently once C2 has had a look at it — or the other way round. Any microbenchmark of "maximum recursion depth" is measuring one method, on one JVM, with one warmup history.
The size itself is a flag:
$ java -Xss1m -jar catalogue-service.jar # per thread, not global
$ java -XX:ThreadStackSize=1024 -jar app.jar # same thing, in KB
# what is this JVM actually using?
$ java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
intx ThreadStackSize = 1024 {pd product} {default}
Defaults land around 512 KB to 1 MB depending on platform and architecture, and {pd product} in that output literally means platform-dependent. Do not memorise a number; print it on the machine you care about.
Two consequences of "per thread" that catch people out. First, -Xss applies to every thread the JVM creates, so doubling it in a service with 400 pool threads changes the reservation from a few hundred megabytes of address space to a few hundred more. It is virtual, mostly untouched, and on a 64-bit machine usually harmless — but in a container with a hard memory limit it counts against you in ways that are annoying to diagnose. Second, the main thread's stack size is often the operating system's rather than the JVM's, which is why a reproduction that runs fine in main can fail inside an executor at the same depth.
Virtual threads change the picture genuinely. A platform thread's stack is a contiguous OS-level reservation, fixed at creation. A virtual thread's stack lives on the heap as a chunked, growable structure, so its depth is limited by heap rather than by -Xss, and it grows as needed instead of being reserved up front. -Xss does not control it. That does not make deep recursion free — you can still exhaust the heap, and you will still get a StackOverflowError when the runtime decides the stack cannot grow further — but the depth at which a given recursion fails can differ substantially between a platform thread and a virtual thread running identical code. If you are migrating to virtual threads, that is a behaviour change worth knowing about rather than discovering.
Cause 1: A Base Case That Never Fires
The textbook version is a method that forgot to stop:
static long factorial(long n) {
return n * factorial(n - 1); // no base case at all
}
Nobody ships that. What ships is a base case that is correct for the inputs you imagined and unreachable for one you did not. Mine was a category tree:
package com.acme.catalogue;
public final class CategoryPath {
/** Walks parent links to the root, building "Home > Books > Fiction". */
String pathOf(Category node) {
if (node.parentId() == null) { // base case: only a root stops us
return node.name();
}
Category parent = repository.byId(node.parentId());
return pathOf(parent) + " > " + node.name();
}
}
The base case is not missing, and it is not wrong in any way a reviewer would catch. It is simply the wrong assumption: it says the recursion terminates when it reaches a node with no parent, which is true if and only if the parent links form a tree. Nothing in the schema enforced that. One bad row in an import and 8841 pointed at 9002, which pointed back at 8841.
Exception in thread "export-worker-3" java.lang.StackOverflowError
at com.acme.catalogue.CategoryRepository.byId(CategoryRepository.java:58)
at com.acme.catalogue.CategoryPath.pathOf(CategoryPath.java:14)
at com.acme.catalogue.CategoryPath.pathOf(CategoryPath.java:15)
at com.acme.catalogue.CategoryRepository.byId(CategoryRepository.java:58)
at com.acme.catalogue.CategoryPath.pathOf(CategoryPath.java:14)
at com.acme.catalogue.CategoryPath.pathOf(CategoryPath.java:15)
... the same 3-frame group repeated 255 times ...
at com.acme.catalogue.ExportJob.writeRow(ExportJob.java:112)
at com.acme.catalogue.ExportJob.run(ExportJob.java:74)
Note what the trace does not contain: the ids. It tells you the code is looping and gives you no data at all about which node started it. That gap is most of the debugging time in these incidents, and it is why the depth guard in the prevention section carries the id in its message.
The cyclic-graph version of this is the most common production instance by a distance. Any traversal over data that is supposed to be acyclic — a category tree, an org chart, a dependency graph, a linked list of revisions — is a self-terminating recursion right up until the day the data disagrees. Referential integrity constraints do not express acyclicity. A FOREIGN KEY on parent_id happily accepts a two-node cycle.
There is also the near miss worth naming: an off-by-one that recurses past the base case. if (n == 0) return called with a negative n never matches, and now you are counting down to Long.MIN_VALUE instead. Writing if (n <= 0) rather than if (n == 0) is a one-character habit that removes an entire failure mode.
Cause 2: Mutual Recursion You Did Not Know You Wrote
These are the ones that do not look like recursion in the source, because no method calls itself. The cycle is spread over two or three methods, or over a framework you did not write.
The classic is toString and friends:
@Override
public String toString() {
return "Order{id=" + id + ", customer=" + customer + "}"; // Customer.toString()
}
// ...and over in Customer:
@Override
public String toString() {
return "Customer{id=" + id + ", orders=" + orders + "}"; // List.toString() → Order.toString()
}
Two perfectly reasonable methods, added by two people, six weeks apart. Neither is recursive. Together they are, and the failure only appears the first time somebody logs an order at DEBUG.
The same shape hits equals and hashCode when a bidirectional relation is included in both. Lombok's @Data and @EqualsAndHashCode generate over every field by default, which on a JPA entity with a back-reference means Order.hashCode() hashes its lines, each line hashes its order, and you are away. The generated code is invisible in the source, which makes the stack trace the only place the cycle is legible.
By far the most reported instance is serialisation of a bidirectional JPA relation:
com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
(through reference chain:
com.acme.order.Order["lines"]->java.util.ArrayList[0]
->com.acme.order.OrderLine["order"]->com.acme.order.Order["lines"]
->java.util.ArrayList[0]->com.acme.order.OrderLine["order"] ...)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772)
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178)
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772)
... 4-frame group repeated ~180 times ...
Jackson is being helpful here in a way the raw error is not: newer versions detect the overflow and rewrap it with the reference chain, which names the exact fields forming the cycle. When you get that, the diagnosis is already done for you. When you get a bare StackOverflowError full of BeanSerializerBase frames, it is the same problem and you have to find the cycle yourself.
The last flavour is a proxy or interceptor that ends up calling the method it wraps. A Spring AOP advice that invokes the advised bean through the proxy rather than the target, a CGLIB subclass whose overriding method calls super incorrectly, a decorator registered twice in a chain so it delegates to itself. The trace here is unmistakable and unhelpful at once: dozens of frames from org.springframework.aop.framework, then one frame of yours, then the same block again. Count the cycle, find the single frame of your code in it, and start there.
Cause 3: Honest Depth on a Stack That Is Too Small
This is the minority case, and it is the only one where raising -Xss is on the table. The recursion terminates. It is simply deeper than the stack allows.
Real instances: a JSON document nested 5,000 levels deep, because a client serialised a linked structure naively; a recursive-descent parser on a pathological expression with 12,000 nested parentheses; a linked list of 200,000 nodes with a recursive length(); a recursive quicksort that degenerates to O(n) depth on already-sorted input because the pivot choice is always the first element.
That last one is worth pausing on. A recursive quicksort on random data recurses to about log₂(n), which is 20 frames for a million elements — nothing. On sorted input with a naive pivot, the same code recurses n deep. The algorithm did not change; the data did. That is the same story as cause 1 with a different mechanism.
Then there is the case that confuses everyone the first time, where the recursion is 40 frames deep and it still overflows. The frames are fat and there are already a lot of them before your code starts:
java.lang.StackOverflowError
at com.acme.rules.RuleNode.evaluate(RuleNode.java:88)
at com.acme.rules.RuleNode.evaluate(RuleNode.java:94) ← only ~40 of these
...
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:233)
... 90+ framework frames below this point ...
A servlet request in a typical Spring Security stack arrives at your controller having already spent 80 to 150 frames on filters, interceptors and dispatch. Some of those frames are large. If the deployment also runs an agent that instruments methods, or a reactive pipeline where each operator adds frames, the budget your recursion gets is a fraction of the nominal stack. The recursion looks trivially shallow and it is competing for what is left.
The tell is the ratio: if the repeating group is short and the non-repeating tail below it is long, you do not have a runaway recursion, you have a stack that was mostly spent before you got there.
Reading the Trace
The diagnostic method is one sentence: find the repeating group and count its length.
A cycle of one frame is direct recursion — a method calling itself, usually a base case problem. A cycle of two or three is mutual recursion, and the frames name the participants outright. A cycle of ten or more with framework packages in it is a proxy or serialisation loop. A trace with no repetition at all, just a very long list of distinct frames, is cause 3 and nothing else.
The obstacle is truncation. By default HotSpot caps the recorded trace at 1024 frames, so a runaway recursion gives you the top 1024 and silently drops everything under it — including, irritatingly, the frames that show what called the recursion in the first place:
$ java -XX:MaxJavaStackTraceDepth=0 -jar catalogue-service.jar
# 0 means "no limit" — record every frame
Setting it to 0 gives you the whole thing. Be ready for a genuinely enormous trace; on a 1 MB stack with small frames this can be tens of thousands of lines, and writing it to a log has a real cost. Turn it on to diagnose, turn it off afterwards. The reason it is worth the trouble is that the bottom of the trace has the entry point — which request, which job, which thread pool — and that is what the truncated version takes away from you.
For the near-miss case, where a thread is running very deep but has not gone over, jstack is the tool. Nothing has been thrown, so there is no trace in the logs:
$ jcmd 48812 Thread.print > threads.txt
$ grep -c 'com.acme.rules.RuleNode.evaluate' threads.txt
874
$ jstack 48812 | awk '/^"/{name=$1; n=0} /^\tat /{n++} /^$/{if(n>200) print n, name}'
874 "http-nio-8080-exec-11"
612 "http-nio-8080-exec-4"
That is a useful thing to run when the symptom is "some requests are slow" rather than "some requests fail". A thread 900 frames deep in your own code is one input away from the error, and it is also, quite often, why the p99 latency moved.
One thing to be careful about when reading traces from a live incident: the message is normally empty. StackOverflowError is thrown without a detail message, so anything you see after the class name came from a wrapper — Jackson's rewrap, a framework's catch (Throwable), your own logging. Do not read meaning into a message the JVM did not write. That is the same discipline the helpful NullPointerException messages reward in the opposite direction: there, the JVM does compose a specific message and it is worth every word.
How to Fix It, and What Each Fix Costs
1. Fix the base case
If the cycle is one frame long, this is the whole job. Make the terminating condition cover the inputs that actually arrive rather than the ones you pictured: <= instead of ==, an explicit guard on null and on negative, a check that the structure you are descending is finite.
Cost: none. This is the fix.
2. Break the cycle
For graph traversal, carry a visited set:
String pathOf(Category node, Set<Long> seen) {
if (!seen.add(node.id())) {
throw new IllegalStateException("Cycle in category parents at id=" + node.id());
}
if (node.parentId() == null) {
return node.name();
}
return pathOf(repository.byId(node.parentId()), seen) + " > " + node.name();
}
That converts a StackOverflowError with no data into an IllegalStateException naming the offending row, which is the difference between a two-hour incident and a two-minute one. It costs an allocation per traversal and it is worth it every time.
For serialisation, break the cycle in the mapping rather than in the object graph. @JsonIgnore on the back-reference is the blunt version; @JsonManagedReference on the forward side with @JsonBackReference on the reverse is the version that keeps deserialisation working, because Jackson reinstates the back-pointer on the way in. @JsonIdentityInfo handles genuine graphs by emitting ids instead of nested objects, at the cost of a payload shape your clients have to understand.
For equals, hashCode and toString: exclude navigable relations. On a JPA entity, base equality on the business key or the id, never on a collection of children, and never on a parent reference. With Lombok that is @EqualsAndHashCode(onlyExplicitlyIncluded = true) and @ToString(exclude = "order"). This is also better for reasons that have nothing to do with stack depth — hashing a lazy collection triggers a database load in the middle of a HashMap lookup, which is its own kind of bad afternoon.
Cost: an API decision. The serialised shape changes and clients notice. Do it deliberately, in the DTO layer if you have one.
3. Convert the recursion to iteration
For anything traversing user-supplied or unbounded structure, an explicit stack is the durable answer. The heap is measured in gigabytes; the thread stack is measured in hundreds of kilobytes:
// Recursive: depth is bounded by the tree, and the tree is not yours.
long sum(Node n) {
if (n == null) return 0;
return n.value() + sum(n.left()) + sum(n.right());
}
// Iterative: depth is bounded by the heap.
long sum(Node root) {
long total = 0;
Deque<Node> stack = new ArrayDeque<>();
if (root != null) stack.push(root);
while (!stack.isEmpty()) {
Node n = stack.pop();
total += n.value();
if (n.right() != null) stack.push(n.right());
if (n.left() != null) stack.push(n.left());
}
return total;
}
Cost: readability, honestly. The recursive version is four lines and obviously correct; the iterative one is twelve and has an ordering subtlety in the push sequence. For post-order traversals or anything with work to do on the way back up, the explicit version gets meaningfully worse — you end up encoding the return state that the call stack was managing for you. It is still the right call for unbounded input, and the wrong call for a balanced structure you control.
4. Trampolines, and the tail-call myth
You will be told to "make it tail recursive". In Java, that is not a fix. HotSpot does not perform tail-call elimination. A tail-recursive method consumes exactly as much stack as any other recursion. There has been long-running work on the idea, and it may arrive eventually, but you cannot write code today that depends on it.
What does work is doing the elimination by hand. An accumulator turns the tail-recursive shape into a loop directly:
// tail recursive — and still uses n frames on the JVM
static long sumTo(long n, long acc) {
return n == 0 ? acc : sumTo(n - 1, acc + n);
}
// the same thing, one frame
static long sumTo(long n) {
long acc = 0;
while (n > 0) { acc += n; n--; }
return acc;
}
A trampoline generalises that for mutual recursion: each step returns a description of the next call rather than making it, and a driver loop keeps invoking steps until one returns a value. It genuinely gives you unbounded depth in constant stack. It also allocates one object per step, obscures the control flow, and turns a stack trace into a driver loop with no history in it. Reach for it when the recursion is mutual and unbounded and iteration is genuinely awkward. Not before.
5. Raise -Xss — last, not first
$ java -Xss4m -jar parser-service.jar
Three reasons this belongs at the bottom of the list.
It usually hides the bug. If the recursion is unbounded, a bigger stack buys you a longer run before the same failure, and the failure now arrives further from the deploy that caused it. Four megabytes just means the cycle spins 8× longer before dying.
It multiplies. -Xss is per thread. In a service with 400 threads, moving from 1 MB to 4 MB moves the reservation from 400 MB of address space to 1.6 GB. It is virtual and mostly untouched, so on a large host you may never notice — but inside a container with a memory limit it is one of the more confusing ways to get killed, because the heap looks perfectly healthy in every dashboard you check.
It does not do what you want on virtual threads. If part of your workload has moved to virtual threads, -Xss does not govern them, and you now have two different depth limits in one process depending on where the code happens to run.
When it is right: the recursion is provably bounded, the bound is legitimately large, iteration is impractical, and you have measured the depth you need with headroom. Then raise it, on the specific thread if you can — new Thread(group, runnable, name, stackSize) takes a per-thread size and lets one deep worker have a big stack without giving one to all 400.
What is not a fix
Catching StackOverflowError to keep the request loop alive.
The argument is the same one I made about NoClassDefFoundError, and it holds here for a stronger reason. Both are Errors because they report a defect rather than a condition — something that should not have been true was true, and there is no sensible action for the caller to take. Handling one means writing code whose purpose is to continue past a fact that invalidates the program.
With StackOverflowError there is the extra problem that recovery runs on the broken resource. Your handler has whatever stack is left after unwinding. Frames that were skipped may have left finally blocks unexecuted, so locks may still be held and files may still be open. Objects half-constructed at the moment of the throw are reachable and invalid. And if the handler is generous — formatting a message, calling a logger that itself recurses through a formatter chain — it can overflow inside the catch, which produces some genuinely baffling logs.
The one narrow case I will defend is a top-level worker boundary. A job runner that catches Throwable at the outermost frame of a task, records it, abandons that task, and returns the thread to the pool clean, is doing something reasonable: the stack fully unwinds before anything else runs, and the blast radius is one task. Even that is fragile. It only holds if the abandoned task leaves no shared state behind, if the logging path itself is shallow, and if somebody is actually reading the alert it raises. It is a way to survive a defect while you fix it, not a substitute for fixing it. A catch (Throwable t) in a request handler that swallows this and returns a 500 is not that — it is the version that lets a cyclic row sit in the catalogue for nine days.
How to Prevent It
Guard depth wherever the structure comes from outside. Any recursion over user-supplied nesting should carry a depth counter and fail loudly and early with a message that names the input, not silently at frame 12,000 with a trace that names nothing. A limit of a few hundred is plenty for real documents and cheap to enforce:
private static final int MAX_DEPTH = 256;
Value parse(JsonNode node, int depth) {
if (depth > MAX_DEPTH) {
throw new IllegalArgumentException(
"Nesting exceeds " + MAX_DEPTH + " levels at " + node.path());
}
...
}
Configure the parser's own limits rather than raising the stack. Jackson 2.15 and later expose StreamReadConstraints, which caps nesting depth, document size and number length before your code ever sees the payload:
JsonFactory factory = JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder()
.maxNestingDepth(500)
.build())
.build();
ObjectMapper mapper = new ObjectMapper(factory);
That turns a hostile 50,000-level document into a clean StreamConstraintsException with a 400 response, instead of a StackOverflowError with a 500 and a page of log. XML parsers have equivalents, and so do most YAML libraries. This is the single highest-value item on the list if you accept nested input from anywhere.
Use iterative traversal by default for anything unbounded. Recursion is the clearer expression and I use it freely for structures I control — a balanced tree, a bounded expression, a directory walk with a known ceiling. For anything whose depth is a property of data arriving from elsewhere, start iterative. Retrofitting it during an incident is a worse time to do the work.
Carry a visited set on every graph walk, and make the cycle an error rather than an overflow. The set is the difference between "something recursed" and "row 8841 points at 9002 which points back at 8841". Same discipline that the fail-fast iterator applies to collections: detect the invalid state at the moment it becomes visible, and say what it was.
Keep equals, hashCode and toString free of navigable relations. On entities, that means the id or a business key and nothing that can be followed. Audit the Lombok annotations specifically, because generated code is the code least likely to be read.
Test pathological depth. One test that builds a 10,000-deep structure and asserts that you get your own exception rather than a StackOverflowError is worth a page of documentation, and it runs in milliseconds. Build the input programmatically; nobody wants a 400 KB fixture in the repository.
@Test
void rejectsDeeplyNestedInputWithoutOverflowing() {
String json = "[".repeat(10_000) + "]".repeat(10_000);
assertThrows(StreamConstraintsException.class, () -> mapper.readTree(json));
}
And add a constraint if the database can carry one. A cycle in a parent chain is an integrity problem that a foreign key does not express, but a recursive CTE in a nightly check does, and finding it at 02:00 in a report beats finding it at 03:41 in a worker thread.
Author's note: The traces here are written to match what current LTS OpenJDK builds actually print, but frame line numbers inside java.base, Spring and Jackson shift between versions, so match on frame names rather than numbers. The 1024-frame trace cap, the red-zone margin that lets the error be thrown at all, frame layout, and whether virtual-thread stacks grow the way they do today are implementation details of the reference implementation rather than guarantees in the JVMS — they have changed before and may change again. StackOverflowError carries no detail message of its own; any text you see after the class name was added by a wrapper. Do not parse any of it in tooling.
Frequently Asked Questions
How much recursion depth do I actually get in Java?
There is no fixed number, and any figure you have seen quoted is a measurement of one method on one JVM rather than a property of the language. A thread stack is a byte budget, not a frame budget, and each frame costs as much as that method's local variables, operand stack and frame data require. A trivial method with two int locals might give you tens of thousands of frames on a default stack of roughly 512 KB to 1 MB; a method with a dozen object references and a long expression might give you a fraction of that. If you need a number for your own code, measure it with a counting recursion on the same JVM, the same flags and the same method, and then assume the real limit is lower because your production thread already has frames on it when the recursion starts.
Should I just increase -Xss?
Rarely, and never first. Raising -Xss is correct only when you have already established that the recursion is bounded, that the bound is genuinely larger than the default stack allows, and that no iterative formulation is practical. Everywhere else it hides a defect rather than fixing one, and it converts a fast deterministic failure into a slower one that lands under production load. It is also a per-thread setting, so raising it multiplies across every thread the process creates, and a service with a few hundred threads can lose a meaningful amount of virtual address space to reservations that are mostly never touched. If a parser is the reason, configure the parser's own depth limit instead.
Can I catch StackOverflowError?
You can write the catch block and the JVM will often run it, but it is not a recovery mechanism. StackOverflowError extends VirtualMachineError, which is the family that says the virtual machine is broken or has run out of a resource it needs to keep operating, and the resource in this case is the very stack your handler has to run on. Any method your catch block calls can overflow again, finally blocks in the unwound frames may have been skipped, and objects on the way out can be left half-initialised. The one narrow case that is defensible is a top-level worker boundary that catches it, logs the trace, abandons that task and lets the thread return to a known-clean state. Even that is fragile, and it is a way to survive a defect while you fix it, not a substitute for fixing it.
Why does Jackson throw StackOverflowError on my JPA entity?
Because a bidirectional relation is a cycle and the default serialiser has no cycle detection. An Order holds a List of OrderLine, each OrderLine holds a reference back to its Order, and Jackson walks the graph depth-first: order, lines, line, order, lines, line, forever. The stack trace shows the give-away, a short repeating group of BeanSerializer and field-accessor frames rather than any of your own code. The fix is to break the cycle in the serialised view, not to give the thread more stack: annotate the back-reference with @JsonIgnore, or pair @JsonManagedReference and @JsonBackReference, or stop serialising entities altogether and map to a DTO that has no back-pointer. The same cycle will bite equals, hashCode and toString if the back-reference is included there too.