Designing Thread-Safe Classes in Java — Confinement, Immutability, and Synchronization
by Eric Hanson, Backend Developer at Clean Systems Consulting
Thread safety as a design property, not an afterthought
A class is thread-safe if it behaves correctly when accessed from multiple threads simultaneously, without requiring the caller to perform any additional synchronization. That last clause matters: a class that requires callers to coordinate externally is not thread-safe — it has pushed the synchronization responsibility outward.
The goal is not to synchronize everything. Unnecessary synchronization reduces throughput and can introduce deadlocks. The goal is to choose the right strategy for each class based on how it will be used, document that choice, and implement it correctly.
Three strategies, in order of preference: confinement, immutability, synchronization.
Strategy 1: Confinement — eliminate sharing
The simplest thread-safety strategy is not sharing state between threads at all. A class that holds mutable state accessible only by one thread at a time needs no synchronization.
Stack confinement is the most common form. Local variables in a method are stack-confined — each thread has its own stack, so local variables are inherently thread-private:
public List<Order> processOrders(List<Long> orderIds) {
// result is stack-confined — only this thread can see it
List<Order> result = new ArrayList<>();
for (long id : orderIds) {
result.add(fetchOrder(id));
}
return result; // safe to return — result is fully constructed before return
}
The risk with stack confinement: publishing the confined object. If result is passed to another thread before the method returns — stored in a field, added to a shared collection, submitted to an executor — it escapes its confinement and is no longer safe.
Thread-local confinement uses ThreadLocal<T> to give each thread its own instance:
// Each thread gets its own DateFormat — DateFormat is not thread-safe
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return DATE_FORMAT.get().format(date); // this thread's instance
}
ThreadLocal is appropriate for objects that are expensive to create, not thread-safe, and needed per-thread — SimpleDateFormat, database connections in thread-per-request architectures, request context holders. The caveat: in thread pool environments, ThreadLocal values persist between tasks. Always clean up with ThreadLocal.remove() after a task completes (covered in the memory leaks article).
Object confinement assigns ownership of mutable state to a single object that controls all access:
public class OrderQueue {
// queuedOrders is confined to OrderQueue — no direct external access
private final Queue<Order> queuedOrders = new ArrayDeque<>();
public synchronized void enqueue(Order order) {
queuedOrders.add(order);
}
public synchronized Optional<Order> dequeue() {
return Optional.ofNullable(queuedOrders.poll());
}
// No getter that returns queuedOrders — would break confinement
}
If getQueuedOrders() were added returning the raw Queue, external callers could modify it without going through the synchronized methods — the confinement is broken.
Strategy 2: Immutability — eliminate mutation
An immutable object cannot be modified after construction. It can be shared freely between threads without synchronization — there's nothing to synchronize because nothing changes.
A class is immutable when:
- All fields are
final - All fields are themselves immutable types or defensively copied
thisdoes not escape during construction- No mutating methods are exposed
public final class Money {
private final long amountInCents;
private final Currency currency;
public Money(long amountInCents, Currency currency) {
Objects.requireNonNull(currency, "currency must not be null");
if (amountInCents < 0) throw new IllegalArgumentException("amount must not be negative");
this.amountInCents = amountInCents;
this.currency = currency;
// 'this' does not escape — no registration with external objects in constructor
}
public Money add(Money other) {
if (!currency.equals(other.currency)) throw new CurrencyMismatchException();
return new Money(amountInCents + other.amountInCents, currency); // new object, not mutation
}
public long getAmountInCents() { return amountInCents; }
public Currency getCurrency() { return currency; }
}
Money is safely sharable across any number of threads. add() returns a new Money rather than modifying this one.
The this escape problem. If this is published during construction — registered as a listener, stored in a static field, passed to another thread — other threads may see a partially constructed object:
public class EventPublisher {
private final EventBus bus;
public EventPublisher(EventBus bus) {
this.bus = bus;
bus.register(this); // 'this' escapes — another thread could call this before construction completes
}
}
The fix: use a factory method that registers after construction is complete:
public static EventPublisher create(EventBus bus) {
EventPublisher publisher = new EventPublisher(bus);
bus.register(publisher); // construction is complete before registration
return publisher;
}
Effectively immutable objects — objects that are not formally immutable but are never mutated after being safely published — can be treated as immutable for thread-safety purposes. A List that is built, frozen with Collections.unmodifiableList(), and then shared is effectively immutable. The safety guarantee requires that no code retains a reference to the mutable underlying list and mutates it after publishing.
Strategy 3: Synchronization — controlling access
When sharing mutable state is unavoidable, synchronization controls access. The key design decisions: what is the synchronization policy, what is the lock, and what invariants must hold across operations.
Define the synchronization policy explicitly. The synchronization policy specifies which lock protects which state. Without an explicit policy, synchronization is ad hoc and invariants are hard to reason about:
/**
* Thread safety: all mutable state (orders, totalValue) is guarded by the
* intrinsic lock on this instance. Callers need not perform external synchronization.
*/
@ThreadSafe
public class OrderBook {
@GuardedBy("this") private final List<Order> orders = new ArrayList<>();
@GuardedBy("this") private long totalValue = 0;
public synchronized void add(Order order) {
orders.add(order);
totalValue += order.getValue();
}
public synchronized long getTotalValue() {
return totalValue;
}
public synchronized int size() {
return orders.size();
}
}
@GuardedBy and @ThreadSafe from the jcip-annotations library document the policy in the source. They're not enforced by the compiler, but they make the design visible and give static analysis tools something to check.
Both orders and totalValue are updated atomically in add() — if they were updated in separate synchronized methods, a reader could observe totalValue updated but orders not yet updated (or vice versa), violating the invariant that totalValue equals the sum of orders. Compound operations on related state must be atomic with respect to each other.
Prefer private locks over intrinsic locks for public classes. Synchronizing on this exposes the lock — external code can also synchronize on the same object, creating the possibility of deadlock or inadvertently holding the lock:
// External code can interfere
synchronized (orderBook) {
// holds the lock that orderBook uses internally
orderBook.add(order); // deadlock if this tries to re-acquire — but intrinsic lock is reentrant
// more code that holds the lock longer than the class intended
}
A private lock object prevents this:
public class OrderBook {
private final Object lock = new Object();
public void add(Order order) {
synchronized (lock) { // private lock — external code can't acquire it
orders.add(order);
totalValue += order.getValue();
}
}
}
ReentrantReadWriteLock for read-heavy state. When reads are frequent and writes are infrequent, a read-write lock allows concurrent reads while serializing writes:
public class ConfigStore {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private Map<String, String> config = new HashMap<>();
public String get(String key) {
readLock.lock();
try {
return config.get(key);
} finally {
readLock.unlock();
}
}
public void reload(Map<String, String> newConfig) {
writeLock.lock();
try {
config = new HashMap<>(newConfig);
} finally {
writeLock.unlock();
}
}
}
Multiple threads can call get() concurrently. reload() acquires an exclusive write lock — all readers are blocked during reload. The write is an assignment to a new map rather than mutation of the existing one — simpler and avoids partially-visible intermediate states.
Composing thread-safe objects
A class that uses thread-safe component objects is not automatically thread-safe. Compound operations across multiple thread-safe objects require external synchronization or atomic operations:
// Both ConcurrentHashMap operations are individually thread-safe
// But the check-then-act is not atomic
if (!map.containsKey(key)) {
map.put(key, value); // another thread may have inserted between containsKey and put
}
// Atomic alternative
map.putIfAbsent(key, value);
// or
map.computeIfAbsent(key, k -> computeValue(k));
The java.util.concurrent collections expose atomic compound operations precisely because the component operations aren't enough. ConcurrentHashMap.computeIfAbsent, ConcurrentLinkedQueue.offer, BlockingQueue.poll(timeout) — these are the atomic compound operations that concurrent code needs. Use them rather than combining separate atomic operations.
Documenting the thread-safety contract
Every class that will be used in a multithreaded context should document its thread-safety guarantee. Not all classes need to be thread-safe — but callers need to know which ones aren't so they can coordinate externally if needed.
Three levels:
- Thread-safe: can be used from multiple threads without external synchronization
- Conditionally thread-safe: thread-safe for individual operations, but compound operations require external synchronization (most
java.utilcollections) - Not thread-safe: requires external synchronization for all access
ArrayList, HashMap, SimpleDateFormat are not thread-safe — their Javadoc says so. ConcurrentHashMap, AtomicInteger, CopyOnWriteArrayList are thread-safe. Vector and Hashtable are conditionally thread-safe — individual operations are synchronized, but iteration requires external synchronization to prevent ConcurrentModificationException.
Documenting the contract in your own classes is not optional in a concurrent codebase. A class that is not documented as thread-safe will be used as if it were, and the resulting bugs will be difficult to find.