Basic Thread Synchronization

When sharing data between threads, two primary issues can arise: cached data and interleaving. In this section, we'll explore how to address these problems using volatile keyword and Java's synchronization mechanisms.

Thread Caching

class Processor extends Thread {
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            System.out.println("Tick!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public void shutdown() {
        running = false;
    }
}

public class Driver {
    public static void main(String[] args) {
        Processor processorOne = new Processor();
        processorOne.start();

        System.out.println("Press return to stop...");
        Scanner scanner = new Scanner(System.in);
        scanner.nextLine();
        processorOne.shutdown();
    }
}

In this example, the volatile keyword ensures that the running variable's value is always read from main memory, preventing the thread from using a cached value.

Thread Synchronization

Consider the following example where we create a simple counter incremented by two threads

public class Application {
    private int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Application application = new Application();
        application.doWork();
    }

    public void doWork() throws InterruptedException {
        Thread threadOne = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                count++;
            }
        });

        Thread threadTwo = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                count++;
            }
        });

        threadOne.start();
        threadTwo.start();

        threadOne.join();
        threadTwo.join();

        System.out.println("Count is: " + count);
    }
}

Issues with Concurrent Updates

In this code, the main thread starts threadOne and threadTwo, each incrementing the count variable 10,000 times. However, the final value of count is not always 20,000. This inconsistency arises due to the non-atomic nature of the count++ operation, which involves three steps:

  1. Reading the current value of count.
  2. Incrementing the value.
  3. Storing the incremented value back.

If both threads read the value of count as 100 at the same time and increment it, they might both write back the value 101, resulting in a lost update

Using AtomicInteger

A simple way to solve this problem is to use AtomicInteger, which provides atomic operations for incrementing integers.

import java.util.concurrent.atomic.AtomicInteger;

public class Application {
    private AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Application application = new Application();
        application.doWork();
    }

    public void doWork() throws InterruptedException {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10_000; i++) {
                    count.incrementAndGet();
                }
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10_000; i++) {
                    count.incrementAndGet();
                }
            }
        });

        threadOne.start();
        threadTwo.start();

        threadOne.join();
        threadTwo.join();

        System.out.println("Count is: " + count.get());
    }
}

Using the synchronized Keyword

While AtomicInteger provides a straightforward solution, sometimes you need to synchronize more complex operations. The synchronized keyword ensures that only one thread can access the critical section at a time.

What the synchronized keyword actually does is allow every object in Java to have an intrinsic lock, also known as a monitor lock or mutex. When you call a synchronized method of an object, the calling thread must acquire the intrinsic lock of that object before it can execute the method. Only one thread can hold the intrinsic lock at a time, meaning if another thread attempts to call a synchronized method on the same object, it must wait until the first thread releases the lock by finishing the method execution.

public class Application {
    private int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Application application = new Application();
        application.doWork();
    }

    public synchronized void increment() {
        count++;
    }

    public void doWork() throws InterruptedException {
        Thread threadOne = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                increment();
            }
        });

        Thread threadTwo = new Thread(() -> {
            for (int i = 0; i < 10_000; i++) {
                increment();
            }
        });

        threadOne.start();
        threadTwo.start();

        threadOne.join();
        threadTwo.join();

        System.out.println("Count is: " + count);
    }
}

In summary, every object has an intrinsic lock, and only one thread can acquire it at any given moment. A method marked as synchronized can only be executed after acquiring this lock. This ensures that when multiple threads access shared data, they do so in a thread-safe manner. When using synchronized, you generally don't need to declare variables as volatile because synchronized guarantees visibility of the current state of the variables it protects, which is one of the main functions of the volatile keyword.

By understanding and applying these synchronization techniques, you can manage shared data between threads effectively, preventing common concurrency issues such as race conditions and ensuring data consistency.