Skip to content
Java Concurrency — Explained with Examples

Java Concurrency — Explained with Examples

DodaTech Updated Jun 15, 2026 8 min read

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 completed

ExecutorService — 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 finished

Thread Pool Types

TypeBehaviorUse Case
newFixedThreadPool(n)Fixed number of threadsGeneral purpose, predictable load
newCachedThreadPool()Creates on demand, reuses idleMany short-lived tasks
newSingleThreadExecutor()Single threadSequential task execution
newScheduledThreadPool(n)Delayed/periodic executionCron 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: 40000

The 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 shutdown

Without 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 → CLEAN

Fork-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

  1. Not handling InterruptedException: Swallowing or ignoring it can leave threads in an inconsistent state. Always restore the interrupt flag with Thread.currentThread().interrupt().

  2. Using synchronized when volatile or Atomic* suffices: Over-synchronizing kills performance. Use synchronized only when multiple operations must be atomic together.

  3. Forgetting to shutdown() the ExecutorService: A non-shutdown executor keeps JVM alive. Use try-finally or try-with-resources patterns.

  4. Deadlocks from nested locks: Always acquire locks in the same order across all threads to prevent circular wait conditions.

  5. Sharing mutable objects across threads without synchronization: Even simple operations like count++ are not atomic. Use AtomicInteger or synchronization.

Practice Questions

  1. What is the difference between Runnable and Callable?
  2. What causes a race condition?
  3. How does synchronized differ from Lock?
  4. What does CompletableFuture.allOf() do?
  5. When should you use ForkJoinPool over ExecutorService?

Answers:

  1. Runnable returns void and cannot throw checked exceptions. Callable returns a value and can throw exceptions.
  2. Multiple threads read and write shared data without synchronization, causing the final state to depend on thread scheduling order.
  3. synchronized is automatic (enters/exits monitor), simpler but less flexible. Lock requires manual lock()/unlock() but offers try-lock, timed-lock, and fairness policies.
  4. It returns a CompletableFuture<Void> that completes when all given futures complete. Useful for waiting on parallel tasks.
  5. 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:

  1. Recursively finds all .exe and .dll files in a directory tree
  2. Submits each file to an ExecutorService for analysis (simulated with random results)
  3. Uses CompletableFuture to aggregate results
  4. Reports: total files, infected count, scan duration
  5. 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.

Related topics: Java, JVM, GC, OOP, AOT

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro