Java Multithreading Interview Questions for Experienced Developers 2026
![]()
You’re sitting across from a Wipro hiring manager. They ask: “You’ve used ThreadPoolExecutor in production. Walk us through how it prevents thread explosion and what happens if all threads are busy.”
This isn’t a “define multithreading” question anymore. After 3+ years, they want to know how you’ve solved real concurrency problems—deadlocks you’ve debugged, thread pools you’ve tuned, and race conditions you’ve caught before production.
I’ve interviewed at TCS, Infosys, Wipro, and EPAM. The multithreading round separates senior developers from mid-level ones. Here are the questions that actually get asked, with answers that show you’ve done the work.
Why Interviewers Ask These Questions
Multithreading is where theory meets pain. If you’ve shipped Java in production, you’ve either:
- Debugged a deadlock at 2 AM
- Fought with thread-safe collections
- Tuned a thread pool because the app was leaking threads
Interviewers at TCS, Infosys, and EPAM ask multithreading deep-dives because:
- Concurrency bugs are costly—they don’t show up in dev, only under load
- Senior devs should know when NOT to use threads (when reactive or async is better)
- Real systems have constraints—bounded queues, timeout strategies, graceful shutdown
Quick Comparison: Thread Models and When to Use Them

| Model | Use Case | Pros | Cons | Example |
|---|---|---|---|---|
| Bounded Thread Pool | I/O-bound tasks (DB calls, APIs) | Predictable memory, prevents runaway threads | Queue backpressure if all threads busy | 10-50 threads for REST API |
| Virtual Threads (Java 21+) | High-concurrency I/O (millions of connections) | Millions of threads possible, GC-friendly | No CPU pinning, blocking calls still block | WebSocket servers, async APIs |
| ForkJoinPool | CPU-bound divide-and-conquer tasks | Work stealing, optimal CPU utilization | Not for I/O or blocking operations | Parallel streams, recursive computations |
| Single-threaded Event Loop | Actor-based (Akka), reactive (Project Reactor) | No synchronization overhead, cache-friendly | Requires non-blocking I/O, learning curve | Akka actors, Node.js-style event loops |
10 Core Interview Questions on Java Multithreading
1. You’ve Configured a ThreadPoolExecutor with Core=5, Max=20, Queue=100. A Spike Hits. Walk Us Through What Happens.
Direct Answer: Tasks queue up to 100. When queue is full, threads scale to max=20. Beyond that, tasks are rejected (RejectedExecutionException) unless you’ve set a custom rejection policy.
Why This Matters: Most devs set up a thread pool once and forget it. In production, you hit that queue ceiling, and the app either crashes or silently drops requests. Senior devs know the rejection policy, the trade-offs, and how to monitor it.
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(100);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
20, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime for non-core threads
queue,
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
// CallerRunsPolicy: if queue is full, the calling thread executes the task
// This provides backpressure—the caller slows down
Better Rejection Policies:
| Policy | Behavior | When to Use |
|---|---|---|
| AbortPolicy (default) | Throws RejectedExecutionException | You want fast failure, not silent drops |
| CallerRunsPolicy | Caller thread executes the task | Backpressure-sensitive: REST endpoints, batch jobs |
| DiscardPolicy | Silently drops the task | Low-priority tasks (metrics, logging) |
| Custom | Log, alert, retry, queue to DB | Production systems with SLAs |
2. When Do You Use `synchronized` vs. `volatile`? Are They the Same?
Direct Answer: No. `volatile` ensures visibility (reads always see latest write), but doesn’t prevent race conditions. `synchronized` prevents both visibility issues AND race conditions (mutual exclusion). Use `volatile` for flags, `synchronized` for shared state updates.
// Example 1: volatile for flag
public class ShutdownManager {
private volatile boolean shutdown = false;
public void requestShutdown() {
shutdown = true; // all threads see this immediately
}
public void process() {
while (!shutdown) {
// do work
}
}
}
// Example 2: synchronized for shared counter
public class TicketCounter {
private int ticketsLeft = 100; // NOT volatile
public synchronized boolean buyTicket() {
if (ticketsLeft > 0) {
ticketsLeft--; // atomic check-then-act
return true;
}
return false;
}
}
// Example 3: Why volatile alone fails for counters
public class BrokenCounter {
private volatile int count = 0;
public void increment() {
count++; // RACE CONDITION: read-modify-write not atomic
// Thread A reads 5, Thread B reads 5, both write 6
}
}
Key Insight: `synchronized` includes the memory visibility guarantee of `volatile`. So if you’re already synchronizing, you don’t need `volatile`. But `volatile` is cheaper (no lock contention) if you only need visibility.
3. You’re Debugging a Production Deadlock. Your Threads Are Stuck. What’s Your First Move?
Direct Answer: Thread dump. `jstack <pid>` or `jcmd <pid> Thread.print`. Look for “waiting to acquire” and “locked by” to find circular waits. Then fix the lock order.
Classic Deadlock Scenario:
public class DeadlockExample {
private Object lock1 = new Object();
private Object lock2 = new Object();
// Thread A calls this
public void methodA() {
synchronized (lock1) {
System.out.println("Thread A acquired lock1");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("Thread A acquired lock2");
}
}
}
// Thread B calls this
public void methodB() {
synchronized (lock2) {
System.out.println("Thread B acquired lock2");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("Thread B acquired lock1");
}
}
}
}
// SOLUTION: Always acquire locks in the same order
public class FixedDeadlock {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void methodA() {
synchronized (lock1) {
synchronized (lock2) {
// safe
}
}
}
public void methodB() {
synchronized (lock1) { // same order as methodA
synchronized (lock2) {
// safe
}
}
}
}
Real-World Prevention: Use higher-level abstractions like `ReadWriteLock` or `StampedLock` instead of nested synchronized blocks. Or use a single lock if the critical section is small.
4. What’s the “Happens-Before” Relationship? Why Should You Care?
Direct Answer: Happens-before guarantees that memory writes in one thread are visible to reads in another. `synchronized`, `volatile`, and thread creation/join establish happens-before relationships. Without them, the JVM may reorder operations, and you see stale data.
Example That Breaks Without Happens-Before:
public class MemoryVisibility {
private int value = 0;
private boolean ready = false;
// Thread A
public void write() {
value = 42;
ready = true; // write without synchronization
}
// Thread B
public void read() {
if (ready) {
System.out.println(value); // might print 0, not 42!
}
}
// FIX: Make ready volatile
private volatile boolean ready = false;
// Now writes to 'value' before 'ready = true' happen-before
// reads of 'ready' followed by reads of 'value'
}
Happens-Before Rules (Java Memory Model):
- Volatile write → volatile read (same variable)
- synchronized unlock → synchronized lock (same monitor)
- Thread.start() → actions in that thread
- Actions in a thread → Thread.join()
5. You’ve Used ThreadLocal. When Does It Leak? How Do You Fix It?
Direct Answer: ThreadLocal leaks when you store a value in a thread pool thread and never remove it. The value stays in memory even after the task completes because the thread doesn’t die—it goes back to the pool. Use try-finally with remove().
public class ThreadLocalLeak {
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
public void processRequest() {
Connection conn = getConnection();
connectionHolder.set(conn); // stored in thread
// If this thread goes back to the pool without remove(),
// the Connection stays in memory and might cause leaks
}
// WRONG WAY (in a thread pool):
public void fetchData() {
connectionHolder.set(getConnection()); // memory leak!
// thread returns to pool, connection never cleaned up
}
// RIGHT WAY:
public void fetchDataSafe() {
Connection conn = getConnection();
try {
connectionHolder.set(conn);
// use connection
} finally {
connectionHolder.remove(); // always clean up
}
}
}
// Better: Use try-with-resources or wrapper
public class SafeThreadLocal {
private static ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(
() -> getConnection()
);
public static void execute(Consumer<Connection> task) {
try {
task.accept(connectionHolder.get());
} finally {
connectionHolder.remove(); // guaranteed cleanup
}
}
}
6. How Do ReadWriteLock and StampedLock Differ? Which One for High-Concurrency Reads?
Direct Answer: ReadWriteLock allows concurrent reads but exclusive writes. StampedLock also allows optimistic reads (no lock at all) but requires validation—much faster for read-heavy workloads. Trade-off: StampedLock is harder to use.
import java.util.concurrent.locks.*;
// ReadWriteLock: safe but slower for heavy reads
public class CacheReadWrite {
private Map<String, String> cache = new HashMap<>();
private ReadWriteLock lock = new ReentrantReadWriteLock();
public String get(String key) {
lock.readLock().lock();
try {
return cache.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, String value) {
lock.writeLock().lock();
try {
cache.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
// StampedLock: faster for reads, but more complex
public class CacheStamped {
private Map<String, String> cache = new HashMap<>();
private StampedLock lock = new StampedLock();
public String get(String key) {
long stamp = lock.tryOptimisticRead(); // no lock!
String value = cache.get(key);
if (!lock.validate(stamp)) { // check if data changed
// Retry with read lock
stamp = lock.readLock();
try {
value = cache.get(key);
} finally {
lock.unlockRead(stamp);
}
}
return value;
}
public void put(String key, String value) {
long stamp = lock.writeLock();
try {
cache.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
}
// Benchmark: StampedLock ~3x faster for 95% reads, 5% writes
// ReadWriteLock: simpler, acceptable for 80% reads
7. Future vs. CompletableFuture. When Does One Block, and When Do You Chain Without Blocking?
Direct Answer: Future.get() blocks the caller. CompletableFuture lets you chain operations (thenApply, thenCombine) without blocking—execution continues in a different thread. For reactive pipelines, always use CompletableFuture.
import java.util.concurrent.*;
// BLOCKING: Future with executor
public void oldWay() {
ExecutorService executor = Executors.newFixedThreadPool(5);
Future<Integer> future = executor.submit(() -> {
Thread.sleep(2000);
return 42;
});
// Main thread blocks here
Integer result = future.get(); // waits 2 seconds
System.out.println("Result: " + result);
}
// NON-BLOCKING: CompletableFuture with chaining
public void newWay() {
CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching data...");
try { Thread.sleep(2000); } catch (InterruptedException e) {}
return 42;
})
.thenApply(result -> result * 2) // runs in executor thread
.thenAccept(result -> System.out.println("Final: " + result))
.exceptionally(ex -> {
System.out.println("Error: " + ex.getMessage());
return null;
});
// Main thread continues immediately, doesn't block
System.out.println("Non-blocking, returns immediately");
}
// Combine multiple async calls
public CompletableFuture<String> fetchUserAndOrders(int userId) {
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> fetchUser(userId));
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> fetchOrders(userId));
return userFuture.thenCombine(ordersFuture, (user, orders) -> {
return "User: " + user.name + ", Orders: " + orders.size();
});
}
8. Semaphore vs. CountDownLatch. What’s the Real Difference?
Direct Answer: CountDownLatch is one-shot (wait for N events to complete). Semaphore is reusable (control access to N resources). Use CountDownLatch for initialization, Semaphore for rate limiting or resource pooling.
import java.util.concurrent.*;
// CountDownLatch: one-time barrier
public class WaitForAllTasks {
public void processInParallel(List<String> items) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(items.size());
ExecutorService executor = Executors.newFixedThreadPool(4);
for (String item : items) {
executor.submit(() -> {
try {
process(item);
} finally {
latch.countDown(); // signal completion
}
});
}
latch.await(); // wait for all tasks
System.out.println("All tasks done");
executor.shutdown();
}
}
// Semaphore: resource pooling / rate limiting
public class BoundedResourcePool {
private Semaphore semaphore = new Semaphore(5); // max 5 concurrent
private List<Connection> connections = new ArrayList<>();
public Connection acquire() throws InterruptedException {
semaphore.acquire(); // blocks if 5 connections in use
return getConnection();
}
public void release(Connection conn) {
returnConnection(conn);
semaphore.release(); // allows another thread to acquire
}
// Reusable: can acquire/release multiple times
}
// Real-world Semaphore: API rate limiting
public class ApiRateLimiter {
private Semaphore requestBudget = new Semaphore(100); // 100 req/second
public void callApi() throws InterruptedException {
requestBudget.acquire();
try {
// Make API call
httpClient.get("https://api.example.com/data");
} finally {
// Release quota back after 1 second
new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
requestBudget.release();
}).start();
}
}
}
9. Java 21 Virtual Threads. How Are They Different from Platform Threads? When Should You Use Them?
Direct Answer: Virtual threads are lightweight (millions possible), managed by the JVM, not OS threads. Blocking a virtual thread doesn’t block the platform thread underneath. Use them for I/O-heavy workloads (REST APIs, WebSockets, database queries). CPU-bound? Platform threads are fine.
// Virtual Threads (Java 21+)
public void virtualThreadServer() throws InterruptedException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
try {
// Simulate I/O (database, HTTP call)
Thread.sleep(100);
System.out.println("Task completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// With 1M virtual threads, uses maybe 100 platform threads
// Each virtual thread blocks, but platform thread serves others
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
}
// Platform Threads (traditional)
public void platformThreadServer() {
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
});
}
// Limited to 100 concurrent tasks
}
// Benchmark result:
// Virtual Threads: 1M tasks × 100ms = handles easily
// Platform Threads: 100 threads × 100ms = can't handle 1M
10. False Sharing. How Does It Tank Performance? Real Example?
Direct Answer: False sharing happens when two threads update different variables that live on the same CPU cache line (64 bytes). One thread’s write invalidates the other’s cache, causing contention. Solution: pad the variable or use @jdk.internal.vm.annotation.Contended.
// SLOW: False sharing
public class FalseSharing {
public static class Counter {
public volatile long value = 0;
}
public static void main(String[] args) throws InterruptedException {
Counter counter1 = new Counter();
Counter counter2 = new Counter();
// Two threads on same cache line—constant cache invalidation
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000_000; i++) {
counter1.value++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000_000; i++) {
counter2.value++;
}
});
long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
long end = System.nanoTime();
System.out.println("False sharing: " + (end - start) / 1_000_000 + "ms");
}
}
// FAST: Padding to avoid false sharing
public class NoFalseSharing {
public static class Counter {
public volatile long value = 0;
// Pad with 7 longs (56 bytes) to push to different cache line
private long p1, p2, p3, p4, p5, p6, p7;
}
// Same code as above, but Counter objects on different cache lines
// Result: ~10x faster
}
// Modern Java (Java 9+): Use @Contended annotation
import jdk.internal.vm.annotation.Contended;
public class ModernNoFalseSharing {
@Contended
public static class Counter {
public volatile long value = 0;
}
// JVM automatically pads for you
// Launch with: java -XX:-RestrictContended ...
}
Real-World Code Examples
Example 1: Thread-Safe Cache with Expiration
import java.util.concurrent.*;
import java.util.*;
public class ExpireableCache<K, V> {
private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "Cache-Cleanup");
t.setDaemon(true);
return t;
});
private static class CacheEntry<V> {
V value;
long expiryTime;
CacheEntry(V value, long ttlMillis) {
this.value = value;
this.expiryTime = System.currentTimeMillis() + ttlMillis;
}
boolean isExpired() {
return System.currentTimeMillis() > expiryTime;
}
}
public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null) return null;
if (entry.isExpired()) {
cache.remove(key);
return null;
}
return entry.value;
}
public void startCleanup() {
cleanupExecutor.scheduleAtFixedRate(() -> {
cache.entrySet().removeIf(e -> e.getValue().isExpired());
}, 1, 1, TimeUnit.MINUTES);
}
public void shutdown() {
cleanupExecutor.shutdown();
}
}
// Usage
public class CacheTest {
public static void main(String[] args) throws InterruptedException {
ExpireableCache<String, String> cache = new ExpireableCache<>();
cache.startCleanup();
cache.put("user:1", "John", 5000); // expires in 5 seconds
System.out.println(cache.get("user:1")); // John
Thread.sleep(6000);
System.out.println(cache.get("user:1")); // null (expired)
cache.shutdown();
}
}
Example 2: Custom Rejection Handler with Logging
public class ProductionThreadPool {
public static ExecutorService createThreadPool(String name, int coreSize, int maxSize) {
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(1000);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
coreSize,
maxSize,
60, TimeUnit.SECONDS,
queue,
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, name + "-" + count.getAndIncrement());
t.setUncaughtExceptionHandler((thread, ex) -> {
System.err.println("Uncaught exception in " + thread.getName());
ex.printStackTrace();
});
return t;
}
},
new RejectionHandler()
);
// Monitor thread pool health
new Thread(() -> {
while (!executor.isShutdown()) {
try {
Thread.sleep(5000);
System.out.println(String.format(
"[%s] Active: %d, Queued: %d, Completed: %d",
name,
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount()
));
} catch (InterruptedException e) {
break;
}
}
}, name + "-Monitor").start();
return executor;
}
static class RejectionHandler implements ThreadPoolExecutor.RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.err.println("Task rejected! Queue size: " + executor.getQueue().size());
try {
// Optional: block caller for a bit, then retry
executor.getQueue().offer(r, 10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedExecutionException("Interrupted while queuing", e);
}
}
}
}
Common Mistakes Experienced Devs Make
Mistake 1: Assuming synchronized() == volatile
Wrong: Using volatile for counters or compound operations.
Right: Use synchronized or atomic classes (AtomicInteger) for mutations. Use volatile only for visibility of reads/writes.
Mistake 2: Not Handling InterruptedException in Thread Pools
// WRONG
executor.submit(() -> {
while (true) {
try {
blockingOperation();
} catch (InterruptedException e) {
// Silently ignored—thread doesn't recognize shutdown!
}
}
});
// CORRECT
executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
blockingOperation();
} catch (InterruptedException e) {
// Restore interrupt status
Thread.currentThread().interrupt();
break;
}
}
});
Mistake 3: Setting Thread Pool Sizes Based on Guess Work
Wrong: “I’ll use 10 threads for a server handling 1000 requests/sec.”
Right: Calculate: threads = (expected response time in ms / 1000) × requests per second. Load test. Monitor active threads and queue depth in production.
Mistake 4: Not Shutting Down ExecutorService Cleanly
// WRONG: leaves thread pool hanging
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.execute(() -> doWork());
// Missing shutdown—threads never terminate
// CORRECT: graceful shutdown
try {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow(); // force kill remaining
}
} finally {
if (!executor.isTerminated()) {
System.err.println("Executor did not terminate");
}
}
Quick Tips from the Interview Room
- Always Have Numbers Ready: “ThreadPoolExecutor with core=10, max=50 handles ~500 req/sec on my server (dual-core). Here’s why…”
- Know Your Profiling Tools: jstack for deadlocks, jcmd for dumps, JFR (Java Flight Recorder) for CPU analysis. Name them in your answer.
- Talk Trade-Offs, Not Absolutes: “ReadWriteLock is simpler but StampedLock is 3x faster for 95% reads. Depends on your workload.”
- Mention Production Issues You’ve Solved: “We had a thread pool leak because we weren’t properly cleaning up ThreadLocal. Fixed it with try-finally in the cleanup code.”
- Understand When Threads Don’t Help: “Threads are I/O-bound. For CPU-bound tasks, ForkJoinPool with work-stealing is better. For truly reactive, use Project Reactor or RxJava.”
- Monitor in Production: “We use Micrometer to track executor.active, executor.queued, and executor.pool.size. Alerts if queue > 500.”
Final Recommendation
You’re competing against developers who know the textbook definitions. Win by:
- Code it up: Write a thread pool executor with monitoring. Debug a deadlock in a real program. Practice CompletableFuture chains until they’re second nature.
- Run benchmarks: Show the interviewer you’ve measured: StampedLock vs. ReadWriteLock, virtual threads vs. platform threads, false sharing impact. Real numbers beat theory.
- Explain trade-offs: “Synchronized is simpler but volatile is lighter. Here’s when I’d pick each.”
- Mention monitoring: Name specific tools: jstack, jcmd, JFR, Micrometer. Interviewers respect engineers who ship to production, not just labs.
That’s how you move past the sophomore multithreading questions and into the senior round at TCS, Infosys, Wipro, or EPAM.
Next Step: Book a mock interview session where we’ll quiz you on these exact scenarios. Real interviewer, real pressure, real feedback.
Also read our Java basics interview questions.
Also read our Java advanced interview questions.
Also read our book a mock interview.
Frequently Asked Questions (FAQ)
What’s the most common ThreadPoolExecutor misconfiguration you see in production?
Setting queue size too small or no queue limit at all. When all threads are busy and queue is full, new tasks get rejected with RejectedExecutionException and the app crashes. Always use a bounded queue with an explicit rejection policy (CallerRunsPolicy for backpressure).
Is Java 21 virtual threads production-ready, or should I wait?
Virtual threads are production-ready for I/O-heavy workloads (REST APIs, WebSockets, DB calls). Don’t use them for CPU-bound tasks or when you rely on ThreadLocal deeply. Start with one non-critical service, measure, then roll out.
How do you debug a race condition in production?
Thread dump (jstack) to see live threads and their stack traces. Java Flight Recorder (JFR) to capture the exact timing of operations. Then reproduce locally with stress tests, often using bytecode instrumentation (javaagent) to add synchronization logging.
Should I use Semaphore or just a bounded thread pool?
Bounded thread pool if you’re controlling concurrent task execution. Semaphore if you’re controlling access to a fixed number of resources (DB connections, API rate limits) across multiple thread pools. They solve different problems.
CompletableFuture vs. Akka actors for async pipelines?
CompletableFuture if you already have simple synchronous code and need to go async gradually. Akka if you’re building a complex, distributed system with fault tolerance. CompletableFuture is easier, Akka is more powerful.
How do you prevent ThreadLocal memory leaks in servlets?
Always remove() in a finally block or a custom filter. Better: wrap ThreadLocal access in a try-finally utility method. Best: use framework features (Spring’s ThreadLocal holder in @Transactional) that handle cleanup automatically.
Can virtual threads use ThreadLocal the same way?
Yes, but be careful. Virtual threads are short-lived (created per task), so ThreadLocal behavior is different. A value set in one virtual thread won’t bleed to another even if they run on the same platform thread. Still remove() when done to be safe.
What’s the difference between Thread.join() and CountDownLatch.await()?
join() waits for a specific thread to finish. CountDownLatch waits for N independent events. Use join() when coordinating with a known thread; use CountDownLatch when you don’t know or control the threads doing the work.

One Comment