Синхронизация потоков

В многопоточной программе не всегда один поток работает независимо от другого. Бывает, они обмениваются данными или обрабатывают одни и те же объекты.

В таких случаях возникают проблемы правильной организации взаимодействия нитей так, чтобы они не мешали работе друг друга. Один поток должен знать об изменениях, внесенных другим потоком.

Синхронизация потоков – это настройка их взаимодействия. Рассмотрим пример, в котором два разных потока работают с одним и тем же объектом:

public class NoSynch {
    public static void main(String[] args) throws InterruptedException {
        Client client = new Client(1000);
        Thread operation = new Operation(client, 1000);
        Thread operation1 = new Operation(client, 500);
        operation.start();
        operation1.start();
        operation.join();
        operation1.join();
        System.out.println(client.getBill());
    }
}
 
class Operation extends Thread {
    private Client mClient;
    private int mPay;
 
    Operation(Client client, int pay) {
        mClient = client;
        mPay = pay;
    }
 
    @Override
    public void run() {
        System.out.println(this.getName() + ": " + mClient.getBill());
        if (mClient.getBill() - mPay >= 0) {
            try {
                sleep(1000);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
            mClient.changeBill(mPay);
        }
        System.out.println(this.getName() + ": " + mClient.getBill());
        System.out.println(this.getName() + " stop");
    }
}
 
class Client {
    private int mBill;
 
    Client(int bill) {
        this.mBill = bill;
    }
 
    int getBill() {
        return mBill;
    }
 
    void changeBill(int pay) {
        mBill -= pay;
    }
}

Результат:

Thread-0: 1000
Thread-1: 1000
Thread-0: 0
Thread-0 stop
Thread-1: -500
Thread-1 stop
-500

Задержка sleep'ом искусственно надумана, чтобы разделить проверку условия и вычитание. В реальных программах между одним действием и другим могло бы быть множество промежуточных, на выполнение которых требуется время.

У клиента на счету оказалась отрицательная сумма, хотя по логике вещей одна из операций вычитания не должна была бы выполняться. Проблема возникла из-за того, что в каждом потоке на момент проверки (mClient.getBill() - mPay >= 0) было по 1000 на счету. И каждый поток "решил", что денег достаточно. После этого поток-0 уменьшил сумму. Когда поток-1 приступил к вычитанию, денег на счету уже не было.

Чтобы исключить подобную логическую ошибку, надо как-то блокировать объект client, чтобы пока он обрабатывается в одном потоке, другие не могли его изменять. Как вариант делать это в методе в run():

class Operation extends Thread {
    private final Client mClient;
    private int mPay;
 
    Operation(Client client, int pay) {
        mClient = client;
        mPay = pay;
    }
 
    @Override
    public void run() {
        System.out.println(this.getName() + ": " + mClient.getBill());
        synchronized (mClient) {
            if (mClient.getBill() - mPay >= 0) {
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                mClient.changeBill(mPay);
            }
        }
        System.out.println(this.getName() + ": " + mClient.getBill());
        System.out.println(this.getName() + " stop");
    }
}

Результат:

Thread-0: 1000
Thread-1: 1000
Thread-0: 0
Thread-0 stop
Thread-1: 0
Thread-1 stop
0

И хотя оба потока сначала увидели 1000. Когда началась проверка и вычитание, действия выполнялись совместно, другой поток в это время доступ к объекту не имел.

Блок кода с ключевым словом synchronized может одновременно выполняться только одним потоком.

В примере синхронизация выполнена по объекту. В Java также возможна синхронизация по методу, т. е. пока методом пользуется один поток, другому он недоступен. Если в вышеприведенной программе мы по отдельности синхронизируем методы getBill() и changeBill() объекта, это ничего не даст. Решением может быть объединение проверки и вычитания в один метод:

class Operation1 extends Thread {
    private Client1 mClient;
    private int mPay;
    Operation1(Client1 client, int pay) {
        mClient = client;
        mPay = pay;
    }
 
    @Override
    public void run() {
        try {
            sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        mClient.changeBill(mPay);
    }
}
 
class Client1 {
    private int mBill;
    Client1(int bill) {
        this.mBill = bill;
    }
 
    synchronized void changeBill(int pay) {
        System.out.println(mBill);
        if (mBill-pay >= 0) {
            mBill -= pay;
            System.out.println(mBill);
        }
    }
}

У каждой нити есть собственный кэш, куда копируются данные, с которыми она работает. Таким образом, одна нить не имеет доступа к данным другой и не может прочитать произошедшие изменения, а читает устаревшие значения из обычной памяти. Если потоки работают с общими данными, можно запретить их помещение в кэш, используя ключевое слово volatile. Например:

private volatile int mBill;

Создано