Java Concurrency — Explained with Examples
Java concurrency enables multiple threads of execution to run simultaneously within a single program, allowing applications to perform background tasks, handle multiple requests, and process large datasets in parallel using the java.util.concurrent package.
Why Concurrency Matters
Modern CPUs have multiple cores, and single-threaded applications use only one. Concurrency lets you harness all available cores. DodaTech’s backend processes thousands of simultaneous device connections, scans files, and compresses data — all concurrently. Without proper concurrency, the system would be slow, unresponsive, and waste hardware resources.
Thread Fundamentals — The Building Blocks
Think of a thread like a worker on an assembly line. One worker can only do one thing at a time. If you have four workers (four cores), you can do four things simultaneously. Java gives you tools to create, manage, and synchronize these workers.
graph TD
A[Java Application] --> B[Main Thread]
A --> C[Thread Pool<br/>ExecutorService]
C --> D[Worker Thread 1]
C --> E[Worker Thread 2]
C --> F[Worker Thread 3]
C --> G[Worker Thread N]
B --> H[CompletableFuture]
H --> D
H --> E
D --> I[Task Queue]
E --> I
F --> I
G --> I
I --> J[Shared Resources<br/>synchronized / Lock]
style A fill:#3b82f6,color:#fff
style J fill:#ef4444,color:#fff
Creating Threads — Thread and Runnable
import java.util.*;
import java.util.concurrent.*;
public class ThreadBasicsDemo {
public static void main(String[] args) {
System.out.println("Main thread: " + Thread.currentThread().getName());
// Method 1: Extend Thread
Thread worker1 = new Thread() {
@Override
public void run() {
System.out.println("Worker 1 running on: " + Thread.currentThread().getName());
}
};
// Method 2: Implement Runnable (preferred)
Runnable task = () -> {
System.out.println("Worker 2 running on: " + Thread.currentThread().getName());
};
Thread worker2 = new Thread(task);
worker1.start();
worker2.start();
try {
worker1.join();
worker2.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("All workers completed");
}
}Expected output (order may vary):
Main thread: main
Worker 2 running on: Thread-1
Worker 1 running on: Thread-0
All workers completedExecutorService — Professional Thread Management
Creating threads manually is error-prone. ExecutorService manages a pool of reusable threads.
import java.util.*;
import java.util.concurrent.*;
public class ExecutorServiceDemo {
public static void main(String[] args) throws Exception {
int coreCount = Runtime.getRuntime().availableProcessors();
System.out.println("Available cores: " + coreCount);
ExecutorService executor = Executors.newFixedThreadPool(coreCount);
List<Future<String>> futures = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
int taskId = i;
Future<String> future = executor.submit(() -> {
Thread.sleep(ThreadLocalRandom.current().nextInt(500, 1500));
return "Task " + taskId + " completed by " + Thread.currentThread().getName();
});
futures.add(future);
}
System.out.println("All tasks submitted. Waiting for results...\n");
for (Future<String> future : futures) {
String result = future.get(); // Blocks until task completes
System.out.println(result);
}
executor.shutdown();
System.out.println("\nAll tasks finished");
}
}Expected output (times and thread names will vary):
Available cores: 8
All tasks submitted. Waiting for results...
Task 3 completed by pool-1-thread-3
Task 1 completed by pool-1-thread-1
Task 2 completed by pool-1-thread-2
Task 5 completed by pool-1-thread-5
Task 4 completed by pool-1-thread-4
Task 7 completed by pool-1-thread-7
Task 6 completed by pool-1-thread-6
Task 8 completed by pool-1-thread-8
Task 9 completed by pool-1-thread-1
Task 10 completed by pool-1-thread-2
All tasks finishedThread Pool Types
| Type | Behavior | Use Case |
|---|---|---|
newFixedThreadPool(n) | Fixed number of threads | General purpose, predictable load |
newCachedThreadPool() | Creates on demand, reuses idle | Many short-lived tasks |
newSingleThreadExecutor() | Single thread | Sequential task execution |
newScheduledThreadPool(n) | Delayed/periodic execution | Cron jobs, health checks |
Synchronization — Preventing Race Conditions
When multiple threads access shared data, you get race conditions — unpredictable results. Synchronization ensures only one thread accesses critical code at a time.
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class SynchronizationDemo {
private static int counter = 0;
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(4);
Runnable unsafeTask = () -> {
for (int i = 0; i < 10000; i++) {
counter++; // NOT thread-safe
}
};
for (int i = 0; i < 4; i++) {
executor.submit(unsafeTask);
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Unsynchronized counter: " + counter + " (expected: 40000)");
}
}Expected output:
Unsynchronized counter: 23147 (expected: 40000)Now let’s fix it with synchronization:
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
import java.util.concurrent.atomic.*;
public class SynchronizedFixDemo {
private static int synchronizedCounter = 0;
private static final Lock lock = new ReentrantLock();
private static AtomicInteger atomicCounter = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(4);
Runnable syncTask = () -> {
for (int i = 0; i < 10000; i++) {
synchronized (SynchronizedFixDemo.class) {
synchronizedCounter++;
}
}
};
Runnable lockTask = () -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
try {
synchronizedCounter++;
} finally {
lock.unlock();
}
}
};
Runnable atomicTask = () -> {
for (int i = 0; i < 10000; i++) {
atomicCounter.incrementAndGet();
}
};
// Test synchronized
for (int i = 0; i < 4; i++) executor.submit(syncTask);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Synchronized counter: " + synchronizedCounter);
System.out.println("Atomic counter: " + atomicCounter.get());
}
}Expected output:
Synchronized counter: 40000
Atomic counter: 40000The volatile Keyword
volatile guarantees visibility — when one thread writes to a volatile variable, other threads immediately see the change. Unlike synchronized, it does not provide atomicity.
import java.util.concurrent.*;
public class VolatileDemo {
private static volatile boolean running = true;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
while (running) {
// Without volatile, this loop might never see the change
}
System.out.println("Worker detected shutdown");
});
worker.start();
Thread.sleep(100);
running = false;
System.out.println("Shutdown signal sent");
worker.join();
}
}Expected output:
Shutdown signal sent
Worker detected shutdownWithout volatile, the worker thread might cache running = true and loop forever.
CompletableFuture — Async Without Callback Hell
CompletableFuture lets you compose asynchronous operations in a functional style.
import java.util.concurrent.*;
import java.util.*;
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(4);
System.out.println("Starting parallel file processing...\n");
List<String> files = Arrays.asList("log-1.txt", "log-2.txt", "log-3.txt");
List<CompletableFuture<ScanResult>> futures = files.stream()
.map(file -> CompletableFuture.supplyAsync(() -> scanFile(file), executor))
.collect(Collectors.toList());
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
allDone.thenRun(() -> {
System.out.println("\nAll scans complete. Summary:");
futures.stream()
.map(CompletableFuture::join)
.forEach(r -> System.out.println(" " + r));
}).join();
executor.shutdown();
}
static ScanResult scanFile(String filename) {
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(500, 2000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
boolean infected = Math.random() < 0.3;
return new ScanResult(filename, infected,
infected ? "Trojan.Generic." + (int)(Math.random() * 1000) : "Clean");
}
}
class ScanResult {
private final String file;
private final boolean infected;
private final String threat;
ScanResult(String file, boolean infected, String threat) {
this.file = file;
this.infected = infected;
this.threat = threat;
}
@Override
public String toString() {
return file + " → " + (infected ? "INFECTED (" + threat + ")" : "CLEAN");
}
}Expected output (varies):
Starting parallel file processing...
All scans complete. Summary:
log-1.txt → CLEAN
log-2.txt → INFECTED (Trojan.Generic.472)
log-3.txt → CLEANFork-Join Pool — Divide and Conquer
The Fork-Join pool is designed for recursive tasks that can be split into smaller subtasks.
import java.util.concurrent.*;
public class ForkJoinDemo {
public static void main(String[] args) {
int[] data = new int[100_000_000];
for (int i = 0; i < data.length; i++) {
data[i] = ThreadLocalRandom.current().nextInt(1, 100);
}
ForkJoinPool pool = new ForkJoinPool();
long start = System.nanoTime();
Integer sequentialResult = sequentialSum(data, 0, data.length);
long seqTime = (System.nanoTime() - start) / 1_000_000;
start = System.nanoTime();
Integer parallelResult = pool.invoke(new SumTask(data, 0, data.length));
long parTime = (System.nanoTime() - start) / 1_000_000;
System.out.println("Sequential sum: " + sequentialResult + " (" + seqTime + "ms)");
System.out.println("Parallel sum: " + parallelResult + " (" + parTime + "ms)");
pool.shutdown();
}
static int sequentialSum(int[] arr, int lo, int hi) {
int sum = 0;
for (int i = lo; i < hi; i++) {
sum += arr[i];
}
return sum;
}
}
class SumTask extends RecursiveTask<Integer> {
private static final int THRESHOLD = 10_000;
private final int[] arr;
private final int lo, hi;
SumTask(int[] arr, int lo, int hi) {
this.arr = arr;
this.lo = lo;
this.hi = hi;
}
@Override
protected Integer compute() {
if (hi - lo <= THRESHOLD) {
int sum = 0;
for (int i = lo; i < hi; i++) sum += arr[i];
return sum;
}
int mid = (lo + hi) / 2;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork();
int rightResult = right.compute();
int leftResult = left.join();
return leftResult + rightResult;
}
}Expected output (varies):
Sequential sum: 494985231 (45ms)
Parallel sum: 494985231 (12ms)Common Mistakes
Not handling
InterruptedException: Swallowing or ignoring it can leave threads in an inconsistent state. Always restore the interrupt flag withThread.currentThread().interrupt().Using
synchronizedwhenvolatileorAtomic*suffices: Over-synchronizing kills performance. Usesynchronizedonly when multiple operations must be atomic together.Forgetting to
shutdown()theExecutorService: A non-shutdown executor keeps JVM alive. Usetry-finallyor try-with-resources patterns.Deadlocks from nested locks: Always acquire locks in the same order across all threads to prevent circular wait conditions.
Sharing mutable objects across threads without synchronization: Even simple operations like
count++are not atomic. UseAtomicIntegeror synchronization.
Practice Questions
- What is the difference between
RunnableandCallable? - What causes a race condition?
- How does
synchronizeddiffer fromLock? - What does
CompletableFuture.allOf()do? - When should you use
ForkJoinPooloverExecutorService?
Answers:
Runnablereturnsvoidand cannot throw checked exceptions.Callablereturns a value and can throw exceptions.- Multiple threads read and write shared data without synchronization, causing the final state to depend on thread scheduling order.
synchronizedis automatic (enters/exits monitor), simpler but less flexible.Lockrequires manuallock()/unlock()but offers try-lock, timed-lock, and fairness policies.- It returns a
CompletableFuture<Void>that completes when all given futures complete. Useful for waiting on parallel tasks. - When you have a recursive divide-and-conquer algorithm (e.g., merge sort, large array processing) that benefits from work-stealing.
Mini Project: Parallel File Scanner
Build a virus scanner that:
- Recursively finds all
.exeand.dllfiles in a directory tree - Submits each file to an
ExecutorServicefor analysis (simulated with random results) - Uses
CompletableFutureto aggregate results - Reports: total files, infected count, scan duration
- Falls back to sequential scanning if parallel throws an error
This pattern is used in Durga Antivirus Pro to efficiently scan thousands of files across all cores.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro