Перейти к содержимому
Теория · Потоки: первое знакомство · урок 5 из 6

Гонка данных и synchronized

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

Тот же сбой бывает в программе. Два потока, main и helper, увеличивают одно поле count, каждый по 100 000 раз. Ждём 200000:

JAVA
public class Main {
    static int count = 0;
    static void addMany() {
        for (int i = 0; i < 100_000; i++) {
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread helper = new Thread(() -> addMany());
        helper.start();
        addMany();
        helper.join();
        System.out.println(count);
    }
}

count — static-поле, поэтому оба потока работают с одной и той же переменной. Программа компилируется без единого предупреждения. Но на компьютере с несколькими ядрами она почти всегда печатает меньше 200000, и при каждом запуске — другое число, например 135017.

Почему пропадают увеличения

count++ выглядит как одно действие, но это не так. Скомпилируйте программу командой javac Main.java и загляните в байт-код командой javap -c Main: для count++ там четыре инструкции, и по смыслу это три шага:

  1. прочитать: getstatic достаёт значение count;
  2. прибавить: iconst_1 берёт единицу, а iadd складывает;
  3. записать: putstatic кладёт результат обратно в count.

Между чтением и записью число живёт не в count, а на стеке операндов — черновике, куда инструкции байт-кода кладут числа и откуда их забирают. У каждого потока свой черновик, как блокнот у каждого повара.

Шаги двух потоков могут перемешаться как угодно. Если оба потока прочитают 5 до того, как кто-то из них запишет результат, оба запишут 6, и одно увеличение пропадёт — как на доске у поваров.

Попробуйте сами: нажимайте «Шаг A» и «Шаг B» в разном порядке и следите, сколько окажется в count, когда оба потока закончат. Под каждым шагом подписана его инструкция байт-кода.

ВживуюДва потока, один count++count = 5
общая переменная
5
count
поток A
  1. прочитатьgetstatic count
  2. прибавитьiconst_1, iadd
  3. записатьputstatic count
стек операндов: —
поток B
  1. прочитатьgetstatic count
  2. прибавитьiconst_1, iadd
  3. записатьputstatic count
стек операндов: —
count++ на int — три шага байт-кода (javap -c)

count = 5. В каждом потоке count++ — три шага. Нажимайте «Шаг A» и «Шаг B» в любом порядке: получится потерять увеличение?

Порядок шагов выбираете вы

Такая ошибка называется гонкой данных: два потока обращаются к одной переменной, и хотя бы один из них пишет. При этом нет договорённости, кто и когда к ней обращается; такой договорённостью будет замок — о нём ниже. Java о ней не сообщает: нет ни ошибки компиляции, ни исключения, только неверное число. Этот вопрос часто задают на собеседовании: почему count++ из двух потоков теряет увеличения?

Гонка коварна тем, что проявляется не всегда. Если потоки делают мало работы, они могут ни разу не пересечься, и программа много запусков подряд печатает верный результат. Но Java этого не обещает: в любой запуск увеличения могут пропасть.

Здесь аналогия с доской неточна: повара могут договориться вслух, кто подойдёт первым. Потоки друг друга не видят и ничего не ждут, пока программа сама не даст им замок.

Замок synchronized

Повара договорились: на доске висит замок с одним ключом, и отметить блюдо может только тот, у кого ключ. В Java такой замок есть у каждого объекта, и называется он монитором. Слово synchronized перед методом велит взять замок на входе в метод и отдать на выходе:

JAVA
static synchronized void increment() {
    count++;
}
static void addMany() {
    for (int i = 0; i < 100_000; i++) {
        increment();
    }
}

Что изменилось:

  1. increment() — static synchronized-метод. Он берёт замок класса Main, один на всю программу.
  2. Пока один поток внутри increment(), другой ждёт у входа, и три шага count++ больше не перемешиваются.
  3. В цикле addMany вместо count++ теперь стоит вызов increment().
  4. Программа печатает 200000 при каждом запуске.

Обычный, не static, synchronized-метод берёт замок своего объекта — this. Данные защищены, только если все потоки берут один и тот же замок.

AtomicInteger

Для одного счётчика есть способ короче. Класс AtomicInteger из пакета java.util.concurrent.atomic хранит int и умеет увеличивать его одной неделимой операцией. Метод incrementAndGet() читает, прибавляет и записывает так, что другой поток не может вклиниться между шагами:

JAVA
static AtomicInteger count = new AtomicInteger();
static void addMany() {
    for (int i = 0; i < 100_000; i++) {
        count.incrementAndGet();
    }
}

В начало файла добавьте import java.util.concurrent.atomic.AtomicInteger;. System.out.println(count) печатает число из AtomicInteger, и программа выводит 200000 при каждом запуске. Переключите виджет выше на AtomicInteger: у каждого потока теперь один шаг, и потерять увеличение не выйдет.

synchronized подходит, когда под замком нужно сделать несколько действий, а AtomicInteger — когда защитить нужно одно число.

Частая ошибка

synchronized поставили на обычный метод, а поле общее для всех объектов:

JAVA
class Board {
    static int marks = 0;
    synchronized void mark() { // ✗
        marks++;
    }
}

Каждый поток пишет на свою доску: один вызывает mark() у одного new Board(), другой — у другого. Программа компилируется, но увеличения по-прежнему пропадают: у двух досок два замка, а поле marks одно (оно static). Исправление — static synchronized void mark(): тогда оба потока берут один замок, замок класса Board.

Главное

  • count++ — это три шага: прочитать, прибавить, записать; шаги двух потоков перемешиваются, и увеличения теряются.
  • Гонка данных — доступ двух потоков к одной переменной без договорённости, кто и когда к ней обращается, когда хотя бы один пишет; Java о ней не сообщает.
  • synchronized пропускает в метод один поток за раз и защищает данные, только если все потоки берут один замок.
  • AtomicInteger.incrementAndGet() увеличивает число одной неделимой операцией.