Mastering Concurrency in Java: Part 2 – Low Level Concurrency API
This blog post is continuation of Mastering Concurrency in Java: Part 1 – Getting Started. If you haven't read it, please check it out.
In the previous post, I have discussed on the basics of concurrency. In this post let us focus on the Low Level Concurrency API in Java. Let us understand the concept and write some code also. This will be a long post. But I am confident that you will get a good idea
Upto Java 1.2, the threads are managed by the Java Virtual Machine (JVM) itself. These threads which are managed by the JVM are called Green Threads. In Java 1.3 the support for green threads is abandoned and Platform Threads (also called Native Threads) are used. Platform threads are the threads manage by the underlying OS directly. In Java 21, we have new type or threads called Virtual Threads. We will discuss about these Virtual Threads later.
Thread Class
The thread class (java.lang.Thread)
is the crucial one using which we can achieve multi-threading in java. This class is there from the earlier versions of Java. Concurrency is one of the main factor for Java becoming so popular in the late 90's. This thread class implements the Runnable (java.lang.Runnable)
interface.
Important Thread Methods
Let us discuss some the important that will be useful when working with multi-threaded code.
start()
: The start()
method is used to begin the execution of a new thread. Once invoked, the newly created thread will execute the run()
method of the associated Runnable
(or the run()
method of the Thread
class if not provided).
Thread thread = new Thread(() -> System.out.println("Running in a new thread"));
thread.start(); // Starts the new thread
join()
: The join()
method is used to make the current thread wait until the thread on which it is called has completed execution. For example, if t
is a thread and t.join()
is called from another thread m
, then thread m
will pause its execution until thread t
has finished.
Thread thread1 = new Thread(() -> System.out.println("Thread 1 is running"));
Thread thread2 = new Thread(() -> System.out.println("Thread 2 is running"));
thread1.start();
thread2.start();
try {
thread1.join(); // Main thread will wait until thread1 finishes
thread2.join(); // Main thread will wait until thread2 finishes
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Both threads have finished");
sleep()
: It is a static method in the Thread
class. The sleep()
method suspends the execution of the thread in which the method is invoked, for a certain amount of specified time. This method throws a InterruptedException
when is interrupted by another thread while the thread is still is sleep mode.
Creating Threads
Extending the Thread Class
In this approach, we extend the Thread
class and override its run()
method to define the code that will be executed by the thread. Note that the Thread
class has an empty run()
method by default.
We can started the thread by calling the .start()
method on our class instance.
Note: This is an older and less flexible approach for creating threads and is generally avoided in modern Java.
Using Thread Constructor and Runnable Interface
Instead of extending the Thread
class, we can implement the Runnable
interface, which defines a single run()
method. Then, we pass the Runnable
instance to the Thread
constructor.
Using Lambda Expressions
In modern Java, we can use lambda expressions for a cleaner, more concise approach. This eliminates the need for a separate class to implement Runnable
.
Using Thread.Builder (Java 19+)
In Java 19 and later, you can use the Thread.Builder class to configure and create threads using Builder Pattern. This is a more flexible and readable approach compared to using the Thread constructor directly.
Things that can go wrong
We have already discussed that threads communicate through shared memory. This communication is efficient but may basic causes issues like,
- Memory Inconsistency
- Thread Interference
Consider the below example,
public class Counter {
private int value = 0;
public void increment() {
value++; // Increment the counter
}
public void decrement() {
value--; // Decrement the counter
}
public int getValue() {
return value; // Get the current value
}
}
public class CounterExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread incrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread decrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.decrement();
}
});
incrementThread.start();
decrementThread.start();
incrementThread.join();
decrementThread.join();
System.out.println("Final Counter Value: " + counter.getValue());
}
}
We have a Counter
class with three instance methods:
increment
: Increases the value of the counter by 1.decrement
: Decreases the value of the counter by 1.getValue
: Returns the current value of the counter.
In the program, we create two threads:
- One thread calls the
increment
method 1000 times. - The other thread calls the
decrement
method 1000 times.
Both threads access the same Counter
object. The expected final value is 0
, because the increments and decrements should cancel each other out.
The actual value might not be 0
due to race conditions. The increment
and decrement
methods are not atomic operations.
The increment
and decrement
operations, written as value++
and value--
, look like a single operation but are actually composed of three distinct steps internally:
- Read the current value of
counter
from memory.
Example: Readcounter = 0
. - Modify the value by performing the operation (
+1
forincrement
,-1
fordecrement
).
Example: Compute0 + 1 = 1
forincrement
or0 - 1 = -1
fordecrement
. - Write the updated value back to memory.
Example: Write1
or-1
tocounter
.
Let's assume the initial value of counter
is 0
. Now, consider two threads performing increment
and decrement
operations simultaneously:
- Thread 1 (Increment): Reads
counter = 0
. - Thread 2 (Decrement): Reads
counter = 0
. - Thread 1 (Increment): Computes
counter + 1 = 1
, but has not written it yet. - Thread 2 (Decrement): Computes
counter - 1 = -1
and writes-1
tocounter
. - Thread 1 (Increment): Writes
1
tocounter
, overwriting Thread 2's-1
.
Final value of counter
: 1
instead of 0
.
The issue
In the above example, the main problem is that two threads are simultaneously accessing and modifying the same shared variable (counter
). This leads to race conditions and inconsistent results.
To address this issue, we have two options:
- Make the
increment
anddecrement
operations atomic:
Ensure that these operations are performed as a single, indivisible step. This prevents interleaving of the read-modify-write sequence by multiple threads. - Prevent simultaneous invocation of
increment
anddecrement
by multiple threads:
Use synchronisation or locking mechanisms to ensure that only one thread at a time can access these methods, maintaining thread safety.
Synchronised Methods and Statements
Let us discuss about synchronised methods and statements
Synchronised Methods
We can prevent multiple threads from simultaneously invoking methods on an object by using synchronised methods. When a thread invokes a synchronized method, it acquires the intrinsic lock (also called the monitor lock) of the corresponding object. The lock is released only after the method execution is complete. While a thread holds the intrinsic lock, no other threads can invoke any synchronised methods on the same object.
For example,
public class Counter {
private int value = 0;
public synchronized void increment() {
value++; // Increment the counter
}
public synchronized void decrement() {
value--; // Decrement the counter
}
public synchronized int getValue() {
return value; // Get the current value
}
}
public class CounterExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread incrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread decrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.decrement();
}
});
incrementThread.start();
decrementThread.start();
incrementThread.join();
decrementThread.join();
System.out.println("Final Counter Value: " + counter.getValue());
}
}
In above example, we use synchronised methods to ensure thread safety when multiple threads modify a shared Counter object. The increment()
, decrement()
, and getValue()
methods are synchronized, so only one thread can access any of them at a time for the same object. Two threads (incrementThread and decrementThread) run simultaneously: one increments the counter 1000 times, and the other decrements it 1000 times. Synchronisation ensures no race conditions occur, and the final counter value is always 0, as the increments and decrements cancel each other out.
Synchronised Statements
Synchronised statements allow us to synchronise only a specific block of code, rather than an entire method. This is useful when we want to protect only critical sections of code that modify shared resources, improving performance by reducing the amount of locked code. A synchronised statement requires a lock on a specific object, and only one thread can execute the synchronised block for that object at a time.
public class Counter {
private int value = 0;
public void increment() {
synchronized (this) { // Synchronize only this block
value++;
}
}
public void decrement() {
synchronized (this) { // Synchronize only this block
value--;
}
}
public int getValue() {
return value; // No synchronization needed for just reading
}
}
public class CounterExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread incrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread decrementThread = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.decrement();
}
});
incrementThread.start();
decrementThread.start();
incrementThread.join();
decrementThread.join();
System.out.println("Final Counter Value: " + counter.getValue());
}
}
In the above example, we use synchronised blocks (synchronized (this)
) inside the increment()
and decrement()
methods to ensure that only one thread can modify the value
variable at a time. This is more efficient than synchronising the entire method. The final output will still be 0
because the threads increment and decrement the counter an equal number of times, and synchronisation prevents race conditions.
Understanding Liveness Issues: Deadlock, Livelock, and Starvation
Liveness refers to a program's ability to make progress. It means threads in a program should eventually complete their tasks without getting stuck indefinitely.
Deadlock
Deadlock occurs when two or more threads are stuck waiting for each other to release locks, and none can proceed.
For example, Thread A
has Lock 1
and waits for Lock 2
, while Thread B
has Lock 2
and waits for Lock 1
. Neither thread can move forward.
public class DeadlockExample {
private static final Object Lock1 = new Object();
private static final Object Lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (Lock1) {
System.out.println("Thread 1: Holding Lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (Lock2) {
System.out.println("Thread 1: Acquired Lock 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (Lock2) {
System.out.println("Thread 2: Holding Lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (Lock1) {
System.out.println("Thread 2: Acquired Lock 1!");
}
}
});
thread1.start();
thread2.start();
}
}
Livelock
Livelock happens when threads keep responding to each other in a way that prevents progress, even though they aren’t blocked. They keep "doing something" but don’t achieve the goal.
public class LivelockExample {
static class Resource {
private boolean inUse = true;
public synchronized boolean isInUse() {
return inUse;
}
public synchronized void setInUse(boolean inUse) {
this.inUse = inUse;
}
}
public static void main(String[] args) {
Resource resource = new Resource();
Thread thread1 = new Thread(() -> {
while (resource.isInUse()) {
System.out.println("Thread 1: Waiting for resource...");
resource.setInUse(false); // Tries to release it for Thread 2
}
});
Thread thread2 = new Thread(() -> {
while (!resource.isInUse()) {
System.out.println("Thread 2: Waiting for resource...");
resource.setInUse(true); // Tries to release it for Thread 1
}
});
thread1.start();
thread2.start();
}
}
Starvation
Starvation occurs when a thread is indefinitely delayed from accessing resources because other higher-priority threads keep using them.
For example, A low-priority thread never gets CPU time because higher-priority threads always take precedence.
public class StarvationExample {
public static void main(String[] args) {
Runnable highPriorityTask = () -> {
while (true) {
// Simulate a long-running task
// This thread will consume CPU time indefinitely
}
};
Runnable lowPriorityTask = () -> {
// Low priority thread will print messages
while (true) {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000); // Sleep to simulate some work
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// Create threads
Thread highPriorityThread = new Thread(highPriorityTask);
Thread lowPriorityThread = new Thread(lowPriorityTask);
// Set thread priorities
highPriorityThread.setPriority(Thread.MAX_PRIORITY); // Maximum priority
lowPriorityThread.setPriority(Thread.MIN_PRIORITY); // Minimum priority
// Start threads
highPriorityThread.start();
lowPriorityThread.start();
}
}
Avoiding Liveness Issues
To avoid deadlock, it's important to ensure that threads don’t wait on each other in a circular way. One way to do this is by acquiring resources in a fixed order. Another approach is to use timeouts, where threads give up waiting after a certain period and retry, preventing them from getting stuck.
To prevent livelock, where threads keep changing their state without making progress, we can make threads wait before retrying. For example, a thread can back off for a short time before trying again, allowing other threads a chance to run and make progress.
Starvation happens when lower-priority threads are always blocked by higher-priority ones. To avoid this, we can use fair scheduling, where every thread, no matter its priority, gets a chance to run. For instance, using a round-robin approach or fair locks can ensure that all threads get their turn to execute.
That's all for now! In the next section, Mastering Concurrency in Java: Part 3 – High-Level Concurrency API, we will explore the high-level concurrency APIs available in Java through the java.util.concurrent
package. If you have any questions or doubts, feel free to leave a comment below.
This blog is heavily inspired by Oracle's JDK 8 documentation.