How to design a thread safe class in Java.

Devraj Singh
7 min readMar 15, 2025

--

What Is a Thread-Safe Class?

Wait, but what is a thread-safe class? A Java class is considered thread-safe when multiple threads can use it simultaneously without causing race condition or inconsistent states. Thread safety guarantee that even if multiple threads are accessing the same object of thread-safe class, the Java object will be in consistent state. If a Java class object will not be accessed by multiple threads, then there is no worry for thread-safety. You don’t need thread-safe class that in such scenario. I have seen many times in my project, theoretically Java class is not thread-safe, but they will not accessed by multiple thread at same time, hence the Java class doesn’t need to be thread-safe in that case.

Understanding the Problem: Race Conditions

Let’s look at a classic non-thread-safe class to understand why thread safety matters:

public class UnsafeCounter {
private int count = 0;

public void increment() {
count++; // This is not an atomic operation!
}

public void decrement() {
count--; // This is not an atomic operation!
}

public int getCount() {
return count;
}
}

Do you think increment and decrementmethod are thread-safe? No, because operations like count++ aren't atomic—they're actually three separate steps:

  1. Read the current value of count
  2. Add/subtract 1
  3. Write the new value back to count

Here’s what can happen when two threads call increment() simultaneously when count is 0:

  • Thread A reads count (0)
  • Thread B reads count (0)
  • Thread A adds 1 and writes 1
  • Thread B adds 1 and writes 1
  • The final result is 1 instead of the expected 2

This is called a race condition, and it leaves our counter in an inconsistent state. The solution is to design the class a thread-safe.

Strategies for Building Thread-Safe Classes

1. Stateless Classes: No State, No Problem

If a class doesn’t maintain any state (fields), it’s automatically thread-safe. After all, if there’s nothing to modify, there’s nothing to corrupt!

public class MathHelper {
// No fields = no shared state

public int add(int a, int b) {
return a + b;
}

public static int multiply(int a, int b) {
return a * b;
}

public double calculateAverage(int[] numbers) {
int sum = 0;
for (int num : numbers) {
sum += num;
}
return numbers.length > 0 ? (double) sum / numbers.length : 0;
}
}

This class is inherently thread-safe because each method operates only on its parameters, with no shared state between calls.

2. Immutable Classes: Read-Only Is Your Friend

Immutability is a powerful way to achieve thread safety. If an object can’t be modified after creation, there’s no risk of concurrent modifications.

public final class ImmutablePoint {
private final int x;
private final int y;

public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}

public int getX() { return x; }
public int getY() { return y; }

// Operations create new objects instead of modifying this one
public ImmutablePoint translate(int dx, int dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}

The String class in Java is a perfect example of an immutable, thread-safe class. This is why you never have to worry about synchronization when using strings.
Use final whenever possible(that’s said by many wise Java experts), as it helps in unintentional modification and thread safety.

3. Encapsulation and Synchronization

For classes that need mutable state, proper encapsulation combined with synchronization is essential:

Step 1: Make fields private

Publicly accessible fields are a thread-safety violation:

// Poor encapsulation - not thread-safe 
public class UnsafeCounter {
public int count; // Directly accessible, can be modified by any thread
}

Step 2: Identify non-atomic operations and synchronize them

Once your fields are private, you need to ensure methods that modify state do so atomically:

public class SafeCounter { 
private int count; // Hidden from external access

public synchronized void increment() {
count++;
}

public synchronized void decrement() {
count--;
}

public synchronized int getCount() {
return count;
}
}

The synchronized keyword ensures that only one thread can execute these methods on a particular instance at any given time. This prevents race conditions, but it does come with a performance cost due to locking overhead.

The volatile keyword for visibility

Sometimes, you don’t need full synchronization but do need to ensure that changes made by one thread are visible to other threads:

public class StatusChecker {
private volatile boolean running = true;

public void stop() {
running = false;
}

public void performTask() {
while (running) {
// Do something
}
}
}

The volatile keyword ensures that changes to the variable are immediately visible to other threads, preventing visibility issues though it doesn't help with atomicity.

Coarse-grained locking vs fine-grained locking

Don’t be scared for the word coarse vs fine. Haha!. In simple word, Coarse-grained-locking means locking big area and fine-grained locking means locking small area.

Synchronizing entire method will work, but it has performance impact. It locks entire method and let’s say the method execution takes time, then many other thread will be waiting for long to enter inside the method.

Coarse-grained locking means using fewer, broader locks that protect larger sections of code or data structures. Like locking an entire list when you only need to modify one element, or locking entire method where only a sub-portion of method needed to be synchronized.

Fine-grained locking means using many smaller, targeted locks that protect specific components or operations. Like locking just the specific list element you’re modifying.

// Coarse-grained locking
public synchronized void transferMoney(Account from, Account to, int amount) {
from.debit(amount);
to.credit(amount);
}
// Fine-grained locking
public void transferMoney(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
from.debit(amount);
to.credit(amount);
}
}
}

Key differences:

  • Performance: Fine-grained typically allows higher concurrency and throughput as multiple threads can access different parts simultaneously. Coarse-grained can create bottlenecks.
  • Complexity: Fine-grained is more complex to implement correctly and increases risk of deadlocks. Coarse-grained is simpler and safer to implement.
  • Overhead: Fine-grained can have higher overhead from managing many locks. Coarse-grained has less lock management overhead but higher contention costs.

The right approach depends on your specific application needs, balancing simplicity against performance requirements.

4. Leveraging Thread-Safe Libraries

To achieve fine-grained locking, Java provides many thread-safe collections and utilities that can simplify building thread-safe classes:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadSafeUserManager {
// Thread-safe map from java.util.concurrent
private final ConcurrentHashMap<String, User> users = new ConcurrentHashMap<>();

// Thread-safe counter using atomic classes
private final AtomicInteger userCount = new AtomicInteger(0);

public void addUser(String id, User user) {
users.put(id, user);
userCount.incrementAndGet();
}

public User getUser(String id) {
return users.get(id);
}

public int getTotalUsers() {
return userCount.get();
}
}

This approach uses fine-grained locking (internal to the concurrent collections) rather than coarse-grained locking (synchronizing entire methods), potentially improving performance under contention.

Common thread-safe components include:

  • Collections: ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue
  • Atomic Variables: AtomicInteger, AtomicLong, AtomicReference
  • Queues: LinkedBlockingQueue, ArrayBlockingQueue
  • Synchronizers: CountDownLatch, CyclicBarrier, Semaphore

5. Thread Confinement: Each Thread Gets Its Own copy

Sometimes, the best way to avoid sharing is not to share at all. Thread confinement means ensuring each thread has its own independent copy of data:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ScopedValue (introduced in Java 21) is the modern replacement for ThreadLocal and offers better performance and cleaner semantics, especially with virtual threads.

public class ScopedValueExample {
// Define a ScopedValue (Java 21+)
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
public static void main(String[] args) {
// Run code with a specific value bound to the ScopedValue
ScopedValue.where(CURRENT_USER, "Alice").run(() -> {
processRequest();

// Value remains available in downstream methods
auditAction("data_access");
});
// Multiple bindings in nested scopes
ScopedValue.where(CURRENT_USER, "Bob").run(() -> {
System.out.println("Outer scope: " + CURRENT_USER.get());

// Can rebind in an inner scope
ScopedValue.where(CURRENT_USER, "Charlie").run(() -> {
System.out.println("Inner scope: " + CURRENT_USER.get());
});

// Outer binding is preserved
System.out.println("Back to outer: " + CURRENT_USER.get());
});
}
private static void processRequest() {
// Access the ScopedValue from another method
System.out.println("Processing request for: " + CURRENT_USER.get());
}
private static void auditAction(String action) {
// Get user from ScopedValue without having to pass it as a parameter
System.out.println("User " + CURRENT_USER.get() + " performed action: " + action);
}
}

6. Defensive Copying: Protect Your Internals

When your class holds references to mutable objects, you should consider making defensive copies when accepting or returning those objects:

public class DefensiveCalendar {
private final Date startDate;

public DefensiveCalendar(Date start) {
// Defensive copy to prevent the caller from modifying our state
this.startDate = new Date(start.getTime());
}

public Date getStartDate() {
// Defensive copy to prevent the caller from modifying our state
return new Date(startDate.getTime());
}
}

Without these defensive copies, a caller could modify the Date object even after passing it to your class, breaking encapsulation and potentially thread safety.

Conclusion

Building thread-safe classes in Java requires careful consideration of how your class will be used in concurrent environments. Let’s summarize our key strategies:

  1. Stateless classes eliminate shared state entirely
  2. Immutable classes prevent modifications after construction
  3. Proper encapsulation with synchronization protects mutable state
  4. Thread-safe libraries provide building blocks for complex classes
  5. Thread confinement isolates state to individual threads
  6. Defensive copying protects against external modifications
  7. Lock granularity choices balance safety and performance.
Visual guide for thread-safe class

--

--

Devraj Singh
Devraj Singh

Written by Devraj Singh

Lifelong learner, Java and Go enthusiasts

Responses (11)

Write a response