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

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

2.1 Порядок выполнения потоков

Пусть имеется следующий код:
class Playground {
    private static boolean running = true;

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

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

        t1.start();
        t2.start();
    }
}
В приведенном выше фрагменте создаются два потока с общей переменной boolean running. Внутри первого потока цикл while будет выполняться, пока эта переменная имеет значение true. Как только он завершится, напечатается 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. Вместо этого он будет считывать ее из основной памяти:
class Playground {
    private static volatile boolean running = true;
    ...
}
Примечание: ключевое слово volatile используется для переменных, которые могут изменяться разными потоками. Оно гарантирует, что значение этой переменной всегда будет читаться из основной памяти, а не из кэша потока, и что изменения этой переменной будут сразу видны всем потокам.
Теперь, если снова запустить программу, то Ситуация 3 никогда не произойдет.
Описанная выше проблема называется проблемой видимости (visibility problem).
Давайте разберем еще одну подобную ситуацию, используя для ее объяснения символы и псевдокод.
Определим следующий набор символов:
L — локальная переменная: L1, L2
S — общая переменная: S1, S2. Видна нескольким потокам, может быть статической
S.X — S является ссылочной переменной, а X — полем объекта
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
А теперь рассмотрим новый пример:
class Playground {
    private static volatile int S1;
    private static volatile int S2;

    public static void main(String[] args) {
        var t1 = new Thread(() -> {
            int L1 = S1; 
            S2 = 2;        
            System.out.println("Thread1: " + L1); 
        });

        var t2 = new Thread(() -> {
            int L2 = S2;        
            S1 = 1;             
            System.out.println("Thread2: " + L2); 
        });

        t1.start();
        t2.start();
    }
Псевдокод программы:
У этой программы вывод может быть следующим:
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

2.2 Оптимизация порядка выполнения

Кроме описанных выше трех вариантов, может существовать четвертый:
И пятый:
Но как это вообще возможно?
Ответ заключается в том, что хоть мы и пишем код в определенной последовательности, но это не означает, что компилятор и виртуальная машина выполнят его в заданном порядке. Они легко могут изменить его в качестве оптимизации (или вообще удалить, как «мертвый код»), если решат, что выходные данные не изменятся. Например, если в первом потоке поменять местами 1.1 и 1.2, то это не повлияет на конечный результат.
Такого рода изменения происходят по разным причинам. Например, интеллектуальный алгоритм компилятора может найти способ оптимизировать конкретный код для более быстрого выполнения. Но нужно понимать, что эти изменения могут стать потенциальным источником багов, которые не всегда легко обнаружить. У этого вида ошибок есть даже свое название — Heisenbugs (близкий по значению термин — «плавающая ошибка»). Баги данного типа могут исчезать при их поиске и появляться в произвольные моменты времени.
Рассмотрим другой пример:
class SharedData {
    volatile int X;
}

public class Playground {
    static SharedData S1 = new SharedData();
    static SharedData S2 = S1;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            SharedData L1 = S1;
            int L2 = L1.X;
            SharedData L3 = S2;
            int L4 = L3.X;
            int L5 = L1.X;
            System.out.println("Thread1: " + L2 + ", " + L4 + ", " + L5);
        });

        Thread thread2 = new Thread(() -> {
            SharedData L6 = S1;
            L6.X = 3;
            System.out.println("Thread2: " + L6.X);
        });

        thread1.start();
        thread2.start();
    }
}
Примечание: S1 и S2 не объявлены как volatile, поскольку их ссылки не изменяются после инициализации. В таком случае гарантируется, что все потоки увидят корректную ссылку на один и тот же объект. Если бы в процессе работы потоков переменным S1 и S2 присваивались новые объекты, тогда потребовалось бы объявить их volatile, чтобы другие потоки гарантированно видели актуальную ссылку.
Псевдокод программы:
В этой программе 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 и процессор. Таким образом, результат программы становится неопределенным. В многопоточной среде мы называем это гонкой данных (data race).
Data race — это проблема, которая возникает, когда два или больше потока одновременно пытаются работать с одной и той же переменной, и хотя бы один из них изменяет её значение, а другие считывают или меняют его. Если потоки не синхронизированы (не "договариваются" между собой, кто и когда может работать с переменной), результат программы становится непредсказуемым.
Data race приводит к разным результатам от запуска к запуску программы. Это чисто техническая проблема, связанная с неправильным доступом к данным.

2.3 Преимущества и недостатки volatile

Для решения проблемы гонки данных можно использовать ключевое слово volatile, которое применимо только для полей объекта. Это связано с тем, что локальные переменные не используются одновременно разными потоками в отличие от полей.
Если поле является final, то в volatile нет необходимости, поскольку final-поля никогда не меняются, а значит не могут создать озвученных ранее проблем.
Также нужно иметь в виду, что если ссылка на объект используется в качестве поля и является volatile, то это не означает, что содержимое объекта также будет volatile.
Преимущества volatile:
  • Поток всегда считывает данные из основной памяти
  • Процессор никогда не кэширует значения, что исключает возникновения проблемы видимости
  • Компилятору сообщается, что значение переменной может быть изменено в любое время и разделено между несколькими потоками, поэтому ему рекомендуется не оптимизировать код, что предотвращает возникновение гонки данных
Хотя ключевое слово volatile может быть хорошим решением, его чрезмерное использование может вызвать проблемы. Поскольку оно запрещает кэширование данных процессором, это немного снижает производительность программы и препятствует дальнейшей оптимизации.
Будьте осторожны при использовании volatile: применяйте его только там, где это действительно необходимо, а не подряд для всех полей.
Статья написана на основе следующих источников:
«Java Thread Programming (Part 3)»
«Java Thread Programming (Part 4)»
Автор и переводчик: Чимаев Максим
Оцените статью, если она вам понравилась!