Загружаем научный разбор
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Автор: Казачкин Даниил Михайлович · Обновлено
Почему два потока не обязаны видеть записи одинаково: happens-before, volatile, безопасная публикация и отличие видимости от атомарности.
Однопоточная программа часто приучает читать код сверху вниз: сначала записали число, затем подняли флаг, значит другой поток увидит всё в том же порядке. Эта привычка недостаточна для разделяемой памяти. Компилятор, JVM и процессор выполняют преобразования, которые сохраняют допустимое поведение отдельного потока, но требуют правил для взаимодействия между потоками.
Java Memory Model задаёт такие правила на уровне языка. Разработчику не нужно угадывать устройство кэшей конкретного процессора. Нужно показать, какое отношение связывает запись данных с чтением, и проверить, что программа соблюдает условия этого отношения. Успешный запуск на рабочем ноутбуке такого аргумента не заменяет.
Работа Manson, Pugh и Adve, POPL 2005, посвящена модели памяти Java и согласованию оптимизаций с поведением многопоточных программ. Для прикладных правил здесь используется глава 17 JLS Java SE 25. Это явно выбранная редакция спецификации; историческую статью не следует читать как отдельную инструкцию для любой будущей JVM. Авторский PDF, JLS.
Ключевой инструмент — happens-before. Он связывает действия порядка внутри потока и действия синхронизации. Отношение транзитивно: если запись данных предшествует публикации флага, а публикация синхронизирована с чтением флага, то можно связать запись данных с последующим чтением данных. Это логический порядок гарантий, а не измерение миллисекунд между инструкциями.
В программе один производитель записывает результат, один потребитель пытается его прочитать. Объект создаётся до запуска потоков; после успешной публикации число больше не меняется. Потребитель делает одну попытку: пример не строит бесконечный busy-wait и не обещает конкретное расписание потоков.
public class PublicationDemo {
static final class Box {
int answer;
volatile boolean ready;
void publish() {
answer = 42;
ready = true;
}
Integer readIfReady() {
return ready ? answer : null;
}
}
public static void main(String[] args) throws InterruptedException {
Box box = new Box();
Thread producer = new Thread(box::publish);
producer.start();
Integer observed = box.readIfReady();
if (observed != null && observed != 42) {
throw new AssertionError(observed);
}
producer.join();
System.out.println(box.readIfReady()); // 42
}
}Первое чтение может вернуть null: производитель ещё не успел опубликовать значение. Если оно увидело true, соответствующее volatile-чтение обеспечивает связь с записью флага, а предшествовавшая запись числа становится видимой. После join есть ещё одна причина корректности последнего чтения: завершение производителя связано с возвратом ожидающего потока из join. Эти две гарантии полезно разбирать отдельно.
Если удалить volatile, первое чтение уже нельзя обосновать прежней цепочкой. При этом последнее чтение после join не превращается автоматически в ошибочное: синхронизация через завершение потока остаётся. Именно поэтому эксперимент, который всегда сначала вызывает join и только потом проверяет данные, не исследует проблему ранней публикации.
Рассмотрим отдельный счётчик. Два потока читают значение 5, каждый вычисляет 6, затем оба записывают 6. Итог увеличился на единицу при двух обновлениях. Сделать поле volatile недостаточно: операция counter++ включает чтение, вычисление и запись, а не одну неделимую операцию.
Для счётчика с требованием точного числа увеличений подойдёт атомарная операция подходящего класса либо блокировка. Для изменения нескольких согласованных полей нужен общий протокол: например, одна блокировка вокруг проверки условия и обновления всей группы. Набор отдельно видимых полей не гарантирует, что потребитель увидит допустимую комбинацию их значений.
Полезный рабочий приём — выписать инвариант до выбора примитива. Для нашего Box он звучит так: «если готовность подтверждена, answer равен опубликованному значению и больше не меняется». Если разрешить производителю перезаписывать answer после ready = true, исходное объяснение уже не описывает весь жизненный цикл. Возможно, нужен неизменяемый снимок, очередь сообщений или новая версия состояния.
Искусственная пауза между записью и чтением не создаёт отсутствующее ребро синхронизации. Она меняет вероятность наблюдения определённого расписания, поэтому может сделать ошибку менее заметной. Аналогично диагностическая печать иногда меняет взаимодействие потоков и скрывает проблему. Перед добавлением задержек спросите, какое событие действительно подтверждает готовность: завершение задачи, получение сообщения или захват согласованной блокировки. Затем выразите ожидание через этот механизм. Проверка должна сохранять связь действий и после удаления отладочных сообщений, смены процессора и оптимизации компилятором.
Многократный стресс-тест помогает находить нарушения, но отсутствие сбоя не доказывает их невозможность. Отдельно сохраняйте небольшую схему действий потоков и указывайте каждое ребро синхронизации. Проверяйте сценарии раннего чтения, повторной публикации, исключения производителя и отмены потребителя. Если требование включает своевременное завершение, анализируйте его отдельно от видимости памяти.
Знание JMM полезно при проектировании кэшей, фоновой инициализации, обмена конфигурацией и параллельной обработки. Часто лучший результат даёт сокращение разделяемого изменяемого состояния: готовый неизменяемый объект передаётся через стандартный механизм синхронизации. Но слово «неизменяемый» тоже требует проверки конструкции объекта и его ссылок. Надёжность возникает из понятного протокола владения и публикации, который можно объяснить независимо от удачного запуска.
Самостоятельный русскоязычный разбор ЯдроКода. Описания первоисточников отделены от авторских учебных примеров и инженерных выводов. Материал не является переводом или перепечаткой.
Учебные данные, расчёты, таблицы и программные примеры созданы для этой публикации. Иллюстрации и программный код из первоисточников не воспроизводятся.