Java Multithreading | Part 4

Published on July 19, 2024 5 min read

Tags:
Java

In multi-threaded programming, efficient synchronization is essential. While lock-based mechanisms are common, lock-free mechanisms, like Compare-and-Swap (CAS), Atomic Variables, offer faster alternatives for specific scenarios. This post explores these concepts, highlighting their advantages and use cases.

Concurrency Mechanism

Lock-Free Mechanisms:

Lock-free mechanisms are not a universal replacement for lock-based mechanisms but are advantageous in certain scenarios. They can be faster and more efficient than traditional locks. One such mechanism is Compare-and-Swap (CAS).

Compare-and-Swap (CAS)

CAS operates as follows:

  • Load data from a memory location.
  • Compare the loaded data with an expected value.
  • If they match, update the memory location with a new value.

What is Atomic?

An atomic operation is one that completes in a single step relative to other threads. This means no other thread can see the operation in an incomplete state. Let's explore the problem with non-atomic operations.

In multi-threaded environments, non-atomic operations can lead to inconsistent and unexpected results. This example demonstrates the issue with non-atomic operations when multiple threads attempt to increment a shared counter.

package multithreading;
 
public class NonAtomicProblemExample {
    public static void main(String[] args) {
        SharedResource103 resource = new SharedResource103();
        Thread th1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                resource.increment();
            }
        });
        Thread th2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                resource.increment();
            }
        });
        th1.start();
        th2.start();
 
        try {
            th1.join();
            th2.join();
            System.out.println("Get counter value: " + resource.getCounter());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
 
class SharedResource103 {
    int counter = 0;
 
    public void increment() {
        counter++;
    }
 
    public int getCounter() {
        return counter;
    }
}

When incrementing the counter with two threads, the expected result might not be achieved because the increment method is not atomic.

Running the code above may sometimes yield the correct output of 20000 (due to the fast nature of modern CPUs and the small size of the input). However, run the program multiple times to see the data inconsistencies.

Solution

There are two common solutions to this problem:

  1. Synchronized Block (Already covered previously)
  2. Using Atomic Integers

Solution 2: Using Atomic Integers

Here's an example using AtomicInteger:

package multithreading;
 
import java.util.concurrent.atomic.AtomicInteger;
 
public class AtomicLockFreeExample {
    public static void main(String[] args) {
        SharedResource102 resource = new SharedResource102();
        Thread th1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                resource.increment();
            }
        });
        Thread th2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                resource.increment();
            }
        });
        th1.start();
        th2.start();
 
        try {
            th1.join();
            th2.join();
            System.out.println("Get counter value : " + resource.getCounter());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
 
class SharedResource102 {
    AtomicInteger counter = new AtomicInteger(0);
 
    public void increment() {
        counter.incrementAndGet();
    }
 
    public int getCounter() {
        return counter.get();
    }
}

AtomicInteger internally uses CAS to ensure thread safety.

If you run the above code multiple times, it will always output 20000.

Atomic variables are designed for scenarios where operations are performed in a sequence of read → modify → update. They ensure thread safety by guaranteeing that operations are performed atomically, meaning the value read and the update are done in a single, uninterrupted step, preventing inconsistencies in concurrent environments.

Volatile

The volatile concept is specific to Java.

When a thread works on a variable, like a counter, it might keep a copy in the CPU cache. The JVM decides when to update the main memory with the counter's value. This means other threads may read a stale value from the main memory.

Declaring a variable as volatile ensures that reads and writes always happen in the main memory. Additionally, all variables visible to the writing thread are also written out to the main memory. Similarly, all variables visible to the reading thread will have the latest values.

Example

Consider the following program:

public class VolatileExample {
 
    boolean flag = false;
 
    void threadA() {
        while (!flag) {
            // ... Do something useful
        }
    }
 
    void threadB() {
        flag = true;
    }
}

In this program, threadA may never exit the loop if it caches the flag variable. Marking flag as volatile will fix this issue.

Another Example

However, volatile doesn’t imply thread-safety. Consider the following program:

public class VolatileCountExample {
    static volatile int count = 0;
 
    public static void main(String[] args) throws InterruptedException {
        int numThreads = 10;
        Thread[] threads = new Thread[numThreads];
 
        for (int i = 0; i < numThreads; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++)
                        count++;
                }
            });
        }
 
        for (int i = 0; i < numThreads; i++) {
            threads[i].start();
        }
 
        for (int i = 0; i < numThreads; i++) {
            threads[i].join();
        }
 
        System.out.println("count = " + count);
    }
}

Running this program multiple times might yield different results other than the expected 10,000.

Volatile is useful due to multiple levels of memory in hardware architecture. If a single thread writes to a volatile variable and other threads only read it, volatile is sufficient. However, if multiple threads write to the volatile variable, "synchronized" is required to ensure atomic writes.

Various collections, their concurrent counterparts.

CollectionConcurrent CollectionLock/Mechanism
PriorityQueuePriorityBlockingQueueReentrantLock
LinkedListConcurrentLinkedDequeCompare-and-swap operation
ArrayDequeConcurrentLinkedDequeCompare-and-swap operation
ArrayListCopyOnWriteArrayListReentrantLock
HashSetnewKeySet method inside ConcurrentHashMapSynchronized
TreeSetCollections.synchronizedSortedSetSynchronized
LinkedHashSetCollections.synchronizedSetSynchronized
Queue InterfaceConcurrentLinkedQueueCompare-and-swap operation