Привет, Хабр!
Сегодня рассмотр��м несколько вопросов на собеседовании, которые могут встретиться: чем 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)); }
Где подвох?
Функция вызывается более одного раза — если параллельные потоки дерутся за один и тот же ключ, они все могут вызвать лямбду, но только один результат пойдёт в карту. А если в этой лямбде побочные эффекты? Например, ты пишешь в БД? Будут дубликаты.
Функция может вернуть null — и тогда ничего не вставится. Хуже того: метод снова будет вызывать функцию при следующем обращении. Ты думаешь, что один раз посчитал — а оно каждый раз.
Блокировки и производительность — если твоя функция тяжёлая (например, ходит в сеть), ты рискуешь заблокировать внутренние сегменты карты. Конкуренция начнёт душить производительность.
Если операция тяжёлая — вычисляй отдельно:
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. Узнать подробнее
