Java programming language

Concurrent programming

Thread synchronization and coordination

There are different ways of synchronizing or coordinating threads when they are launched from the same application. We can, for instance, use join to make a thread wait until another thread finishes its task completely, or use synchronization mechanisms when several threads access shared data. Platform threads also have a priority value, although it is only a scheduling hint and must never be used to guarantee execution order or program correctness. From that point on, there are more complex synchronization structures, such as mutual exclusion and explicit locks, which we will study in this section.

1. Basic coordination. Joining threads

If we want a thread to wait until another thread finishes, we can use the join method from the thread that we want to wait for. In this example, the main application creates a thread and waits until it finishes before going on:

public static void main(String[] args) 
{
    Thread t = new MyThread();
    t.start();

    try 
    {
        t.join();
    } catch (InterruptedException e) {
        // Restore the interruption status so upper-level code can detect it
        Thread.currentThread().interrupt();
    }
}

The join method can throw an InterruptedException because the thread that is waiting may itself be interrupted. A successful return from join also provides an important memory-visibility guarantee: actions performed by the finished thread happen-before the thread that successfully returns from join continues.

If we want a secondary thread (not main program) to wait for another thread, then we need to tell this thread which is the thread it must wait for. We typically use an attribute inside thread class to store this information:

public class MyThread extends Thread 
{
    Thread waitThread;

    // We will use this constructor 
    // if thread does not have to wait for anyone
    public MyThread() 
    {
        waitThread = null;
    }

    // We will use this constructor 
    // if thread has to wait for thread "wt"
    public MyThread(Thread wt) 
    {
        waitThread = wt;
    }

    // We check if waitThread attribute is not null,
    // and then call join before continuing with this thread's task
    @Override
    public void run() 
    {
        try 
        {
            if (waitThread != null)
                waitThread.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }

        ...
    } 
}

Then, in main application, we create two threads of type MyThread, and ask one of them to wait for the other:

public static void main(String[] args) 
{
    Thread t1 = new MyThread();
    Thread t2 = new MyThread(t1);
    t1.start();
    // Thread t2 is started too, but it will wait inside join()
    // before continuing with the rest of its run() method
    t2.start();
}

Note: When using join, always decide how interruption should be handled. If the current method cannot propagate InterruptedException, a common policy is to restore the interruption status with Thread.currentThread().interrupt() and finish the task cleanly. Silently ignoring the exception may make cancellation and coordination harder to reason about.

Exercise 1:

Create a project called ThreadRaceJoin based on previous project of Exercise 3. Change the behavior of the three running threads (A, B and C) so that each one starts running when previous thread has finished:

Exercise 2:

Create a project called MultiplierThreadsJoin based in previous project of Exercise 2. Change the behavior of the main application so that it waits for each thread to finish before starting the following. Therefore, all the multiplication tables will be shown in order:

0 x 0 = 0
0 x 1 = 0
...
0 x 10 = 0
1 x 0 = 0
...

2. Access to shared resources. The need of thread synchronization

It is quite usual that multiple threads want to get the same resource (e.g. a variable, a text file, a database…), and it is difficult to guarantee that the information in that resource will not be mistakenly modified (for instance, that a thread changes the value of a variable while another thread is using it).

A critical section is a piece of code that accesses shared mutable state and therefore requires controlled access. When mutual exclusion is required, only one thread using the same synchronization mechanism should execute that critical section at a time. Java provides intrinsic locks through synchronized, as well as higher-level synchronization utilities that we will see later.

Let’s see the problem in depth with this example: first of all, we create an object of class Counter, that will be shared among threads:

public class Counter
{
    int value;

    public Counter(int value)
    {
        this.value = value;
    }

    public void increment()
    {
        value++;
    }

    public void decrement()
    {
        value--;
    }

    public int getValue()
    {
        return value;
    }
}

You can see that Counter class has only one attribute, value, which is the value that will be read and/or modified by the threads, by calling increment or decrement methods.

Then, we create two types of threads: one that will increment Counter value in a loop, and another one that will decrement it:

public static void main(String[] args)
{
    Counter c = new Counter(100);

    Thread tinc = new Thread(() -> {
        for (int i = 0; i < 100; i++)
        {
            c.increment();
            try 
            {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    });

    Thread tdec = new Thread(() -> {
        for (int i = 0; i < 100; i++)
        {
            c.decrement();
            try
            {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    });

    tinc.start();
    tdec.start();

    try
    {
        tinc.join();
        tdec.join();
        System.out.println("Final value = " + c.getValue());
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

What will happen? If you try this example on your IDE, you may find that the final value of c is different from 100, and it may change between executions. It starts at 100, one thread increments it 100 times and the other decrements it 100 times, so the expected result is 100. The join() calls only make the main thread wait until both worker threads finish; they do not make increment() and decrement() atomic.

Why can this happen? Well, it may occur that tinc gets into increment method and then the control goes to tdec, that gets into decrement method. Then, one of these operations (either value++ or value--) will have no effect. For instance, if tinc reads the value 100 and tries to set it to 101 but then the control goes to tdec that reads the same value 100 (tinc has not changed it yet) and sets it to 99, then when the control comes back to tinc, it will set value to 101, and the decrement will have disappeared.

To solve this problem, Java offers synchronization mechanisms based on locks. Before entering a synchronized critical section, a thread must acquire the corresponding monitor. If another thread already owns that monitor, the new thread becomes blocked until the lock is released. When several threads are waiting for the same monitor, Java does not guarantee which one will acquire it next, so program correctness must never depend on a particular scheduling order.

2.1. Synchronizing methods

One of the most basic methods of synchronization in Java is the synchronized keyword. We can use it to control the access to a method, so that it becomes a critical section.

Every Java object has an intrinsic lock, also called a monitor. A synchronized instance method acquires the monitor of the current object (this), so two synchronized instance methods on the same object cannot execute simultaneously in different threads. However, synchronized methods belonging to different objects can execute concurrently because they use different monitors.

A static synchronized method does not lock any particular instance: it acquires the monitor associated with the corresponding Class object. Therefore, static synchronized methods of the same class exclude each other, independently of the locks of its instances.

In previous example, if we just add the synchronized keyword to the increment and decrement methods of Counter class:

public class Counter 
{
    int value;

    public Counter(int value) 
    {
        this.value = value;
    }

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

    public synchronized void decrement() 
    {
        value--;
    }

    public synchronized int getValue() 
    {
        return value;
    }
}

and we run again the program, the increments and decrements are protected by the same Counter monitor. If tinc is executing increment, tdec cannot enter decrement on that same object until the monitor is released, and vice versa. The synchronized getter uses the same monitor as well, so reads also have the appropriate visibility guarantees.

Synchronization has a cost because threads may need to wait for a lock and because the JVM must maintain the required memory-ordering guarantees. In many applications this cost is small compared with the correctness benefits, so synchronization should be applied where shared mutable state actually requires it rather than avoided blindly.

Exercise 3:

Create a project called BankAccountSynchronized with these classes and methods:

public BankAccount(int balance) { ... }
public void addMoney(int money) { ... }
public void takeOutMoney(int money) { ... }
public int getBalance() { ... }

2.2. Synchronizing objects

We can also apply the synchronized keyword to a block of code and explicitly choose the object whose monitor will be used. For example, if someValue is an attribute shared by several threads:

private int someValue;

public void myMethod() 
{
    ...
    synchronized(this) 
    {
        someValue++;
        System.out.println("Value changed: " + someValue);
    }
    ...
}

When thread A enters this block, it acquires the monitor of this. Another thread that tries to enter a synchronized block or synchronized instance method using the same object must wait until that monitor is released. Notice that synchronizing a purely local variable would normally be unnecessary because each method invocation has its own local variables.

We can also use a dedicated private lock object. This is often preferable when we do not want unrelated synchronized methods on this to use the same monitor:

private final Object fileLock = new Object();

public void someMethod() 
{
    ...
    synchronized(fileLock) 
    {
        ... // Critical section that accesses shared file-related state
    }
    ...
}

Note: synchronized(fileLock) locks the monitor of the Java object fileLock; it does not lock an operating-system file by itself. Every thread that must coordinate this access has to synchronize on the same Java lock object. Lock objects should normally be kept private final so external code cannot accidentally participate in or interfere with the locking protocol.

Exercise 4:

Create a project BankAccountSynchronizedObject based on previous exercise. In this case, you can’t synchronize any method, you can only synchronize objects. What changes would you add to the project to make sure that it will keep on running properly?

3. Thread priorities

Platform threads have a priority value that can be used as a scheduling hint. A higher priority may influence how the JVM and operating system schedule a thread, but Java does not guarantee that a higher-priority thread will run first, run more often, or finish before a lower-priority one. Therefore, thread priorities must never be used to implement synchronization, ordering or program correctness.

For platform threads, priorities are integer values from 1 (Thread.MIN_PRIORITY) to 10 (Thread.MAX_PRIORITY). Thread.NORM_PRIORITY is 5. A newly created platform thread initially inherits the priority of the thread that creates it, unless the value is later changed.

We can change and read the priority with setPriority and getPriority:

Thread t1 = new MyThread();
Thread t2 = new MyThread();
Thread t3 = new MyThread();

t1.setPriority(Thread.MIN_PRIORITY);
t2.setPriority(Thread.NORM_PRIORITY);
t3.setPriority(Thread.MAX_PRIORITY);

System.out.println("Priority of thread #2 is " + t2.getPriority());

3.1. Platform dependency and virtual threads

The actual effect of platform-thread priorities depends on the JVM implementation and the operating-system scheduler. Different systems may map Java priorities differently, and the scheduler is free to take many other factors into account. For this reason, an application must work correctly even if changes made with setPriority have little or no visible effect.

Virtual threads, available as a standard Java feature since Java 21, behave differently: their priority is always Thread.NORM_PRIORITY, and calls to setPriority are ignored. Virtual threads are designed for scalable concurrent tasks, especially tasks that spend much of their time waiting for I/O, rather than for manual priority-based scheduling.

If an application really needs tasks to be processed according to business priority, we should model that priority explicitly—for example, by putting tasks in a priority-aware queue or designing an appropriate executor strategy—instead of relying on operating-system thread scheduling. Likewise, Thread.yield() and Thread.sleep() must not be used to manufacture a reliable scheduling policy: yield() is only a hint and sleep() simply pauses the current thread for at least approximately the requested time.

Exercise 5:

Create a project called ThreadRacePriorities based on the previous ThreadRace project. Modify the code so that thread A has MAX_PRIORITY, thread B has NORM_PRIORITY and thread C has MIN_PRIORITY. Use the setPriority method. Run the program several times and, if possible, on different operating systems. Record whether the finishing order is stable or not, and explain why thread priority cannot be used to guarantee execution order.

Exercise 6:

Create a project called ThreadRacePrioritiesVirtual. Run the same type of task using three virtual threads. Create them initially without starting them, for example with Thread.ofVirtual().unstarted(...), call setPriority with different values, and then start them. Check their priority with getPriority() and verify that virtual threads always report Thread.NORM_PRIORITY, because changes made with setPriority are ignored for virtual threads. Explain why priority-based scheduling is not applicable to them.

4. The producer-consumer problem

The producer-consumer problem is a classic problem in concurrent programming. In this type of problems, we have a data buffer, some producers that put data into that buffer and some consumers that take data from the buffer. We have to make sure that consumers will not try to take data when the buffer is empty and, in some cases, that producers will not produce more data until consumers take the existing one, or if buffer is full.

In this type of problem, mutual exclusion alone is not enough. Producers and consumers also need to coordinate according to a condition, such as “data is available” or “the buffer is not full”. At the intrinsic-lock level, Java provides the wait, notify and notifyAll methods in Object:

A thread may also wake up without the condition becoming true (a spurious wakeup), or another thread may change the condition before it reacquires the monitor. For this reason, calls to wait() should be placed inside a while loop that rechecks the condition.

Let’s see an example: we will create two types of threads: a Producer that will put some data (for instance, an integer) into a given object (we will call it SharedData), and a Consumer that will get this data.

Our SharedData class is this one:

public class SharedData 
{
    int data;

    public int get() 
    {
        return data;
    }

    public void put(int newData) 
    {
        data = newData;
    } 
}

Our Producer and Consumer threads are these ones:

public class Producer extends Thread 
{
    SharedData data;

    public Producer(SharedData data) 
    {
        this.data = data;
    }

    @Override
    public void run() 
    {
        for (int i = 0; i < 50; i++) 
        {
            data.put(i);
            System.out.println("Produced number " + i);
            try 
            {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}

public class Consumer extends Thread 
{
    SharedData data;

    public Consumer(SharedData data) 
    {
        this.data = data;
    }

    @Override
    public void run() 
    {
        for (int i = 0; i < 50; i++) 
        {
            int n = data.get();
            System.out.println("Consumed number " + n);
            try 
            {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}

The main application will create a SharedData object and a thread of each type, and will start both.

public static void main(String[] args)
{
    SharedData sd = new SharedData();
    Producer p = new Producer(sd);
    Consumer c = new Consumer(sd);
    p.start();
    c.start();
}

If we copy this example and see how it works, we will see something like this:

Consumed number 0
Produced number 0
Consumed number 0
Produced number 1
Consumed number 1
Produced number 2
Produced number 3
Consumed number 3
Produced number 4
Consumed number 4
Consumed number 4

See how, sometimes, the producer puts numbers too fast, and sometimes, the consumer gets numbers too fast as well, so that they are not coordinated (the consumer may read twice the same number, or the producer may put two consecutive numbers).

We could think that, if we just add the synchronized keyword to get and put methods from SharedData class, we would solve the problem:

public class SharedData 
{
    int data;

    public synchronized int get() 
    {
        return data;
    }

    public synchronized void put(int newData) 
    {
        data = newData;
    } 
}

However, if we run the program again, we may notice that it still fails:

Consumed number 0
Produced number 0
Consumed number 1
Produced number 1
Produced number 2
Consumed number 1
Produced number 3
Consumed number 3
Produced number 4
Consumed number 4

In fact, there are two problems that we need to solve. But let’s start with the most important one: producer and consumer have to work coordinated: as soon as the producer puts a number, the consumer can get it, and the producer will not be able to produce more numbers until the consumer gets the previous ones.

To do this, we need to add some changes to our SharedData class. First of all, we need a flag that tells producers and consumers who goes next. It will depend on whether there is new data to be consumed (turn for the consumer) or not (turn for the producer).

public class SharedData 
{
    int data;
    boolean available = false;

    public synchronized int get() 
    {
        available = false;
        return data;
    }

    public synchronized void put(int newData) 
    {
        data = newData;
        available = true;
    } 
}

Besides, we need to make sure that get and put methods will be called alternatively. To do this, we need to use the boolean flag and the wait and notify/notifyAll methods, this way:

public class SharedData 
{
    private int data;
    private boolean available = false;

    public synchronized int get() throws InterruptedException
    {
        while (!available)
        {
            wait();
        }

        int result = data;
        available = false;
        notifyAll();
        return result;
    }

    public synchronized void put(int newData) throws InterruptedException
    {
        while (available)
        {
            wait();
        }

        data = newData;
        available = true;
        notifyAll();
    }
}

See how we use wait and notifyAll together with a condition. Regarding get (called by the Consumer), if there is nothing available, the consumer waits in a while loop. When it wakes up, it checks the condition again before reading the value. Then it marks the slot as empty and notifies the waiting threads. In put (called by the Producer), the producer waits while the previous value is still available; once the slot becomes free, it stores the new value, marks it as available and notifies the waiting threads.

Both methods declare throws InterruptedException instead of silently ignoring interruptions. The calling thread should either propagate the exception or finish its task cleanly, normally restoring the interruption status when appropriate.

If both threads try to enter the critical section at the same time, only one can own the SharedData monitor. Since available starts as false, a consumer that arrives first will wait and release the monitor, allowing the producer to store the first value. From then on, both threads coordinate through the condition and the monitor.

Consumed number 0
Produced number 0
Produced number 1
Consumed number 1
Consumed number 2
Produced number 2
Produced number 3
Consumed number 3
Produced number 4
Consumed number 4
...

The exact order of the printed messages may still vary because Producer and Consumer print after put() or get() has returned and the monitor has already been released. What the synchronization guarantees is the correct order of accesses to the shared slot, not a particular order for unrelated console output.

Note: notifyAll does not magically prevent every deadlock. Its advantage in examples with several possible waiters or conditions is that every waiting thread gets the opportunity to wake up and recheck its own condition, instead of relying on notify to choose a suitable waiter. Correctness still depends on protecting the shared state with the same monitor and checking each waiting condition in a loop.

The Producer and Consumer code must now handle InterruptedException. For example:

@Override
public void run()
{
    try
    {
        for (int i = 0; i < 50; i++)
        {
            data.put(i);
            System.out.println("Produced number " + i);
            Thread.sleep(10);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

For many real producer-consumer applications, higher-level structures such as BlockingQueue from java.util.concurrent are preferable because they already implement the waiting and signalling protocol. Nevertheless, understanding wait/notifyAll is useful for learning how Java intrinsic monitors coordinate threads.

Exercise 7:

Create a project called DishWasher. We are going to simulate a dish washing process at home, when someone wash the dishes and someone else dries them. Create the following classes:

Washed dish #1, total in pile: 1
Drying dish #1, total in pile: 0
Washed dish #2, total in pile: 1
Drying dish #2, total in pile: 0
Washed dish #3, total in pile: 1
Washed dish #4, total in pile: 2
Drying dish #4, total in pile: 1
Washed dish #5, total in pile: 2
Washed dish #6, total in pile: 3
Drying dish #6, total in pile: 2
Washed dish #7, total in pile: 3
...