Многопоточность в Java. Часть 2

Введение
В предыдущей статье мы рассмотрели базовые темы многопоточности: потоки, класс Thread и интерфейс Runnable. В данной публикации продолжаем осваивать эту непростую тему, разбирая такие понятия, как процесс, проблема видимости, volatile, состояние гонки и многое другое.
1. Что такое процесс?
Процесс — это совокупность ресурсов (кода, виртуального адресного пространства, открытых файлов, уникального идентификатора PID и т. д.), выделенных операционной системой для выполнения запускаемого приложения.
Иногда, в качестве упрощения, под процессом понимают исполняемое приложение.
У каждого процесса имеется хотя бы один поток, называемый главным, с которого начинается выполнение программы. В Java после создания процесса работа главного потока стартует с метода main(). Затем в заданных программистом местах запускаются другие потоки.
Для нужд процесса выделяется необходимый объем памяти из оперативной памяти (в Java — из пространства кучи) в границах которой и происходит обработка данных приложения, а для каждого потока — свой стек и регистры. При этом сам процесс не исполняет код программы — этим занимаются потоки, создаваемые внутри него.
Доступ к памяти друг друга у процессов ограничен, что позволяет избежать множества проблем.
2. Подпроцессы
Когда мы хотим выполнить большие куски задач, разбивая их на более мелкие части, то зачастую не все они могут работать независимо друг от друга. Некоторые из них должны обмениваться данными, при этом работая в разных процессах. Для такого обмена используется межпроцессное взаимодействие.
Проблема с этой идеей заключается в том, что наличие слишком большого количества процессов на компьютере, которые должны общаться между собой, несет и большие расходы. И именно в этот момент становится актуальным использование потоков.
Идея потока заключается в том, что процесс может иметь внутри себя множество крошечных подпроцессов, которые совместно используют пространство его памяти. Это позволяет им быстрее получать доступ к ней, что, несомненно, ведет к увеличению производительности. Эти подпроцессы и называются потоками.
Считается, что процессы являются достаточно ресурсоемкими сущностями, а подпроцессы — их облегченным вариантом или легковесными процессами.
Процессы используются для группировки ресурсов; потоки — для выполнения приложения.
Вроде бы идея хорошая. Однако, есть и ряд недостатков, хотя преимущества все равно перевешивают проблемы. Давайте обсудим некоторые из них и то, как мы можем с ними справиться.
3. Порядок выполнения потоков
Пусть у нас имеется следующий код:

public class Playground {
    private static boolean running;

    public static void main(String[] args) {
        var t1 = new Thread(() -> {
            while (!running) {} 
            System.out.println("topjava.ru");
        });

        var t2 = new Thread(() -> {
            running = true;
            System.out.println("I love");
        });

        t1.start();
        t2.start();
    }
}
В приведенном выше фрагменте создаются два потока с общей переменной boolean running. Внутри первого потока while будет продолжать выполняться до тех пор, пока переменная имеет значение false. Как только он прервется, напечатается topjava.ru. Второй поток изменяет переменную running, а затем печатает I love.
Возникает вопрос, что в итоге будет выведено на консоль? Можно предположить следующий вариант (пусть это будет Ситуация 1):

I love
topjava.ru
Такой вывод возможен, но если вы запустите приведенный выше код несколько раз, то увидите разные результаты. Это связано с тем, что мы можем только попросить потоки выполнить какую-то часть кода, но нет гарантии, что они сделают эту работу в нужном нам порядке.
Разберем другие возможные варианты работы потоков программы, что, несомненно, повлияет на порядок вывода текста в консоль.
Ситуация 2. Второй поток запускается первым и меняет значение переменной. В первом потоке прерывается цикл и отображается topjava.ru. А второй поток печатает I love.

topjava.ru
I love
Ситуация 3. Первый поток может застрять, а второй напечатает I love. И на этом все, больше никакого вывода. Это трудновоспроизводимая ситуация, но она может произойти.
Это связано с тем, что т. к. в современном компьютере может быть несколько процессоров, то мы не можем гарантировать, в каком ядре в конечном итоге будет выполняться поток. Например, вышеупомянутые два потока могут выполняться в двух разных процессорах, что, скорее всего, так и есть.
Когда процессор выполняет код, он считывает данные из основной памяти. Однако современные процессоры имеют различные кэши для более быстрого доступа к памяти: L1 Cache, L2 Cache и L3 Cache — в этом и кроется проблема из третьей ситуации.
При запуске первого потока процессор, который его стартует, может закэшировать переменную running. При этом, второй поток запустится на другом процессоре и изменит переменную, что сделает ее новое значение невидимым первому потоку, т. к. он будет пользоваться старым значением из кэша.
Чтобы этого не происходило, мы можем запретить процессору кэшировать значение переменной, используя модификатор volatile. Вместо этого он будет считывать ее из основной памяти:

public class FoojayPlayground {
    private static volatile boolean running;
    ...
}
Теперь, если снова запустить программу, то Ситуация 3 не произойдет.
Описанная выше проблема называется проблемой видимости (visibility problem).
Давайте разберем еще одну подобную ситуацию, используя для ее объяснения символы и псевдокод.
4. Символы и псевдокод
Определим следующий набор символов:
  • L — локальная переменная, например, L1, L2
  • S — ссылочная переменная, например, S1, S2. Видна нескольким потокам, может быть статической
  • S.X — S является ссылочной переменной, а X — полем объекта
В псевдокоде мы будем использовать номер потока и номер строки. Например, 1.1, где 1 — ID потока, а число после точки — номер строки. Или 1.2 — поток 1, строка 2.
Запишем программу, обсуждаемую выше, используя символы и псевдокод:
Порядок ее выполнения, как мы выяснили ранее, может быть следующим:

1) 1.1, 2.1, 1.2, 2.2
2) 1.1, 2.1, 2.2, 1.2
3) 1.1, 2.1, 2.2
Рассмотрим еще одну проблему на другом примере:
У этой программы вывод может быть следующим:

1) 1.1, 1.2, 1.3, 2.1, 2.2, 2.3
2) 2.1, 2.2, 2.3, 1.1, 1.2, 1.3
В первом случае вывод будет:
Во втором:
Существует еще один возможный порядок выполнения:

3) 1.1, 2.1, 1.2, 2.2, 1.3, 2.3
Вывод будет следующим:
5. Оптимизация порядка выполнения
Несмотря на то, что мы показали три возможных варианта выполнения, которые могут прийти на ум в первую очередь, однако, также можно получить следующий результат:
При:

4) 1.2, 2.1, 2.2, 1.1, 1.3, 2.3
Но как это вообще возможно?
Ответ заключается в том, что хоть мы и пишем код в определенной последовательности, но это не означает, что компилятор и виртуальная машина выполнят его в заданном порядке. Они легко могут изменить его в качестве оптимизации (или вообще удалить, как «мертвый код»), если решат, что выходные данные не изменятся. Например, если в первом потоке поменять местами 1.1 и 1.2, то это не повлияет на конечный результат.
Такого рода вмешательства происходят по разным причинам. Например, интеллектуальный алгоритм компилятора может найти способ оптимизировать конкретный код для более быстрого выполнения. Но нужно понимать, что эти изменения могут стать потенциальным источником багов, которые не всегда легко обнаружить. У этого вида ошибок есть даже свое название — Heisenbugs (близкий по значению термин — «плавающая ошибка»). Баги данного типа могут исчезать при их поиске и появляться в произвольные моменты времени.
Теперь посмотрим на другой пример:
В этой программе S1 и S2 являются ссылками на один и тот же объект.
Если второй поток запускается первым, то результатом его работы будет сообщение Thread2: 3.
Тут все очевидно: если порядок выполнения будет отличаться от порядка в программе, значение, выводимое в 2.3, будет другим. Вот почему здесь компилятор не изменит его.
Теперь давайте посмотрим на первый поток, результат работы которого будет Thread1: 000.
В этом случае 1.2, 1.4 и 1.5 должны были выполняться до 2.2. Если 2.2 выполняется первым, то вывод изменится на Thread1: 333.
Если 1.2 выполнить до 2.2, а затем выполнить 1.4 и 1.5, результат будет Thread1: 033.
Если 1.2 и 1.4 выполняются до 2.2, а затем выполняется 1.5, вывод будет Thread1: 003.
Обратите внимание, что строки 1.2 и 1.5 присваивают одно и то же значение. А L2 и L5 используются для его вывода. Чтобы оптимизировать приведенный выше код, компилятор может полностью удалить L5, а вместо нее использовать L2. В однопоточной среде это изменение не отразится на выводе. В нашем коде вместо L2, L4 и L5 для вывода значения, компилятор может использовать L2, L4, L2.
В таком случае, если сначала выполняется 1.2, а затем 2.2, поскольку компилятор удалил L5, вместо оператора print он выведет значение L2 вместо L5, которое было присвоено в строке 1.2.
Приведенный выше пример можно найти в спецификации языка Java.
Порядок выполнения зависит от техники оптимизации компилятора. В дальнейшем он может полагаться на виртуальную машину Java и процессор. Таким образом, результат программы становится неопределенным. В многопоточной среде мы называем это состоянием гонки.
Это состояние возникает, когда два или более потока из одного процесса одновременно обращаются к одной и той же ячейке памяти, при этом какие-то из них считывают из нее данные, а какие-то записывают, что и приводит к разным результатам от запуска к запуску программы.
6. Преимущества и недостатки volatile
Для решения проблемы состояния гонки можно использовать ключевое слово volatile, которое применимо только для полей объекта, но не для локальных переменных. Это связано с тем, что мы не делим их (не используем одновременно) между разными потоками в отличие от полей.
Если поле является final, то в volatile нет необходимости. Это происходит потому, что final-поля никогда не меняются, а значит не могут создать озвученных ранее проблем.
Также нужно иметь в виду, что если ссылка на объект используется в качестве поля и является volatile, то это не означает, что содержимое объекта также является volatile.

Преимущества использования volatile:

  • Поток всегда считывает данные из основной памяти
  • Процессор никогда не кэширует значения, что исключает возникновения проблемы видимости
  • Компилятору сообщается, что значение переменной может быть изменено в любое время и разделено между несколькими потоками, поэтому ему рекомендуется не оптимизировать его, что предотвращает возникновение состояния гонки
Хотя ключевое слово volatile может быть хорошим решением, его чрезмерное использование может вызвать проблемы, т. к. оно запрещает кэширование данных процессором, что, безусловно, снижает производительность программы и препятствует дальнейшей оптимизации.
Поэтому нужно быть осторожными при его использовании и применять только там, где это действительно необходимо, а не подряд для всех полей.
7. Состояние гонки (race condition)
На данный момент мы знаем, что из-за совместного использования потоками пространства памяти процесса, они могут одновременно как считывать данные из переменной, так и записывать в нее. Хоть эта возможность и дает более быстрый доступ к памяти, она имеет неприятный эффект в виде состояния гонки, которое порождает несогласованность данных в программе.
Для разбора данного состояния попытаемся смоделировать банковский счет, где будем продолжать списывать и зачислять одну и ту же сумму из двух разных потоков. При этом итоговый баланс по завершению программы не должен измениться.

public class BankAccount {
   private long balance;

   public BankAccount(long balance) {
       this.balance = balance;
   }

   public void withdraw(long amount) {
       long newBalance = balance - amount;
       balance = newBalance;
   }

   public void deposit(long amount) {
       long newBalance = balance + amount;
       balance = newBalance;
   }

   @Override
   public String toString() {
       return String.valueOf(balance);
   }
}
Данный класс имеет два метода, выполняемые в многопоточной среде: один вносит сумму, а другой снимает ее с банковского счета.
Операции записи или считывания не являются атомарными, что означает, что они не выполняется как одна, неделимая операция, а состоят из трех операций: получение текущего значение баланса, его изменение и запись полученного значение обратно.
Поработаем с методами BankAccount, написав многопоточный код:

public class BankAccountTest {
    public static void main(String[] args) throws InterruptedException {
        BankAccount ba = new BankAccount(100);
        var t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                ba.deposit(100);
            }

        });

        var t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                ba.withdraw(100);
            }
        });
        
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(ba);
    }    
}
В коде выше мы создали экземпляр класса BankAccount с начальным балансом 100. Затем создали два потока, один из которых увеличивает счет, а другой наоборот — снимает средства со счета. Оба они выполняют свою операцию внутри цикла по 1000 раз.
Хоть и ожидается, что баланс измениться не должен, но это не так, причем конечное значение будет меняться при каждом запуске программы: иногда отрицательный, а иногда положительный, но он практически никогда не будет соответствовать первоначальному балансу.
Ситуация, при которой существует возможность получения некорректных результатов в неудачный момент времени настолько важна в параллельном программировании, что получила название, как мы выяснили выше, состояние гонки.
Условие гонки возникает, когда правильность вычисления зависит от относительного момента времени или от чередования нескольких потоков во время выполнения; другими словами, получение правильного ответа зависит от удачного момента времени.
Можем ли мы решить эту проблему, объявив переменную баланса как volatile? Нет. Ключевое слово volatile решает проблему видимости (чтения), но ситуация, с которой мы имеем дело сейчас, иная.
Давайте напишем нашу программу с помощью псевдокода:
Итак, только следующий порядок (который мы не можем гарантировать) работы программы сохранит точность расчета:

1) 1.1, 1.2, 2.1, 2.2
2) 2.1, 2.2, 1.1, 1.2
Что, если он будет такой?

3) 1.1, 2.1, 2.2, 1.2
Если код выполняется в указанном выше порядке, результат будет не таким, как мы ожидаем.
Переменная balance используется совместно в обоих потоках. Когда один поток изменяет/обновляет переменную, то для этого он выполняет несколько операций: в начале читает, а затем записывает.
Если поток читает до того, как другой поток закончит свою запись, то вот тут все и выходит из-под контроля.
Его работа  начинается с 1.1. После выполнения этой строки локальная переменная становится 100 + 100, что равно 200. 2.1 начинается сразу после нее, поэтому первый поток еще не может обновить значение переменной balance. В 2.1 поток считывает значение из переменной balance, вычитает из нее 100 и сохраняет результат в локальной переменной, которая теперь равна 0. В 2.2 обновляется значение переменной balance. А затем, когда выполняется 1.2, локальная переменная здесь равна 200, и она обновляет переменную balance вместе с ней.
На рис. выше показано возможное чередование с переупорядочением, приводящее к печати (0, 0).
Единственный способ устранить эту проблему — это если поток будет выполнять операцию записи автоматически. Пока он это делает, ни один другой поток не сможет прочитать его, пока он не завершит операции.
Решение проблемы заключается в создании взаимного исключения между потоками. Приведем практический пример: когда мы идем в уборную, мы закрываем ее, чтобы в это время никто другой не мог ей воспользоваться. Однако, когда кто-то заканчивает пользоваться удобствами, ими может воспользоваться кто-то другой.
Идея замка может быть использована и здесь. Когда поток считывает и записывает общую переменную, мы должны защитить ее блокировкой, чтобы никакой другой поток не мог получить к ней доступ во время этих операций.
Область в коде, которая считывает и записывает в переменную, называется критическим разделом. Если раздел кода не выполняется атомарно, то существует вероятность возникновения состояния гонки.
Его можно предотвратить, сохраняя критические значения внутри специального блока кода, называемого синхронизированным (synchronized block). Для его создания служит ключевое слово synchronized с объектом блокировки (в качестве аргумента у этого слова). Например, если мы перепишем с его использованием класс BankAccount, то проблема исчезнет.
Когда поток получает объект блокировки, никакой другой поток не сможет использовать этот объект. Как только поток разблокирует блокировку, другие потоки, отличные от исходного потока, могут получить этот объект снова (уже в своей блокировке). Это означает, что критическая часть кода теперь будет выполняться автоматически.

public class BankAccount {
   private long balance;
   private final Object lock = new Object();

   public BankAccount(long balance) {
       this.balance = balance;
   }

   public void withdraw(long amount) {
       synchronized (lock) {
           System.out.println("Acquired Lock: " + Thread.currentThread());
           long newBalance = balance - amount;
           balance = newBalance;
           System.out.println("Unlocked the lock: " + Thread.currentThread());
       }
   }

   public synchronized void deposit(long amount) {
       synchronized (lock) {
           System.out.println("Acquired Lock: " + Thread.currentThread());
           long newBalance = balance + amount;
           balance = newBalance;
           System.out.println("Unlocked the lock: " + Thread.currentThread());
       }
   }

   @Override
   public String toString() {
       return String.valueOf(balance);
   }
}
Если вы снова запустите метод main, результат будет таким, каким мы изначально его ожидали.
Другой способ заключается в том, что каждый объект Java имеет встроенную блокировку, которая называется monitor lock. Если мы добавим ключевое слово synchronized в сигнатуру метода, он использует встроенную блокировку. Пример:

public class Counter {
    private int count;

    public synchronized void increment() {
        this.count = this.count + 1;
    }

    public int getCount() {
        return count;
    }
}
Заключение
Теперь подытожим всё:
  • Когда переменная совместно используется несколькими потоками, при этом один из них записывает в нее — это критическая секция
  • Критическая секция должна быть защищена lock'ом. В противном случае, произойдет состояние гонки
  • Любой объект может быть использован в качестве блокировки в Java. Однако каждый объект имеет встроенную блокировку или monitor lock. Если мы используем ключевое слово synchronized в сигнатуре метода, то используется внутренняя блокировка
  • Synchronized-блок работает как атомарная операция, даже если он содержит более одного оператора
  • Если мы используем synchronized-блок для критической секции, то для общей переменной не нужно использовать ключевое слово volatile. Ключевое слово synchronized само по себе устраняет проблему видимости. Это означает, что переменная всегда считывается или записывается в основную память, в то время как volatile обеспечивает только считывание из основной памяти
Оцените статью, если она вам понравилась!