Многопоточность в Java. Часть 3

Введение

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

1. Состояние гонки

На данный момент мы знаем, что из-за совместного использования потоками пространства памяти процесса, они могут одновременно как считывать данные из переменной, так и записывать в нее. Хоть эта возможность и дает более быстрый доступ к памяти, она имеет неприятный эффект в виде состояния гонки, которое порождает несогласованность данных в программе.
Состояние гонки (race condition) — это ошибка в многопоточном коде, которая возникает, когда поведение программы зависит от порядка выполнения потоков или процессов, работающих с общими данными. Если этот порядок непредсказуем или не контролируется, это может привести к некорректным результатам или неожиданному поведению программы.
Для разбора данного состояния смоделируем банковский счет, с которого будем списывать и зачислять одну и ту же сумму из двух разных потоков. При этом итоговый баланс по завершению программы не должен измениться.
public class BankAccount {
   private long balance;

   public BankAccount(long balance) {
       this.balance = balance;
   }

   public void withdraw(long amount) {
       long newBalance = balance - amount;
       balance = newBalance;
   }

   public void deposit(long amount) {
       long newBalance = balance + amount;
       balance = newBalance;
   }

   @Override
   public String toString() {
       return String.valueOf(balance);
   }
}
Данный класс имеет два метода, выполняемых в многопоточной среде: один вносит сумму, а другой снимает ее с банковского счета.
Операции записи или считывания не являются атомарными. Это означает, что они не выполняется как одна, неделимая операция, а состоят из трех операций: получение текущего значение баланса, его изменение и запись полученного значение обратно.
Атомарность — это свойство операции, которое гарантирует, что она выполняется как единое целое, без возможности прерывания или вмешательства со стороны других потоков или процессов. Атомарная операция либо выполняется полностью, либо не выполняется вообще, что исключает частичное выполнение и обеспечивает корректность данных в многопоточном коде.
Для демонстрации работы счета создадим следующий многопоточный код:
public class BankAccountTest {
    public static void main(String[] args) throws InterruptedException {
        BankAccount ba = new BankAccount(100);
        var t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                ba.deposit(100);
            }

        });

        var t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                ba.withdraw(100);
            }
        });
        
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(ba);
    }    
}
В коде выше мы создали экземпляр класса BankAccount с начальным балансом 100. Затем создали два потока, один из которых увеличивает счет, а другой — снимает средства со счета. Оба они выполняют свою операцию внутри цикла по 1000 раз.
Примечание: вызов join() необходим для того, чтобы главный поток, выполняемый метод main(), не завершался, пока не отработают потоки t1 и t2. В противном случае он сразу перейдет к выводу результата, а t1 и t2 не успеют завершить свою работу
Хоть и ожидается, что баланс измениться не должен, но это не так, причем его конечное значение будет меняться при каждом запуске программы.
Состояние гонки происходит, когда два или более потока одновременно пытаются изменить общие данные, и конечный результат зависит от того, какой поток выполнил свою операцию первым. Это может привести к ошибкам, которые сложно воспроизвести и исправить.
Можем ли мы решить эту проблему, объявив переменную баланса как volatile? Нет. Ключевое слово volatile решает проблему видимости (чтения), но ситуация, с которой мы имеем дело сейчас, иная.
Запишем программу с помощью псевдокода:
Итак, только следующий порядок (который мы не можем гарантировать) работы программы сохранит точность расчета:
1) 1.1, 1.2, 2.1, 2.2
2) 2.1, 2.2, 1.1, 1.2
Что, если он будет такой?
3) 1.1, 2.1, 2.2, 1.2
Тогда результат будет не таким, как мы ожидаем.
Переменная balance используется совместно в обоих потоках. Когда один поток изменяет ее значение, то для этого он выполняет несколько операций: в начале читает, а затем записывает.
Если один поток читает до того, как другой поток закончит свою запись, то вот тут все и выходит из-под контроля.
Рассмотрим подробнее работу варианта 3:
  • 1.1 Первый поток вычисляет выражение (balance + 100), сохраняя результат 200 в локальную переменную newBalance
  • 2.1 Первый поток еще не успел поместить 200 в поле balance, а второй поток уже начал считывать ее значение (100). Затем он вычитает из нее 100 и сохраняет результат (0) в локальную переменную newBalance
  • 2.2 Второй поток полю balance присваивает значение newBalance (0)
  • 1.2 Для первого потока локальная переменная newBalance все еще равна 200. Это значение он сохраняет в balance
Графическое представление работы одной итерации потоков для варианта 3

2. Синхронизация

Решение проблемы, изложенной выше, заключается в создании взаимного исключения между потоками.
Например, когда мы идем в уборную, то закрываем ее, чтобы в это время никто другой не мог ей воспользоваться. Однако, когда кто-то заканчивает пользоваться удобствами, ими может воспользоваться кто-то другой.
Идея замка может быть использована и здесь. Когда поток считывает и записывает общую переменную, мы должны защитить ее блокировкой, чтобы никакой другой поток не мог получить к ней доступ во время этих операций.
Область в коде, которая считывает и записывает в переменную, называется критическим участком. Если такой участок кода не выполняется атомарно, то существует вероятность возникновения состояния гонки.
Эти проблемы можно предотвратить, сохраняя критические значения внутри синхронизированного блока кода (synchronized block). Для его создания используется ключевое слово synchronized с указанием объекта блокировки в качестве аргумента.
Синхронизация (synchronized) — это механизм, позволяющий контролировать доступ к общим ресурсам, чтобы избежать состояний гонки и обеспечить корректность данных.
Когда поток получает объект блокировки, никакой другой поток не сможет использовать этот объект. Как только поток разблокирует блокировку, другие потоки, отличные от исходного потока, могут получить этот объект снова (уже в своей блокировке).
Объект блокировки (lock object) — это механизм синхронизации, который используется для управления доступом к общим ресурсам в многопоточных программах. Он гарантирует, что только один поток может выполнять критический участок кода в любой момент времени. Это предотвращает состояние гонки , когда несколько потоков одновременно пытаются изменить общие данные.
В Java синхронизация может быть реализована с использованием ключевого слова synchronized двумя способами:
  • Синхронизация на методе (использование ключевого слова synchronized на уровне метода)
  • Синхронизация на объекте (использование блока synchronized с указанием объекта блокировки)

2.1 Синхронизация на объекте

Синхронизация на объекте — гибкий способ управления блокировками. В этом случае используется блок synchronized с указанием объекта блокировки.
Перепишем класс BankAccount, используя synchronized на объекте:
public class BankAccount {
   private long balance;
   private final Object lock = new Object();

   public BankAccount(long balance) {
       this.balance = balance;
   }

   public void withdraw(long amount) {
       synchronized (lock) {
           System.out.println("Acquired Lock: " + Thread.currentThread());
           long newBalance = balance - amount;
           balance = newBalance;
           System.out.println("Unlocked the lock: " + Thread.currentThread());
       }
   }

   public synchronized void deposit(long amount) {
       synchronized (lock) {
           System.out.println("Acquired Lock: " + Thread.currentThread());
           long newBalance = balance + amount;
           balance = newBalance;
           System.out.println("Unlocked the lock: " + Thread.currentThread());
       }
   }

   @Override
   public String toString() {
       return String.valueOf(balance);
   }
}
Если вы снова запустите метод main(), результат будет равен 100 — таким, каким мы изначально его ожидали.
Когда поток входит в блок synchronized, он пытается захватить монитор объекта, указанного в блоке. Если монитор свободен, поток захватывает его и выполняет код внутри блока. Если монитор уже захвачен другим потоком, текущий поток блокируется и ждёт, пока монитор не будет освобождён.
Монитор — это механизм синхронизации, который обеспечивает контроль доступа к общим ресурсам в многопоточной среде. В Java каждый объект имеет встроенный монитор, который используется для управления доступом к этому объекту. Монитор позволяет только одному потоку выполнять критический участок в любой момент времени.
Монитор — это основа синхронизации в Java. Он обеспечивает:
  • Взаимное исключение (mutual exclusion): только один поток может выполнять критический участок кода в любой момент времени
  • Координацию потоков: потоки могут ждать определённых условий и уведомлять друг друга об изменениях

2.2 Синхронизация на методе

Синхронизация на методе — это частный случай синхронизации на объекте. Для нее используется ключевое слово synchronized в объявлении метода.
public class Counter {
    private int count;

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

    public int getCount() {
        return count;
    }
}
Когда метод помечен как synchronized, он захватывает монитор объекта, на котором вызывается (т.е. this). Только один поток может выполнять синхронизированный метод для данного объекта в любой момент времени.

2.3 Плюсы и минусы синхронизации

Синхронизация на методе
Плюсы:
  • Простота использования: не нужно явно указывать объект блокировки
  • Минимализм: подходит для простых случаев, когда вся логика метода должна быть синхронизирована
Минусы:
  • Жёсткая привязка к this: блокировка захватывается на текущем объекте, что может привести к нежелательным блокировкам, если другие части кода также используют this для синхронизации
  • Низкая гибкость: нельзя использовать таймауты или условные переменные
  • Избыточность: если метод содержит только часть кода, требующую синхронизации, блокировка на уровне метода может быть излишней
Используйте, если:
  • Вся логика метода должна быть синхронизирована
  • Вам нужен простой и минималистичный подход
  • Вы не хотите явно управлять объектом блокировки
Пример: простые классы, такие как счётчики или кэши.
Синхронизация на объекте
Плюсы:
  • Гибкость: можно использовать любой объект для блокировки
  • Точность: можно синхронизировать только те участки кода, которые действительно требуют синхронизации
  • Избежание блокировок на this: использование отдельного объекта блокировки предотвращает конфликты с другими частями кода, которые могут использовать this
Минусы:
  • Сложность: требуется явное управление блокировкой
  • Дополнительный код: необходимо создавать объект блокировки и явно указывать его в блоке synchronized
Используйте, если:
  • Только часть метода требует синхронизации
  • Вам нужна гибкость (например, использование таймаутов или условных переменных)
  • Вы хотите избежать блокировок на this
Пример: сложные классы, такие как банковские счета, пулы ресурсов или многопоточные коллекции.

Заключение

Теперь подытожим всё:
  • Когда переменная совместно используется несколькими потоками, при этом один из них записывает в нее — это критическая секция
  • Критическая секция должна быть защищена lock'ом. В противном случае, произойдет состояние гонки
  • Любой объект может быть использован в качестве блокировки, поскольку имеет встроенную блокировку или monitor lock. Если мы используем ключевое слово synchronized в сигнатуре метода, то используется внутренняя блокировка
  • Synchronized-блок работает как атомарная операция, даже если он содержит более одного оператора
  • Если мы используем synchronized-блок для критической секции, то для общей переменной не нужно использовать ключевое слово volatile. Ключевое слово synchronized само по себе устраняет проблему видимости. Это означает, что переменная всегда считывается или записывается в основную память, в то время как volatile обеспечивает только считывание из основной памяти
Статья написана на основе следующих источников:
«Java Thread Programming (Part 5)»
Автор и переводчик: Чимаев Максим
Оцените статью, если она вам понравилась!