Are Local Variables Thread Safe in Java? Stack vs Heap

Are local variables thread safe in Java? Yes, each thread keeps its own stack copy. See when shared objects, lambdas and escaping references break it.

Two thread stacks, each with its own frame and local variables, and a shared heap object reachable from both

Yes, local variables and method parameters in Java are thread safe, because every thread gets its own copy of them in its own stack frame, and no other thread can read or change that copy. The object that a local reference points to is a different matter. It is stored on the shared heap, so it is safe only while no other thread can reach it.

We depend on this rule every time a service method builds its result in local variables. Such a method needs no lock, even when hundreds of request threads call it at the same moment.

The following example submits the same task twice to a pool of two threads, so both copies run in parallel.

Callable<Integer> sumTo100 = () -> {
    int total = 0;                       // a new copy on each call's stack
    for (int i = 1; i <= 100; i++) {
        total += i;
    }
    return total;
};
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> first = pool.submit(sumTo100);
Future<Integer> second = pool.submit(sumTo100);
int firstSum = first.get();              // 5050
int secondSum = second.get();            // 5050
pool.close();

Notice that both calls return 5050 on every run, because each thread adds up its own total. We look at why the JVM gives each thread its own locals, what changes when a local holds a reference, how an object escapes its thread, and the lambda rule that protects locals from other threads.

1. Why Local Variables Are Thread Safe

Every thread in the JVM has a private JVM stack, created together with the thread. Each method call pushes a new frame onto that stack, and the frame holds the method’s parameters and local variables. When the method returns, the frame is gone.

Two thread stacks, each with its own frame and local variables, and a shared heap object reachable from both
Each thread has a private copy of its locals, while objects on the heap are shared by every thread that holds a reference

Two threads that run the same method therefore work on two different frames, so they never see each other’s locals. Local variables, formal method parameters and exception handler parameters are never shared between threads. The Java memory model article explains the other memory areas.

The rule covers the variable itself, not the object behind a reference. The difference decides whether code is safe.

What we declareWhere it is storedShared between threads?
Local primitive, e.g. int totalStack frame of the callNo
Method parameterStack frame of the callNo (the variable is a copy)
Local reference, e.g. List<String> namesStack frame of the callNo, but the object it points to may be
Object created with newHeapYes, if another thread gets a reference
Instance fieldHeap, inside the objectYes, when the object is shared
Static fieldHeap, with the classYes, always

2. Method Parameters Follow the Same Rule

Java passes arguments by value. For a primitive, the method gets a copy of the number. For an object, the method gets a copy of the reference, and both references point to the same object.

static void addTag(List<String> tags, int limit) {
    tags.add("java");                    // changes the caller's list
    limit = 0;                           // changes only the local copy
    tags = new ArrayList<>();            // the caller keeps its list
}
List<String> postTags = new ArrayList<>(List.of("threads"));
int maxTags = 5;
addTag(postTags, maxTags);
String tagsAfter = postTags.toString();  // "[threads, java]"
int maxAfter = maxTags;                  // 5

We can see that reassigning limit or tags inside the method has no effect on the caller, because only the copies change. The call tags.add() does change the caller’s list, so a parameter is only as thread safe as the object it refers to.

3. A Local Reference to a Shared Object

Most real bugs in this area start with a local variable that looks private but points to an object that two threads use. The variable is confined to one frame, whereas the object on the heap is not.

Say an analytics job counts page hits. A developer keeps the counter in a local array so that two worker tasks can update it, because a lambda cannot change a local int. The array reference is effectively final, so the code compiles, yet both tasks write to the same array element.

int[] hits = {0};                        // local reference, shared array
Runnable countHits = () -> {
    for (int i = 0; i < 1_000_000; i++) {
        hits[0]++;                       // read, add, write: not atomic
    }
};
Thread worker1 = Thread.ofPlatform().start(countHits);
Thread worker2 = Thread.ofPlatform().start(countHits);
worker1.join();
worker2.join();
int lostUpdates = hits[0];               // less than 2000000 in most runs

A local reference is thread safe only while the object behind it stays inside one thread. The safest fix keeps the counting local to each task and combines the results after both tasks finish, so no object is shared at all.

Callable<Integer> countLocally = () -> {
    int count = 0;                       // private to this call
    for (int i = 0; i < 1_000_000; i++) {
        count++;
    }
    return count;
};
ExecutorService workers = Executors.newFixedThreadPool(2);
Future<Integer> part1 = workers.submit(countLocally);
Future<Integer> part2 = workers.submit(countLocally);
int totalHits = part1.get() + part2.get();   // 2000000
workers.close();

When the tasks must update one value while they run, we share a thread-safe object instead, such as an AtomicInteger or a LongAdder.

LongAdder sharedHits = new LongAdder();
Runnable addHits = () -> {
    for (int i = 0; i < 1_000_000; i++) {
        sharedHits.increment();
    }
};
Thread adder1 = Thread.ofPlatform().start(addHits);
Thread adder2 = Thread.ofPlatform().start(addHits);
adder1.join();
adder2.join();
long safeHits = sharedHits.sum();        // 2000000

4. How a Local Object Escapes Its Thread

An object created inside a method is confined to the calling thread until some code passes a reference to it to other code. This idea is called thread confinement, and a confined object is safe to use even when its class is not thread safe, e.g. StringBuilder or ArrayList.

static String invoiceLine(String item, int quantity, double price) {
    StringBuilder line = new StringBuilder();   // never leaves this call
    line.append(item).append(" x").append(quantity);
    line.append(" = ").append(quantity * price);
    return line.toString();                     // returns an immutable String
}
String line = invoiceLine("pen", 3, 1.5);      // "pen x3 = 4.5"

The method returns a new String, which is immutable, so the StringBuilder never escapes. An object escapes its thread in one of these ways.

  • The method stores the reference in an instance field or a static field.
  • The method returns the reference, and the caller passes it to another thread.
  • A lambda or anonymous class captures the reference and runs on another thread, as the hits array did in section 3.
  • The method adds the object to a shared collection, a queue or a cache.
  • A constructor passes this to a listener or starts a thread before the constructor finishes.

A common case in Spring Boot applications is a singleton service bean that keeps a working buffer in a field. Every request thread calls the same bean, so they all share that buffer. Moving the buffer into a local variable of the method makes the bean thread safe without a lock.

5. Lambdas and Effectively Final Locals

A lambda expression can read a local variable of the enclosing method only if the variable is final or effectively final, which means it is never assigned after its initialization. The compiler enforces this rule for lambda bodies.

// does not compile: local variables referenced from a lambda must be final or effectively final
List<Integer> amounts = List.of(10, 20, 30);
int sum = 0;
amounts.forEach(amount -> sum += amount);

The lambda gets a copy of the value, not the variable, and it may run later on another thread. If the compiler allowed the assignment, two threads could update the same local variable, which is the one thing stack confinement rules out.

Wrapping the value in an array or an AtomicInteger makes the error go away, but it also makes the state shared. With streams, we return the result from the pipeline instead, which stays correct in parallel streams too.

List<Integer> orderAmounts = List.of(10, 20, 30);
int orderTotal = orderAmounts.parallelStream().mapToInt(Integer::intValue).sum();   // 60
List<Integer> doubled = orderAmounts.parallelStream().map(n -> n * 2).toList();       // [20, 40, 60]

The other approach, where a parallel stream calls add() on a shared ArrayList from forEach(), is a side effect to avoid. That code loses elements or throws ArrayIndexOutOfBoundsException in some runs.

6. Local Variables in Virtual Threads

The rule is the same for virtual threads, final since Java 21. A virtual thread has its own stack too. When it waits, the JVM copies its frames to the heap and later copies them back, but no other thread can reach them in the meantime.

Callable<String> greet = () -> {
    String name = Thread.currentThread().isVirtual() ? "virtual" : "platform";
    return "hello from " + name;
};
ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor();
Future<String> reply = virtualPool.submit(greet);
String message = reply.get();            // "hello from virtual"
virtualPool.close();

Code that keeps its working state in locals scales to many thousands of virtual threads without any locking, which is one reason request handlers written in this style move to virtual threads without changes.

7. Other Ways to Make Shared Data Safe

Stack confinement is the cheapest form of thread safety, since it needs no lock and no special class. When data must cross threads, the Java concurrency toolbox offers other options, each with its own cost.

TechniqueExampleCost
Keep state in localsBuild the result inside the methodNone
Immutable objectsA record with final fields, immutable classesA new object for each change
One copy per threadThreadLocalCleanup in thread pools
Thread-safe typesAtomicInteger, LongAdder, ConcurrentMapSome contention under load
LocksThe synchronized keyword, ReentrantLockThreads wait for each other

A useful order is to try the first rows before the last ones. Most code that needs a lock today can keep its state in locals or in immutable objects instead.

8. Thread Safety of Locals FAQs

Each answer depends on one distinction, whether the code shares a variable or the object that the variable points to.

8.1. Are static variables thread safe in Java?

No. A static field belongs to the class, so every thread that uses the class sees the same field. We protect it with a lock, an atomic class or a volatile field, depending on how it changes, as the volatile guide explains.

8.2. Are instance variables thread safe?

Only when the object is not shared. Two threads that use two different objects never touch the same field, whereas two threads that share one object, such as a singleton bean, need protection for every mutable field.

8.3. Is a local variable inside a static method thread safe?

Yes. A static method still gets a new stack frame on every call, so its locals are as private as those of an instance method. The static modifier affects fields, not locals.

8.4. Can we get thread safety without synchronization?

Yes, with stack confinement, immutable objects, ThreadLocal or lock-free classes such as AtomicInteger and ConcurrentHashMap. Locks are needed only when several threads must change shared state together in one step.

9. Conclusion

Local variables and method parameters are thread safe because each call gets its own frame on the calling thread’s stack. That guarantee ends at the reference, since the object it points to is stored on the shared heap.

A local object stays safe as long as it never escapes through a field, a return value, a collection or a lambda that runs on another thread. When results from several threads must come together, we return them from each task and combine them afterwards, or we share a thread-safe class such as LongAdder.

10. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.