Scenario Setup
Consider a minimal Spring Boot controller that offloads work to a simple background thread while passing the incoming HttpServletRequest object:
@GetMapping("/fetchData")
public String fetchData(HttpServletRequest incomingRequest) {
String initialValue = incomingRequest.getParameter("code");
System.out.println("Main thread value: " + initialValue);
new Thread(() -> {
try {
// Simulate some delay to ensure the main request completes
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// Attempting to read parameter in the background thread
String backgroundValue = incomingRequest.getParameter("code");
System.out.println("Background thread value: " + backgroundValue);
}).start();
return "Processing started";
}
When you hit the endpoint http://localhost:8080/fetchData?code=123 onnce, the output behaves as expected:
Main thread value: 123
Background thread value: 123
However, if you trigger the endpoint a second time immediately after, the backgroundValue in the new thread often prints null, even though the initialValue in the main thread correctly shows 123.
Root Cause Analysis
The issue stems from how Tomcat manages request objects. To optimize performance, Tomcat reuses request instances. When a request finishes processing, Tomcat calls a recycle() method on the request object.
- Parameter Parsing State: The request object contains a flag (e.g.,
didQueryParameters) indicating whether the query string has been parsed. It is set tofalseinitially. - First Call: The main thread processes the request, parses parameters (setting the flag to
true), and returns. The background thread sleeps. Eventually, the background thread wakes up, callsgetParameter(), sees the flag istrue(already parsed), and returns the value successfully if the recycle hasn't happened yet. - Recycling: Once the response is committed, Tomcat recycles the request object. This resets the internal buffer holding the raw query string (
queryMB) to null and resets the parsed flag back tofalse, preparing the object for the next HTTP connection. - The Conflict: If the background thread (from the first request) wakes up after Tomcat has recycled the object but before it processes a new request:
- It calls
getParameter(). - The flag is
false(due to recycle), so it tries to parse parameters. - However, the raw query buffer (
queryMB) is nownullbecause the object was cleaned up. - It finds no parameters, sets the flag to
true(thinking it parsed an empty set), and returnsnull.
- It calls
- Second Call Impact: When a new HTTP request arrives, Tomcat picks up the recycled request object. The background thread from the previous request is still running. When that old thread calls
getParameter(), it sets the flag totrueon the shared object. When the new main thread tries to get parameters, it sees the flag istrue(set by the old background thread) and skips parsing the new query string entirely.
The Lifecycle Constraint
According to the Java Servlet Specification (Section 3.13), a request object is valid only within the scope of the service method or a filter's doFilter method.
"Containers common recycle request objects... maintaining references to request objects... outside the scope described above... may have indeterminate results."
Passing the raw request to a standard new Thread() violates this lifecycle because the thread runs outside the scope of the request processing.
The Correct Approach: AsyncContext
To handle asynchronous operations correctly without encountering recycling issues, use the standard Servlet asynchronous support:
@GetMapping("/fetchDataAsync")
public void fetchDataAsync(HttpServletRequest request) {
String value = request.getParameter("code");
System.out.println("Initial value: " + value);
// Start the asynchronous context
AsyncContext asyncContext = request.startAsync();
// Set timeout if necessary, or handle in the thread
asyncContext.setTimeout(10000);
new Thread(() -> {
try {
// Perform background work
Thread.sleep(5000);
String backgroundValue = request.getParameter("code");
System.out.println("Async thread value: " + backgroundValue);
// Get response writer if needed
asyncContext.getResponse().getWriter().write("Done");
} catch (Exception e) {
e.printStackTrace();
} finally {
// Signal that the asynchronous operation is complete
asyncContext.complete();
}
}).start();
}
By calling request.startAsync(), you extend the lifecycle of the request and response objects. Tomcat will not recycle the request object until asyncContext.complete() is invoked, ensuring that the background thread can safely access request attributes and parameters.