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

Общие ошибки параллелизма в Java

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

Автор оригинала: Catalin Burcea.

1. введение

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

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

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

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

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

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

2.2. Обеспечение Потокобезопасности Коллекций

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

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

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

2.3. Специализированные Многопоточные коллекции

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

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

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

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

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

2.4. Работа с Нерезьбобезопасными типами

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

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

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

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

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

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

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

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

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

class Counter {
    private int counter = 0;

    public void increment() {
        counter++;
    }

    public int getValue() {
        return counter;
    }
}

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

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

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

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

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

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

3.2. Решение На Основе Синхронизации

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

class SynchronizedCounter {
    private int counter = 0;

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

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

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

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

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

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

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

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

4.1. Проблема

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 считывает его значение. Может произойти следующая последовательность событий:

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

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

5.2. Решение

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

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

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

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

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

class SyncronizedCounter {
    private volatile int counter = 0;

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

    public int getValue() {
        return counter;
    }
}

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

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

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

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

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

6. Злоупотребление Синхронизацией

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

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

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

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

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

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

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

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

Кроме того, клиент нашего кода также может получить блокировку 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();
    }
}

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

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

Мы должны отметить, что это всего лишь один пример, и есть много других, которые могут привести к тупику.

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

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

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

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

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

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