Многопоточность — одна из самых сложных тем в Java. По сути это ситуация, когда несколько потоков работают с общими данными и в процессе работы легко столкнуться с состоянием гонки (race condition), потерей обновлений или даже повреждением структуры данных. Чтобы этого избежать, доступ к критической секции кода нужно ограничить: одновременно там должен находиться только один поток.
В Java есть два основных механизма для решения этой задачи: встроенное ключевое слово synchronized и явные блокировки из пакета java.util.concurrent.locks, наиболее популярная из которых — ReentrantLock. Оба подхода обеспечивают взаимное исключение (mutex), но работают по-разному и подходят для разных сценариев.
В этой статье разберем, почему обычная переменная не подходит для многопоточного счета, как работают synchronized и ReentrantLock, и поможем выбрать правильный инструмент для вашей задачи.
Почему не «просто переменная»?
Представим задачу: два потока должны увеличить общую переменную count на 100 000 каждый. Ожидаемый результат — 200 000. Напишем код без какой-либо синхронизации:
public class RaceConditionExample { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) count++; }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) count++; }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Result: " + count); } }
Запустив этот код несколько раз, вы заметите, что результат каждый раз разный и почти всегда меньше 200 000. Но почему так? Дело в том, что операция count++ выглядит атомарной только на уровне исходного кода. На уровне процессора она состоит из трех шагов (read–modify–write):
Read: Прочитать текущее значение
countиз памяти.Modify: Увеличить значение на 1.
Write: Записать новое значение обратно.
Если два потока выполняют эти шаги одновременно, они могут прочитать одно и то же старое значение, увеличить его и записать одинаковый результат. Одно из обновлений теряется. Чтобы исправить это, нужно гарантировать, что вся операция инкремента выполняется неделимо (атомарно) для одного потока.
synchronized
Первый и самый известный способ синхронизации в Java — ключевое слово synchronized. Оно работает на основе монитора объекта (intrinsic lock). Каждый объект в Java имеет ассоциированный с ним монитор. Поток должен захватить этот монитор, чтобы войти в синхронизированный блок.
Есть несколько форм синхронизации:
синхронизация блока (
synchronized(lockObject) { ... })синхронизация метода (
public synchronized void method())синхронизация статического метода (
public static synchronized void method())
Используем общий объект-лок для синхронизации:
public class SynchronizedExample { private static int count = 0; private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) { synchronized (lock) { count++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) { synchronized (lock) { count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Result: " + count); // Всегда 200000 } }
Теперь у нас гарантированный результат 200 000.
У synchronized есть несколько важных свойств:
Во-первых, автоматическое освобождение. Вам не нужно явно освобождать lock. Он освобождается автоматически, когда поток выходит из синхронизированного блока (даже если выброшено исключение).
Во-вторых, повторная входимость. Если поток уже владеет монитором, он может войти в другой синхронизированный блок на том же объекте без блокировки
Lock (ReentrantLock)
Теперь поговорим о другом способе синхронизации. Начиная с Java 5, в пакете java.util.concurrent.locks появился интерфейс Lock. Его стандартная реализация — ReentrantLock. В отличие от synchronized, это не ключевое слово, а обычный класс API. Ключевое здесь, что блокировка захватывается и освобождается вручную. Это дает нам больше гибкости, но требует большей внимательности.
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockExample { private static int count = 0; private static final Lock lock = new ReentrantLock(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) { lock.lock(); try { count++; } finally { lock.unlock(); } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) { lock.lock(); try { count++; } finally { lock.unlock(); } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Result: " + count); } }
Обратите внимание на unlock() в finally. Если между lock() и unlock() возникнет исключение, и вы не поместите освобождение в блок finally, то другие потоки, пытающиеся захватить этот lock, зависнут навсегда (deadlock).
Что выбрать?
synchronized это более простой инструмент. Он проще, меньше кода, меньше шансов забыть unlock(). В современных версиях JVM производительность synchronized сильно оптимизирована и часто сопоставима с ReentrantLock.
ReentrantLock стоит использовать, когда нужны расширенные функции. Например, через него можно прервать ожидание входа (через lockInterruptibly()) , можно попробовать осуществить попытку захвата (tryLock()) , выставить таймауты (tryLock(time, unit)) и можно гарантировать порядок (FIFO).
Одна из главных фишек ReentrantLock — возможность не блокировать поток, если ресурс занят. Этого нельзя сделать с synchronized.
if (lock.tryLock()) { try { // Критическая секция doSomething(); } finally { lock.unlock(); } } else { // Lock занят, делаем что-то другое или пропускаем log.warn("Resource busy, skipping task"); }
Заключение
Оба механизма synchronized и ReentrantLock решают задачу взаимного исключения. synchronized проще в использовании и безопаснее благодаря автоматическому управлению блокировкой. ReentrantLock гибче и предоставляет расширенный контроль над процессом синхронизации.
Выбирайте synchronized, если вам нужна простая защита данных. Переключайтесь на ReentrantLock, только если вам действительно нужны его специфические возможности, такие как tryLock, таймауты или несколько условий ожидания.
