Best Practices for Writing Concurrent Java Code

Illustration for Best Practices for Writing Concurrent Java Code
By Last updated:

Concurrency is at the heart of modern software development. Whether you're writing backend services, UI apps, or real-time systems, writing correct and efficient concurrent Java code is crucial for performance, scalability, and maintainability.

In this guide, we’ll walk through the best practices every Java developer should follow when writing multithreaded code—complete with code samples, analogies, and real-world use cases.


🚀 What is Concurrent Programming?

Concurrent programming is the ability to run multiple tasks at overlapping times. In Java, this means using threads to perform parallel computations, process requests, or handle asynchronous events.

It’s about managing task execution, shared resources, and synchronization to achieve higher efficiency and responsiveness.


🔁 Thread Lifecycle Overview

NEW → RUNNABLE → BLOCKED/WAITING → TERMINATED

States

  • NEW: Thread is created but not started.
  • RUNNABLE: Eligible for execution.
  • BLOCKED: Waiting for a lock.
  • WAITING/TIMED_WAITING: Paused via wait(), sleep(), or join().
  • TERMINATED: Execution finished or exception thrown.

Understanding these states helps avoid issues like zombie threads, deadlocks, and race conditions.


🧵 Best Practice 1: Prefer ExecutorService over Manual Threads

Instead of this:

new Thread(() -> doWork()).start();

Use:

ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> doWork());
executor.shutdown();

✅ Benefits:

  • Thread pooling and reuse
  • Better control and monitoring
  • Easier to scale

💥 Best Practice 2: Always Shutdown Executors

Forgetting this causes thread leaks and app hangups.

executor.shutdown(); // graceful
executor.shutdownNow(); // forceful

🧠 Best Practice 3: Avoid Shared Mutable State

Mutable shared data is the root cause of most concurrency bugs.

✅ Use:

  • AtomicInteger, AtomicReference
  • Immutable objects
  • ConcurrentHashMap, CopyOnWriteArrayList

🔒 Best Practice 4: Lock Smartly

Don’t do this:

synchronized (this) { ... }

Do this:

private final Object lock = new Object();
synchronized (lock) { ... }

✅ Use ReentrantLock for more flexibility:

  • Try-locking
  • Interruptibility
  • Fairness policy

👀 Best Practice 5: Understand Memory Visibility

Without volatile, changes made by one thread may not be visible to others.

private volatile boolean running = true;

Or use synchronized to enforce happens-before relationships.


⛔ Best Practice 6: Avoid Busy Waiting

Instead of:

while (!condition) { }

Use BlockingQueue, wait()/notify(), or CountDownLatch.


🧮 Best Practice 7: Favor High-Level Concurrency Utilities

  • ExecutorService for thread pools
  • ForkJoinPool for divide-and-conquer
  • CompletableFuture for async flows
  • BlockingQueue for producer-consumer
  • Semaphore, CountDownLatch, CyclicBarrier for coordination

⚠️ Best Practice 8: Handle Exceptions in Threads

Uncaught exceptions silently kill threads.

Thread t = new Thread(task);
t.setUncaughtExceptionHandler((th, ex) -> log(ex));
t.start();

🔀 Best Practice 9: Use Thread-Safe Collections

Don't use ArrayList or HashMap in multithreaded code.

✅ Use:

  • ConcurrentHashMap
  • CopyOnWriteArrayList
  • ConcurrentLinkedQueue

🧵 Best Practice 10: Use Thread Naming and Monitoring

Thread t = new Thread(task, "FileWorker-1");
System.out.println(t.getName());

✅ Helps in debugging and logging.


🧪 Best Practice 11: Test with Concurrency in Mind

  • Use stress tests
  • Include timeouts
  • Test interleavings using tools like JCStress, JMH

🌐 Best Practice 12: Use Virtual Threads for Lightweight Tasks (Java 21+)

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> fetchData());
}

✅ Perfect for I/O-bound operations
❌ Avoid blocking synchronizations inside virtual threads


🧠 Design Patterns That Encourage Safe Concurrency

Pattern Use Case
Worker Thread Task queue with a fixed number of workers
Thread-per-message Spawn thread per event
Future Task Asynchronous computation with result
Producer-Consumer Decouple producer and consumer
Balking Avoid duplicate thread work

📌 What's New in Java Versions?

Java 8

  • Lambdas for Runnable, Callable
  • CompletableFuture
  • parallelStream()

Java 9

  • Flow API (Reactive Streams)

Java 11

  • Minor CompletableFuture updates

Java 21

  • Virtual Threads
  • Structured Concurrency
  • Scoped Values

🚫 Common Anti-Patterns

  • Thread.sleep() for coordination
  • Not shutting down executor services
  • Overusing synchronized
  • Shared mutable state without locks
  • Unbounded thread creation
  • Holding locks while doing I/O

🧠 Expert FAQ

Q1: What’s the difference between synchronized and ReentrantLock?

ReentrantLock provides advanced capabilities like timeout, interruptibility, and fairness policies.

Q2: Why should I avoid Thread.sleep()?

It’s not reliable for coordination. Use wait(), join(), or concurrency utilities.

Q3: Can volatile ensure atomicity?

No. It ensures visibility, not atomicity. Use atomic classes or locks.

Q4: When should I use virtual threads?

For high-throughput I/O-bound operations with lightweight concurrency needs.

Q5: What is false sharing?

When multiple threads modify variables in the same CPU cache line, degrading performance.

Q6: What is a memory barrier?

A CPU instruction that ensures memory visibility across threads.

Q7: Is ConcurrentHashMap 100% safe?

It’s safe for concurrent reads/writes but compound operations still need synchronization.

Q8: How does work-stealing improve performance?

Idle threads steal tasks from busy ones, balancing load dynamically in ForkJoinPool.

Q9: Can I mix virtual threads with executors?

Yes, with Executors.newVirtualThreadPerTaskExecutor().

Q10: Why not call run() directly on a thread?

It executes in the current thread instead of starting a new one.


✅ Conclusion and Key Takeaways

Writing safe and efficient concurrent Java code is a matter of understanding the tools, avoiding common pitfalls, and using best practices:

  • Avoid reinventing thread management—use ExecutorService
  • Favor immutability and high-level utilities
  • Always think about visibility and atomicity
  • Monitor and test for concurrency bugs
  • Leverage modern Java features like virtual threads and structured concurrency