Welcome to the first lesson in the runtime and concurrency module. Before working with threads, locks, executors, or asynchronous APIs, you need a reliable way to answer one diagnostic question:
Which data can this executing request access, and can another request access the same mutable data at the same time?
That question is the foundation of thread safety in a Java backend. In this lesson, you will trace a typical commerce-style backend request through method stack frames and heap objects, then classify each piece of state as thread-confined, shared, or safely shared because it is immutable.
A useful runtime model: frames, references, and objects
When an HTTP request reaches a Java web application, the server assigns work to a thread. In a traditional servlet-based Spring Boot application, that is usually a worker thread taken from a server-managed pool. The thread executes your controller, service, repository, and serialization code.
As methods call one another, Java conceptually creates a stack frame for each active method invocation. A frame holds that invocation’s:
- parameters,
- local primitive values,
- local object references,
- bookkeeping needed to resume its caller.
When a method returns, its frame is removed. The caller’s frame becomes active again. This is the call stack: last called, first returned.
Objects, arrays, and their instance fields are conceptually allocated on the heap. A stack frame normally holds a reference to an object rather than embedding the whole object. Two different references can point to the same heap object; that situation is called aliasing.
There is one important accuracy note: the JVM specification does not require a literal physical stack-versus-heap layout for every value. The JIT compiler can optimize aggressively, including eliminating some allocations. But this model correctly explains object lifetime, reachability, method calls, and—most importantly for us—how concurrent code shares state.

Use this rule as a starting point:
A local variable belongs to one method invocation on one thread. The object referenced by that variable may still be shared.
For example:
void addItem(String sku) {
List<String> items = new ArrayList<>();
items.add(sku);
}
items is a local reference in the method frame, so the variable itself is thread-confined. The ArrayList object is also thread-confined only if no other thread can reach it. In this short example, the list is created within the call and never escapes, so it is private to that invocation.
Now compare:
class CartMetrics {
private final List<String> recentSkus = new ArrayList<>();
void addItem(String sku) {
recentSkus.add(sku);
}
}
Here, sku is still a local parameter, but recentSkus is a field of a CartMetrics object. If one long-lived CartMetrics instance is used by multiple request threads, every request reaches the same mutable ArrayList. private limits source-code visibility; it does not give each thread a separate copy.
Watch: the shared heap is not the same as shared state
The following short video provides the visual model for the rest of the lesson. Its central distinction is exactly the one you will use in backend debugging: separate thread stacks, references in frames, and heap objects that may or may not be reachable by multiple threads.
The Java Memory Model - The Basics
Watch “The Java Memory Model - The Basics” by Jakob Jenkov. It visually distinguishes a thread’s private stack from heap objects and shows why fields may become shared while local variables do not.
Start with the memory map for the two-stack, one-heap model. Then watch locals and fields, focusing on the difference between a local reference and a field inside a shared Runnable object. Finish with object sharing, where objects created within each execution are contrasted with a single object supplied to multiple threads.
A detail worth retaining from the video: two threads can have separate local reference variables that both point to one shared object.
This distinction is easy to miss in code:
Runnable task = sharedTask;
Thread first = new Thread(task);
Thread second = new Thread(task);
Each thread gets its own invocation of run() and therefore its own local variables. But both invoke run() on the same sharedTask object. Any mutable instance field of that object is shared.
By contrast:
Thread first = new Thread(new Task());
Thread second = new Thread(new Task());
There are two Task instances. Their instance fields are distinct unless both tasks were given references to some other common mutable object.
Trace one backend request
Consider this simplified application service. Do not focus on Spring annotations yet; treat OrderApplicationService as a long-lived service object used to process incoming requests.
final class OrderApplicationService {
private final List<String> recentlyCreatedOrderIds = new ArrayList<>();
OrderView createOrder(CreateOrderCommand command) {
String requestId = java.util.UUID.randomUUID().toString();
List<OrderLine> acceptedLines = new ArrayList<>();
for (CreateOrderLine line : command.lines()) {
acceptedLines.add(new OrderLine(line.sku(), line.quantity()));
}
Order order = new Order(
command.customerId(),
List.copyOf(acceptedLines)
);
recentlyCreatedOrderIds.add(order.id().toString());
return new OrderView(order.id(), requestId);
}
}
Assume request is executing createOrder() on worker thread http-nio-8080-exec-17.
Frame 1: the server and controller
The HTTP server has a frame for its request-processing code. Framework code parses JSON and creates a CreateOrderCommand object. A controller method receives a local reference to that command and invokes the service.
At this point, the command object is on the heap. The controller’s local command reference is in the controller’s frame. The reference is local, but the object is not automatically “on the stack” or inherently private.
In the usual request path, the framework has just constructed a distinct command object for request , and no other thread has been given a reference to it. Under that assumption, the command is effectively thread-confined for this request.
Frame 2: createOrder()
When createOrder(command) begins, thread exec-17 gets a new frame containing local slots such as:
State in createOrder() | What it is | Initial classification |
|---|---|---|
command | Local reference to the request DTO | Thread-confined if the DTO has not been handed to another thread |
requestId | Local reference to a newly created String | Thread-confined; the String is also immutable |
acceptedLines | Local reference to a new mutable list | Thread-confined while it remains reachable only from this invocation |
loop variable line | Local reference for each iteration | Thread-confined |
order | Local reference to a newly created Order | Initially thread-confined |
recentlyCreatedOrderIds | Not a local variable; a field reached through this | Potentially shared mutable state |
The most useful tracing habit is to draw two things separately:
- Frames for each currently running thread
- Heap objects and arrows from references to those objects
For request , the important picture is conceptually:
- Thread
exec-17has its owncreateOrder()frame. - That frame points to request 's command, list, order, and view objects.
- The frame also has access to
this, the long-livedOrderApplicationService. - The service points to the single
recentlyCreatedOrderIdslist.
The fact that acceptedLines is mutable is not a problem by itself. It is mutable and private to this execution. The call to List.copyOf(acceptedLines) creates an immutable list for the Order; the temporary mutable construction list is not retained.
When createOrder() returns, its frame is removed. That does not immediately destroy the returned OrderView or the Order; objects remain alive as long as something can still reach them. The controller receives the returned reference, serializes the response, and eventually its own frame also returns. Garbage collection and object lifetime are the focus of the next lesson.
Add a second request: where sharing appears
Now suppose request arrives at nearly the same time and runs on http-nio-8080-exec-21.
It has separate frames:
- its own controller frame,
- its own
createOrder()frame, - its own local
requestId, - its own
acceptedLines, - its own
Order.
Those are not shared merely because both requests execute the same method. Each invocation has its own local-variable slots. This remains true even if the same worker thread processes two requests sequentially: each call receives a new frame.
But both request threads invoke methods on the same long-lived service instance. Therefore, both can reach:
private final List<String> recentlyCreatedOrderIds = new ArrayList<>();
final here means the field reference cannot be reassigned after construction. It does not make the referenced ArrayList immutable, and it does not prevent calls to add().
So, during concurrent requests:
- Request has a private
acceptedLineslist. - Request has a different private
acceptedLineslist. - Both requests can reach the same
recentlyCreatedOrderIdslist.
That final list is shared mutable state. In later lessons, you will reproduce the resulting failure modes and protect compound updates correctly. For now, the key diagnostic result is simply that two stacks contain different local references that lead to one common mutable heap object.
This is why “objects live on the heap” is not enough to diagnose concurrency. The better question is:
Can two concurrent execution paths reach this same mutable object?
If yes, it is shared state. If no, the object can be thread-confined even though it resides on the heap.
The backend sources of shared state
In a Spring Boot microservice, these are common ways an object becomes reachable from multiple request threads:
| Source | Why it is often shared | Example to inspect |
|---|---|---|
| Instance field of a long-lived service | One application-level service object handles many requests | Mutable List, Map, counter, cached DTO |
| Static field | One value exists per loaded class | Static HashMap used as a cache |
| Object passed to multiple tasks | Each task receives a reference to the same object | Shared Runnable, shared accumulator |
| Captured value in asynchronous work | A lambda retains a reference after the current method continues or returns | executor.submit(() -> process(order)) |
| Queue, cache, registry, or singleton | Multiple producers and consumers can access the same stored object | In-memory cache or listener registry |
| Object returned or published to another component | Another execution path may later acquire an alias | Mutable collection stored in a field or event payload |
An object can also be safely shared when it is genuinely immutable. A String, UUID, or immutable value object has no state that callers can change after creation. Sharing its reference does not create a write race.
Be precise, though: an unmodifiable collection is not necessarily immutable. For example, Collections.unmodifiableList(existingList) blocks mutation through one reference but still reflects mutations made through another reference to existingList. List.copyOf(existingList) is usually the safer choice when you need an independent immutable snapshot.
Read: confinement is about reachability, not just local variables
The MIT reading gives a concise formalization of the reasoning used above. It also highlights two dangerous shortcuts: assuming that a local reference makes its target private, and assuming that a static field is harmless.
Read the sections on confinement and global variables from MIT’s 6.031 course. They turn the stack-and-heap picture into a practical rule for recognizing thread-confined data.
In “Strategy 1: confinement,” begin at the explanation that defines confinement. Continue through the factorial example and focus on the warning that a local reference can point to a mutable object that is not confined. Then read “Avoid global variables,” especially the global-state warning, and inspect the memoization example. Relate its static HashMap to a service-level cache in a backend application.
The reading’s distinction applies directly to fields in application services:
- A local variable is private to a particular method invocation.
- An instance field is not automatically private to a thread, because another thread may invoke the same object.
- A static field is not automatically private to a thread, because it is reachable through the class rather than an individual request.
- A mutable object referenced by a local variable remains private only while no reference to it escapes to another thread.
A repeatable tracing procedure
Use this procedure in a code review, an incident investigation, or an interview design discussion.
1. Name the concurrent actors
Do not start with “this service has a list.” Start with who can execute concurrently:
- two HTTP request threads,
- one request thread and an executor task,
- a Kafka listener thread and an HTTP request,
- a scheduled job and a normal request.
A single thread executing one call at a time cannot race with itself. The concern begins when independent execution paths overlap.
2. Mark the active method invocations
For each actor, list the active methods and their locals. Treat every invocation as its own frame, even when both are executing the same method.
For two concurrent calls to createOrder(), there are two separate command parameters and two separate acceptedLines local variables.
3. Follow every reference into the heap
For each local and field reference, ask what object it points to. Then follow that object’s fields if necessary.
The important question is not “is this a field or a local?” alone. It is “where else can a reference to this same object be found?”
4. Search for aliasing and escape
An object ceases to be purely thread-confined when its reference becomes reachable from another concurrent actor. Common escape points include:
- assigning it to a field,
- placing it in a static collection,
- returning it to a component that stores it,
- passing it to an executor, future, queue, or listener,
- capturing it in a lambda that outlives the current call.
Passing an object reference as a method argument does not copy the object. Java passes the reference value by value. After the call, the caller and callee have separate local reference variables, but both may point to the same object.
5. Classify both sharing and mutability
Use one of these labels:
- Thread-confined mutable state: mutable but reachable by one thread only during the relevant lifetime.
- Shared immutable state: reachable by many threads but cannot change.
- Shared mutable state: reachable by multiple threads and can change. This requires an intentional safety design.
Do not assume the third category is always wrong. A bounded cache, a metrics counter, or a connection pool is intentionally shared mutable infrastructure. It simply must be designed for concurrent access.
Practical code-review pass
Take one service method from a SAP Commerce customization or a Java backend project. Avoid framework internals for now; choose a method with a request DTO, a local collection, and at least one service field.
Make a short worksheet with these columns:
| Expression or field | Object reached | Other concurrent path can reach it? | Mutable? | Classification |
|---|---|---|---|---|
command | Request DTO | Usually no | Depends on DTO | Usually thread-confined |
items | Newly created ArrayList | No, unless it escapes | Yes | Thread-confined mutable |
this.cache | Application cache | Often yes | Yes | Shared mutable |
DEFAULT_CURRENCY | String constant | Yes | No | Shared immutable |
Do not label a field “shared” merely because it is a field. First establish whether its owning object is shared. In a typical backend application, long-lived service objects are used concurrently, so mutable fields on them deserve immediate scrutiny.
Also do not label all request DTOs “safe” automatically. A DTO is normally request-confined if it is created for one request and remains on that synchronous request path. If it is retained in a field or passed into asynchronous work, you must reassess it.
Interview revision
A concise interview answer to “Are local variables thread-safe in Java?” is:
Each method invocation has its own local-variable slots in that thread’s stack frame, so local variables themselves are thread-confined. However, a local variable can hold a reference to a mutable heap object shared with other threads. I therefore trace the object’s aliases, not just where the reference variable is declared.
A common follow-up is: “Does private final List<Order> orders make a service thread-safe?”
The essential response is that private restricts direct source-level access, and final prevents assigning a different list to the field. Neither prevents concurrent mutation of the existing List; if multiple request threads use the same service instance, the list is shared mutable state.
Key takeaways
- A backend request runs through a sequence of method stack frames on a thread.
- Each invocation has its own parameters and local variables, even when two requests call the same method concurrently.
- Objects and fields are conceptually on the heap; separate local references may still alias one heap object.
- Heap allocation does not automatically mean “shared,” and a local reference does not automatically mean that its object is private.
- State is thread-confined when no other concurrent thread can reach it during the relevant lifetime.
- Long-lived service fields, static fields, queues, caches, and objects handed to asynchronous tasks are frequent sources of shared state.
- The category that needs deliberate concurrency design is shared mutable state.
Next, you will build on object lifetime and heap reachability by examining generational garbage collection, allocation rate, and the basic metrics that matter when a Java service is under load.
Can't find a good explanation? Sign up and we'll make it for you
Sign up