Итак, что такое потокобезопасный класс? Класс Java считается потокобезопасным, если несколько потоков могут обращаться к нему одновременно, не вызывая гонки данных или некорректного состояния. Потокобезопасность гарантирует, что даже если несколько потоков обращаются к одному и тому же объекту потокобезопасного класса, объект при этом будет оставаться корректным. Если объект Java не будет доступен для нескольких потоков, то беспокоиться о потокобезопасности не нужно. Класс может быть формально не потокобезопасным, но если его никогда не трогают из нескольких потоков — проблем не будет.

В чём проблема: гонка данных

Давайте рассмотрим класс, который не является потокобезопасным, чтобы понять, почему потокобезопасность имеет значение:

public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // Это не атомарная операция!
    }

    public void decrement() {
        count--; // Это не атомарная операция!
    }

    public int getCount() {
        return count;
    }
}

Кажутся ли вам методы increment и decrement потокобезопасными? Нет, потому что операции типа count++ не являются атомарными — на самом деле это три отдельных шага:

  1. Прочитать текущее значение count.

  2. Добавить/вычесть 1.

  3. Записать новое значение обратно в count.

Вот что может произойти, если два потока одновременно вызывают метод increment(), когда count равен 0:

  1. Поток A читает count (0).

  2. Поток B читает count (0).

  3. Поток A прибавляет 1 и записывает 1.

  4. Поток B прибавляет 1 и записывает 1.

В результате мы получаем 1, а не ожидаемое 2. Это называется состоянием гонки, и оно оставляет наш счётчик в несогласованном состоянии. Решение состоит в том, чтобы спроектировать класс как потокобезопасный.

Стратегии создания потокобезопасных классов

1. Классы без состояния: нет состояния — нет проблемы

Если класс не хранит состояние (поля), он автоматически является потокобезопасным. Ведь если нечего изменять, то нечего и портить!

public class MathHelper {
    // Нет полей = нет общего состояния
    
    public int add(int a, int b) {
        return a + b;
    }
    
    public static int multiply(int a, int b) {
        return a * b;
    }
    
    public double calculateAverage(int[] numbers) {
        int sum = 0;
        for (int num : numbers) {
            sum += num;
        }
        return numbers.length > 0 ? (double) sum / numbers.length : 0;
    }
}

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

2. Неизменяемые классы: только для чтения — ваш друг

Если объект нельзя изменить после создания — это почти гарантия потокобезопасности. Если объект нельзя изменить, то нет риска одновременных изменений.

public final class ImmutablePoint {
    private final int x;
    private final int y;
    
    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    public int getX() { return x; }
    public int getY() { return y; }
    
    // Операции создают новые объекты вместо изменения текущего
    public ImmutablePoint translate(int dx, int dy) {
        return new ImmutablePoint(x + dx, y + dy);
    }
}

Класс String в Java является идеальным примером неизменяемого, потокобезопасного класса. Именно поэтому вам никогда не нужно беспокоиться о синхронизации при использовании строк.

Используйте final, когда это возможно (так говорят многие опытные Java-эксперты), так как это защищает от случайных изменений и делает код ближе к потокобезопасности.

3. Инкапсуляция и синхронизация

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

Шаг 1: Сделайте поля приватными

Открытые поля — это приглашение к проблемам: никакого контроля, никакой безопасности.

// Плохая инкапсуляция — не потокобезопасно
public class UnsafeCounter { 
   public int count; // Доступно напрямую, может быть изменено любым потоком
}

Шаг 2: Выявите неатомарные операции и синхронизируйте их

Сделали поля приватными — отлично. Теперь важно, чтобы методы работали с ними атомарно.

public class SafeCounter { 
    private int count; // Скрыто от внешнего доступа
    
    public synchronized void increment() { 
        count++; 
    } 
    
    public synchronized void decrement() {
        count--;
    }
    
    public synchronized int getCount() { 
        return count; 
    } 
}

Ключевое слово synchronized гарантирует, что только один поток может выполнить эти методы у объекта. Так мы избегаем гонки данных.

Ключевое слово volatile для видимости

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

public class StatusChecker {
    private volatile boolean running = true;
    
    public void stop() {
        running = false;
    }
    
    public void performTask() {
        while (running) {
            // Выполнение задачи
        }
    }
}

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

Широкая и узкая синхронизация

Не пугайтесь слов «широкая» и «узкая». Проще говоря: широкая блокировка — это когда вы лочите весь метод или объект, даже если нужно только кусочек. А узкая — когда лочите ровно то, что нужно.

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

Широкая синхронизация означает использование меньшего количества более широких блокировок, которые защищают большие участки кода или структуры данных. Например, блокировка всего списка, когда нужно изменить только один элемент, или блокировка всего метода, даже если только часть метода нуждается в синхронизации.

Узкая синхронизация означает использование множества мелких, целенаправленных блокировок, которые защищают конкретные компоненты или операции. Например, блокировка только того элемента списка, который вы изменяете.

// Широкая синхронизация
public synchronized void transferMoney(Account from, Account to, int amount) {
    from.debit(amount);
    to.credit(amount);
}
// Узкая синхронизация
public void transferMoney(Account from, Account to, int amount) {
    synchronized(from) {
        synchronized(to) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

Основные различия:

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

  • Сложность: узкая синхронизация сложнее в реализации и увеличивает риск возникновения взаимных блокировок. Широкая синхронизация проще и безопаснее в реализации.

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

Выбор между широкой и узкой блокировкой — всегда компромисс. Хотите проще — берите шире. Нужна производительность — лочите точечно, но аккуратно, иначе словите дедлок.

4. Использование потокобезопасных библиотек 

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

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadSafeUserManager {
    // Потокобезопасный словарь из java.util.concurrent
    private final ConcurrentHashMap<String, User> users = new ConcurrentHashMap<>();
    
    // Потокобезопасный счётчик с использованием атомарных классов
    private final AtomicInteger userCount = new AtomicInteger(0);
    
    public void addUser(String id, User user) {
        users.put(id, user);
        userCount.incrementAndGet();
    }
    
    public User getUser(String id) {
        return users.get(id);
    }
    
    public int getTotalUsers() {
        return userCount.get();
    }
}

Здесь используется внутренняя синхронизация самих коллекций — без ручных synchronized-блоков. Это часто быстрее при высокой нагрузке.

Основные потокобезопасные компоненты:

  • Коллекции: ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue
    Атомарные переменные: AtomicInteger, AtomicLong, AtomicReference

  • Очереди: LinkedBlockingQueue, ArrayBlockingQueue

  • Синхронизаторы: CountDownLatch, CyclicBarrier, Semaphore

5. Изоляция потока: каждый со своим набором данных

Иногда лучший способ избежать совместного использования — это вовсе не делиться данными. Ограничение потока означает, что каждый поток получает свою независимую копию данных:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

ScopedValue — современная альтернатива ThreadLocal, появившаяся в Java 21. Работает быстрее и чище, особенно с виртуальными потоками.

public class ScopedValueExample {
    // Определяем ScopedValue (Java 21+)
    private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
    public static void main(String[] args) {
        // Запуск кода с определённым значением, привязанным к ScopedValue
        ScopedValue.where(CURRENT_USER, "Alice").run(() -> {
            processRequest();
            
            // Значение остаётся доступным в последующих методах
            auditAction("data_access");
        });
        // Несколько привязок в вложенных областях
        ScopedValue.where(CURRENT_USER, "Bob").run(() -> {
            System.out.println("Outer scope: " + CURRENT_USER.get());
            
            // Во внутренней области можно переопределить значение
            ScopedValue.where(CURRENT_USER, "Charlie").run(() -> {
                System.out.println("Inner scope: " + CURRENT_USER.get());
            });
            
            // Внешняя привязка сохраняется
            System.out.println("Back to outer: " + CURRENT_USER.get());
        });
    }
    private static void processRequest() {
        // Доступ к ScopedValue из другого метода
        System.out.println("Processing request for: " + CURRENT_USER.get());
    }
    private static void auditAction(String action) {
        // Получение пользователя из ScopedValue без передачи его как параметра
        System.out.println("User " + CURRENT_USER.get() + " performed action: " + action);
    }
}

6. Оборонительное копирование: защита внутренних данных

Если ваш класс работает с изменяемыми объектами, лучше подстраховаться и делать копии при передаче туда-обратно.

public class DefensiveCalendar {
    private final Date startDate;
    
    public DefensiveCalendar(Date start) {
        // Оборонительная копия, чтобы предотвратить изменение нашего состояния
        this.startDate = new Date(start.getTime());
    }
    
    public Date getStartDate() {
        // Оборонительная копия, чтобы предотвратить изменение нашего состояния
        return new Date(startDate.getTime());
    }
}

Без этих оборонительных копий вызывающий код может изменить объект Date даже после того, как передаст его вашему классу, нарушив инкапсуляцию и потенциально нарушив потокобезопасность.

Заключение

Создание потокобезопасных классов в Java требует тщательного анализа того, как ваш класс будет использоваться в многозадачных средах. Давайте подведем итог основным стратегиям:

  • Классы без состояния устраняют общее состояние полностью.

  • Неизменяемые классы защищены от любых изменений после создания.

  • Правильная инкапсуляция с синхронизацией защищает изменяемое состояние.

  • Готовые библиотеки — отличный фундамент для создания сложных потокобезопасных решений.

  • Ограничение потока изолирует состояние в отдельных потоках.

  • Оборонительное копирование защищает от внешних изменений.

  • Выбор, где и как лочить, — это всегда компромисс между стабильностью и скоростью.

Визуальное руководство по потокобезопасному классу (перевод изображения)
Визуальное руководство по потокобезопасному классу (перевод изображения)

Задумывались ли вы, сколько времени уходит на отладку и исправление багов в вашем коде? А как насчет того, чтобы этот процесс стал быстрее и эффективнее? На этих открытых уроках вы получите практические навыки, которые сделают вашу работу как разработчика легче и быстрее.

Всё, что нужно знать о современной Java-разработке, можно с нуля изучить на специализации "Java разработчик".

Комментарии (0)