Concurrency

Thread safety: interview questions and how to answer them

A class is thread-safe when it behaves correctly under concurrent access from multiple threads with no additional coordination from the caller — a claim about invariants, not about keywords.

Written and reviewed by Sahil Srivastav

ConcurrencyAPI designJava

What it actually is

Thread safety is a property of a *contract*, not of a syntax. Saying "this class is thread-safe" means: its invariants hold no matter how callers interleave its methods, and callers need add nothing. That is a strong promise, and most classes described as thread-safe actually make a weaker one — each method is individually atomic — which is not the same thing.

The four strategies available are worth enumerating because they are the whole toolkit. Do not share the state (confinement — thread-local, per-request objects, actor mailboxes). Do not mutate it (immutability). Share mutable state behind a lock that every access takes. Or use a structure whose operations are already atomic. Every real thread-safe class is one of these or a combination, and being able to name which one you chose is most of the interview answer.

The most misunderstood part is composition. `ConcurrentHashMap` makes `get` and `put` atomic; it does not make `get` followed by `put` atomic, and a caller who writes `if (map.get(k) == null) map.put(k, v)` has a race in their own code using a perfectly thread-safe map. Atomicity is not transitive, which is why the compound methods — `computeIfAbsent`, `merge`, `putIfAbsent` — exist at all.

Why it matters in production

Because in a server every shared object is concurrently accessed by default. A Spring singleton bean with a mutable field, a `SimpleDateFormat` held as a static, a shared `StringBuilder` — these are accessed by every request thread simultaneously, and the failure is not a crash but wrong output. `SimpleDateFormat` under concurrency produces garbage dates and occasionally throws `NumberFormatException` from inside the JDK, which is a memorable way to learn that its internal `Calendar` is mutable state.

Because the cost of getting it wrong is invisible corruption. An unsynchronised `HashMap` resized by two threads can produce a cycle in a bucket's chain, and a later `get` spins forever at 100% CPU. Nothing logged anything wrong; a thread simply never returns. This class of bug is why "it works in testing" carries no information for concurrent code.

And because interviewers use it as a proxy for whether you think about ownership. A candidate who answers "make the fields private and synchronise the methods" has memorised a recipe. A candidate who asks what the invariant is, whether the state escapes, and who mutates it, is doing the actual work.

How it works

Confinement is the cheapest safety

State that never crosses a thread boundary needs no synchronisation at all. A per-request object created and discarded inside one request is confined by construction. The discipline is to make confinement visible — construct locally, never store in a field, never hand the reference to an executor — because confinement is a property that a single careless assignment destroys.

Immutability makes publication the only problem left

An object whose fields are all final and whose referenced state is itself immutable can be read by any number of threads safely. What remains is publishing the reference so other threads see a fully constructed object, which `final` fields plus a safe publication mechanism — a volatile field, an `AtomicReference`, a concurrent collection, or static initialisation — provides.

Guarding means every access, including reads

A lock provides mutual exclusion only over the accesses that take it. Synchronising writes and leaving reads unsynchronised gives readers no visibility guarantee and no atomicity across a multi-field invariant. Document the guard — "all access to `entries` is guarded by `this`" — because the invariant is not checkable by the compiler.

Escaping references silently unshare your state

Returning an internal collection, or storing a caller-supplied mutable object without copying it, hands out a path around your lock. A getter that returns the live `List` means a caller can mutate it while you hold no lock. Return a copy, an unmodifiable view, or an immutable snapshot.

Compound actions need one atomic operation, not two safe ones

Check-then-act and read-modify-write remain racy when built from thread-safe pieces. Either use the collection's own compound method, hold a lock across the whole action, or restructure it as a single CAS. The rule of thumb: if correctness depends on two calls happening with nothing in between, you need one call.

Implementing it

State the policy in the class's documentation: which fields are guarded, by which lock, and which methods are safe to compose. Concurrency contracts that live only in someone's head are broken by the next change.

Prefer immutable value objects for anything that crosses a thread boundary, and keep mutability inside a single owner. Most "thread safety" work disappears once the mutable region is small and confined.

Replace shared mutable JDK classes that are not safe — `SimpleDateFormat` with `DateTimeFormatter`, a shared `Random` with `ThreadLocalRandom`, a synchronised wrapper over `HashMap` with `ConcurrentHashMap` — rather than wrapping them in locks.

Test with a barrier: release N threads at once against the object and assert the invariant afterwards, looping the scenario thousands of times. A single concurrent run that passes tells you nothing.

// Thread-safe map, racy caller: two calls with a gap between them
if (!sessions.containsKey(id)) {
    sessions.put(id, new Session(id));   // two threads, two Sessions
}

// One atomic operation instead of two safe ones
Session s = sessions.computeIfAbsent(id, Session::new);

// Escaping reference defeats the lock
public synchronized List<Item> getItems() { return items; }      // caller can mutate
public synchronized List<Item> getItems() { return List.copyOf(items); }  // snapshot

// Shared mutable formatter: corrupts output under concurrency
static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd");   // not safe
static final DateTimeFormatter FMT = DateTimeFormatter.ISO_LOCAL_DATE;    // immutable

Interview questions and how to answer them

What does it mean for a class to be thread-safe?

That its invariants hold under any interleaving of calls from multiple threads, with no extra synchronisation required from the caller. I would push on two things before accepting the label: what the invariant is, since safety is meaningless without one; and whether callers ever need to compose two methods atomically, because a class can have perfectly atomic methods and still be unusable safely if the useful operations span two of them.

Is `ConcurrentHashMap` enough to make a cache thread-safe?

For single operations, yes. For a cache, usually not, because the natural cache operation is "if absent, load and store", which is two calls with a gap — so two threads both miss and both perform the expensive load, and in the worst case one overwrites a newer value. `computeIfAbsent` makes it one atomic operation, with the caveat that the mapping function runs while the bin is locked, so it must be fast and must not touch the same map. If the load is slow, storing a `CompletableFuture` as the value is the usual shape.

A Spring `@Service` singleton has a mutable field. What is wrong?

Singleton beans are shared by every request thread, so that field is concurrently read and written with no coordination. Depending on what it holds, the outcome ranges from stale reads to interleaved state from two different requests appearing in one response, which is a data-leak class of bug rather than merely a correctness one. The fix is to make request-scoped state local to the method, or to make the field immutable, or — rarely — to guard it, but per-request state in a singleton is almost always a design mistake rather than a synchronisation one.

How do you safely publish an object to another thread?

Publication has to establish a happens-before edge between construction and the other thread's read, or the reader can see a partially constructed object — non-final fields at default values even though the constructor finished. Safe mechanisms: initialise in a static initialiser; store the reference into a `volatile` field or an `AtomicReference`; put it into a properly synchronised or concurrent collection; or guard both write and read with the same lock. Making all fields `final` gives you the freeze guarantee for the object's own contents, which covers most immutable value objects.

Which is preferable: a thread-safe class, or a class that documents it is not thread-safe?

Often the second. Thread safety inside a class costs performance for every caller including single-threaded ones, and it tends to be the wrong granularity — the caller's invariant usually spans more than one call, so they end up locking anyway. A small, clearly documented not-thread-safe class whose instances are confined to one thread is easier to reason about than a broadly synchronised one. `ArrayList` and `StringBuilder` chose this deliberately, after `Vector` and `StringBuffer` demonstrated the cost of the alternative.

Answers that lose the round

  • Defining thread safety as "uses synchronized" instead of "holds its invariants under concurrent access"
  • Claiming a class is thread-safe because each method is synchronised, without addressing whether callers need to compose methods
  • Synchronising writes but not reads, which leaves readers with no visibility guarantee at all
  • Returning internal mutable collections from getters, giving callers a route around the lock
  • Using `Collections.synchronizedMap` and then iterating it without holding the wrapper's lock, which throws `ConcurrentModificationException` or reads torn state
  • Holding a shared `SimpleDateFormat` or a shared `Random` in a static field and being surprised by corrupted output
  • Assuming `ConcurrentHashMap` makes `get`-then-`put` atomic, which is the single most common composition bug

Practise thread safety in a real repository

Gronex ships this as a runnable repository: a service whose lock is released on the happy path and leaked when the guarded block throws. The tests drive the failing path under concurrency, so the second caller blocks forever and only correct release semantics pass.

FAQ

Are immutable objects automatically thread-safe?

Their contents are, provided everything they reference is also immutable — an immutable wrapper holding a mutable `List` is not. The remaining concern is publication: another thread must obtain the reference through a mechanism that establishes happens-before, though `final` fields give the JMM freeze guarantee that covers the object's own state.

Does `synchronized` on a method lock the class or the instance?

An instance method locks `this`; a static method locks the `Class` object. That distinction bites when a class has both, because they are different monitors and provide no mutual exclusion against each other. Locking on a public object — including `this` — also lets outside code take your lock, which is why a private final lock object is the safer default.

Is a `record` thread-safe?

A record's components are final, so the record itself is effectively immutable and safe to share — but only shallowly. A record holding a mutable array or collection exposes that mutability, and the standard accessor returns the same reference every caller gets.

Related

More backend concepts