Рубрики
Без рубрики

Что такое потокобезопасность и как ее достичь?

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

Автор оригинала: Alejandro Ugarte.

1. Обзор

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

Хотя многопоточность-это мощная функция, она имеет свою цену. В многопоточных средах нам необходимо писать реализации потокобезопасным способом. Это означает, что разные потоки могут обращаться к одним и тем же ресурсам, не обнаруживая ошибочного поведения или не приводя к непредсказуемым результатам. Эта методология программирования известна как “потокобезопасность”.

В этом уроке мы рассмотрим различные подходы к его достижению.

2. Реализации Без Состояния

В большинстве случаев ошибки в многопоточных приложениях являются результатом неправильного распределения состояния между несколькими потоками.

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

Чтобы лучше понять этот подход, давайте рассмотрим простой служебный класс со статическим методом, который вычисляет факториал числа:

public class MathUtils {
    
    public static BigInteger factorial(int number) {
        BigInteger f = new BigInteger("1");
        for (int i = 2; i <= number; i++) {
            f = f.multiply(BigInteger.valueOf(i));
        }
        return f;
    }
}

Метод factorial() является детерминированной функцией без состояния. Учитывая конкретный вход, он всегда выдает один и тот же результат.

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

Все потоки могут безопасно вызвать метод factorial() и получат ожидаемый результат, не мешая друг другу и не изменяя выходные данные, которые метод генерирует для других потоков.

Поэтому реализации без состояния являются самым простым способом обеспечения потокобезопасности .

3. Неизменяемые Реализации

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

Неизменность-это мощная концепция, не зависящая от языка, и ее довольно легко достичь в Java.

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

Самый простой способ создать неизменяемый класс в Java-это объявить все поля private и final и не предоставлять установщики:

public class MessageService {
    
    private final String message;

    public MessageService(String message) {
        this.message = message;
    }
    
    // standard getter
    
}

Объект Message Service фактически неизменяем, поскольку его состояние не может измениться после его построения. Следовательно, он потокобезопасен.

Более того, если MessageService действительно изменчив, но несколько потоков имеют к нему доступ только для чтения, он также потокобезопасен.

Таким образом, неизменность-это всего лишь еще один способ достижения потокобезопасности .

4. Локальные поля потока

В объектно-ориентированном программировании (ООП) объекты фактически должны поддерживать состояние с помощью полей и реализовывать поведение с помощью одного или нескольких методов.

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

Мы можем легко создавать классы, поля которых являются локальными по потоку, просто определяя частные поля в Поток классы.

Мы могли бы определить, например, Поток класс, хранящий массив целых чисел :

public class ThreadA extends Thread {
    
    private final List numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
    
    @Override
    public void run() {
        numbers.forEach(System.out::println);
    }
}

В то время как другой может содержать массив из строк :

public class ThreadB extends Thread {
    
    private final List letters = Arrays.asList("a", "b", "c", "d", "e", "f");
    
    @Override
    public void run() {
        letters.forEach(System.out::println);
    }
}

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

Аналогично, мы можем создавать локальные поля потока, назначая ThreadLocal экземплярам поля.

Рассмотрим, например, следующий класс StateHolder :

public class StateHolder {
    
    private final String state;

    // standard constructors / getter
}

Мы можем легко сделать его локальной переменной потока следующим образом:

public class ThreadState {
    
    public static final ThreadLocal statePerThread = new ThreadLocal() {
        
        @Override
        protected StateHolder initialValue() {
            return new StateHolder("active");  
        }
    };

    public static StateHolder getState() {
        return statePerThread.get();
    }
}

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

5. Синхронизированные коллекции

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

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

Collection syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();

Давайте иметь в виду, что синхронизированные коллекции используют внутреннюю блокировку в каждом методе (мы рассмотрим внутреннюю блокировку позже).

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

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

6. Параллельные Коллекции

В качестве альтернативы синхронизированным коллекциям мы можем использовать параллельные коллекции для создания потокобезопасных коллекций.

Java предоставляет пакет java.util.concurrent , который содержит несколько параллельных коллекций, таких как ConcurrentHashMap :

Map concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");

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

Параллельные коллекции гораздо более эффективны , чем синхронизированные коллекции , из-за присущих преимуществ параллельного доступа к потокам.

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

7. Атомные объекты

Также можно обеспечить потокобезопасность с помощью набора атомарных классов , предоставляемых Java, включая AtomicInteger , AtomicLong , AtomicBoolean и AtomicReference .

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

Чтобы понять проблему, которую это решает, давайте рассмотрим следующий класс Counter :

public class Counter {
    
    private int counter = 0;
    
    public void incrementCounter() {
        counter += 1;
    }
    
    public int getCounter() {
        return counter;
    }
}

Предположим, что в состояние гонки , два потока получают доступ к счетчик приращений() метод в то же время.

Теоретически конечное значение поля счетчик будет равно 2. Но мы просто не можем быть уверены в результате, потому что потоки выполняют один и тот же блок кода одновременно, и приращение не является атомарным.

Давайте создадим потокобезопасную реализацию класса Counter с помощью объекта AtomicInteger :

public class AtomicCounter {
    
    private final AtomicInteger counter = new AtomicInteger();
    
    public void incrementCounter() {
        counter.incrementAndGet();
    }
    
    public int getCounter() {
        return counter.get();
    }
}

Это потокобезопасно, потому что, хотя инкрементирование ++ требует более одной операции, incrementAndGet является атомарным .

8. Синхронизированные методы

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

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

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

Мы можем создать потокобезопасную версию incrementCounter() другим способом, сделав ее синхронизированным методом:

public synchronized void incrementCounter() {
    counter += 1;
}

Мы создали синхронизированный метод, добавив к сигнатуре метода префикс с ключевым словом synchronized.

Поскольку один поток одновременно может получить доступ к синхронизированному методу, один поток выполнит метод incrementCounter () , и, в свою очередь, другие будут делать то же самое. Никакого перекрывающегося выполнения не произойдет.

Синхронизированные методы основаны на использовании “встроенных блокировок” или “блокировок монитора” . Внутренняя блокировка – это неявная внутренняя сущность, связанная с конкретным экземпляром класса.

В многопоточном контексте термин monitor является просто ссылкой на роль, которую блокировка выполняет для связанного объекта, поскольку она обеспечивает исключительный доступ к набору указанных методов или операторов.

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

Мы можем реализовать синхронизацию в методах экземпляра, статических методах и операторах (синхронизированные операторы).

9. Синхронизированные Заявления

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

Чтобы проиллюстрировать этот пример использования, давайте рефакторингуем метод incrementCounter() :

public void incrementCounter() {
    // additional unsynced operations
    synchronized(this) {
        counter += 1; 
    }
}

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

В отличие от методов synchronized, операторы synchronized должны указывать объект, обеспечивающий внутреннюю блокировку, обычно ссылку this.

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

9.1. Другие объекты в качестве замка

Мы можем немного улучшить потокобезопасную реализацию класса Counter , используя другой объект в качестве блокировки монитора вместо this.

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

public class ObjectLockCounter {

    private int counter = 0;
    private final Object lock = new Object();
    
    public void incrementCounter() {
        synchronized(lock) {
            counter += 1;
        }
    }
    
    // standard getter
}

Мы используем простой Объект экземпляр для обеспечения взаимного исключения. Эта реализация немного лучше, так как она повышает безопасность на уровне блокировки.

При использовании this для внутренней блокировки злоумышленник может вызвать взаимоблокировку, получив внутреннюю блокировку и запустив условие отказа в обслуживании (DoS).

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

9.2. Предостережения

Несмотря на то, что мы можем использовать любой объект Java в качестве встроенной блокировки, мы должны избегать использования Strings для целей блокировки:

public class Class1 {
    private static final String LOCK  = "Lock";

    // uses the LOCK as the intrinsic lock
}

public class Class2 {
    private static final String LOCK  = "Lock";

    // uses the LOCK as the intrinsic lock
}

На первый взгляд кажется, что эти два класса используют в качестве блокировки два разных объекта. Однако из-за интернирования строк эти два значения “Блокировки” могут фактически ссылаться на один и тот же объект в пуле строк . То есть/| Класс 1 и Класс 2 используют одну и ту же блокировку!

Это, в свою очередь, может привести к неожиданному поведению в параллельных контекстах.

В дополнение к Строкам, мы должны избегать использования любых кэшируемых или повторно используемых объектов в качестве встроенных блокировок . Например, метод Integer.valueOf() кэширует небольшие числа. Поэтому вызов Integer.valueOf(1) возвращает один и тот же объект даже в разных классах.

10. Изменчивые Поля

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

Чтобы предотвратить эту ситуацию, мы можем использовать поля класса volatile:

public class Counter {

    private volatile int counter;

    // standard constructors / getter
    
}

С помощью ключевого слова volatile мы инструктируем JVM и компилятор хранить переменную counter в основной памяти. Таким образом, мы гарантируем, что каждый раз, когда JVM считывает значение переменной counter , он будет фактически считывать его из основной памяти, а не из кэша процессора. Аналогично, каждый раз, когда JVM записывает в переменную counter , значение будет записываться в основную память.

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

Давайте рассмотрим следующий пример:

public class User {

    private String name;
    private volatile int age;

    // standard constructors / getters
    
}

В этом случае каждый раз, когда JVM записывает переменную age | volatile в основную память, она также записывает переменную name в основную память. Это гарантирует, что последние значения обеих переменных хранятся в основной памяти, поэтому последующие обновления переменных будут автоматически видны другим потокам.

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

Эта расширенная гарантия, которую предоставляют переменные volatile , известна как гарантия полной изменчивой видимости .

11. Реентерабельные Замки

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

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

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

Экземпляры ReentrantLock позволяют нам делать именно это, следовательно, предотвращая некоторые типы потоков в очереди от нехватки ресурсов :

public class ReentrantLockCounter {

    private int counter;
    private final ReentrantLock reLock = new ReentrantLock(true);
    
    public void incrementCounter() {
        reLock.lock();
        try {
            counter += 1;
        } finally {
            reLock.unlock();
        }
    }
    
    // standard constructors / getter
    
}

Конструктор ReentrantLock принимает необязательный параметр fair |/boolean . Если установлено значение true , и несколько потоков пытаются получить блокировку, JVM отдаст приоритет самому длинному ожидающему потоку и предоставит доступ к блокировке .

12. Блокировка чтения/записи

Еще одним мощным механизмом, который мы можем использовать для обеспечения потокобезопасности, является использование реализаций Read Write Lock .

A ReadWriteLock lock фактически использует пару связанных блокировок, одну для операций только для чтения, а другую для операций записи.

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

Мы можем использовать ReadWriteLock lock следующим образом:

public class ReentrantReadWriteLockCounter {
    
    private int counter;
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    
    public void incrementCounter() {
        writeLock.lock();
        try {
            counter += 1;
        } finally {
            writeLock.unlock();
        }
    }
    
    public int getCounter() {
        readLock.lock();
        try {
            return counter;
        } finally {
            readLock.unlock();
        }
    }

   // standard constructors
   
}

13. Заключение

В этой статье мы узнали, что такое потокобезопасность в Java, и подробно рассмотрели различные подходы к ее достижению .

Как обычно, все примеры кода, показанные в этой статье, доступны на GitHub .