Thread Safety
Thread safety means a class or piece of code behaves correctly when accessed by multiple threads concurrently — and Java offers several distinct strategies to achieve it, each with different tradeoffs.
A class or piece of code is thread-safe if it behaves correctly — produces no data races, race conditions, or corrupted state — when accessed concurrently by multiple threads, without requiring any additional coordination from the calling code. Thread safety is not one single technique; Java offers several distinct strategies (immutability, confinement, synchronization, lock-free atomics, concurrent collection design) and choosing the right one for a given piece of state is a core design skill.
What Thread Safety Means
A thread-safe class guarantees correct behavior under any interleaving of concurrent calls from multiple threads, with no external synchronization required by the caller. This is a strong guarantee — it means the class itself has handled all the coordination internally, not that 'it usually works' or 'it works if you're careful about how you call it.'
Thread safety is a property of how state is used and shared, not an inherent property of a class in isolation — a class can be thread-safe when used one way and unsafe when its instances are shared and mutated concurrently in another way (e.g. a thread-safe collection can still suffer a check-then-act race if you compose two separate calls on it without synchronization).
Strategy 1: Immutable Objects
The simplest and strongest strategy: if an object's state cannot change after construction, it is automatically thread-safe — there is nothing to race over. A class is immutable when all fields are final, the class is not designed for extension in ways that break invariants, and no method allows mutation of the object's observable state (including not exposing mutable internal references directly).
Point can be freely shared and read across threads with no synchronization — no method ever changes an existing Point's fields.
final on a field prevents reassignment, not deep immutability of what it references — if a final field holds a reference to a mutable object (e.g. a List), that referenced object can still be mutated unless it too is defensively copied or itself immutable.
final class Point {
private final int x;
private final int y;
Point(int x, int y) { this.x = x; this.y = y; }
int getX() { return x; }
int getY() { return y; }
Point translated(int dx, int dy) { return new Point(x + dx, y + dy); } // returns a NEW Point
}Strategy 2: Thread Confinement
If mutable state is never shared between threads — each thread has and uses its own exclusive copy — there is no possibility of a race, because there is no concurrent access at all. ThreadLocal is Java's built-in tool for this: it gives each thread its own independent copy of a variable.
Each thread sees its own independent StringBuilder via LOG.get() — no synchronization is needed because there is no shared mutable object.
Always call remove() on a ThreadLocal when done, especially in thread-pooled environments — otherwise a pooled thread reused for a different task can see stale state left behind by a previous task.
class RequestContext {
private static final ThreadLocal<StringBuilder> LOG = ThreadLocal.withInitial(StringBuilder::new);
static void append(String msg) { LOG.get().append(msg).append('\n'); }
static String drain() {
String result = LOG.get().toString();
LOG.remove(); // avoid leaking state across thread-pool reuse
return result;
}
}Strategy 3: Synchronization
When mutable state genuinely must be shared, synchronized blocks/methods or explicit Locks enforce mutual exclusion, ensuring only one thread executes a critical section at a time, and (per the happens-before rules) also provide visibility of the changes made inside.
Every method that reads or writes the same shared state must use the same consistent locking discipline (the same lock object) — partial synchronization, where only some accessors are synchronized, does not provide thread safety.
class SafeCounter {
private int count;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}Strategy 4: Concurrent Collections
The java.util.concurrent package provides collections purpose-built for concurrent access — ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue, various BlockingQueue implementations. These offer better scalability than externally synchronizing a plain HashMap/ArrayList, and provide atomic compound operations (putIfAbsent, computeIfAbsent) that avoid check-then-act races the caller would otherwise have to guard against manually.
ConcurrentHashMap<String, Integer> visits = new ConcurrentHashMap<>();
visits.merge("home", 1, Integer::sum); // atomic get-or-init-then-add, safe under concurrencyStrategy 5: Atomic Classes
java.util.concurrent.atomic classes (AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference) provide lock-free thread safety for single variables, using low-level compare-and-swap (CAS) hardware operations rather than locks. They are typically faster than locking under moderate contention for simple counters and flags, though CAS-based retry loops can degrade under very high contention.
Lock-freedom is a distinct property from atomicity: the atomic classes are guaranteed atomic (all-or-nothing, correctly serialized), but 'lock-free' is a specific implementation characterization (typically true for these classes on mainstream JVMs, but not something the JLS itself mandates as part of the atomic classes' contract).
Strategy 6: Stateless Design
A class with no instance fields at all — or whose fields are all final references to immutable, thread-safe collaborators — has no per-instance mutable state to race over, so a single shared instance is automatically safe to call from any number of threads concurrently. This is a common pattern for service/utility classes.
class TaxCalculator { // stateless: no instance fields
double calculate(double amount, double rate) {
return amount * rate; // pure function of its arguments
}
}
// One shared TaxCalculator instance is safe for any number of concurrent callersComposing Thread-Safe Pieces Is Not Automatic
A common mistake is assuming that because every individual operation on a shared object is thread-safe, any sequence of those operations is also thread-safe. ConcurrentHashMap is internally thread-safe, but if (!map.containsKey(k)) map.put(k, v) is still a check-then-act race, because the two separate calls are not atomic together — only the built-in compound operations (putIfAbsent, computeIfAbsent, compute) are.
// UNSAFE despite ConcurrentHashMap being thread-safe internally
if (!map.containsKey(key)) { map.put(key, value); }
// SAFE: single atomic compound operation
map.putIfAbsent(key, value);Choosing a Strategy
State never changes after construction -> Immutability
State exists but never shared across threads -> Thread confinement (ThreadLocal, local variables)
State shared and mutated, simple counter/flag -> Atomic classes
State shared and mutated, collection -> Concurrent collections
State shared, complex multi-field invariant -> Synchronization (locks)
No instance state at all -> Stateless designKey Takeaways
A thread-safe class behaves correctly under any concurrent interleaving without requiring the caller to add external synchronization.
Immutability is the strongest and simplest strategy — no mutation means no possibility of a race.
Thread confinement (e.g. ThreadLocal) avoids races by ensuring state is never actually shared.
Synchronization, atomic classes, and concurrent collections each handle shared mutable state, with different performance and complexity tradeoffs.
Composing multiple individually thread-safe calls does not automatically make the combined sequence thread-safe — check-then-act races can still occur; use the atomic compound operations a class provides.
A stateless class with no mutable instance fields is trivially safe to share across threads.
class Config { final List<String> tags = new ArrayList<>(); // final reference, but list itself is mutable } // externally: config.tags.add("x"); // mutates 'immutable' object from any thread class Config { private final List<String> tags; Config(List<String> tags) { this.tags = List.copyOf(tags); } // defensively copied, unmodifiable List<String> getTags() { return tags; } } final only prevents reassigning the tags reference itself; the referenced List remains mutable unless it is copied into an unmodifiable collection. True immutability requires guarding referenced mutable objects too.
if (!concurrentMap.containsKey(key)) { concurrentMap.put(key, value); // still racy, even though ConcurrentHashMap is 'thread-safe' } concurrentMap.putIfAbsent(key, value); // atomic compound operation
Individual thread safety of ConcurrentHashMap's methods does not extend to sequences of separate calls; use its atomic compound methods instead of composing containsKey+put yourself.
class Widget { private int state; synchronized void update() { state++; } int read() { return state; } // missing synchronized! } class Widget { private int state; synchronized void update() { state++; } synchronized int read() { return state; } }
Every method that touches the shared field must use the same locking discipline; an unsynchronized reader can observe a stale or partially-visible value even if the writer is synchronized.
What does it mean for a class to be thread-safe, precisely?
It behaves correctly under any interleaving of concurrent calls from multiple threads, without requiring the calling code to add any external synchronization of its own.
Why is immutability considered the strongest thread-safety strategy?
If an object's state literally cannot change after construction, there is no mutation to race over — any number of threads can read it concurrently with no coordination needed at all, eliminating the possibility of a data race by construction.
How does ThreadLocal achieve thread safety, and what is a key risk of using it in a thread-pooled environment?
It gives each thread its own independent copy of a variable, so there is no shared mutable state to race over. The risk is that in a pooled-thread environment (e.g. an executor service), a thread's ThreadLocal value persists across tasks unless explicitly removed via remove(), potentially leaking stale state from a previous task into a new one.
Why can a program using ConcurrentHashMap still have a race condition despite the map being internally thread-safe?
Individual method calls on ConcurrentHashMap are thread-safe, but composing two separate calls (like a containsKey check followed by a put) is not atomic as a pair — another thread can interleave between them. Only the map's own atomic compound operations (putIfAbsent, computeIfAbsent, compute) guarantee atomicity across the check-and-act.
What is the difference between atomicity and lock-freedom for classes like AtomicInteger?
Atomicity means the operation is guaranteed to execute as one indivisible step with a correct result. Lock-freedom is a separate implementation property (typically achieved via compare-and-swap without blocking) — atomic classes are guaranteed atomic by their contract, but lock-freedom, while typical, is an implementation characteristic rather than something the language specification itself mandates for them.
Why is a stateless class automatically thread-safe?
With no instance fields (or only final references to immutable, thread-safe collaborators), there is no per-instance mutable state at all — every method call operates purely on its arguments, so concurrent calls cannot interfere with each other.
Does final on a field make the referenced object immutable?
No. final only prevents the field itself from being reassigned to point to a different object; if it references a mutable object (like an ArrayList), that object's internal state can still be changed unless it is separately made immutable or defensively copied.
Write an immutable Money class that safely represents a currency amount and can be shared across threads without synchronization.
import java.math.BigDecimal; public final class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch"); return new Money(amount.add(other.amount), currency); } public BigDecimal getAmount() { return amount; } public String getCurrency() { return currency; } }
All fields are final, BigDecimal and String are themselves immutable, and add() returns a new Money instead of mutating either operand — so a single Money instance can be freely read by any number of threads with no synchronization, and no method ever changes an existing instance's state.
Is this Cache class thread-safe? What happens if two threads call put() concurrently?
No, it is not thread-safe — the underlying HashMap is not designed for concurrent modification. Concurrent put() calls can corrupt the map's internal structure (in older JDKs this could even cause an infinite loop during resizing; in general it risks lost entries, exceptions, or an inconsistent internal state), and concurrent get()/put() can throw ConcurrentModificationException in some interleavings or simply return stale/incorrect data.
Fix the Cache class above using a thread-safe strategy, and briefly justify the choice.
import java.util.concurrent.ConcurrentHashMap; import java.util.Map; class Cache { private final Map<String, String> data = new ConcurrentHashMap<>(); String get(String key) { return data.get(key); } void put(String key, String value) { data.put(key, value); } String getOrCompute(String key, java.util.function.Function<String, String> loader) { return data.computeIfAbsent(key, loader); // atomic, no check-then-act race } }
Swapping HashMap for ConcurrentHashMap gives internal thread safety for individual operations with good concurrent scalability, and using computeIfAbsent (rather than a separate containsKey+put) keeps any check-then-act logic atomic, avoiding the classic race where two threads might both compute and insert for the same missing key.
class Cache {
private final Map<String, String> data = new HashMap<>();
String get(String key) { return data.get(key); }
void put(String key, String value) { data.put(key, value); }
}
// shared across multiple threads without external synchronization