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
  • this does 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.util collections)
  • 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Backend Engineers Often Become the Most Overloaded People in a Team

Backend engineers are often the unsung heroes who carry more than their fair share of responsibility. Their work is crucial, yet the load they bear is frequently invisible.

Read more

The Hidden Work Developers Do That Clients Rarely See

Clients see features appear, but they rarely see the effort behind them. What looks like “instant delivery” is often hundreds of invisible decisions and hours of work.

Read more

The Hidden Cost of Over-Managed Developer Teams

At first, more control feels safer—more meetings, more approvals, more tracking. But slowly, productivity drops, and no one can quite explain why.

Read more

When Hiring Freelancers Is the Right Decision

Freelancers often get a bad reputation in software projects. But used correctly, they can be one of the smartest decisions you make.

Read more