Mastering Concurrency in Java: Part 2 – Low Level Concurrency API

Mastering Concurrency in Java: Part 2 – Low Level Concurrency API
Photo by Konrad Koller / Unsplash

This blog post is continuation of Mastering Concurrency in Java: Part 1 – Getting Started. If you haven't read it, please check it out.

Mastering Concurrency in Java: Part 1 – Getting Started
Introduction Concurrent programming allows a program to handle multiple tasks at the same time. These tasks can either take turns using the CPU (overlap) or run at the same time if there are multiple CPU cores. For example, consider a music streaming application like Apple Music or Amazon Music. These

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.

// Extending the Thread Class
public class DummyThread extends Thread {

    // The spawned thread executes the code inside this run() method
    @Override
    public void run() {
        System.out.println("Hello World from DummyThread");
    }
}

DummyThread.java

We can started the thread by calling the .start() method on our class instance.

public class Main {
    public static void main(String[] args) {
        DummyThread dummyThread = new DummyThread();

        // Start the thread (this calls the run() method)
        dummyThread.start();

        // Will be executed in the main thread
        System.out.println("Hello World from MainThread");
    }
}

Main.java

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.

// Implementing the Runnable Interface
public class Runner implements Runnable {

    // Spawned thread executed the code inside this run() method
    public void run() {
        System.out.println("Hello World from Runner");
    }
}

Runner.java

public class Main {
    public static void main(String[] args) {
        // Passing Runner instance to the thread constructor
        Thread thread1 = new Thread(new Runner());

        // Starts the thread
        thread1.start();

        // Will be executed in the main thread
        System.out.println("Hello World from MainThread");
    }
}

Main.java

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.

public class Main {
    public static void main(String[] args) {
        // Using a lambda expression to create the Runnable
        Thread thread1 = new Thread(() -> {
            System.out.println("Hello World from New Thread");
        });
        
        // Start the thread
        thread1.start();

        // This code will be executed in the main thread
        System.out.println("Hello World from MainThread");
    }
}

Main.java

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.

public class Main {
    private static void doSomeTask() {
        System.out.println("Hello World from New Thread");
    }

    public static void main(String[] args) {
        // Using Thread.Builder (Java 19+)
        Thread.ofPlatform().start(() -> doSomeTask());

        // This code will be executed in the main thread
        System.out.println("Hello World from MainThread");
    }
}

Main.java

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,

  1. Memory Inconsistency
  2. 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:

  1. increment: Increases the value of the counter by 1.
  2. decrement: Decreases the value of the counter by 1.
  3. 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:

  1. Read the current value of counter from memory.
    Example: Read counter = 0.
  2. Modify the value by performing the operation (+1 for increment-1 for decrement).
    Example: Compute 0 + 1 = 1 for increment or 0 - 1 = -1 for decrement.
  3. Write the updated value back to memory.
    Example: Write 1 or -1 to counter.

Let's assume the initial value of counter is 0. Now, consider two threads performing increment and decrementoperations simultaneously:

  1. Thread 1 (Increment): Reads counter = 0.
  2. Thread 2 (Decrement): Reads counter = 0.
  3. Thread 1 (Increment): Computes counter + 1 = 1, but has not written it yet.
  4. Thread 2 (Decrement): Computes counter - 1 = -1 and writes -1 to counter.
  5. Thread 1 (Increment): Writes 1 to counter, overwriting Thread 2's -1.

Final value of counter1 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:

  1. Make the increment and decrement 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.
  2. Prevent simultaneous invocation of increment and decrement 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.

Lesson: Concurrency (The Java™ Tutorials > Essential Java Classes)
This Java tutorial describes exceptions, basic input/output, concurrency, regular expressions, and the platform environment