Back to Blog
Java

Java Threading: Creating and Managing Threads

java threading: Learn how to create and manage threads in Java using the Thread class, Runnable, and ExecutorService, with practical examples and common pitfalls.

java concurrencythreadexecutor servicesynchronizationmultithreadingjava util concurrent
Illustration of Java threads running concurrently with a lock symbol representing synchronization

Java threading is a core skill for building responsive applications, but the API surface is broad. This article focuses on the practical mechanics: how to start threads, how to hand off tasks to an executor, and how to protect shared state without over-engineering.

Starting a Thread with the Thread Class

The simplest way to run code in a separate thread is to extend Thread and override run(). This works, but it couples the task logic to the thread lifecycle, which becomes restrictive when you need to reuse the same task in different contexts.

class Worker extends Thread { @Override public void run() { System.out.println("Working in thread: " + getName()); } } Thread t = new Worker(); t.start();

Calling start() launches the new thread, which then executes run(). Calling run() directly would execute the task on the current thread, which is a common mistake. The Thread class also gives you access to methods like join(), interrupt(), and setDaemon(), but the class hierarchy is not the best fit for every task.

Using Runnable to Separate Task from Execution

A cleaner design is to implement Runnable and pass it to a Thread constructor. This separates the unit of work from the execution mechanism, making it easier to test and reuse.

Runnable task = () -> System.out.println("Running in: " + Thread.currentThread().getName()); Thread t = new Thread(task); t.start();

The lambda syntax works because Runnable is a functional interface. This approach is preferable when the task does not need to access thread-specific methods directly. However, Runnable.run() cannot return a result or throw a checked exception, which limits its use for tasks that produce a value.

Managing Threads with ExecutorService

Creating a new Thread for every task is inefficient and can exhaust system resources. The ExecutorService framework decouples task submission from thread management. You define a pool of worker threads and submit tasks to a queue.

ExecutorService executor = Executors.newFixedThreadPool(4); executor.submit(() -> System.out.println("Task executed by " + Thread.currentThread().getName())); executor.shutdown();

submit() accepts a Runnable or a Callable. The latter returns a Future that can hold a result or an exception. The executor reuses threads, so you avoid the overhead of thread creation for each task. Always call shutdown() when the executor is no longer needed; otherwise, the JVM may not exit because non-daemon threads are still alive.

For one-off tasks, Executors.newSingleThreadExecutor() is a simple choice. For bursty workloads, newCachedThreadPool() creates threads on demand and reuses idle ones. The fixed pool is best when you want to cap concurrency to a known number.

Synchronizing Access to Shared State

When multiple threads read and write the same variable, you can get stale data or inconsistent results. The synchronized keyword is the most direct tool to enforce mutual exclusion.

public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }

Every instance of Counter has an intrinsic lock. When a thread calls increment(), it acquires the lock, executes the method body, and releases the lock. Other threads block until the lock is free. This guarantees that count++ is atomic with respect to other synchronized methods on the same object. However, synchronized can become a bottleneck if the critical section is long or if many threads contend for the same lock.

Choosing Between Synchronized and Concurrent Collections

For collections, synchronizing every access is often unnecessary. The java.util.concurrent package provides thread-safe collections that use finer-grained locking or lock-free algorithms.

CollectionBehaviorBest use case
ConcurrentHashMapThread-safe map, high concurrencyFrequent reads and writes from many threads
CopyOnWriteArrayListThread-safe list, copy on writeRead-heavy workloads with rare modifications
BlockingQueueQueue that blocks on empty/fullProducer-consumer patterns

Using a ConcurrentHashMap instead of wrapping a HashMap with Collections.synchronizedMap() avoids locking the entire map for every operation. The concurrent version allows concurrent reads and a configurable level of write concurrency. For a queue, LinkedBlockingQueue is a common choice when you need to pass tasks between threads.

Handling Interruptions and Timeouts

Threads often need to stop cooperatively. The interrupt() method sets a flag that the target thread can check. Long-running operations like Thread.sleep() or Object.wait() respond to interruption by throwing InterruptedException.

Thread worker = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // do work } }); worker.start(); // later worker.interrupt();

When a method throws InterruptedException, you should restore the interrupt flag by calling Thread.currentThread().interrupt() before propagating the exception. Otherwise, the caller loses the information that the thread was interrupted. For executor tasks, Future.get(timeout, unit) lets you wait for a result with a deadline, which is useful for avoiding indefinite hangs.

Thread Safety in Practice: Volatile and Atomic Variables

synchronized is not always the right tool. For simple flags or counters, you can use volatile or classes from java.util.concurrent.atomic.

volatile boolean running = true;

A volatile field guarantees visibility: a write to the field happens-before any subsequent read. It does not provide atomicity for compound operations like count++. For that, use AtomicInteger.

AtomicInteger count = new AtomicInteger(0); count.incrementAndGet();

Atomic classes use compare-and-swap instructions, which are non-blocking and typically scale better than a synchronized block under moderate contention. Use volatile when you only need visibility of a single field, and use atomic classes when you need to perform read-modify-write operations without a lock.

Common Pitfalls in Java Threading

A frequent mistake is sharing a mutable object without synchronization and expecting the change to be visible to other threads. The Java Memory Model does not guarantee visibility without a happens-before relationship. Another pitfall is calling run() instead of start(), which executes the task on the calling thread. Deadlocks occur when two threads acquire locks in opposite orders; you can avoid them by always acquiring locks in a consistent global order. Finally, forgetting to call shutdown() on an executor can leak threads and prevent the JVM from terminating. Always use try-with-resources or a finally block to shut down executors in production code.

java threading: Practical Usage and Code Examples | RYUSLOG DEV