Гонка данных и synchronized
На стене кухни висит доска, где отмечают, сколько блюд отдано. Отдав блюдо, повар подходит к доске, читает число, прибавляет единицу у себя в блокноте и пишет новое число поверх старого. Однажды два повара подходят к доске одновременно: оба читают 5, оба пишут 6. Отдано два блюда, а на доске прибавилось одно.
Тот же сбой бывает в программе. Два потока, main и helper, увеличивают одно поле count, каждый по 100 000 раз. Ждём 200000:
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++ там четыре инструкции, и по смыслу это три шага:
- прочитать:
getstaticдостаёт значениеcount; - прибавить:
iconst_1берёт единицу, аiaddскладывает; - записать:
putstaticкладёт результат обратно вcount.
Между чтением и записью число живёт не в count, а на стеке операндов — черновике, куда инструкции байт-кода кладут числа и откуда их забирают. У каждого потока свой черновик, как блокнот у каждого повара.
Шаги двух потоков могут перемешаться как угодно. Если оба потока прочитают 5 до того, как кто-то из них запишет результат, оба запишут 6, и одно увеличение пропадёт — как на доске у поваров.
Попробуйте сами: нажимайте «Шаг A» и «Шаг B» в разном порядке и следите, сколько окажется в count, когда оба потока закончат. Под каждым шагом подписана его инструкция байт-кода.
count- прочитать
getstatic count - прибавить
iconst_1, iadd - записать
putstatic count
- прочитать
getstatic count - прибавить
iconst_1, iadd - записать
putstatic count
count = 5. В каждом потоке count++ — три шага. Нажимайте «Шаг A» и «Шаг B» в любом порядке: получится потерять увеличение?
Такая ошибка называется гонкой данных: два потока обращаются к одной переменной, и хотя бы один из них пишет. При этом нет договорённости, кто и когда к ней обращается; такой договорённостью будет замок — о нём ниже. Java о ней не сообщает: нет ни ошибки компиляции, ни исключения, только неверное число. Этот вопрос часто задают на собеседовании: почему count++ из двух потоков теряет увеличения?
Гонка коварна тем, что проявляется не всегда. Если потоки делают мало работы, они могут ни разу не пересечься, и программа много запусков подряд печатает верный результат. Но Java этого не обещает: в любой запуск увеличения могут пропасть.
Здесь аналогия с доской неточна: повара могут договориться вслух, кто подойдёт первым. Потоки друг друга не видят и ничего не ждут, пока программа сама не даст им замок.
Замок synchronized
Повара договорились: на доске висит замок с одним ключом, и отметить блюдо может только тот, у кого ключ. В Java такой замок есть у каждого объекта, и называется он монитором. Слово synchronized перед методом велит взять замок на входе в метод и отдать на выходе:
static synchronized void increment() {
count++;
}
static void addMany() {
for (int i = 0; i < 100_000; i++) {
increment();
}
}Что изменилось:
increment()—static synchronized-метод. Он берёт замок классаMain, один на всю программу.- Пока один поток внутри
increment(), другой ждёт у входа, и три шагаcount++больше не перемешиваются. - В цикле
addManyвместоcount++теперь стоит вызовincrement(). - Программа печатает
200000при каждом запуске.
Обычный, не static, synchronized-метод берёт замок своего объекта — this. Данные защищены, только если все потоки берут один и тот же замок.
AtomicInteger
Для одного счётчика есть способ короче. Класс AtomicInteger из пакета java.util.concurrent.atomic хранит int и умеет увеличивать его одной неделимой операцией. Метод incrementAndGet() читает, прибавляет и записывает так, что другой поток не может вклиниться между шагами:
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 поставили на обычный метод, а поле общее для всех объектов:
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()увеличивает число одной неделимой операцией.