How to Synchronize Threads in Java

In this article, we will look at how to synchronize threads in Java.

Synchronized blocks

In Java the word synchronized can be used for methods and code blocks. It is a kind of “base” for correct work with shared resources in a multithreaded environment.

Example:

public class Counter {
    private int count = 0;

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

    public synchronized int getCount() {
        return count;
    }
}

Methods increment() And getCount() synchronized, which ensures that only one thread can modify or read the value count at one point in time.

Example of a synchronized block:

public class Counter {
    private int count = 0;
    private final Object lock = new Object();

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

    public int getCount() {
        synchronized(lock) {
            return count;
        }
    }
}

Here we use a synchronized block with an explicit lock object lock. This way you can control synchronization, limiting it to only the necessary parts of the code.

Every object in Java has a monitor associated with it. When a thread enters a synchronized block or method, it acquires the monitor of that object. If the monitor is already occupied by another thread, the current thread will wait for it to become available:

public void addToQueue(String item) {
    synchronized(queue) {
        queue.add(item);
    }
}

In the example, if one thread is already adding an element to the queue queuethe other thread will wait until the first one finishes before it can perform the append.

wait(), notify() and notifyAll() methods

Methods wait(), notify()And notifyAll() — are tools for coordinating work between threads that use shared resources. They are called on an object of a class that implements the interface Objectand should only be used in synchronized blocks or methods.

  • wait() causes the current thread to wait until another thread calls notify() or notifyAll() at the same site.

  • notify() wakes up one randomly chosen thread that is waiting on this object.

  • notifyAll() wakes up all threads that are waiting on this object.

Let's look at the classic producer-consumer example:

public class Buffer {
    private int contents;
    private boolean available = false;

    public synchronized void put(int value) {
        while (available) {
            try {
                wait();
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
        contents = value;
        available = true;
        notifyAll();
    }

    public synchronized int get() {
        while (!available) {
            try {
                wait();
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
        available = false;
        notifyAll();
        return contents;
    }
}

HereBuffer class stores one integer value. Method put() waiting for Buffer will not be writable, and then adds the value. Method get() waiting for Buffer will not be available for reading, then returns the value and frees the buffer. wait() called when a thread must wait for a buffer to become available, and notifyAll() wakes up all threads after changing the buffer state.

InterruptedException is an exception that is thrown when another thread interrupts the current thread while it is waiting. The correct way to handle this exception is to set the interrupt flag back and terminate execution if appropriate:

try {
    wait();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt(); // восстановить статус прерывания
    return; // и выйти, если поток не может продолжить выполнение
}

A couple of tips:

  1. Minimize blocking time: Keep synchronized blocks as short as possible.

  2. Avoid nested locks: This may lead to deadlocks.

  3. Use separate lock objects: to control access to different parts of the data.

  4. Be careful with the waiting conditions: always use loops whilenot conditions ifto check the wait condition, because the thread can wake up without changing the conditions.

Joining and blocking threads

Method join() allows one thread to wait for another to complete its work. A must-have when you want the data being processed in one thread to be fully prepared before performing any actions in another.

Example of use join():

public class ThreadJoinExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            System.out.println("Thread 1 running");
            try {
                Thread.sleep(1000); // Подождем 1 секунду
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Thread 1 finished");
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("Thread 2 running");
            try {
                thread1.join(); // Ожидаем завершения thread1
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("Thread 2 finished");
        });

        thread1.start();
        thread2.start();
    }
}

Here thread2 waiting for thread1 will not complete its work due to the call join(). Thus, the message “Thread 2 finished” will always be displayed after “Thread 1 finished“.

Timeouts are good for preventing a thread from remaining blocked forever if the event it is waiting for never happens. This is an important flow control that helps avoid program freezes.

Example with timeout in join():

Thread thread3 = new Thread(() -> {
    try {
        thread1.join(500); // Ожидаем максимум 500 мс
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    System.out.println("Thread 3 may or may not have waited for Thread 1 to finish");
});
thread3.start();

thread3 awaiting completion thread1 no more than 500 milliseconds. If thread1 will not be completed in this time, thread3 will continue execution.

Method sleep() is used to pause the current thread for a specified period of time. It is used to simulate long-running operations, control execution frequency, and create delays.

Example of use sleep():

Thread sleeperThread = new Thread(() -> {
    try {
        System.out.println("Sleeper thread going to sleep");
        Thread.sleep(2000); // Спим 2 секунды
    } catch (InterruptedException e) {
        System.out.println("Sleeper thread was interrupted");
        Thread.currentThread().interrupt();
    }
    System.out.println("Sleeper thread woke up");
});
sleeperThread.start();

Here is the flow sleeperThreadsleeps” 2 seconds, which is good, for example, to limit the speed of execution of the data processing cycle.

Blocking objects and synchronization constructs

Java has an interface Lock with its remarkable implementation ReentrantLockThese mechanisms offer greater flexibility than the traditional approach using synchronized. They allow lock acquisition attempts, lock timeouts, and many other operations that are not supported by synchronized.

Example of use ReentrantLock:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

Here lock() is called to acquire the lock before the critical section starts, and it is important to always call unlock() in the block finallyto ensure that the lock is released even if exceptions occur.

Condition increases the flexibility of lock management by allowing some threads to suspend themselves (wait), until another thread reports some condition. This can be compared to an extended version of Object.wait() And Object.notify()but with the ability to create multiple wait conditions on a single lock.

Example with Condition:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class BoundedBuffer {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    private final Object[] items = new Object[100];
    private int putptr, takeptr, count;

    public void put(Object x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();
            }
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public Object take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();
            }
            Object x = items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();
        } finally {
            lock.unlock();
        }
        return x;
    }
}

Let's implement the producer-consumer pattern with a blocking queue:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumerExample {
    private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);

    static class Producer extends Thread {
        public void run() {
            try {
                for (int i = 0; i < 20; i++) {
                    queue.put(i);
                    System.out.println("Produced " + i);
                }
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
        }
    }

    static class Consumer extends Thread {
        public void run() {
            try {
                while (true) {
                    Integer item = queue.take();
                    System.out.println("Consumed " + item);
                }
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        new Producer().start();
        new Consumer().start();
    }
}

In conclusion, we invite everyone to open lessons dedicated to Java development:

  • August 7: “Reflection API” – let's get acquainted with the reflection mechanism in the Java language and see where it is used. Sign up

  • August 21: “Generics in Java” – Let's learn what they are for, where they are used in the standard Java library, and how you can use them in your code. Sign up

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *