Перейти к основному содержимому

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

· 12 мин. чтения

Задача: Наибольшая подстрока без повторений

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

ANDROMEDA 42

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 — объявить все поля закрытыми и окончательными и не предоставлять сеттеры:

public class MessageService {

private final String message;

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

// standard getter

}

Объект MessageService фактически неизменяем, поскольку его состояние не может измениться после его создания. Итак, это потокобезопасно.

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

Как мы видим, неизменяемость — это просто еще один способ добиться потокобезопасности.

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

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

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

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

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

public class ThreadA extends Thread {

private final List<Integer> 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<String> 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<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {

@Override
protected StateHolder initialValue() {
return new StateHolder("active");
}
};

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

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

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

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

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

Collection<Integer> 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<String,String> 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;
}
}

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

Теоретически окончательное значение поля счетчика будет равно 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() , а другие, в свою очередь, будут делать то же самое. Никакого перекрывающегося выполнения не произойдет.

Синхронизированные методы основаны на использовании «внутренних блокировок» или «блокировок монитора». Внутренняя блокировка — это неявный внутренний объект, связанный с конкретным экземпляром класса.

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

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

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

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

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

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

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

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

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

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

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

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

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

public class ObjectLockCounter {

private int counter = 0;
private final Object lock = new Object();

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

// standard getter
}

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

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

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

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

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

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
}

На первый взгляд кажется, что эти два класса используют в качестве блокировки два разных объекта. Однако из-за интернирования строк эти два значения «Lock» могут на самом деле ссылаться на один и тот же объект в пуле строк . То есть Class1 и Class2 используют одну и ту же блокировку!

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

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

10. Летучие поля

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

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

public class Counter {

private volatile int counter;

// standard constructors / getter

}

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

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

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

public class User {

private String name;
private volatile int age;

// standard constructors / getters

}

В этом случае каждый раз, когда JVM записывает энергозависимую переменную age в основную память, она также записывает энергонезависимую переменную 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 принимает необязательный логический параметр справедливости . Если установлено значение true и несколько потоков пытаются получить блокировку, JVM отдаст приоритет самому длинному ожидающему потоку и предоставит доступ к блокировке. ****

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

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

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

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

Вот как мы можем использовать блокировку ReadWriteLock :

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 .