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()
, orjoin()
. - 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 poolsForkJoinPool
for divide-and-conquerCompletableFuture
for async flowsBlockingQueue
for producer-consumerSemaphore
,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