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

Распространенные ошибки параллелизма в Java

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

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

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

ANDROMEDA 42

1. Введение

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

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

2.1. Совместное использование объектов

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

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

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

2.2. Создание потокобезопасных коллекций

Как и любой другой объект, коллекции поддерживают внутреннее состояние. Это может быть изменено несколькими потоками, изменяющими коллекцию одновременно. Итак, один из способов безопасно работать с коллекциями в многопоточной среде — синхронизировать их :

Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());

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

2.3. Специальные многопоточные коллекции

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

По этой причине Java предоставляет параллельные коллекции, такие как CopyOnWriteArrayList и ConcurrentHashMap , доступ к которым может осуществляться одновременно несколькими потоками:

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();

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

ConcurrentHashMap по своей сути является потокобезопасным и более производительным, чем оболочка Collections.synchronizedMap для не потокобезопасного Map . На самом деле это поточно-безопасная карта потокобезопасных карт, позволяющая одновременно выполнять различные действия в своих дочерних картах.

2.4. Работа с не потокобезопасными типами

Мы часто используем встроенные объекты, такие как SimpleDateFormat , для разбора и форматирования объектов даты. Класс SimpleDateFormat изменяет свое внутреннее состояние при выполнении своих операций.

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

Итак, как мы можем безопасно использовать SimpleDateFormat ? У нас есть несколько вариантов:

  • Создавайте новый экземпляр SimpleDateFormat каждый раз, когда он используется
  • Ограничьте количество объектов, создаваемых с помощью объекта ThreadLocal<SimpleDateFormat> . Это гарантирует, что каждый поток будет иметь свой собственный экземпляр SimpleDateFormat .
  • Синхронизируйте одновременный доступ несколькими потоками с ключевым словом synchronized или блокировкой

SimpleDateFormat — лишь один из примеров этого. Мы можем использовать эти методы с любым не потокобезопасным типом.

3. Условия гонки

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

3.1. Пример состояния гонки

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

class Counter {
private int counter = 0;

public void increment() {
counter++;
}

public int getValue() {
return counter;
}
}

Класс Counter устроен таким образом, что каждый вызов метода increment добавляет 1 к счетчику . Однако, если на объект Counter ссылаются из нескольких потоков, взаимодействие между потоками может помешать тому, чтобы это произошло, как ожидалось.

Мы можем разбить оператор counter++ на 3 шага:

  • Получить текущее значение счетчика
  • Увеличьте полученное значение на 1
  • Сохраните увеличенное значение обратно в счетчик

Теперь предположим, что два потока, thread1 и thread2 , одновременно вызывают метод приращения. Их чередующиеся действия могут следовать такой последовательности:

  • thread1 считывает текущее значение счетчика ; 0
  • thread2 считывает текущее значение счетчика ; 0
  • thread1 увеличивает полученное значение; результат 1
  • thread2 увеличивает полученное значение; результат 1
  • thread1 сохраняет результат в counter ; результат теперь 1
  • thread2 сохраняет результат в counter ; результат теперь 1

Мы ожидали, что значение счетчика будет 2, но оно было 1.

3.2. Синхронизированное решение

Мы можем исправить несоответствие, синхронизировав критический код:

class SynchronizedCounter {
private int counter = 0;

public synchronized void increment() {
counter++;
}

public synchronized int getValue() {
return counter;
}
}

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

3.3. Встроенное решение

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

AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();

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

4. Условия гонки вокруг коллекций

4.1. Проблема

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

Давайте рассмотрим код ниже:

List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
list.add("foo");
}

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

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

4.2. Решение для списков

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

synchronized (list) {
if (!list.contains("foo")) {
list.add("foo");
}
}

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

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

4.3. Встроенное решение для ConcurrentHashMap

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

ConcurrentHashMap предлагает лучшее решение для этого типа проблем. Мы можем использовать его атомарный метод putIfAbsent :

Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");

Или, если мы хотим вычислить значение, его атомарный метод calculateIfAbsent :

map.computeIfAbsent("foo", key -> key + "bar");

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

5. Проблемы согласованности памяти

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

В дополнение к основной памяти большинство современных компьютерных архитектур используют иерархию кэшей (кеши L1, L2 и L3) для повышения общей производительности. Таким образом, любой поток может кэшировать переменные, потому что это обеспечивает более быстрый доступ по сравнению с основной памятью.

5.1. Проблема

Давайте вспомним наш пример счетчика :

class Counter {
private int counter = 0;

public void increment() {
counter++;
}

public int getValue() {
return counter;
}
}

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

  • thread1 считывает значение счетчика из своего кэша; счетчик 0
  • t hread1 увеличивает значение счетчика и записывает его обратно в свой кэш; счетчик 1
  • thread2 считывает значение счетчика из своего кэша; счетчик 0

Конечно, может случиться и ожидаемая последовательность событий, и поток t будет считывать правильное значение (1), но нет гарантии, что изменения, сделанные одним потоком, каждый раз будут видны другим потокам. `` ****

5.2. Решение

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

Есть несколько стратегий, которые создают отношения «случается раньше». Один из них — синхронизация, которую мы уже рассмотрели.

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

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

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

class SyncronizedCounter {
private volatile int counter = 0;

public synchronized void increment() {
counter++;
}

public int getValue() {
return counter;
}
}

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

5.3. Неатомарные длинные и двойные значения

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

Согласно JLS-17 , JVM может обрабатывать 64-битные операции как две отдельные 32-битные операции . Следовательно, при чтении длинного или двойного значения можно прочитать обновленное 32-разрядное значение вместе с устаревшим 32-разрядным. Следовательно, мы можем наблюдать случайные длинные или двойные значения в параллельных контекстах.

С другой стороны, операции записи и чтения volatile значений long и double всегда атомарны.

6. Неправильное использование синхронизации

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

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

6.1. Синхронизация по этой ссылке

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

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

Эти методы эквивалентны:

public synchronized void foo() {
//...
}
public void foo() {
synchronized(this) {
//...
}
}

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

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

6.2. Тупик

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

Рассмотрим пример:

public class DeadlockExample {

public static Object lock1 = new Object();
public static Object lock2 = new Object();

public static void main(String args[]) {
Thread threadA = new Thread(() -> {
synchronized (lock1) {
System.out.println("ThreadA: Holding lock 1...");
sleep();
System.out.println("ThreadA: Waiting for lock 2...");

synchronized (lock2) {
System.out.println("ThreadA: Holding lock 1 & 2...");
}
}
});
Thread threadB = new Thread(() -> {
synchronized (lock2) {
System.out.println("ThreadB: Holding lock 2...");
sleep();
System.out.println("ThreadB: Waiting for lock 1...");

synchronized (lock1) {
System.out.println("ThreadB: Holding lock 1 & 2...");
}
}
});
threadA.start();
threadB.start();
}
}

В приведенном выше коде мы ясно видим, что первый поток A получает блокировку1, а поток B получает блокировку2 . Затем threadA пытается получить блокировку2 , которая уже получена потоком B, а потокB пытается получить блокировку1 , которая уже получена потокомA . Таким образом, ни один из них не будет продолжать, что означает, что они находятся в тупике. ``

Мы можем легко решить эту проблему, изменив порядок блокировок в одном из потоков.

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

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

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

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

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

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

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