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

6f5a3d694d597e9d781feb2de16dd1d7.jpg

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

Сегодня рассмотрим несколько вопросов на собеседовании, которые могут встретиться: чем 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));
}

Где подвох?

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

Habrahabr.ru прочитано 6132 раза