3 вопроса на собеседование о многопоточности в Java

от автора

Привет, Хабр!

Сегодня рассмотрим несколько вопросов на собеседовании, которые могут встретиться: чем synchronized отличается от ReentrantLock, что такое happens‑before и как оно влияет на volatile и final и почему ConcurrentHashMap.computeIfAbsent() не всегда безопасен?

Чем synchronized отличается от ReentrantLock?

Вопрос вроде бы базовый, но только на поверхности.

synchronized — это синтаксический сахар для захвата монитора объекта. Написал метод с этим словом — и всё, JVM сама всё делает: захватывает, ждёт, освобождает. Просто, надёжно:

public class SyncCounter {     private int count = 0;      public synchronized void increment() {         count++;     }      public synchronized int getCount() {         return count;     } }

Но стоит тебе захотеть больше гибкости — всё, нужен ReentrantLock:

import java.util.concurrent.locks.ReentrantLock;  public class LockCounter {     private int count = 0;     private final ReentrantLock lock = new ReentrantLock();      public void increment() {         lock.lock();         try {             count++;         } finally {             lock.unlock();         }     }      public int getCount() {         lock.lock();         try {             return count;         } finally {             lock.unlock();         }     } }

В чем разница?

  • ReentrantLock даёт контроль. Хочешь попробовать захватить замок без ожидания — tryLock(). Надо прервать поток при ожидании — lockInterruptibly(). Понадобилось условие ожидания — Condition await()/signal(). С synchronized это недоступно.

  • У ReentrantLock можно вручную управлять порядком захвата нескольких замков (и, соответственно, избегать дедлоков). В synchronized всё — как повезёт.

Не забываем, что lock() и unlock() нужно всегда оборачивать в try‑finally. Потеряешь unlock() — здравствуй, дедлок.

Что такое happens-before и как это влияет на volatile и final?

Что такое happens‑before? Это правило, которое гарантирует: если одна операция happens‑before другой, то все эффекты первой будут видны второй. То есть не просто выполнено раньше, а видно в памяти. В многопоточности каждый поток может жить в своей версии реальности, с кешами, reorder‑оптимизациями и прочими сюрпризами.

Volatile — простая гарантия видимости:

public class VolatileFlag {     private volatile boolean flag = false;      public void writer() {         flag = true;     }      public void reader() {         if (flag) {             System.out.println("Флаг сработал!");         }     }

Когда переменная volatile, запись в неё happens‑before любому последующему чтению. То есть гарантируется не только видимость, но и что всё, что было до записи, станет видно второму потоку.

Final — публикация объекта без лишнего

Если правильно публикуем объект (а именно: не даёшь this утечь из конструктора), то финальные поля будут видны корректно:

public class ImmutableThing {     private final int value;      public ImmutableThing(int v) {         this.value = v;     }      public int getValue() {         return value;     } }

Если объект создаётся, и потом ссылка на него попадает в другой поток — поля final внутри него будут корректны. Но только если конструктор не вызывает start(), не кладёт this в статику и т. д.

Без happens‑before гарантии можно увидеть частично инициализированный объект. Классика: null вместо List, 0 вместо значения, и ночной кошмар дебага.

Почему ConcurrentHashMap.computeIfAbsent() не всегда потокобезопасен?

На бумаге метод computeIfAbsent хорош: атомарно добавляет значение, если ключа ещё нет.

ConcurrentHashMap<String, ExpensiveObject> cache = new ConcurrentHashMap<>();  public ExpensiveObject get(String key) {     return cache.computeIfAbsent(key, k -> new ExpensiveObject(k)); }

Где подвох?

  1. Функция вызывается более одного раза — если параллельные потоки дерутся за один и тот же ключ, они все могут вызвать лямбду, но только один результат пойдёт в карту. А если в этой лямбде побочные эффекты? Например, ты пишешь в БД? Будут дубликаты.

  2. Функция может вернуть null — и тогда ничего не вставится. Хуже того: метод снова будет вызывать функцию при следующем обращении. Ты думаешь, что один раз посчитал — а оно каждый раз.

  3. Блокировки и производительность — если твоя функция тяжёлая (например, ходит в сеть), ты рискуешь заблокировать внутренние сегменты карты. Конкуренция начнёт душить производительность.

Если операция тяжёлая — вычисляй отдельно:

public ExpensiveObject getSafely(String key) {     ExpensiveObject result = cache.get(key);     if (result == null) {         result = calculateExpensive(key);         ExpensiveObject existing = cache.putIfAbsent(key, result);         return existing != null ? existing : result;     }     return result; }

Так избежим побочных эффектов, множественных вызовов и нежеланных дубликатов. А если нужно прямо computeIfAbsent, то делаем функцию максимально чистой: без сайд‑эффектов, без внешних вызовов, без null.

Но тут нужно отметить, что все это не ошибка в реализации computeIfAbsent, а скорее особенность его дизайна: он оптимизирован для более высоко производительных операций, но может вызывать проблемы в условиях высокой конкуренции, если лямбда‑функция имеет побочные эффекты.


Статья подготовлена для будущих студентов специализации «Java‑разработчик». Хорошая новость: в рамках этого курса студенты получат поддержку карьерного центра Otus. Узнать подробнее


ссылка на оригинал статьи https://habr.com/ru/articles/896178/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *