Концепция happens-before в Java: основа надежных многопоточных систем

Пройдите тест, узнайте какой профессии подходите
Сколько вам лет
0%
До 18
От 18 до 24
От 25 до 34
От 35 до 44
От 45 до 49
От 50 до 54
Больше 55

Для кого эта статья:

  • Java-разработчики или программисты, работающие с многопоточными приложениями
  • Архитекторы и инженеры программного обеспечения, занимающиеся проектированием и оптимизацией высоконагруженных систем
  • Студенты и начинающие программисты, желающие углубить свои знания в области многопоточного программирования и модели памяти Java

    Многопоточное программирование в Java — это не просто набор инструментов для параллельного выполнения кода. Это целая философия разработки, требующая глубокого понимания базовых механизмов JVM и модели памяти. "Happens-before" — ключевая концепция, без которой невозможно создавать потокобезопасные приложения. Удивительно, но даже опытные разработчики допускают критические ошибки, не понимая, как JVM упорядочивает операции в многопоточной среде. В условиях растущей сложности full-stack систем это становится непростительной роскошью. Давайте разберемся, почему happens-before — это тот фундамент, без которого ваше приложение рискует обрушиться в самый неподходящий момент. 🚀

Хотите избежать многопоточных ловушек в своих проектах? На Курсе Java-разработки от Skypro вы не только изучите теорию happens-before, но и освоите практические приемы построения высоконагруженных систем без непредсказуемого поведения. Наши эксперты с опытом промышленной разработки научат вас видеть скрытые проблемы параллелизма и решать их элегантно и эффективно.

Концепция happens-before в модели памяти Java

Модель памяти Java (JMM) — это спецификация, определяющая, как потоки взаимодействуют через память. Ключевой элемент этой модели — отношение happens-before, формализующее понятие "видимости" изменений между потоками. В отличие от наивного представления о последовательном выполнении операций, JVM и процессор могут переупорядочивать инструкции для оптимизации производительности, если сохраняется видимый результат в рамках одного потока.

Отношение happens-before гарантирует, что если операция A happens-before операции B, то результаты A видны B, независимо от фактического порядка выполнения на аппаратном уровне. Это абстракция, позволяющая разработчикам не погружаться в детали работы кэшей процессора и оптимизаций компилятора.

Официальное определение из JLS (Java Language Specification) звучит так: если действие A happens-before действия B, то эффекты A видны B, а A выполняется раньше B. Важно понимать, что happens-before — это отношение порядка между действиями в разных потоках, а не физическое время выполнения.

Концепция Определение Значение для разработчика
Happens-before Отношение порядка между действиями в программе Позволяет рассуждать о видимости изменений между потоками
Переупорядочивание Изменение порядка операций JVM или процессором Может нарушить ожидаемую логику без happens-before
Видимость Доступность изменений для других потоков Критична для потокобезопасности

Базовые правила happens-before включают:

  • Правило программного порядка: если A и B — действия в одном потоке, и A появляется в программе раньше B, то A happens-before B
  • Правило монитора: разблокировка монитора happens-before последующей блокировки того же монитора
  • Правило volatile: запись в volatile-поле happens-before последующего чтения этого поля
  • Правило запуска потока: вызов метода start() потока happens-before любым действиям этого потока
  • Правило завершения потока: любые действия потока happens-before обнаружению завершения потока другими потоками (через Thread.join() или Thread.isAlive())
  • Правило транзитивности: если A happens-before B и B happens-before C, то A happens-before C

Понимание этих правил позволяет создавать корректные многопоточные программы без гонок данных и с предсказуемым поведением. 🧩

Александр Петров, Lead Java-разработчик

Помню проект высоконагруженной платежной системы, где столкнулись с мистическими сбоями при обработке транзакций. Код выглядел безупречно — мы использовали все современные инструменты: AtomicReference, ConcurrentHashMap, CompletableFuture. Однако под нагрузкой система периодически выдавала неверные расчеты.

После недели отладки обнаружили проблему: один из сервисов использовал обычную переменную для хранения критического состояния, доступную из нескольких потоков. Разработчик полагал, что раз операции происходят в разных методах, то они выполнятся последовательно.

Истина оказалась куда сложнее — без proper happens-before отношения JVM свободно переупорядочивала операции. Замена на AtomicReference с правильными барьерами памяти решила проблему. Этот случай стал для нас ценным уроком — happens-before это не теоретическая концепция, а реальная основа стабильности многопоточных систем.

Пошаговый план для смены профессии

Механизмы обеспечения happens-before в многопоточной среде

Java предоставляет несколько механизмов для установления отношений happens-before между операциями в разных потоках. Каждый механизм имеет свои особенности, преимущества и недостатки, которые важно учитывать при проектировании многопоточных приложений.

1. Volatile-переменные

Ключевое слово volatile — самый легковесный способ обеспечить happens-before. Оно гарантирует, что запись в volatile-переменную happens-before любого последующего чтения этой переменной из любого потока.

Java
Скопировать код
// Пример использования volatile для сигнализации между потоками
public class SharedResource {
private volatile boolean ready = false;
private int data = 0;

public void producer() {
data = 42; // Запись в обычное поле
ready = true; // Запись в volatile создаёт happens-before
}

public void consumer() {
while (!ready) {
// Ожидаем, пока producer не установит флаг
}
// Благодаря happens-before, значение data = 42 теперь видно этому потоку
assert data == 42;
}
}

Важно понимать, что volatile гарантирует только видимость, но не атомарность составных операций.

2. Синхронизированные блоки и методы

Механизм synchronized устанавливает happens-before между последовательными захватами монитора. Когда один поток выходит из синхронизированного блока, а другой потом входит в синхронизированный блок по тому же монитору, возникает отношение happens-before.

Java
Скопировать код
public class Counter {
private int count = 0;

public synchronized void increment() {
count++;
}

public synchronized int getCount() {
return count;
}
}

Здесь выход из метода increment() happens-before входу в метод getCount(), если они вызываются последовательно, даже из разных потоков.

3. Lock из пакета java.util.concurrent.locks

Явные блокировки, такие как ReentrantLock, также устанавливают отношения happens-before:

Java
Скопировать код
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;

public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}

Освобождение блокировки happens-before последующему получению той же блокировки.

4. Классы из пакета java.util.concurrent

Многие классы из пакета concurrent, такие как ConcurrentHashMap, AtomicInteger, BlockingQueue, имеют встроенные гарантии happens-before для своих операций.

Java
Скопировать код
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
private final AtomicInteger count = new AtomicInteger(0);

public void increment() {
count.incrementAndGet();
}

public int getCount() {
return count.get();
}
}

5. ThreadLocal переменные

Хотя ThreadLocal обычно используется для изоляции данных между потоками, следует понимать, что инициализация ThreadLocal happens-before любому доступу к этой переменной из того же потока.

Механизм Преимущества Недостатки Когда использовать
volatile Легковесный, без блокировок Только видимость, не атомарность Для сигнальных флагов, счетчиков завершения
synchronized Простота использования, гарантия атомарности Потенциальные проблемы производительности Для базовой синхронизации, простых случаев
Lock API Гибкость, управление тайм-аутами Более сложное использование Для сложных сценариев синхронизации
Concurrent классы Оптимизированная реализация Ограничены своей функциональностью Для стандартных структур данных

Выбор правильного механизма обеспечения happens-before критичен для баланса между корректностью и производительностью вашего приложения. 🔒

Практическое применение happens-before в Java-приложениях

Теоретическое понимание happens-before необходимо, но недостаточно. Практическое применение этой концепции в реальных Java-приложениях требует стратегического мышления и знания типичных паттернов многопоточного программирования.

Шаблон инициализации с двойной проверкой (DCLP)

Классический пример применения happens-before — реализация ленивой инициализации объекта с использованием Singleton-паттерна. Вот корректная реализация с учетом happens-before:

Java
Скопировать код
public class Singleton {
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) { // Первая проверка (не синхронизирована)
synchronized (Singleton.class) {
if (instance == null) { // Вторая проверка (синхронизирована)
instance = new Singleton();
}
}
}
return instance;
}
}

Ключевое слово volatile здесь критично — оно обеспечивает, что полностью инициализированный объект будет виден всем потокам после его создания. Без volatile другие потоки могут увидеть частично инициализированный объект из-за переупорядочивания операций.

Неблокирующие алгоритмы

В высоконагруженных системах блокировки могут стать узким местом. Неблокирующие алгоритмы используют атомарные операции и volatile-переменные для обеспечения корректности без блокировок:

Java
Скопировать код
public class NonBlockingCounter {
private volatile int value;

public int getValue() {
return value;
}

public boolean compareAndSet(int expected, int newValue) {
// Предполагаем использование sun.misc.Unsafe для атомарных операций
// (в реальном коде лучше использовать AtomicInteger)
if (value == expected) {
value = newValue;
return true;
}
return false;
}
}

Публикация объектов

Безопасная публикация объектов — ключевая задача в многопоточных приложениях. Happens-before гарантирует, что правильно опубликованный объект будет полностью инициализирован для всех потоков:

Java
Скопировать код
public class SafePublisher {
// Использование final для безопасной публикации
private final List<String> safeList;

public SafePublisher() {
List<String> list = new ArrayList<>();
list.add("Item 1");
list.add("Item 2");
this.safeList = Collections.unmodifiableList(list);
}

public List<String> getSafeList() {
return safeList;
}
}

Ключевые практики безопасной публикации:

  • Инициализация в конструкторе с использованием final-полей
  • Публикация через volatile-переменные
  • Публикация через synchronized-блоки
  • Использование потокобезопасных коллекций из пакета java.util.concurrent
  • Инициализация через статические инициализаторы

Событийно-ориентированное программирование

В событийно-ориентированных системах, включая серверные части full-stack приложений, happens-before обеспечивает корректную обработку событий:

Java
Скопировать код
public class EventBus {
private final ConcurrentHashMap<String, List<EventListener>> listeners = new ConcurrentHashMap<>();

public void register(String eventType, EventListener listener) {
listeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
.add(listener);
}

public void post(String eventType, Event event) {
List<EventListener> eventListeners = listeners.get(eventType);
if (eventListeners != null) {
for (EventListener listener : eventListeners) {
listener.onEvent(event);
}
}
}
}

Использование потокобезопасных коллекций, таких как ConcurrentHashMap и CopyOnWriteArrayList, обеспечивает необходимые гарантии happens-before без явного кодирования отношений.

Акторные системы

В акторных системах, таких как Akka, happens-before гарантируется правилом "отправка сообщения happens-before получения этого сообщения". Это позволяет реализовать сложную многопоточную логику без явных блокировок:

Java
Скопировать код
// Псевдокод акторной системы
public class OrderProcessor extends Actor {
private final Map<String, Order> orders = new HashMap<>();

@Override
public void receive(Object message) {
if (message instanceof CreateOrder) {
CreateOrder create = (CreateOrder) message;
orders.put(create.orderId, new Order(create.orderId));
sender().tell(new OrderCreated(create.orderId), self());
} else if (message instanceof UpdateOrder) {
// Обработка обновления заказа
}
}
}

В этой модели акторы обрабатывают сообщения последовательно, а happens-before гарантирует, что изменения, сделанные при обработке одного сообщения, видны при обработке последующих сообщений. 📨

Михаил Соколов, Архитектор высоконагруженных систем

В 2019 году нам довелось переписывать ядро платформы для обработки финансовых транзакций. Существующий код использовал традиционный подход с блокировками и синхронизацией, что приводило к заметным задержкам под нагрузкой.

Мы решили применить архитектуру LMAX Disruptor — высокопроизводительный механизм передачи событий между потоками с минимальными блокировками. Вся магия построена на тонком понимании happens-before и барьерах памяти.

Кольцевой буфер Disruptor использует volatile-записи указателей для сигнализации между производителями и потребителями данных. Казалось бы — просто volatile-переменные, но грамотно выстроенные отношения happens-before позволили нам добиться пропускной способности в 30 миллионов операций в секунду на обычном серверном железе.

Самым сложным было объяснить команде, почему это работает. Мы даже создали внутренний воркшоп по модели памяти Java, чтобы разработчики понимали, что такое happens-before не на интуитивном уровне, а как формальное математическое отношение. Это инвестиция окупилась сторицей — теперь команда пишет эффективный многопоточный код без мистицизма и "магических" решений.

Happens-before и производительность full-stack систем

Корректная реализация happens-before отношений напрямую влияет на производительность full-stack Java-приложений. Избыточная синхронизация создаёт узкие места, а недостаточная — приводит к недетерминированному поведению. Найти золотую середину — ключевая задача архитектора.

Влияние выбора механизма happens-before на производительность

Каждый механизм обеспечения happens-before имеет свою стоимость в терминах производительности:

Механизм Влияние на производительность Применимость в high-load системах
Volatile Умеренное — запрещает определенные оптимизации, создаёт барьеры памяти Высокая — для некритических путей и сигнальных переменных
Synchronized Высокое — требует получения/освобождения монитора, может вызывать контекстные переключения Средняя — для простой синхронизации с низкой конкуренцией
Explicit Locks Умеренное-высокое — более гибкий, но всё ещё затратный механизм Средняя-высокая — когда требуется тайм-аут или условная логика
CAS-операции Низкое — аппаратная поддержка, минимальная синхронизация Очень высокая — основа для неблокирующих алгоритмов
Immutable objects Минимальное — не требуют синхронизации Очень высокая — идеальны для разделяемого состояния

Оптимизация hot-path в сервисном слое

В full-stack приложениях сервисный слой часто становится узким местом. Оптимизация критических путей выполнения (hot-path) с учетом happens-before может значительно улучшить производительность:

Java
Скопировать код
public class OptimizedProductService {
private final ConcurrentHashMap<Long, Product> productCache = new ConcurrentHashMap<>();
private final AtomicLong cacheHits = new AtomicLong(0);

public Product getProduct(long productId) {
// Быстрый путь (hot-path) – без блокировок
Product product = productCache.get(productId);
if (product != null) {
cacheHits.incrementAndGet();
return product;
}

// Медленный путь (cold-path) – с синхронизацией
synchronized (this) {
// Повторная проверка в случае, если другой поток уже загрузил продукт
product = productCache.get(productId);
if (product != null) {
return product;
}

// Загрузка из базы данных (медленная операция)
product = loadProductFromDatabase(productId);
productCache.put(productId, product);
return product;
}
}
}

В этом примере мы используем паттерн "check-then-act" с оптимизацией для быстрого пути, когда продукт уже в кэше, и полной синхронизацией только для медленного пути загрузки из базы данных.

Масштабирование серверной части full-stack приложений

При горизонтальном масштабировании серверной части важно учитывать, как happens-before влияет на распределение нагрузки:

  • Stateless-сервисы — легко масштабируются, т.к. не требуют сложной синхронизации между экземплярами
  • Stateful-сервисы — требуют тщательного проектирования для обеспечения консистентности между узлами
  • Шардирование данных — позволяет локализовать синхронизацию в рамках одного шарда
  • Event sourcing — устраняет проблему конкурентных обновлений, переводя систему в модель "только добавление"

Например, для шардированной архитектуры микросервисов можно использовать стратегию "local-first", минимизируя кросс-сервисные транзакции:

Java
Скопировать код
@Service
public class ShardedOrderService {
private final Map<Integer, OrderRepository> shardedRepositories;

public Order createOrder(CreateOrderRequest request) {
int shardId = calculateShardId(request.getUserId());
OrderRepository repository = shardedRepositories.get(shardId);

// Локальная транзакция в рамках одного шарда
return repository.runInTransaction(() -> {
Order order = new Order(request);
repository.save(order);
publishOrderCreatedEvent(order); // Асинхронное оповещение других сервисов
return order;
});
}
}

Мониторинг и профилирование конкурентных проблем

Понимание happens-before критично для диагностики проблем производительности в многопоточных системах. Ключевые метрики для мониторинга:

  • Время блокировки потоков (lock contention)
  • Количество context switches
  • Частота CAS-операций и их успешность
  • Пул потоков и его утилизация
  • Время ожидания в очередях

Инструменты, такие как JFR (Java Flight Recorder), async-profiler и JMH (Java Microbenchmark Harness), позволяют выявить проблемы, связанные с неправильной реализацией happens-before отношений.

Оптимизация фронтенд-бэкенд взаимодействия

В контексте full-stack разработки важно понимать, как happens-before влияет на взаимодействие между фронтендом и бэкендом:

  • Eventual consistency — модель, в которой фронтенд может временно отображать несогласованные данные, но в конечном итоге система приходит к согласованному состоянию
  • Оптимистичные UI-обновления — предполагают изменение UI до получения подтверждения от бэкенда
  • WebSocket и Server-Sent Events — требуют внимания к порядку сообщений и их видимости для клиентов

Например, при реализации real-time обновлений через WebSocket важно обеспечить, чтобы изменения, внесенные через REST API, были видны для WebSocket-подписчиков:

Java
Скопировать код
@RestController
public class OrderController {
private final OrderService orderService;
private final WebSocketMessagingService messagingService;

@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.createOrder(request);

// Гарантируем, что сообщение о создании заказа будет отправлено
// только после сохранения заказа в базе данных
messagingService.sendToTopic("orders", new OrderCreatedEvent(order));

return ResponseEntity.ok(order);
}
}

Здесь последовательность операций гарантирует, что WebSocket-клиенты не получат уведомление раньше, чем заказ будет создан, что обеспечивает консистентность между REST и WebSocket интерфейсами. ⚡

Распространённые ошибки и их решения с учетом happens-before

Даже опытные Java-разработчики регулярно допускают ошибки, связанные с непониманием или неправильным применением концепции happens-before. Рассмотрим наиболее коварные антипаттерны и способы их исправления.

Ошибка #1: Неправильная реализация Double-Checked Locking

Классический пример — ошибочная реализация Singleton с двойной проверкой без использования volatile:

Java
Скопировать код
// НЕПРАВИЛЬНО!
public class BrokenSingleton {
private static BrokenSingleton instance;

private BrokenSingleton() {}

public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton(); // Проблема!
}
}
}
return instance;
}
}

Проблема: Из-за возможного переупорядочивания инструкций компилятором или JVM, поток может увидеть не полностью инициализированный объект.

Решение: Объявить instance как volatile, чтобы обеспечить happens-before между инициализацией объекта и присваиванием ссылки переменной:

Java
Скопировать код
// ПРАВИЛЬНО
public class CorrectSingleton {
private static volatile CorrectSingleton instance;

private CorrectSingleton() {}

public static CorrectSingleton getInstance() {
if (instance == null) {
synchronized (CorrectSingleton.class) {
if (instance == null) {
instance = new CorrectSingleton();
}
}
}
return instance;
}
}

Ошибка #2: Неправильная публикация объектов

Публикация не полностью сконструированных объектов может привести к недетерминированному поведению:

Java
Скопировать код
// НЕПРАВИЛЬНО!
public class UnsafePublisher {
private List<String> list;

public UnsafePublisher() {
// Возможно другие потоки получат доступ к частично инициализированному списку
list = new ArrayList<>();
list.add("Item 1");
list.add("Item 2");
}

public List<String> getList() {
return list; // Возвращает изменяемую ссылку
}
}

Проблема: Объект может быть виден другим потокам до завершения его инициализации, а возвращаемая коллекция позволяет потокам изменять её без синхронизации.

Решение: Использовать final-поля и неизменяемые коллекции для безопасной публикации:

Java
Скопировать код
// ПРАВИЛЬНО
public class SafePublisher {
private final List<String> list;

public SafePublisher() {
ArrayList<String> tempList = new ArrayList<>();
tempList.add("Item 1");
tempList.add("Item 2");
this.list = Collections.unmodifiableList(tempList);
}

public List<String> getList() {
return list; // Возвращает неизменяемую коллекцию
}
}

Ошибка #3: Недооценка volatile при lazy-инициализации

Распространенная ошибка при ленивой инициализации без синхронизации:

Java
Скопировать код
// НЕПРАВИЛЬНО!
public class LazyHolder {
private ExpensiveObject instance;

public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject(); // Проблема!
}
return instance;
}
}

Проблема: Без синхронизации или volatile, другие потоки могут увидеть частично инициализированный объект или вообще не увидеть изменение.

Решение: Использовать holder class idiom для ленивой инициализации без volatile и синхронизации:

Java
Скопировать код
// ПРАВИЛЬНО
public class LazyHolder {
private static class Holder {
static final ExpensiveObject INSTANCE = new ExpensiveObject();
}

public static ExpensiveObject getInstance() {
return Holder.INSTANCE; // Класс Holder загружается только при первом обращении
}
}

Этот паттерн использует гарантии JVM по инициализации классов, которые включают happens-before между инициализацией класса и любым последующим доступом к его статическим полям.

Ошибка #4: Иллюзия видимости в циклах ожидания

Распространенная ошибка — предположение, что изменение переменной в одном потоке немедленно видно в другом:

Java
Скопировать код
// НЕПРАВИЛЬНО!
public class BrokenSpinLock {
private boolean locked = false;

public void lock() {
while (locked) {
// Ждем, пока флаг не станет false
}
locked = true; // Проблема!
}

public void unlock() {
locked = false;
}
}

Проблема: Без proper happens-before, изменения переменной locked могут быть не видны другим потокам, что приведет к бесконечному циклу или нарушению взаимного исключения.

Решение: Использовать volatile или атомарные классы:

Java
Скопировать код
// ПРАВИЛЬНО
public class CorrectSpinLock {
private AtomicBoolean locked = new AtomicBoolean(false);

public void lock() {
while (!locked.compareAndSet(false, true)) {
// Ждем, используя атомарную операцию CAS
}
}

public void unlock() {
locked.set(false);
}
}

Ошибка #5: Игнорирование happens-before в асинхронном коде

Современный Java-код часто использует CompletableFuture и асинхронные callbacks, где неправильное понимание happens-before может привести к проблемам:

Java
Скопировать код
// НЕПРАВИЛЬНО!
public class AsyncProcessor {
private Map<String, Object> results = new HashMap<>();

public CompletableFuture<Void> processAsync(String id, Object data) {
return CompletableFuture.runAsync(() -> {
Object result = expensiveComputation(data);
results.put(id, result); // Проблема! Не синхронизированный доступ
});
}

public Object getResult(String id) {
return results.get(id); // Может вернуть null или устаревшее значение
}
}

Проблема: HashMap не потокобезопасен, и без proper happens-before между записью и чтением, результаты могут быть не видны или повреждены.

Решение: Использовать потокобезопасные коллекции и правильно обрабатывать асинхронные результаты:

Java
Скопировать код
// ПРАВИЛЬНО
public class SafeAsyncProcessor {
private ConcurrentHashMap<String, Object> results = new ConcurrentHashMap<>();

public CompletableFuture<Object> processAsync(String id, Object data) {
return CompletableFuture.supplyAsync(() -> {
Object result = expensiveComputation(data);
results.put(id, result);
return result;
});
}

public CompletableFuture<Object> getResultAsync(String id) {
Object result = results.get(id);
if (result != null) {
return CompletableFuture.completedFuture(result);
}
// Если результат еще не готов, можно ждать или вернуть ошибку
return CompletableFuture.supplyAsync(() -> {
// Логика ожидания результата или альтернативного действия
});
}
}

Помните, что осведомленность о happens-before является ключевым навыком для написания надежного многопоточного кода. Вместо того, чтобы полагаться на интуитивное понимание, всегда следуйте формальным правилам и используйте проверенные паттерны для обеспечения правильной синхронизации. 🛡️

Концепция happens-before — это не просто теоретическая абстракция, а фундаментальный инструмент для создания корректных и эффективных многопоточных приложений. Овладение этой концепцией позволяет разработчикам переходить от интуитивного программирования с избыточной синхронизацией к точному инструментальному подходу, где каждый барьер памяти имеет свою цель. Следуя правилам happens-before при проектировании full-stack приложений, вы обеспечиваете не только корректность, но и оптимальную производительность — баланс, который определяет успех современных высоконагруженных систем.

Читайте также

Проверь как ты усвоил материалы статьи
Пройди тест и узнай насколько ты лучше других читателей
Что такое концепция 'happens before' в Java?
1 / 5

Загрузка...