Interview Playbook: Java Backend Middle
Многопоточность и JMM
Модель памяти Java и правило Happens-Before
Когда несколько потоков работают с общими данными, возникает две главные проблемы: видимость (visibility) и упорядоченность (ordering). Один поток может изменить значение переменной, но другой поток этого изменения не увидит. Или же компилятор и процессор могут переупорядочить инструкции для оптимизации, нарушив задуманную логику. Чтобы решить эти проблемы, была создана модель памяти Java (JMM).
JMM — это спецификация, которая определяет, когда изменения, сделанные одним потоком, становятся видимыми для других. Ключевым понятием в JMM является отношение happens-before. Это правило гарантирует, что если действие А happens-before действия Б, то результаты действия А будут видны действию Б. Это не про время, а про гарантии видимости и порядка.
Правило happens-before устанавливается несколькими способами:
- Синхронизация: Выход из
synchronizedблока в одном потоке устанавливает happens-before отношение с последующим входом вsynchronizedблок для того же монитора в другом потоке. - Volatile переменные: Запись в
volatileпеременную устанавливает happens-before с последующим чтением этой же переменной. - Запуск и завершение потока: Вызов
Thread.start()для нового потока происходит happens-before любого действия в этом новом потоке. В свою очередь, все действия в потоке происходят happens-before, чемjoin()успешно вернется в другом потоке.
Атомарные операции и блокировки
Для обеспечения потокобезопасности можно использовать блокировки. Но они имеют свою цену: если один поток заблокировал ресурс, другие вынуждены ждать. Это называется пессимистичной блокировкой (Pessimistic Locking). synchronized и ReentrantLock — классические примеры. ReentrantLock более гибкий: он позволяет задавать тайм-ауты для захвата блокировки и может быть прерываемым.
Однако существует и другой подход — оптимистичная блокировка (Optimistic Locking). Идея в том, чтобы выполнять операцию без блокировки, но перед сохранением результата проверить, не изменил ли кто-то данные за это время. Если да — повторить операцию. Этот механизм реализуется через операции CAS.
CAS
other
Compare-And-Swap (Сравни и Замени) — это атомарная инструкция процессора. Она принимает три операнда: адрес в памяти (V), ожидаемое старое значение (A) и новое значение (B). Инструкция атомарно обновляет значение по адресу V на B, только если текущее значение V равно A. В противном случае ничего не происходит. Операция возвращает информацию о том, удалась ли замена.
Классы java.util.concurrent.atomic, такие как AtomicInteger, используют CAS под капотом. Когда вы вызываете incrementAndGet(), внутри происходит цикл: читается текущее значение, вычисляется новое, а затем с помощью CAS делается попытка атомарно заменить старое значение новым. Если другой поток успел изменить значение, CAS-операция не удастся, и цикл начнется заново.
// Псевдокод для incrementAndGet()
public final int incrementAndGet() {
for (;;) { // Бесконечный цикл
int current = get(); // Читаем текущее значение
int next = current + 1; // Вычисляем новое
if (compareAndSet(current, next)) // Пытаемся атомарно заменить
return next; // Успех, выходим
}
}
Этот подход отлично работает при низкой и средней конкуренции, так как потоки не блокируются. Но при высокой конкуренции потоки могут тратить много времени на повторные попытки, что снижает производительность. В таких случаях пессимистичная блокировка может оказаться эффективнее.
Пулы потоков и ForkJoinPool
Создавать новый поток на каждую задачу — дорого. Операционная система тратит ресурсы на выделение памяти и переключение контекста. Гораздо эффективнее использовать пул потоков: набор уже созданных, готовых к работе потоков. ThreadPoolExecutor — это мощный и гибкий инструмент для управления пулом потоков.
Он позволяет настраивать:
corePoolSize: количество потоков, которые остаются в пуле, даже если бездействуют.maximumPoolSize: максимальное количество потоков в пуле.keepAliveTime: время жизни для «лишних» потоков (сверхcorePoolSize).workQueue: очередь для задач, которые ждут своего выполнения.
Отдельно стоит ForkJoinPool. Он создан для задач, которые можно рекурсивно разбить на более мелкие подзадачи. Этот пул использует алгоритм «воровства работы» (work-stealing): если поток в пуле выполнил все свои задачи, он может «украсть» задачу из очереди другого потока. Это позволяет максимально эффективно загрузить все ядра процессора. На ForkJoinPool работают, например, Parallel Streams в Java 8.
Конкурентные коллекции
При выборе коллекции для многопоточной среды нужно учитывать характер нагрузки. ConcurrentHashMap — это высокопроизводительная хэш-таблица. Она разбивает свою внутреннюю структуру на сегменты (или бакеты в современных версиях), и блокировка происходит на уровне сегмента, а не всей карты. Это позволяет множеству потоков одновременно читать и писать в разные части карты.
CopyOnWriteArrayList работает по другому принципу. Любая операция изменения (добавление, удаление) создает полную копию внутреннего массива. Это дорогая операция. Поэтому CopyOnWriteArrayList подходит для сценариев, где количество чтений на порядки превышает количество записей. Например, для хранения списка слушателей (listeners) событий. Чтение происходит очень быстро и без блокировок, так как потоки просто работают с неизменяемой на данный момент копией массива.
Понимание этих механизмов позволяет писать не просто работающий, а эффективный и надежный многопоточный код, правильно выбирая инструменты для конкретной задачи.
