The Foundations of Java Concurrency
The modern software engineering requires high expertise since the applications must be responsive and efficient in terms of machine resource usage. Java being a powerful programming language enables developers to create high-performance applications. Its concurrency model allows to execute complex and time-consuming task concurrently, independently from the main thread without blocking the entire application. As a result, it improves performance, enhances responsiveness and reducing the overall time required for task completion.
Understanding Processes, Threads, and Java Memory Model
A process is an instance of a running program, independent and isolated from other processes on the operating system. A running JVM instance is essentially an operating system process that establishes its own runtime environment. Within this environment, threads are created, executed and managed, along with memory allocation and management. The Java Memory Model plays a critical role in thread execution, as it defines how threads interact and communicate through shared memory. Since a Java thread is implemented as a native OS kernel thread, CPU caches and registers may hold copies of variables outside the main heap memory. This can lead to visibility issues, where one thread may not immediately see the most recent changes made by another, making proper synchronization is essential for consistency.
+---------------------+ +---------------------+ +---------------------+
| CPU | | CPU | | CPU |
|---------------------| |---------------------| |---------------------|
| Java Thread 1 | | Java Thread 3 | | Java Thread 5 |
| ↓ | | ↓ | | ↓ |
| Core | | Core | | Core |
| ↓ | | ↓ | | ↓ |
| CPU Registers | | CPU Registers | | CPU Registers |
|---------------------| |---------------------| |---------------------|
| Java Thread 2 | | Java Thread 4 | | Java Thread 6 |
| ↓ | | ↓ | | ↓ |
| Core | | Core | | Core |
| ↓ | | ↓ | | ↓ |
| CPU Registers | | CPU Registers | | CPU Registers |
+----------↓----------+ +----------↓----------+ +----------↓----------+
\__________________________ __________________________/
\ /
+-----------------------------------------------------------+
| CPU Cache Memory |
+-----------------------------------------------------------+
↓
+-----------------------------------------------------------+
| RAM Memory |
+-----------------------------------------------------------+
| |
| JVM Heap Memory |
| |
+-----------------------------------------------------------+
Each thread has its own stack memory, which holds local variables, both primitives and references to objects located in the heap memory, as well as information about method calls and their execution context. The image illustrates how the objects are created into heap memory and layers that may create copies of data that are frequently used for faster access.
The Building Block: Essence of Threads and Thread Class
Threads in Java, enable developers to run multiple tasks within an application simultaneously; however, this does not guarantee true parallel execution, as each CPU core can execute only one thread at a given point in time. In order to give a chance for active threads to make progress, the operating system rapidly switches the attention of the CPU between the threads, allocating a small slice of execution time to each thread. This process follows Round Robin schedule method for time-slicing algorithm, which ensures execution time to all active threads.
+------------------------------------------------------------------------------------------+
| CPU |
|------------------------------------------------------------------------------------------|
| |
| +-------------+ |
| | | |->[#######]------------->[########]-----------------------> Thread #1 |
| | Core | |--------------->[########]------------------->[########]--> Thread #2 |
| | | |--------->[#####]---------------->[###########]-----------> Thread #3 |
| +-------------+ |
| |
+------------------------------------------------------------------------------------------+
----------------------------------------- TIME ------------------------------------------>
At the heart of Java’s concurrency model lies the Thread class, it’s the core API to create and manage the thread. To start the thread need to invoke Thread#start() method, it will start the task on separate thread while the current execution will continue on current thread without notifying about thread termination. The Thread object is designed to manage the operating system native threads rather than define the task to be executed. In that way, it’s recommended to pass the runnable task in constructor parameter when creating the thread object, an object of Runnable type or pass a lambda expression that defines the task (the Runnable is a functional interface).
Every thread in Java passes through several well-defined states:
- NEW: thread object is created but not started
- RUNNABLE: thread is ready to run or is actually running (happens after invoking method Thread#start() )
- BLOCKED / WAITING / TIMED_WAITING – thread is paused, waiting for a resource or signal due to any reason
- TERMINATED: thread finished the execution
Runnable: The Core of Task Definition
The previous section explored the thread execution and mechanism that brings the concurrency behavior to life. But while Thread knows how to create and run an operating system thread, it doesn’t inherently know what to run. That’s the responsibility of Runnable interface.
The Runnable defines the unit of work and task itself. Moving the task definition away from thread into a simple and reusable contract, Java separates the task definition from the task execution, creating a clean boundary between behavior and mechanism. Any class that implements the Runnable interface is eligible to be executed by a thread.
package org.course.concurrency;
public class MultithreadingApplication {
public static void main(String[] args) {
Runnable runnableTask = new Task();
System.out.println("Starting application...");
Thread worker = new Thread(runnableTask);
worker.start();
System.out.println("Application completed.");
}
private static class Task implements Runnable {
@Override
public void run() {
System.out.println("Running task...");
for (int i = 0; i < 10; i++) {
System.out.println("Processing index: " + i);
}
System.out.println("Task completed.");
}
}
}
With the introduction of lambda expressions in Java 8, it became easier to pass tasks directly to a thread for execution without the need to create a separate class.
package org.course.concurrency;
public class MultithreadingApplication {
public static void main(String[] args) {
System.out.println("Starting application...");
Thread worker =
new Thread(
() -> {
System.out.println("Running task...");
for (int i = 0; i < 10; i++) {
System.out.println("Processing index: " + i);
}
System.out.println("Task completed.");
});
worker.start();
System.out.println("Application completed.");
}
}
When run the code snippet, could notice the main thread completes the execution before the task. It demonstrates the concurrency, both tasks are executed independently on each other, the worker thread which we created and the one created by JVM to run main method. It’s important to note that JVM will not complete the process until there is at least one non-daemon thread alive, will cover daemon flag later.
However, developers retain control over the thread task completion or premature task termination. The easiest way to wait for task completion is the method Thread#join(), by calling the method, current thread waits until the task is completed or can specify timeout. When thread completes before reaching out the timeout, the method returns Boolean#TRUE immediately or returns Boolean#FALSE when reaching out the timeout and stop waiting for task completion. Useful parameter when has certain time to allocate for the task execution. It is possible to interrupt a thread execution by method Thread#interrupt() but have to remember it could happen only when specific conditions are met during runtime and proper task completion is implemented.
package org.course.concurrency;
public class MultithreadingApplication {
public static void main(String[] args) throws Exception {
System.out.println("Starting application...");
Thread worker =
new Thread(
() -> {
System.out.println("Running task...");
for (int i = 0; i < 10; i++) {
System.out.println("Processing index: " + i);
}
System.out.println("Task completed.");
});
worker.start();
System.out.println("Waiting for task completion...");
worker.join();
System.out.println("Application completed.");
}
}
Callable: Next Level of Task Definition
Java 5 introduced new concurrency feature, enabling developers to execute tasks that produce a result. In addition to Runnable interface, new interface Callable<V> is available together with different utilities. The Callable<V> task type differ from Runnable in that it can return a result and throw exceptions (checked and unchecked), developers cannot use directly within Thread but there are utilities that can still be used to leverage this behavior. Using improvements to framework, it’s not necessary to wait for thread completion because retrieving the task result from Future will make the current thread to wait for task completion before assigning the result. The Future was introduced as part of enhancements in Java 5 and provides the way to represent the task result, allowing developers to check if task is completed or retrieve its result once available. For Callable<V> task run, will use help of FutureTask that’s compatible with Runnable, single way to pass task for execution. Beside InterruptedException that signals the thread interruption, the Future#get() method throws additional checked exception ExecutionException that indicates the task failed with an exception where the cause of ExecutionException is the root exception that cause the task failure.
package org.course.concurrency;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
public class MultithreadingApplication {
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting application...");
Callable<Integer> callableTask = new Task();
FutureTask<Integer> futureTask = new FutureTask<>(callableTask);
Thread worker = new Thread(futureTask);
worker.start();
try {
int nextPositiveInteger = futureTask.get();
System.out.println("Application completed with value: " + nextPositiveInteger);
} catch (ExecutionException e) {
e.getCause().printStackTrace(); // prints the reason of execution failure
System.out.println("The callable task completed with exception: " + e.getCause());
}
}
private static class Task implements Callable<Integer> {
private static final Random RANDOM = new Random();
@Override
public Integer call() throws Exception {
System.out.println("Waiting next positive value generation...");
TimeUnit.SECONDS.sleep(5);
int next = RANDOM.nextInt(-100, 100);
if (next < 1) {
throw new IllegalStateException("A negative int has been generated");
}
System.out.println("Returning next positive integer: " + next);
return next;
}
}
}
Control Behavior with Priority And The Daemon Flag
Within the software application, the task may vary in complexity and importance, and some may require background execution. Java Concurrency enables developers to configure the thread execution. With parameter Thread#setPriority(), developers can adjust a thread’s priority to influence how it is scheduled relative to other threads; the method accepts an integer value between 1 and 10, inclusive (see Thread#MIN_PRIORITY, Thread#NORM_PRIORITY, Thread#MAX_PRIORITY). However, it’s not recommended for developer to completely reply on priority while building program logic as it doesn’t guarantee the specific thread runs upon others, it’s rather a hint for scheduler to allocate more CPU time while switching between threads or potentially schedule them earlier. On the other hand, the management of background thread is fully configurable and threads running independently to the parent thread and the JVM may complete or cancel the process if active threads are running in daemon mode. To set daemon mode use Thread#setDaemon().
Ensuring Consistency and Coordination with Synchronized Block
Moving forward, focus shifts to examining how the threads interferes each other and how controlled access to critical and shared resources is maintained. The synchronization mechanism is builded around Java object and synchronized block of code; each object created in Java holds a hidden intrinsic lock (also called monitor lock) and synchronized keyword is used to mark a block of code that only one thread can execute at a time preventing concurrent access when multiple threads invoke it simultaneously. The object’s intrinsic lock and synchronized keyword are used together all the time to guarantee controlled access to critical or shared resources. Important to remember about low level caching that occurs when objects are read or written to heap memory and for critical variables use volatile keyword. Variables marked with the volatile keyword enforce visibility guarantees so JVM ensures that any cached value remains consistent with the value in main memory.
Before a thread starts execution of a synchronized block, it must first acquire the lock associated with the object owned by the synchronized block. When the lock is available, the thread acquires the lock, holds it and proceeds with the code execution; if not, the thread waits until the lock is released by the thread currently holding the lock, which occurs when the synchronized block completes the execution.
package org.course.concurrency;
import java.util.concurrent.TimeUnit;
import java.time.LocalDateTime;
public class MultithreadingApplication {
public static void main(String[] args) {
Object mutex = new Object();
// tasks initialisation
Task task = new Task(mutex);
// task running
new Thread(task::execute).start();
new Thread(task::execute).start();
new Thread(task::execute).start();
}
private static class Task {
private final Object mutex;
public Task(Object mutex) {
this.mutex = mutex;
}
public void execute() {
synchronized (mutex) {
try {
String taskName = Thread.currentThread().getName();
TimeUnit.SECONDS.sleep(5); // simulate task execution
System.out.println("Hello from thread " + taskName + " at " + LocalDateTime.now());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
The result will show "Hello from thread %s at %s” with an interval of 5 seconds because each thread waits for lock release on mutex object.
To simplify the execute method, can put the synchronized keyword to the method, it’s allowed by Java but be careful since the synchronized block acquires the lock on this object and other threads executing another synchronized method of this cannot acquire the lock, regardless of whether they can run independently.
package org.course.concurrency;
import java.util.concurrent.TimeUnit;
import java.time.LocalDateTime;
public class MultithreadingApplication {
public static void main(String[] args) {
// tasks initialisation
Task task = new Task();
// task running
new Thread(task::printHello).start();
new Thread(task::printGuide).start();
new Thread(task::printHello).start();
new Thread(task::printGuide).start();
}
private static class Task {
public synchronized void printHello() {
try {
TimeUnit.SECONDS.sleep(5); // simulate task execution
String taskName = Thread.currentThread().getName();
System.out.println("Hello from thread " + taskName + " at " + LocalDateTime.now());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public synchronized void printGuide() {
try {
TimeUnit.SECONDS.sleep(3); // simulate task execution
String taskName = Thread.currentThread().getName();
System.out.println("Guide by thread " + taskName + " at " + LocalDateTime.now());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
Another important note to remember, a separate task still run the synchronized method because the intrinsic lock is different for each task. Working with mutable and not synchronized resources inside a synchronized method still introduces rase condition.
package org.course.concurrency;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class MultithreadingApplication {
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
List<Thread> threads = new ArrayList<>();
// initializing of workers
for (int i = 0; i < 1000; i++) {
threads.add(new Thread(() -> new Task(list).execute()));
}
// start the workers
threads.forEach(Thread::start);
// waiting for all threads to complete execution
for (Thread thread : threads) {
thread.join();
}
// printing the final list size
System.out.printf("Final list size: %d", list.size());
}
private static class Task {
private final List<Integer> list;
public Task(List<Integer> list) {
this.list = list;
}
public synchronized void execute() {
list.add(new Random().nextInt(10000));
}
}
}
Thread Communication: wait(), notify(), notifyAll()
So far, the synchronization of a code execution has been the main focus; however, it’s also important to consider how the threat can interfere with another and how the execution can be passed between them during runtime. In Java the coordination is achieved through the Object methods wait(), notify() and notifyAll(). These methods enables the threads to pause its executions, release the lock and resume the execution when the specific condition is met. Next, it's worth exploring the particularities of each method to see how they enable communication between threads.
Object#wait(): It is important to remember the wait() method can be invoked only to the object that owns the intrinsic lock acquired by the thread, simply saying the parameter of synchronized block. The Object#wait() releases the lock and other threads can acquire it, thread enters the waiting state and pause the following execution.
Object#notify(): Similar to wait(), the notify() method can be invoked to object that owns the intrinsic lock. The Object#notify() wakes up an arbitrary selected thread (single thread), the JVM moves the selected thread from “wait set” to “entry set” so the thread becomes candidate to acquire the lock once available (still cannot yet proceed, have to acquire the lock along with other threads) and continue the execution (the code after wait() invocation) while the current thread still holds the lock and continues the execution without any interruption, the lock is released when exits the synchronized block .
Object#notifyAll(): Similar to notify(), the method wakes up waiting threads. The difference consists that ones method is called will move from “wait set” to “entry set” all the threads.
package org.course.concurrency;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class MultithreadingApplication {
public static void main(String[] args) throws Exception {
ValueHolder valueHolder = new ValueHolder();
// initiate 10 subscribers
List<Thread> subscriberWorkers = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
Thread subscriberWorker = new Thread(new Subscriber(valueHolder));
subscriberWorkers.add(subscriberWorker);
subscriberWorker.start();
}
// initiate the publisher
Thread publisherWorker = new Thread(new Publisher(valueHolder));
publisherWorker.start();
// run the application for 60 seconds
TimeUnit.SECONDS.sleep(60);
// complete the publisher execution
publisherWorker.interrupt();
// complete the subscriptions' execution
subscriberWorkers.forEach(Thread::interrupt);
}
private static class ValueHolder {
private Integer value;
public void setValue(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
}
private static class Publisher implements Runnable {
private final Random random;
private final ValueHolder valueHolder;
public Publisher(ValueHolder valueHolder) {
this.random = new Random();
this.valueHolder = Objects.requireNonNull(valueHolder);
}
@Override
public void run() {
while (true) {
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
break;
}
synchronized (valueHolder) {
valueHolder.setValue(random.nextInt(100));
valueHolder.notify();
}
}
}
}
private static class Subscriber implements Runnable {
private final ValueHolder valueHolder;
public Subscriber(ValueHolder valueHolder) {
this.valueHolder = Objects.requireNonNull(valueHolder);
}
@Override
public void run() {
String subscriber = Thread.currentThread().getName();
while (true) {
synchronized (valueHolder) {
try {
valueHolder.wait();
} catch (InterruptedException e) {
break;
}
System.out.println("Subscriber: " + subscriber + ", value: " + valueHolder.getValue());
}
}
}
}
}
When working on a multithreading environment, the methods designed to pause the thread execution like Object#wait(), Thread#sleep() or Thread#join() throws a checked exception InterruptedException . The exception is the signal for the task to stop what is doing and shut down gracefully the execution because another thread called Thread#interrupt() and the application expects the task completes the execution. However it’s important to note, the Thread#interrupt() does not complete the operating system thread, it’s being completed when the task completes the execution and ignoring the exception or doing noting about canceling the execution of the task is always a mistake, as it discards the cancellation by a reasonable request and may lead to resource leaks, unpredictable result or even unresponsive application.
Summary
Concurrency is one of the most intricate aspects of software engineering and one of the most rewarding. Java offers a mature, well-structured concurrency model that balances control and abstraction. From low-level threads to high-level executors, synchronization mechanisms, and memory guarantees, Java provides a complete toolkit to design reliable, scalable, and responsive applications.