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 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. Узнать подробнее
Habrahabr.ru прочитано 6132 раза