1. Что такое общий ресурс
С общими ресурсами вы уже сталкивались. Дом, в котором живёт семья, — общий ресурс этой семьи. Офисный холодильник — общий ресурс для всех коллег. Думаю, идею вы поняли.
В программировании общий ресурс — это переменная, объект или структура данных, к которым одновременно могут обращаться несколько потоков. Это может быть:
- Переменная-счётчик количества обработанных заказов.
- Список заявок, который заполняется одними потоками и обрабатывается другими.
- Открытый файл, в который пишут несколько потоков.
- Подключение к базе данных, с которым работают разные части программы.
В Java любой объект или переменная, к которым могут обратиться несколько потоков, становится потенциально «общим ресурсом».
Пример общего ресурса: глобальный счётчик
public class Counter {
public int value = 0;
}
Если несколько потоков будут увеличивать этот счётчик, они будут обращаться к одной и той же переменной value — вот вам и общий ресурс.
2. Проблемы одновременного доступа
В однопоточной программе всё просто: один поток — один исполнитель — он идёт по коду, как поезд по рельсам. Но как только в дело вступают несколько потоков, начинается настоящий «танец с саблями»: потоки могут вмешиваться в работу друг в друга в самых неожиданных местах.
Race Condition (состояние гонки)
Race condition — это ситуация, когда результат работы программы зависит от того, как именно «перемешались» действия потоков. То есть, если вы несколько раз запустите одну и ту же программу, результат может быть разным — и это не баг, это «фича» многопоточности.
Классический пример: два потока увеличивают счётчик
Давайте попробуем смоделировать простую ситуацию: у нас есть общий счётчик, и два потока по тысяче раз увеличивают его значение.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // ОПАСНОЕ МЕСТО!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Ожидаемое значение: 2000");
System.out.println("Реальное значение: " + counter.value);
}
}
Что увидим на экране?
Иногда — 2000, иногда — 1985, иногда — 1937... Почему? Потому что операция counter.value++ не атомарна! Она состоит из трёх шагов:
- Прочитать текущее значение counter.value.
- Увеличить его на 1.
- Записать обратно.
Если два потока одновременно прочитают одно и то же значение, оба увеличат его, а затем оба запишут результат — в итоге одно увеличение «потеряется». Это и есть lost update — потеря обновления.
Неконсистентное состояние объекта
Если у вас есть сложный объект, состоящий из нескольких полей, и потоки одновременно меняют разные поля, объект может оказаться в «странном» или неконсистентном состоянии. Например, сумма на счёте уменьшилась, но история операций не обновилась — клиент в панике, бухгалтер в шоке.
3. Зачем нужна синхронизация
Синхронизация — это способ сказать программе: «Стоп, этот кусок кода должен выполняться только одним потоком одновременно! Остальные — подождут!» Это как табличка «Уборка! Не входить!» на двери туалета: пока один человек внутри, остальные ждут снаружи (и мысленно проклинают того, кто долго не выходит).
Гарантия целостности данных
Если мы хотим, чтобы наш счётчик всегда увеличивался правильно, нам нужно запретить одновременное изменение его значения несколькими потоками.
Пример: синхронизация увеличения счётчика
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Теперь, если два потока вызовут increment(), только один из них сможет выполнить этот метод в данный момент времени. Второй будет ждать, пока первый не закончит.
Схема: что происходит при синхронизации
+-------------------+
| Поток 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Поток 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Поток 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Поток N |/
+-------------------+
Все потоки встают в очередь на выполнение защищённого участка кода. Только один поток может быть внутри «критической секции» ( synchronized-блока) одновременно.
4. Краткое введение в способы синхронизации
Синхронизация в Java — это не какой-то один способ, а целый «арсенал» инструментов, которые позволяют защитить общий ресурс от одновременного доступа.
Ключевое слово synchronized
Это основной инструмент синхронизации в Java. Его можно использовать двумя способами:
Синхронизированный метод
public synchronized void increment() {
value++;
}
Синхронизированный блок
public void increment() {
synchronized (this) {
value++;
}
}
Здесь this — объект, на котором происходит блокировка. Пока один поток выполняет этот блок, другие потоки, желающие войти в такой же блок с тем же объектом, будут ждать.
Специализированные классы из java.util.concurrent
- Lock, ReentrantLock — более гибкая альтернатива synchronized.
- ReadWriteLock — для разделения блокировок на чтение и запись.
- Semaphore — ограничение количества потоков, одновременно выполняющих код.
- CountDownLatch, CyclicBarrier и другие — для координации работы потоков.
Важно: Сегодня мы только знакомимся с основами — про эти классы поговорим чуть позже.
5. Практический пример: приложение с многопоточным счётчиком
Предположим, мы пишем статистику обращений пользователей к какому-то сервису. Каждый поток — это отдельный пользователь, который увеличивает общий счётчик.
Без синхронизации
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Ожидаемое значение: 30000");
System.out.println("Реальное значение: " + counter.value);
}
}
Результат: Почти всегда меньше 30000. Иногда — намного меньше! Почему? Потому что потоки «перебивают» друг друга.
Синхронизация: исправляем ошибку
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Ожидаемое значение: 30000");
System.out.println("Реальное значение: " + counter.value);
}
}
Результат: Всегда 30000. Ура, синхронизация работает!
6. Полезные нюансы
Визуализация: как выглядит race condition
Давайте нарисуем небольшую таблицу, чтобы показать, как два потока могут «потерять» инкремент:
| Шаг | Поток 1 | Поток 2 | Значение value |
|---|---|---|---|
| 1 | Читает value=0 | 0 | |
| 2 | Читает value=0 | 0 | |
| 3 | Увеличивает до 1 | 0 | |
| 4 | Увеличивает до 1 | 0 | |
| 5 | Записывает 1 | 1 | |
| 6 | Записывает 1 | 1 |
Когда нужна синхронизация
Синхронизация нужна не всегда. Когда переменная живёт в своём маленьком мире и с ней работает только один поток — можно расслабиться. Но стоит только поделиться ею с другими потоками, как без синхронизации уже никуда. Даже если кажется, что всё обойдётся — не верь. Ошибка гонки коварна: она может долго прятаться, а потом внезапно выстрелить в самый неподходящий момент.
Задел на будущее: какие ещё бывают средства синхронизации
Сегодня мы познакомились только с базовым инструментом — synchronized. В следующих лекциях мы рассмотрим:
- Как работает монитор объекта и какие бывают типы блокировок.
- Что такое статические синхронизированные методы (static + synchronized).
- Как работает ключевое слово volatile и зачем оно нужно.
- Какие бывают современные классы для синхронизации (Lock, Semaphore и др.).
7. Типичные ошибки при работе с общими ресурсами
Ошибка №1: Игнорирование многопоточности.
Одна из самых распространённых ошибок — не задумываться о том, что переменная может быть доступна из нескольких потоков. Даже если сейчас программа однопоточная, позже кто-то добавит потоки — и баги появятся «из ниоткуда».
Ошибка №2: Недостаточная или избыточная синхронизация.
Если не синхронизировать доступ к общему ресурсу — получите race condition и неконсистентные данные. Если же синхронизировать всё подряд, программа «задохнётся» от блокировок и станет медленной. Всегда старайтесь синхронизировать только то, что действительно нужно.
Ошибка №3: Синхронизация на неправильном объекте.
Если синхронизировать доступ на разных объектах (например, на локальных переменных или строковых литералах), это не защитит общий ресурс. Все потоки должны синхронизироваться на одном и том же объекте.
Ошибка №4: Ожидание атомарности от неатомарных операций.
Операция i++ не атомарна! Даже если переменная объявлена как volatile, это не делает инкремент атомарным. Для таких операций нужна синхронизация.
Ошибка №5: «Мне повезло, у меня всё работает».
Race condition может не проявляться на вашем компьютере, но обязательно проявится на сервере или у пользователя. Никогда не полагайтесь на «авось» в многопоточных программах!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ