Threading Deep-Dive & Virtual Threads¶
Advanced threading concepts: thread synchronization, thread pools, and Java 21's virtual threads that revolutionize concurrent programming.
Thread vs Runnable¶
Thread Class Approach¶
// Extend Thread class
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running in thread: " + Thread.currentThread().getName());
}
}
// Create and start
MyThread thread = new MyThread();
thread.start(); // Starts new thread, calls run() asynchronously
Pros: - Direct access to Thread methods (setName, setPriority, setDaemon) - Simple for single purpose classes
Cons: - ❌ Can only extend one class (Java doesn't support multiple inheritance) - Locks you into Thread class; can't extend another class - Less flexible
Runnable Interface Approach¶
// Implement Runnable interface
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Running in thread: " + Thread.currentThread().getName());
}
}
// Create thread with runnable
Thread thread = new Thread(new MyRunnable());
thread.start(); // Starts new thread, calls run() asynchronously
// Lambda syntax (modern)
new Thread(() -> {
System.out.println("Running in thread");
}).start();
// Using ExecutorService (recommended)
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(new MyRunnable()); // Runnable submitted to pool
Pros: - ✅ Implement alongside other interfaces (can extend another class) - Better for object composition - Reusable runnable object - Works with thread pools/executors naturally
Cons: - Need to wrap in Thread object manually (without executor)
Comparison Table¶
| Aspect | Thread | Runnable |
|---|---|---|
| Type | Class | Interface |
| Inheritance | Extends Thread; only one per class | Implements; can implement others too |
| Flexibility | Low; tied to Thread | High; composable |
| Thread Management | Manual new Thread(...) |
Use ExecutorService (cleaner) |
| Reusability | Single-use object | Reusable across threads |
| Best For | Simple one-off threads | Thread pools, enterprise apps |
Best Practice: Runnable + ExecutorService¶
@Service
public class TaskProcessor {
private ExecutorService executor = Executors.newFixedThreadPool(10);
// Define reusable task
private class DataProcessor implements Runnable {
private final Long userId;
public DataProcessor(Long userId) {
this.userId = userId;
}
@Override
public void run() {
User user = userService.findById(userId);
processUser(user);
}
}
// Submit many runnables to executor
public void processAllUsers(List<Long> userIds) {
for (Long userId : userIds) {
executor.submit(new DataProcessor(userId));
}
}
}
Runnable vs Callable¶
Runnable (No Return Value)¶
class PrintTask implements Runnable {
@Override
public void run() {
// No return value
System.out.println("Task executing...");
}
}
Thread thread = new Thread(new PrintTask());
thread.start(); // Fire and forget
Callable (Returns Result)¶
class ComputeTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
// Can return value, throw checked exceptions
return 42;
}
}
ExecutorService executor = Executors.newFixedThreadPool(5);
Future<Integer> future = executor.submit(new ComputeTask());
Integer result = future.get(); // Waits for task completion, gets result
Comparison¶
| Aspect | Runnable | Callable |
|---|---|---|
| Return Type | void | Generic <V> |
| Exception Handling | Can't throw checked exceptions | Can throw checked exceptions |
| Usage | Fire-and-forget tasks | Tasks needing result/status |
| With Executor | executor.submit(runnable) → Future |
executor.submit(callable) → Future |
Practical Example: Runnable vs Callable¶
// Runnable: Logging (side effect only)
Runnable logTask = () -> {
System.out.println("Logging event");
};
// Callable: Computation (returns value)
Callable<String> fetchTask = () -> {
Thread.sleep(1000);
return "Data fetched";
};
ExecutorService executor = Executors.newFixedThreadPool(2);
// Submit both
executor.submit(logTask); // Returns Future<Void>
Future<String> result = executor.submit(fetchTask); // Returns Future<String>
System.out.println(result.get()); // "Data fetched"
Thread Synchronization¶
1. Synchronized Keyword¶
Ensures only one thread can access a block/method at a time.
// Synchronized method
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Only one thread can execute at a time
}
public synchronized int getCount() {
return count;
}
}
// Synchronized block (more fine-grained)
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++; // Only lock this critical section
}
// Do other stuff without holding the lock
}
}
Pros: Simple, built-in, intuitive
Cons: Coarse-grained, can deadlock, reentrant issues
2. Volatile Keyword¶
Ensures visibility of changes across threads, but NOT atomicity.
public class Flag {
private volatile boolean running = true; // Always read latest value
public void stop() {
running = false; // All threads see this immediately
}
public void work() {
while (running) {
// Do work
}
}
}
Use case: Simple flag changes, not counters
Difference from synchronized: Visibility only; no mutual exclusion
3. Atomic Variables¶
Thread-safe operations without locking (lock-free via CAS - Compare-And-Swap).
import java.util.concurrent.atomic.*;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Atomic, no synchronization needed
}
public int getCount() {
return count.get();
}
// Compare-and-swap
public boolean compareAndSet(int expect, int update) {
return count.compareAndSet(expect, update);
}
}
Common types: AtomicInteger, AtomicLong, AtomicReference, AtomicBoolean
Pros: Lock-free, high performance
Use: Counters, flags, simple values
4. Locks (java.util.concurrent.locks)¶
Fine-grained control over locking behavior.
import java.util.concurrent.locks.*;
public class LockCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // Always unlock in finally
}
}
// Try-lock with timeout
public boolean tryIncrement(long timeout, TimeUnit unit) {
if (lock.tryLock(timeout, unit)) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // Couldn't acquire lock in time
}
}
// ReadWriteLock: multiple readers, single writer
public class Cache<K, V> {
private final Map<K, V> cache = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public V get(K key) {
lock.readLock().lock();
try {
return cache.get(key); // Multiple threads can read
} finally {
lock.readLock().unlock();
}
}
public void put(K key, V value) {
lock.writeLock().lock();
try {
cache.put(key, value); // Exclusive write access
} finally {
lock.writeLock().unlock();
}
}
}
Pros: Flexible, timeout support, fair/unfair options
Use: When synchronized is too coarse, or need timeouts/fairness
Thread Pools & ExecutorService¶
Instead of creating threads manually, use thread pools for efficiency.
import java.util.concurrent.*;
// Fixed thread pool
ExecutorService executor = Executors.newFixedThreadPool(10);
// Submit tasks
for (int i = 0; i < 100; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " on " + Thread.currentThread().getName());
});
}
executor.shutdown(); // No new tasks accepted
executor.awaitTermination(1, TimeUnit.MINUTES); // Wait for completion
// Better: try-with-resources (Java 7+)
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
IntStream.range(0, 100).forEach(i ->
executor.submit(() -> doWork(i))
);
} // Automatically shutdown
Common Pool Types:
- newFixedThreadPool(n): Fixed number of threads; queues excess tasks
- newCachedThreadPool(): Creates threads as needed; reuses idle threads
- newSingleThreadExecutor(): One thread; tasks run sequentially
- newScheduledThreadPool(n): For delayed/periodic tasks
Virtual Threads (Java 21) — Game Changer¶
Virtual threads are lightweight, JVM-managed threads that enable millions of concurrent tasks without OS thread overhead.
Problem: OS Thread Limits¶
// ❌ Traditional OS threads (heavy)
ExecutorService executor = Executors.newFixedThreadPool(1000);
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
// Make HTTP request (blocks)
response = httpClient.get("https://api.example.com/data");
// Process response
});
}
// Problem: 1000 threads × 1-2MB = 1-2GB memory, context switching overhead
Solution: Virtual Threads¶
// ✅ Virtual threads (lightweight, millions feasible)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
// Make HTTP request (blocks, but cheap suspend/resume)
response = httpClient.get("https://api.example.com/data");
// Process response
});
}
} // Automatic shutdown and wait for all to complete
Key differences: - OS Thread: 1-2MB memory, expensive context switch - Virtual Thread: Few KB, lightweight suspend/resume (no context switch) - Scalability: ~1,000 OS threads vs. millions of virtual threads
Virtual Thread Examples¶
Simple creation:
Thread vt = Thread.ofVirtual()
.name("vt-", 1) // Name prefix, starting number
.start(() -> {
System.out.println("Virtual thread running");
});
vt.join(); // Wait for completion
High-concurrency scenario:
// Before (OS threads): bottleneck at ~1,000 concurrent requests
ExecutorService executor = Executors.newFixedThreadPool(1000);
// After (virtual threads): handle 100,000+ concurrent requests
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100000; i++) {
executor.submit(() -> {
// Blocking I/O is now cheap
String result = blockingIoCall();
processResult(result);
});
}
}
Spring Boot integration (Java 21+):
@Configuration
public class ThreadingConfig {
@Bean
public TaskExecutor taskExecutor() {
// Use virtual threads for async tasks
return new SimpleAsyncTaskExecutor("vt-"); // Spring auto-detects virtual thread env
}
}
@Service
public class PaymentService {
@Async // Runs on virtual thread
public CompletableFuture<PaymentResponse> processPaymentAsync(Order order) {
// Blocking I/O is now scalable
PaymentResponse response = paymentGateway.charge(order);
return CompletableFuture.completedFuture(response);
}
}
Virtual Threads vs. Reactive Frameworks¶
| Aspect | Virtual Threads | Reactive (WebFlux) |
|---|---|---|
| Code Style | Synchronous, imperative | Async, callback-heavy |
| Learning Curve | Easy (looks like normal code) | Steep (chains, subscribers, backpressure) |
| Debugging | Stack traces are clear | Stack traces fragmented |
| Thread Pool | Millions (virtual) | Bounded (usually ~N threads) |
| Blocking I/O | ✅ OK to block (cheap suspend) | ❌ Blocks thread pool |
| Error Handling | Normal try-catch | Operators (.onErrorResume(), etc.) |
| When to Use | I/O-bound, greenfield Java 21+ | Existing reactive systems, strict resource limits |
Virtual threads eliminate most reasons to use reactive frameworks!
Common Concurrency Patterns¶
1. Producer-Consumer¶
BlockingQueue<Message> queue = new LinkedBlockingQueue<>();
// Producer thread
executor.submit(() -> {
for (int i = 0; i < 100; i++) {
queue.put(new Message(i)); // Blocks if queue full
}
});
// Consumer threads
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
while (true) {
Message msg = queue.take(); // Blocks if queue empty
processMessage(msg);
}
});
}
2. Countdown Latch (Wait for N threads to finish)¶
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
doWork();
latch.countDown(); // Signal completion
});
}
latch.await(); // Wait until all count down
System.out.println("All tasks done!");
3. Cyclic Barrier (Wait for N threads at a point)¶
CyclicBarrier barrier = new CyclicBarrier(3);
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " is ready");
barrier.await(); // Wait for all to reach this point
System.out.println(Thread.currentThread().getName() + " starting!");
});
}
4. Semaphore (Control access to N resources)¶
Semaphore semaphore = new Semaphore(3); // Max 3 concurrent accesses
executor.submit(() -> {
semaphore.acquire(); // Wait if 3 already using
try {
accessLimitedResource();
} finally {
semaphore.release();
}
});
Interview Talking Points¶
- Synchronized vs. locks? Synchronized is simpler, locks are flexible (timeout, fairness, try-lock)
- Volatile vs. synchronized? Volatile = visibility only; synchronized = mutual exclusion + visibility
- Virtual threads impact? Eliminates thread pool as bottleneck; I/O scales to millions; code stays imperative
- Virtual thread limitations? Pinning to OS thread if using synchronized (use locks instead); JVM-managed
- Thread pool sizing? Compute-bound = CPU count; I/O-bound = much larger or use virtual threads
- Structured concurrency (Java 21)? Automatic resource cleanup for thread groups (preview feature)
- When to use AtomicInteger vs. synchronized? Atomic: simple counters/flags; synchronized: complex shared state
See also: Concurrency & Threads (Basics), Java Collections & Concurrency Index