Код ниже выглядит настолько простым, что ошибаться в нём вроде бы нечему:
counter++;
Но если два потока выполняют эту строку одновременно, итоговое значение counter может оказаться меньше ожидаемого.
Исключений нет. JVM не падает. Оба потока честно завершили работу — только часть увеличений куда-то пропала.
Чтобы разобраться, куда именно, придётся пройти весь путь: от процесса операционной системы через heap и стеки потоков до ядер процессора, кэшей и Java Memory Model.
JVM работает внутри процесса
Когда мы запускаем:
java Application
операционная система создаёт процесс.
Процесс — это изолированная среда выполнения программы. У него есть собственное виртуальное адресное пространство, открытые файлы, сетевые соединения, системные дескрипторы и другие ресурсы.
Фраза «процессу выделяется кусок оперативной памяти» подходит как первое приближение, но физически всё устроено сложнее. Процесс работает с виртуальными адресами, а операционная система сопоставляет их со страницами физической памяти. Эти страницы не обязаны лежать рядом и не обязаны постоянно находиться в RAM.
Для Java-приложения внутри процесса запускается JVM. Она загружает классы, управляет памятью, компилирует горячий байт-код в машинный и создаёт потоки.
Получается примерно такая конструкция:
Операционная система
└── процесс Java
└── JVM
├── heap
├── служебные области памяти
├── поток main
├── потоки сборщика мусора
├── потоки JIT-компилятора
└── потоки приложения
Один процесс может содержать много потоков. Они выполняют код независимо, но используют ресурсы одного процесса.
Именно последняя часть постепенно приводит нас к многопоточной радости.
Heap общий, stack у каждого свой
Внутри JVM есть несколько областей памяти. Для понимания обычного Java-кода сейчас достаточно двух: heap и стеки потоков.
Heap
Heap — общая для потоков область памяти JVM. В ней размещаются экземпляры классов и массивы.
Если создать объект:
Order order = new Order(1_500);
сам объект Order логически относится к heap. Ссылка order, через которую код обращается к объекту, является
отдельным значением.
Heap создаётся при запуске JVM и управляется сборщиком мусора. Его память также не обязана представлять собой один физически непрерывный участок.
Стеки потоков
У каждого потока есть собственный JVM stack. Когда поток вызывает метод, в его стеке создаётся новый frame — кадр вызова.
В frame хранятся:
- локальные переменные;
- параметры метода;
- промежуточные результаты вычислений;
- данные, необходимые для возврата из метода.
Когда метод вызывает другой метод, сверху появляется новый frame. Когда вызов завершается, соответствующий frame удаляется.
public final class OrderService {
private int processedAmount;
public void process(Order order) {
int amount = order.amount();
processedAmount += amount;
}
public record Order(int amount) {
}
}
При выполнении process() в frame метода находятся локальная переменная amount и ссылка order. Объект Order, на
который указывает эта ссылка, находится в общей памяти. Поле processedAmount тоже принадлежит объекту OrderService,
а значит, потенциально доступно другим потокам, если у них есть ссылка на тот же сервис.
Отсюда появляется популярная формулировка:
Примитивы хранятся в stack, а объекты — в heap.
Для первого объяснения она терпима, но буквально полагаться на неё не стоит.
Примитивное значение, являющееся локальной переменной, входит в frame метода. Примитивное поле объекта хранится как часть объекта. Локальная переменная ссылочного типа находится во frame, а объект, на который она указывает, — в heap.
Кроме того, спецификация JVM описывает логическую модель, а не обязательное физическое расположение каждого значения. JIT-компилятор может держать локальную переменную в регистре, устранить ненужный объект или разложить его поля на отдельные значения. Для рассуждений о многопоточности достаточно более простой модели: локальные переменные одного потока недоступны другому напрямую, а поля объектов и элементы массивов могут быть общими.
Один поток — ещё и расходы на память
Под каждый обычный платформенный поток JVM создаёт stack. Часто можно встретить утверждение, что его размер равен одному мегабайту.
На Linux x64 и macOS x64 значение 1 МБ действительно является типичным значением по умолчанию для современных JDK. На
ARM оно может составлять 2 МБ, а на Windows зависит от настроек виртуальной памяти и исполняемого файла.
Размер можно изменить параметром:
java -Xss512k Application
Поэтому правильнее говорить не «каждый поток занимает один мегабайт», а «под stack каждого потока резервируется отдельное адресное пространство, размер которого зависит от платформы и настроек JVM».
С -Xss мне чаще приходилось сталкиваться не как с параметром, который хочется настраивать заранее, а уже во время разбора слишком большого количества потоков. В одном из сервисов thread dump показывал несколько сотен потоков: серверные пулы, HTTP-клиенты, Kafka, шедулеры и несколько собственных executor’ов, созданных разными частями приложения.
При стандартном размере stack около 1 МБ это уже заметный объём зарезервированной памяти. При этом простое уменьшение -Xss проблему не решало. Сначала пришлось разбираться, зачем сервису столько пулов, почему некоторые из них почти всё время ожидали внешние системы и почему несколько компонентов создавали собственные executor’ы вместо использования общих.
После сокращения количества потоков размер stack уже можно было аккуратно уменьшить и проверить под нагрузкой. Делать наоборот опасно: приложение станет потреблять меньше памяти, но при достаточно глубокой цепочке вызовов начнёт падать с StackOverflowError.
Поэтому тысяча потоков — это давно не тысяча лёгких объектов Thread. Это тысяча стеков, системных структур и участников, которых планировщику нужно постоянно распределять по процессорам.
Поток не исполняется постоянно
Поток может работать только тогда, когда операционная система назначила его на доступный логический процессор.
В остальное время он может:
- ожидать своей очереди на выполнение;
- спать;
- ждать освобождения монитора;
- читать данные из сети;
- ожидать файл, таймер или ответ базы данных;
- быть припаркованным библиотекой конкурентности.
Поэтому наличие десяти запущенных потоков не означает, что процессор прямо сейчас исполняет десять фрагментов Java-кода.
Одно ядро и переключение контекста
Допустим, у процессора доступно одно ядро, а приложение запустило два вычислительных потока.
Операционная система выделяет немного процессорного времени первому потоку, затем приостанавливает его, сохраняет состояние и передаёт ядро второму. Позже первый поток продолжит работу.
Это называется time slicing, а переход между потоками — context switch.
Со стороны кажется, что потоки работают одновременно. Физически в конкретный момент ядро исполняет инструкции только одного из них.
В нашем любимым примере про Зайца и Волка можно представить, что Заяц убегает, а Волк одновременно готовит ловушку. На двух ядрах они действительно могут заниматься этим параллельно. На одном ядре режиссёр просто очень быстро переключает кадр: несколько шагов Зайца, несколько действий Волка, снова Заяц.
Персонажам кажется, что всё происходит одновременно. Процессор знает, что это монтаж.
Несколько ядер
Если системе доступно несколько ядер или аппаратных потоков, разные программные потоки действительно могут выполняться параллельно.
Но из этого не следует, что восемь потоков на восьми ядрах всегда ускорят задачу ровно в восемь раз.
Часть программы может оставаться последовательной. Потоки тратят время на координацию, конкурируют за общие данные, обращаются к памяти и иногда ждут друг друга. При большом количестве потоков добавляются переключения контекста и нагрузка на планировщик.
Для вычислительных задач число доступных процессоров задаёт естественную границу полезного параллелизма. С операциями ввода-вывода всё менее очевидно: потоков может быть больше, потому что значительную часть времени они ждут сеть, базу или файловую систему.
На одном из интеграционных сервисов мы пытались увеличить пропускную способность простым расширением пула. Сначала подняли количество рабочих потоков примерно с 30 до 100. На небольшой нагрузке результат выглядел лучше, но дальше производительность почти перестала расти, а время ответа, наоборот, увеличилось.
Причина оказалась достаточно обычной: HTTP connection pool позволял выполнять только около 20 одновременных запросов, а пул соединений к базе тоже был заметно меньше пула потоков. Большая часть новых потоков не выполняла полезную работу, а ждала соединение. Дополнительно выросло количество переключений контекста, очереди стали длиннее, а тайм-ауты начали порождать повторные запросы.
После согласования размеров пулов и ограничения параллелизма сервис работал стабильнее с меньшим количеством потоков. Этот случай хорошо отучает от идеи, что поток — бесплатный способ ускорить обработку. Пул должен соответствовать не только количеству ядер, но и пропускной способности ресурсов, к которым обращаются задачи.
Спецификация Java допускает как настоящее параллельное выполнение на нескольких процессорах, так и разделение процессорного времени между потоками.
Между процессором и RAM есть кэши
Обращение к оперативной памяти заметно медленнее выполнения простой процессорной инструкции. Поэтому процессор использует несколько уровней кэша.
Упрощённая схема выглядит так:
L1 обычно самый маленький и быстрый. L2 больше, но медленнее. L3 ещё больше и часто используется несколькими ядрами совместно.
«Обычно» здесь не случайное слово. Точная структура зависит от архитектуры процессора: какие-то уровни могут быть частными для ядра, какие-то — общими, а аппаратные потоки одного ядра могут совместно использовать часть ресурсов.
Допустим, два ядра работают с одним полем Java-объекта. Каждое ядро может читать данные через собственную цепочку кэшей. Процессор поддерживает согласованность кэшей, но это ещё не даёт Java-программе простой гарантии вида:
Один поток записал значение — другой поток немедленно его увидел.
Между исходным Java-кодом и машинными инструкциями находятся компилятор, JIT, регистры, процессорные оптимизации и правила конкретной архитектуры.
Объяснять все проблемы только «разными копиями значения в L1» удобно, но недостаточно. Значение может быть повторно использовано из регистра. Компилятор может переставить независимые инструкции. JIT может вообще убрать повторное чтение поля, если по правилам однопоточного выполнения оно выглядит ненужным.
Чтобы Java-код не зависел напрямую от особенностей конкретного процессора, существует Java Memory Model.
Но сначала сломаем один счётчик.
Куда пропадают увеличения счётчика
public final class LostUpdate {
private static int counter;
public static void main(String[] args) throws InterruptedException {
Thread wolf = new Thread(LostUpdate::increment, "wolf");
Thread hare = new Thread(LostUpdate::increment, "hare");
wolf.start();
hare.start();
wolf.join();
hare.join();
System.out.println(counter);
}
private static void increment() {
for (int i = 0; i < 1_000_000; i++) {
counter++;
}
}
}
Каждый поток выполняет миллион увеличений. Логично ожидать:
2000000
Но программа не обязана вывести это число. Иногда результат действительно может совпасть с ожидаемым, но полагаться на него нельзя.
Операция:
counter++;
не является одним неделимым действием. Упрощённо она состоит из трёх шагов:
прочитать counter
прибавить 1
записать результат
Возможна такая последовательность:
wolf: прочитал 100
hare: прочитал 100
wolf: записал 101
hare: записал 101
Оба потока выполнили увеличение, а значение изменилось только на единицу.
Это называется lost update — потерянное обновление.
В программе есть data race: два потока обращаются к одной переменной, хотя бы одно обращение является записью, а
между операциями нет отношения happens-before.
Одновременно здесь есть race condition: правильность результата зависит от того, в каком порядке пересеклись действия потоков.
Эти понятия близки, но не полностью совпадают. Data race имеет достаточно точное определение в Java Memory Model. Race condition — более широкая логическая ошибка, при которой результат зависит от взаимного порядка событий. Даже отдельные потокобезопасные операции можно соединить в последовательность с race condition.
За годы работы я не раз встречал такую проблему например со статусом бизнес-операции. Один поток получал подтверждение от внешней системы и переводил запрос из PROCESSING в COMPLETED, а параллельный шедулер считал запрос зависшим и записывал FAILED.
Оба потока успевали прочитать старый статус PROCESSING. Каждый делал вполне корректный со своей точки зрения переход, после чего сохранял сущность. Итоговый статус определялся тем, чья запись последней попадала в базу.
if (request.getStatus() == PROCESSING) {
request.setStatus(COMPLETED);
repository.save(request);
}
Проверка статуса и его изменение здесь не являются одной атомарной операцией. На одном инстансе проблему ещё можно попытаться спрятать под synchronized, но в распределённом сервисе это почти бесполезно: второй поток может выполняться в другой JVM.
В таких местах мне приходилось использовать оптимистическую блокировку с версией сущности, условный UPDATE по текущему статусу или отдельную модель допустимых переходов. Иначе учебный lost update превращается во вполне реальную заявку, которая одновременно и успешно завершена, и помечена ошибочной — в зависимости от того, куда смотреть.
Поток может не увидеть новое значение
Потерянное увеличение — проблема атомарности. Есть ещё одна неприятность: видимость изменений.
public final class VisibilityProblem {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
// Выполняем работу
}
System.out.println("Worker stopped");
});
worker.start();
Thread.sleep(100);
running = false;
worker.join();
}
}
Поток main меняет running на false. Кажется, что worker должен прочитать новое значение и завершиться.
Гарантии этого нет.
Код содержит data race. Ни running, ни доступ к нему не защищены механизмом синхронизации. JVM вправе скомпилировать
цикл так, будто значение running не меняется внутри потока worker, и не выполнять новое чтение на каждой итерации.
Программа может завершиться. Может долго работать. После прогрева и JIT-компиляции поведение может отличаться от первых запусков.
На словах звучит ненадёжно. В спецификации так и задумано: неправильно синхронизированная программа не получает удобных гарантий.
Исправление для этого конкретного случая:
private static volatile boolean running = true;
Запись в volatile-переменную становится видимой последующим чтениям этой переменной другими потоками.
Но volatile не исправит наш счётчик:
private static volatile int counter;
Операция counter++ всё равно состоит из чтения, вычисления и записи. Видимость появилась, атомарность всей
последовательности — нет.
Для счётчика подойдёт AtomicInteger:
private static final AtomicInteger counter = new AtomicInteger();
private static void increment() {
for (int i = 0; i < 1_000_000; i++) {
counter.incrementAndGet();
}
}
Можно также использовать synchronized, Lock, LongAdder или вообще отказаться от общего изменяемого состояния.
Выбор зависит от задачи, а не от того, какой класс первым вспомнился.
Что именно контролирует Java Memory Model
Java Memory Model не описывает конкретное устройство L1, L2 или L3. Она задаёт правила, по которым можно определить, какие значения разрешено увидеть каждому чтению в многопоточной программе.
Центральное понятие здесь — happens-before.
Если действие A находится в отношении happens-before с действием B, результат A должен быть виден для B в соответствии с правилами модели памяти.
Такую связь, среди прочего, создают:
- выход и последующий вход в один монитор
synchronized; - запись и последующее чтение одной
volatile-переменной; - вызов
Thread.start()и действия запущенного потока; - завершение потока и успешный возврат из
Thread.join(); - операции классов из
java.util.concurrent, согласно контрактам этих классов.
Без happens-before два потока могут читать и записывать общие данные, но программист не получает привычной картины общего последовательного выполнения.
Процессор может переупорядочивать инструкции. То же может делать компилятор или JIT. Значения могут временно находиться в регистрах и кэшах. Java Memory Model не запрещает эти оптимизации — она ограничивает наблюдаемый результат программы.
Если программа не содержит data race, её выполнение должно выглядеть последовательно согласованным: как будто все действия потоков можно расположить в одном общем порядке, сохраняющем порядок действий каждого отдельного потока. Если data race есть, становятся возможны результаты, которые трудно объяснить только порядком строк в исходном коде.
Модель, которой достаточно для начала
Для повседневного кода можно держать в голове такую картину.
Java-приложение работает внутри процесса. Потоки процесса разделяют heap, но у каждого потока есть собственный stack с кадрами вызовов. Операционная система распределяет готовые к выполнению потоки между доступными процессорами. Процессор использует регистры и кэши, а JVM и JIT могут оптимизировать код.
Как только два потока получают доступ к одному изменяемому объекту, появляются три вопроса:
- Может ли один поток увидеть изменения другого?
- Должны ли несколько операций выполняться как единое целое?
- Каким механизмом между действиями создаётся happens-before?
Если ответа на третий вопрос нет, первые два обычно держатся на надежде и удачном порядке выполнения.
На локальной машине такой код может месяцами выглядеть исправным. Потом изменится нагрузка, версия JDK, количество ядер или форма скомпилированного кода — и Волк снова останется без результата, хотя на этот раз ловушку расставил вполне правильно.
Следующий шаг — отдельно разобрать, какие гарантии дают synchronized, volatile, атомарные классы и блокировки. У
каждого из этих механизмов своя задача. Универсальной кнопки «сделать потокобезопасно» у Java нет.

