All Posts

June 15, 2025

6 min read
JavaMultithreadingConcurrencyThread SafetyPerformanceExecutorService

Java Multithreading: Complete Guide to Concurrent Programming

Welcome back to the Java Fundamentals series! 👋

What Is a Thread?

A thread is the smallest unit of execution within a process. In Java, every application has at least one thread: the main thread.

A thread is a lightweight subprocess — smallest unit of CPU execution.

Java enables multithreading via:

  • Thread class
  • Runnable interface

Why Use Threads?

  • Concurrent execution: Perform multiple tasks simultaneously
  • Improved responsiveness: Handle UI + background work
  • Utilize multicore processors: Better hardware utilization

Ways to Create Threads

1. Extend Thread Class

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread running");
    }
}

2. Implement Runnable Interface

Preferred in clean designs since it allows multiple inheritance via interfaces.

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Thread running");
    }
}

3. Implement Callable (Java 5+)

If you want the thread to return a value and throw checked exceptions.

Callable<Integer> task = () -> { return 10; };
FutureTask<Integer> future = new FutureTask<>(task);
new Thread(future).start();

Thread Lifecycle

States:

NEW → RUNNABLE → RUNNING → TERMINATED
          ↓
       BLOCKED / WAITING / TIMED_WAITING

Common Thread Methods:

MethodPurpose
start()Starts the thread (calls run())
run()Actual code to execute
sleep(ms)Sleep for given milliseconds
join()Waits for another thread to finish
isAlive()Check if thread is still running

Thread Priorities

Values: 1 (MIN_PRIORITY) to 10 (MAX_PRIORITY), default is 5.

Thread t = new Thread();
t.setPriority(Thread.MAX_PRIORITY);

Note: Thread priorities are hints, not guarantees. OS thread scheduler decides.

Synchronization

To avoid race conditions when multiple threads access shared data.

synchronized void increment() {
    count++;
}
synchronized(this) {
    count++;
}

Inter-thread Communication

wait(), notify(), notifyAll() — object methods for coordinating threads.

synchronized(obj) {
    obj.wait();
}
synchronized(obj) {
    obj.notify();
}

Note: Avoid overusing — better use BlockingQueue or modern concurrency tools.

Executor Framework (Modern Approach)

From Java 5 onwards — better thread management.

ExecutorService executor = Executors.newFixedThreadPool(3);
executor.execute(() -> System.out.println("Task"));
executor.shutdown();

You can use:

  • FixedThreadPool
  • CachedThreadPool
  • SingleThreadExecutor
  • ScheduledThreadPool

Why use this?

  • Avoids creating threads manually
  • Better performance tuning
  • Cleaner shutdown

Thread-safe Collections

  • Vector (legacy)
  • Collections.synchronizedList(new ArrayList<>())
  • ConcurrentHashMap
  • CopyOnWriteArrayList

Why Is Processing Huge Lists a Problem?

When processing a large list (say millions of entries):

  • Single-threaded: Slow, as it processes sequentially
  • Multithreaded: Can improve throughput by dividing the list into chunks and processing in parallel

But you have challenges:

  • Correctly partitioning work
  • Avoiding race conditions on shared data
  • Not exceeding memory limits (GC overhead)
  • Choosing the right concurrency tools

Processing Large Lists with Multithreading

1. Partition the List into Chunks

You can't have multiple threads working on the same list positions unless you properly partition.

Note: Use subList carefully because it's a view, not a copy — changes in sublist affect original list.

int chunkSize = 1000;
for (int i = 0; i < list.size(); i += chunkSize) {
    List<String> subList = list.subList(i, Math.min(i + chunkSize, list.size()));
    executor.execute(() -> processSubList(subList));
}

2. Use ExecutorService (FixedThreadPool or ForkJoinPool)

  • FixedThreadPool when tasks are similar in size
  • ForkJoinPool when task splitting is recursive (divide-and-conquer)
ExecutorService executor = Executors.newFixedThreadPool(10);
// or 
ForkJoinPool customPool = new ForkJoinPool(8);
customPool.submit(() -> list.parallelStream().forEach(item -> process(item)));

3. Avoid Shared Mutable State

No shared counters, maps, or lists without protection.

Use:

  • ConcurrentHashMap
  • AtomicInteger/AtomicLong
  • Avoid non-thread-safe collections like ArrayList unless synchronized

Key Scenarios

Case 1: Read-Only Operations

Use parallelStream() directly — it's backed by ForkJoinPool.

list.parallelStream().forEach(item -> process(item));

Caveat: Number of threads is Runtime.getRuntime().availableProcessors() by default.

You can control it:

ForkJoinPool customPool = new ForkJoinPool(16);
customPool.submit(() -> list.parallelStream().forEach(...));

Case 2: Writing Results Back

If multiple threads write to a shared result, use:

  • ConcurrentLinkedQueue
  • ConcurrentHashMap
  • CopyOnWriteArrayList (less performant on large writes)
  • Or synchronize explicitly (costly)

Case 3: High Volume Real-Time Processing

If you continuously receive new items:

  • Use BlockingQueue
  • Producer threads put data
  • Consumer threads poll and process
BlockingQueue<String> queue = new LinkedBlockingQueue<>();

Then use an executor to consume items from it.

Example: Parallel Processing of List

List<Integer> numbers = IntStream.range(0, 1_000_000)
    .boxed()
    .collect(Collectors.toList());
    
ForkJoinPool customThreadPool = new ForkJoinPool(8);

customThreadPool.submit(() ->
    numbers.parallelStream().forEach(num -> System.out.println(num))
);
customThreadPool.shutdown();

Multithreading Pitfalls with Lists

  • ConcurrentModificationException if modifying list while iterating without proper safeguards
  • OutOfMemoryError if too many threads created
  • Threads competing for GC time on large object graphs
  • Shared list's state corruption without synchronization

Golden rule:

  • ✅ Immutable data is the safest in multithreading
  • ✅ Mutable shared data needs Concurrent collections or explicit locks

Summary: Multithreading Key Rules with Lists

IssueWhat to do
Thread safetyUse concurrent collections or sync
Task divisionPartition list into non-overlapping chunks
Thread managementUse ExecutorService, not raw threads
Shared resultsUse thread-safe collections
Exception handlingUse Future and check for exceptions
Resource limitsKeep thread count ~ CPU cores

Advanced Patterns

Producer-Consumer Pattern with BlockingQueue

When to use:

  • You have one or more producers generating tasks (e.g., reading large list data)
  • And one or more consumers processing those tasks concurrently

How it helps:

  • Decouples task production and consumption
  • Handles backpressure — consumers block when no data, producers block when queue full
BlockingQueue<String> queue = new LinkedBlockingQueue<>(1000); // bounded

// Producer thread(s)
new Thread(() -> {
    for (String item : largeList) {
        queue.put(item); // blocks if full
    }
    queue.put("EOF"); // signal end
}).start();

// Consumer thread(s)
for (int i = 0; i < numConsumers; i++) {
    new Thread(() -> {
        while (true) {
            String item = queue.take(); // blocks if empty
            if ("EOF".equals(item)) break;
            process(item);
        }
    }).start();
}

Fork/Join Framework for Divide and Conquer

When to use:

  • Recursive decomposition of tasks
  • Large lists can be split into smaller tasks that themselves split further

Advantage:

  • Work-stealing algorithm balances load efficiently
  • Fine-grained parallelism
class ListProcessor extends RecursiveTask<Integer> {
    private List<Integer> list;
    private int start, end;
    private static final int THRESHOLD = 1000;

    public ListProcessor(List<Integer> list, int start, int end) {
        this.list = list; 
        this.start = start; 
        this.end = end;
    }

    @Override
    protected Integer compute() {
        if (end - start <= THRESHOLD) {
            // process directly
            int sum= 0;
            for (int i= start; i < end; i++) {
                sum = list.get(i);
            }
            return sum;
        } else {
            int mid= (start + end) / 2;
            ListProcessor left= new ListProcessor(list, start, mid);
            ListProcessor right= new ListProcessor(list, mid, end);
            left.fork();
            int rightResult= right.compute();
            int leftResult= left.join();
            return leftResult + rightResult;
        }
    }
}

Thread Confinement

What:

  • Limit a piece of data to be accessed by only one thread
  • Instead of synchronization, each thread has its own copy of data

Application:

  • Process different chunks of a list in threads, each with its own local result
  • After processing, merge results

Benefit:

  • Avoids locks, reduces contention

Futures and CompletableFuture for Asynchronous Processing

When:

  • You want to submit tasks and retrieve results asynchronously
  • Chain tasks, handle exceptions, combine multiple async results
ExecutorService executor = Executors.newFixedThreadPool(4);

List<Future<Integer>> futures = new ArrayList<>();
for (List<Integer> chunk : chunks) {
    Future<Integer> future = executor.submit(() -> process(chunk));
    futures.add(future);
}

// Later, gather results
int total = 0;
for (Future<Integer> f : futures) {
    total += f.get(); // blocks if result not ready
}

CompletableFuture allows chaining:

CompletableFuture.supplyAsync(() -> processChunk(chunk), executor)
    .thenApply(result -> result * 2)
    .exceptionally(ex -> { log(ex); return 0; });

Work Stealing

ForkJoinPool's work-stealing: idle threads steal tasks from busy threads.

  • Best for irregular, unbalanced tasks
  • Avoid fixed thread pools for such tasks

Handling Exceptions in Multithreaded List Processing

  • With ExecutorService.submit(), exceptions are captured in Future
  • Use Future.get() to detect exceptions; otherwise, they're swallowed
  • Use ThreadPoolExecutor hooks for logging uncaught exceptions
  • With CompletableFuture, use exceptionally() or handle() to recover

Batch Processing with Backpressure

  • Avoid flooding thread pools or queues with all tasks at once
  • Submit tasks in batches
  • Wait for batch completion before submitting next batch
  • Prevents memory exhaustion and overload

Parallel Pipelines

  • Split list processing into stages (like filters, maps)
  • Use thread-safe queues between stages
  • Each stage is processed by dedicated thread pools

Summary Table

PatternWhen to UseProsCons
Producer-ConsumerDecoupled production/consumptionBackpressure, easy coordinationComplex shutdown
Fork/JoinRecursive divide and conquerWork stealing, load balancingHarder debugging
Thread ConfinementAvoid shared mutable stateNo locking, better performanceNeeds merge logic
Futures/CompletableFutureAsync, chaining tasksFlexible async processingComplexity for beginners
Batch ProcessingLimit resource usageMemory control, stabilityIncreased latency
Parallel PipelinesMultiple processing stepsDecoupled, scalableComplexity

Conclusion

  • Thread management is crucial for performance and stability
  • Executor framework provides better control than raw threads
  • Thread-safe collections prevent data corruption
  • Proper partitioning is key to effective parallel processing
  • Exception handling becomes more complex in multithreaded environments
  • Choose the right pattern based on your specific use case

Happy coding! 💻