10 Proven Techniques to Ensure Thread Safety in Java Backend Development

This article explains why thread safety is critical for backend developers, describes common data race problems, and provides ten practical strategies—including stateless design, immutability, synchronized blocks, locks, distributed locks, volatile variables, ThreadLocal, concurrent collections, CAS, and data isolation—to reliably protect shared resources in multithreaded Java applications.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
10 Proven Techniques to Ensure Thread Safety in Java Backend Development

Introduction

For backend developers, thread safety is a daily concern. It occurs when multiple threads simultaneously read and write shared (critical) resources, leading to data anomalies that can break business functionality. This article presents ten practical techniques to guarantee thread safety.

1. Stateless

If a service does not hold any shared mutable state, it is inherently thread‑safe. The following example shows a class without any public fields; therefore, concurrent calls cannot corrupt data.

public class NoStatusService {

    public void add(String status) {
        System.out.println("add status:" + status);
    }

    public void update(String status) {
        System.out.println("update status:" + status);
    }
}

2. Immutable

When shared resources are immutable (e.g., static final constants), they cannot be modified by any thread, eliminating race conditions.

public class NoChangeService {
    public static final String DEFAULT_NAME = "abc";

    public void add(String status) {
        System.out.println(DEFAULT_NAME);
    }
}

3. No Modification Permission

If a public resource only exposes read access and never provides a mutator, it remains thread‑safe.

public class SafePublishService {
    private String name;

    public String getName() {
        return name;
    }

    public void add(String status) {
        System.out.println("add status:" + status);
    }
}

4. synchronized

The JDK offers built‑in synchronization via synchronized methods and synchronized blocks. Synchronized blocks are preferred because they limit the locked scope, reducing performance impact.

public class SyncService {
    private int age = 1;
    private Object object = new Object();

    // synchronized method
    public synchronized void add(int i) {
        age = age + i;
        System.out.println("age:" + age);
    }

    // synchronized block (object lock)
    public void update(int i) {
        synchronized (object) {
            age = age + i;
            System.out.println("age:" + age);
        }
    }

    // synchronized block (class lock)
    public void updateClass(int i) {
        synchronized (SyncService.class) {
            age = age + i;
            System.out.println("age:" + age);
        }
    }
}

5. Lock

Beyond synchronized, the JDK provides the Lock interface (e.g., ReentrantLock) with features such as fairness, re‑entrancy, and read/write modes.

public class LockService {
    private ReentrantLock reentrantLock = new ReentrantLock();
    public int age = 1;

    public void add(int i) {
        try {
            reentrantLock.lock();
            age = age + i;
            System.out.println("age:" + age);
        } finally {
            reentrantLock.unlock();
        }
    }
}

6. Distributed Lock

In a distributed environment, JVM‑level synchronization cannot coordinate across nodes. Distributed locks (e.g., based on Redis, Zookeeper, or databases) are required. The following pseudo‑code demonstrates a Redis lock.

try {
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
    return true;
  }
  return false;
} finally {
  unlock(lockKey);
}

7. volatile

When only visibility (not atomicity) is needed—such as a boolean flag that stops a service—declaring the variable volatile ensures all threads see the latest value.

@Service
public class CanalService {
    private volatile boolean running = false;
    private Thread thread;
    // ... handle(), start(), stop() methods omitted for brevity ...
}
Note: volatile must not be used for counters or other operations that require atomicity.

8. ThreadLocal

ThreadLocal provides a “space‑for‑time” trade‑off by giving each thread its own copy of a variable, eliminating contention for that data.

public class ThreadLocalService {
    private ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public void add(int i) {
        Integer integer = threadLocal.get();
        threadLocal.set(integer == null ? 0 : integer + i);
    }
}
Remember to call remove() in a finally block to avoid memory leaks.

9. Thread‑Safe Collections

When shared collections are needed, use the concurrent implementations provided by the JDK, such as CopyOnWriteArrayList, ConcurrentHashMap, etc.

public class HashMapTest {
    private static ConcurrentHashMap<String, Object> hashMap = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        new Thread(() -> hashMap.put("key1", "value1")).start();
        new Thread(() -> hashMap.put("key2", "value2")).start();
        try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println(hashMap);
    }
}

10. CAS

The Compare‑And‑Swap (CAS) mechanism offers lock‑free atomic updates via the Unsafe class or higher‑level java.util.concurrent.atomic utilities.

public class AtomicService {
    private AtomicInteger atomicInteger = new AtomicInteger();

    public int add(int i) {
        return atomicInteger.getAndAdd(i);
    }
}
CAS can suffer from the ABA problem; using AtomicStampedReference adds a version stamp to avoid it.

11. Data Isolation

Isolating data per thread or per partition (e.g., Kafka partitions) ensures that only one thread processes a given piece of data, thereby avoiding contention.

public class ThreadPoolTest {
    public static void main(String[] args) {
        ExecutorService threadPool = new ThreadPoolExecutor(
            8, 10, 60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(500),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        List<User> userList = Lists.newArrayList(
            new User(1L, "Su San", 18, "Chengdu"),
            new User(2L, "Su San Tech", 20, "Sichuan"),
            new User(3L, "Tech", 25, "Yunnan")
        );
        for (User user : userList) {
            threadPool.submit(new Work(user));
        }
        try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println(userList);
    }
    static class Work implements Runnable {
        private User user;
        public Work(User user) { this.user = user; }
        @Override public void run() { user.setName(user.getName() + "Test"); }
    }
}

In Kafka, sending all messages of the same order to the same partition and processing that partition with a single consumer thread achieves the same isolation.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Javathread safetydistributed-lock
Su San Talks Tech
Written by

Su San Talks Tech

Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.