Многопоточность в Java. Лекция 2: потоки, свойства потоков, блокировки

6 мая
Владимир Фролов, Java-разработчик, Никита Сизинцев, Android-разработчик
Многопоточность в Java. Лекция 2: потоки, свойства потоков, блокировки

Темную силу чувствую я.
Даешь парсек за три года.

Вводную статью о многопоточности в Java читайте здесь! В ее продолжении мы рассмотрим основы многопоточных программ: создание, запуск и свойства потока, синхронизацию потоков. Далее поговорим об использовании ключевого слова synchronized, volatile переменных и отношении happens-before. 

2.1 Средства для работы с многопоточностью в Java и модели многопоточных программ

В первой версии Java инструментов для работы с многопоточностью было немного. Основные средства: класс Thread, интерфейс Runnable, ключевое слово synchronized и методы для синхронизации wait(), notify() и notifyAll() в классе Object. В версию Java 1.5 уже был включен пакет java.util.concurrent, в котором появилось много новых классов и интерфейсов. Также в версии Java 1.8 добавили класс ComplitableFuture, который позволяет строить цепочки из асинхронных задач и комбинировать их. 

Существуют несколько подходов (моделей) в многопоточном программировании:

  • синхронизация, блокировки и ключевое слово volatile;
  • транзакционная память — прослойка между JVM и API программы, рекурсивный параллелизм;
  • модель акторов — когда каждый объект это поток, который обмениваются сообщениями с другими потоками. 

Сейчас процессоры хорошо поддерживают концепцию потоков. Например, akka (фрэймворк для работы с многопоточностью, портированный на разные языки программирования: Java, Scala, C#) написан на основе потоков и блокировок.

Способы организации многопоточности в программах:

  • потоки не взаимодействуют друг с другом, работают сами по себе; 
  • потоки взаимодействуют друг с другом;
  • потоки работают сами по себе, а потом собирают данные в единый результат. 

2.2  Свойства потоков, запуск потоков, присоединение других потоков

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

В Java поток представлен классом Thread. Создать и запустить поток можно двумя способами: 

1) Создать наследника от класса Thread и переопределить метод run().

 

Листинг 1:

public class MyThread extends Thread {
    public void run() {
        long sum = 0;
        for (int i = 0; i < 1000; ++i) {
            sum += i;
        }
        System.out.println(sum);
    }
}

MyThread t = new MyThread();

2) Реализовать интерфейс Runnable и передать объект полученного класса в конструктор класса Thread.

 

Листинг 2:

Runnable r = new MyRunnable() { () ->
    System.out.println(“Hello!”);
}

Thread t = new Thread(r);

Для запуска потока необходимо использовать метод Thread.start().  Если вызвать метод run(), то он выполнится в вызывающем потоке:

Листинг 3:

Thread t = new Thread(r);

t.run(); //код r выполняется в текущем потоке

t.start(); //код r выполняется в новом потоке

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

Объект текущего потока можно получить, вызвав статический метод: Thread.currentThread().

Имена потокам можно задавать через метод setName() или через параметр конструктора. Рекомендуется давать потокам осмысленные имена, это пригодится при отладке. Не рекомендуется давать потокам одинаковые имена, хотя имена потоков не валидируются JVM. 

Стандартный формат имен потоков, которые были созданы одиночно — thread-N, где N порядковый номер потока. Для пула потоков, стандартное наименование — pool-N-thread-M, где N обозначает последовательный номер пула (каждый раз, когда вы создаете новый пул, глобальный счетчик N увеличивается), а M — порядковый номер потока в пуле.

У потоков есть приоритет, который можно задать целым числом от 1 до 10. Чем больше число, тем выше приоритет потока. Поток main имеет приоритет 5. А приоритет новых потоков равен приоритету потока-родителя, его можно изменить при помощи метода setPriority(int). Поток с большим приоритетом будет иметь больше процессорного времени на выполнение. Если два потока имеют одинаковый приоритет, то решение о том, какой из них будет выполняться первым, зависит от алгоритма планировщика: (Round-Robin, First Come First Serve).

Есть несколько констант для приоритета потоков:

  • Thread.MIN_PRIORITY — минимальный приоритет, значение 1;
  • Thread.NORM_PRIORITY — приоритет по умолчанию, значение 5;
  • Thread.MAX_PRIORITY — максимальный приоритет потока, значение 10.

Листинг 4:

public class Main {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        Thread.currentThread().setPriority(8);
        Thread thread = new Thread() {
            public void run() {
                Thread.currentThread().setName("My name");
                System.out.println(Thread.currentThread().getName());
                System.out.println(Thread.currentThread().getPriority());
            }
        };
        thread.start();
    }
}

В Java есть такое понятие, как поток-демон. Работа JVM заканчивается, когда закончил выполняться последний поток не-демон, несмотря на работающие потоки-демоны. Для работы с этим свойством существуют два метода: setDaemon() и isDaemon(). 

Класс ThreadGroup. Все потоки находятся в группах, представленных экземплярами класса ThreadGroup. Группа указывается при создании потока. Если группа не была указана, то поток помещается в ту же группу, в которой находится поток-родитель. Методы activeCount() и enumerate() возвращают, соответственно, количество и полный список всех активных потоков в группе. 

Способы приостановления выполнения потока на указанное количество времени: Thread.sleep(long millis) и TimeUnit.<UNIT>.sleep(long timeout). Они приостанавливают выполнение текущего потока на указанный период времени. Вызов методов требует обработки исключения InterruptedException. 

Нестатический метод join() позволяет одному потоку дождаться выполнения другого. Если текущий поток t1 вызывает у другого потока t2h2t2.join(), то поток th2 останавливается до тех пор, пока поток t2 не завершит свою работу. Вызвать метод join() можно также и с аргументом, указывающим лимит времени ожидания (в миллисекундах или в миллисекундах с нано секундами). Если целевой поток t2 не закончит работу за указанный период времени, метод join() все равно вернет управление инициатору t1. 

2.3 Остановка и прерывание потоков

Для остановки потока в Java версии 1 использовался метод stop(). Однако в версии Java 1.1 этот метод сделали deprecated, потому что использование метода stop() не гарантирует корректного завершения работы потока и стабильной работы программы в целом. Поэтому при написании программ использовать его настоятельно не рекомендуется. 

Вместо метода stop() следует использовать метод interrupt(). В отличие от метода stop(), который принудительно останавливал поток, метод interrupt() предлагает потоку остановить свое выполнение путем установки флага interrupted в true внутри потока. Этот флаг отображает статус прерывания и имеет начальное значение false. Когда поток прерывается другим потоком, происходит одно из двух:

  • Если поток ожидает выполнения прерываемого метода блокирования, таких как Thread.sleep(), Thread.join() или Object.wait(), то ожидание прерывается и метод генерирует InterruptedException. Флаг interrupted устанавливается в false.
  • Флаг interrupted устанавливается в true.

Есть три метода для работы с прерыванием потока:

Листинг 5:

public class Thread {
    public void interrupt() { ... }
    public boolean isInterrupted() { ... }
    public static boolean interrupted() { ... }
...
}

  • Работа метода interrupt() описана выше.
  • isInterrupted() возвращает значение флага и не изменяет его.
  • interruped() возвращает значение флага и устанавливает его значение в false. Если флаг interrupted установлен в true и вызывается этот метод, то первый раз метод вернет true, а последующие вызовы вернут false.

Существуют два вида операций: блокирующие инеблокирующие. Неблокирующие операции не приостанавливают выполнения потока. К блокирующим операциям можно отнести вызовы методов sleep(), wait(), join() и, например, некоторые методы класса Socket. Если поток был прерван, пока он выполнял неблокирующие вычисления, они не будут прерваны незамедлительно. Однако поток уже отмечен как прерванный, поэтому любая следующая блокирующая операция немедленно прервется и выбросит InterruptedException. 

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

Листинг 6:

public void run() {
    while (Thread.currentThread().isInterrupted()) {
        someHeavyComputations();
    }
}

Обработка InterruptedException.

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

Первый способ обработки InterruptedException — объявление этого исключения в вышестоящем методе. Также при перехвате метода InterruptedException можно произвести какие-то действия (например, очистку ресурсов или переменных) и повторно пробросить InterruptedException. 

Во втором случае, когда InterruptedException объявить невозможно, при генерации и перехвате InterruptedException флаг interrupted устанавливается в false, и вызывающие методы не увидят, что было совершено прерывание потока. Однако можно восстановить флаг прерывания, вызвав Thread.currentThread().interrupt() при обработке прерывания. 

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

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

Листинг 7:

try {
    Object o = queue.take();
} catch InterruptedException e) {
}

Этот код некорректен, потому что поглощает (swallows) прерывание. Если этот код выполняется в tread pool, то воркер (thread pool worker) tread pool`а должен завершить исполнение, но этого не произойдёт, потому что исключение будет поглощено, и флаг будет сброшен.
Корректный код будет выглядеть так:

Листинг 8:

try {
    Object o = queue.take();
} catch InterruptedException e) {
    Thread.currentThread().interrupt();
}

В блоке catch происходит перехват исключения и установка флага в true. 

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

2.4 Синхронизация между потоками

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

Классический пример такого поведения: два потока инкрементируют одно значение. Так как операция инкремента не выполняется за одну инструкцию процессора, то два потока изменят значение переменной произвольным образом — это называется race condition. Блоки кода, в которых может возникнуть race condition, называются критическими секциями. Чтобы избежать такой ситуации в Java предусмотрены способы синхронизации потоков.

Простейший способ синхронизации — концепция «монитора» и ключевое слово synchronized.  Изначально эта концепция была введена в языке Pascal. В Java такого класса «монитор», нет, однако у каждого объекта типа Object есть свой собственный «монитор». Так как у всех классов общий родитель — Object, все они имеют свой собственный «монитор». 

Концепция «монитор» внутри себя содержит 4 поля: 

  1. locked типа boolean, которое показывает, захвачен монитор или нет; 
  2. owner типа Thread — в это поле записывается поток, который захватил данный монитор;
  3. blocked set — в это множество попадают потоки, которые не смогли захватить блокировку, или поток, который выходит из состояния wait;
  4. wait set — в это множество попадают потоки, для которых был вызван метод wait.

Рис 1. Внутреннее устройство концепции  «монитора»

Blocked set, как и wait set, представляет собой неупорядоченное множество, не допускающее дубликатов. Т. е. в wait set или blocked set один и тот же поток не может быть записан два раза.

Поля монитора невозможно получить через рефлексию. У каждого объекта есть методы wait(), notify() и notifyAll(), которые этот объект унаследовал от класса Object. Использование ключевого слова synchronized гарантирует, что блоки кода будут выполняться только одним потоком в каждую конкретную единицу времени. 

Есть два варианта использования ключевого слова synchronized: 

  1. Два потока выполняют код (так называемая критическая секция), который в каждый момент времени может выполнять только один поток. 
  2. Один поток ожидает какое-то событие. Это поведение обеспечивается методами wait(), notify() и notifyAll().

Рассмотрим первую ситуацию: поток попадает в synchronized блок, выполняет критическую секцию и выходит из блока синхронизации. Ключевое слово synchronized всегда используется с объектом монитор. Сперва проверяются переменные locked и owner. Если эти поля false и null, соответственно,  они заполняются. Поле locked принимает значение true, а в поле owner записывается ссылка на захватывающий поток. Как только это произошло, считается, что поток выполнил код, который соответствует открывающей фигурной скобке synchronized блока, и поток занял эту блокировку. После того как поток выполнил код, который соответствует закрывающейся фигурной скобке блока синхронизации, переменные locked и owner в мониторе очищаются. 

Рассмотрим ситуацию, когда поток пытается захватить уже занятый монитор. Сначала проверяется, что переменная locked == true, затем сравнивается переменная owner. Если переменная owner не равна тому потоку, который хочет захватить монитор, то второй поток блокируется и попадает в blocked set монитора. Если сравнение переменных owner дает результат true, это значит, что один и тот же поток пытается захватить монитор повторно — в этом случае поток не блокируется. Такое поведение называется реентернабельностью. Пример такой ситуации — рекурсивные методы. После того, как блокировка освободилась, другой поток покидает blocked set и захватывает монитор. В blocked set может находится множество потоков. В этом случае выбирается произвольный поток, который далее может захватить монитор.

Монитором может выступать простой объект, ключевое слово this, а также объект типа .class. Примеры в Листинге 9.

Листинг 9:

public class SomeClass {
    private final Object PRIVATE_LOCK_OBJECT = new Object();
    public synchronized void firstMethod() {
        //some code
    }
    public void theSameAsFirstMethod() {
        synchronized(this) {
            //some code
        }
    }
    public void theBestMethodUsingSynchr() {
        synchronised(PRIVATE_LOCK_OBJECT) {
            //some code
        }
    }
    public static void synchronizedOnStaticMethod() {
        synchronized(SomeClass.class) {
            //some code
        }
    }
    public static synchronized void synchronizedOnStaticMethod() {
        //some code
    }
}

Когда метод объявляется с ключевым словом synchronized, это эквивалентно коду, когда всё его тело обернуто в synchronized блок и блокировкой служит объект this. Когда статический метод используется с ключевым словом synchronized, это эквивалентно тому, когда в качестве блокировки используется объект SomeClass.class. Однако самый лучший способ — объявить private final константу, по которой и производится синхронизация. Стоит заметить, что конструкция с использованием ключевого слова synchronized — синтаксическая и проверяется компилятором. Т. е. всегда должна быть открывающая фигурная скобка и соответствующая ей закрывающая фигурная скобка synchronized блока. Synchronized блоки могут быть вложенными друг в друга (см. Листинг 10).

Листинг 10:

……

final Object LOCK = new Object();
synchronized(LOCK) {
    synchronized(LOCK) {
        synchronized(LOCK) {
        }
    }
}

Как показано в Листинге 10, можно несколько раз захватить монитор на одном и том же объекте. Нет способа определить, сколько раз был захвачен монитор, и не стоит строить такую логику в программе. Освобождение монитора происходит после выхода из верхнего synchronized блока. В Листинге 11 показан еще один вариант вложенных синхронизаций.

Листинг 11:

….

Object LOCK_A = new Object();
Object LOCK_B = new Object();
Object LOCK_C = new Object();
synchronized(LOCK_A) {
    synchronized(LOCK B) {
        synchronized(LOCK_C) {
        }
    }
}

В Листинге 11 сначала захватываются мониторы LOCK_A, затем LOCK_B и LOCK_С, а освобождаются мониторы в обратном порядке.

Еще одна ситуация, в которой используется ключевое слово synchronized — использование методов wait(), notify() и notifyAll(). При использовании этих методов необходимо всегда захватывать монитор объекта, на котором будут вызываться эти методы. Если не захватывать монитор, будет сгенерировано IllegalMonitorStateException (см. Листинг 12).

Листинг 12:

public class MainClass  {
    public static void main(String [] args) throws InterruptedException {
        final Object lock = new Object();
        lock.wait(); //будет сгенерирован IllegalMonitorStateException
    }
}

Листинг 13:

public class MainClass  {
    private static final Object LOCK = new Object();
    public static void main(String [] args) throws InterruptedException {
        synchronized(LOCK) {
            LOCK.wait();
        }
    }
}

В Листинге 13 поток main захватывает монитор объекта LOCK и вызывает метод wait() на LOCK. После вызова этого метода поток main попадает в wait set монитора LOCK. При этом монитор LOCK ОСВОБОЖДАЕТСЯ, т. е. очищается поле owner, а поле locked принимает значение false. Такое поведение гарантирует, что если какой-то другой поток захочет ожидать какого-то события на этом объекте, то он может захватить монитор LOCK и попасть в wait set. 

Для того чтобы потоки, которые находятся в wait set, продолжили свое выполнение, другой поток должен захватить монитор LOCK и на LOCK вызвать методы notify() или notifyAll(). После вызова метода notify() из wait set выбирается произвольный поток и переводится в blocked set. Если был вызван метод notifyAll(), то все потоки из wait set переводятся в blocked set. Это происходит потому, что монитор LOCK занят тем потоком, который вызвал метод notify или notifyAll(). После того как этот поток выйдет из synchronized блока, нотифицированные потоки будут по одному захватывать монитор и продолжать выполнение. Методы wait(), notify() и notifyAll() используются для ожидания выполнения какого-то условия, а не для передачи данных.

Из состояния wait можно выйти несколькими способами:

  • были вызваны методы notify() или notifyAll();
  • у потока вызвали метод interrupt(), после чего будет сгенерирован InterruptedException;
  • поток случайно проснулся (spurious wakeup);
  • по истечение таймаута, если использовали wait с таймаутом.

Иногда поток, вызвавший метод wait на каком-то объекте блокировки, может случайно проснуться. Эта ситуация называется spurious wakeup. Случайные пробуждения случаются крайне редко (такого почти не бывает) но чтобы гарантированно избежать этого эффекта, необходимо вызывать метод wait() в цикле.

Листинг 14:

synchronized(obj) {
    while(<condition doesn`t hold>) {
        obj.wait();
    }
}

Есть два случая, когда поток может попасть в blocked set:

  1. Блокировка занята другим потоком, как это было показано ранее.
  2. Поток после ожидания в wait set продолжает выполнение, промежуточно попадая в blocked set. Это происходит потому, что другой поток, вызвавший notify() или notifyAll(), захватил блокировку.

Рассмотрим, почему объект блокировки необходимо всегда делать закрытой неизменяемой переменной в классе private final Object obj = new Object(). Считается плохим стилем, если объект синхронизации виден снаружи класса.

Листинг 15: 

class X {
    public synchronized void method1() {
    }
}

public class TestX {
    public void someMethod(X x) {
        synchronized(x) {
            while(true);
        }
    }
}

В таком коде никакой поток не сможет вызвать метод method1() у объекта x. Все потоки, которые попытаются вызвать метод method1() у объекта x, будет заблокированы. Еще один некорректный пример в Листинге 16.

Листинг 16:

public class TestX {
    public void someMethod(X x) {
        synchronized(x) {
            while(true) {
                x.wait();
            }
        }
    }
}

Если у объекта x будут вызывать x.notify(), цикл в Листинге 16 будет поглощать все вызовы метода notify(), т. е. поток, который выполняет код, будет всегда в wait set. Чтоб избежать таких ошибок, следует использовать private final объект-блокировку, как в одном из примеров выше. Также не следует использовать объект-блокировку для хранения какой либо информации. Это нарушает принцип single responsibility и усложняет чтение и понимание программы.

2.5 Состояния потока

У потоков есть следующие состояния:

  • NEW объект потока был создан, но не запущен; 
  • RUNNABLE поток запущен и выполняет код;
  • WAITING поток не выполняет код и находится в wait set монитора;
  • BLOCKED поток не выполняет код и находится в blocked set монитора;
  • TERMINATED поток завершил свое выполнение.

Рис 2. Схема переходов потока из одного состояния в другое

Состояния потоков представлены в перечислении Thread.State. 

2.6 Ключевое слово volatile

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

В многопоточном приложении, когда потоки используют не volatile переменные, они могут скопировать значение переменных в кэш процессора для улучшения производительности. Если в процессоре несколько ядер и каждый поток выполняется на отдельном ядре процессора, одна и та же переменная может иметь разное значение на каждом ядре процессора. В результате будет несколько копии одной и той же переменной: копии в кэше каждого ядра процессора и копия переменной в основной памяти. При использовании не volatile переменных нельзя знать наверняка, когда JVM читает значение переменной из главной памяти и когда записывается значение переменной в главную память. Это может привести к проблемам. Предположим, есть два потока, имеющих доступ к общему объекту, у которого есть счетчик (См. Листинг 17).

Листинг 17:

public class SharedObject {
    public int counter = 0;
}

Предположим, что только первый поток инкрементирует переменную и оба потока могут читать переменную. Если переменная counter не volatile, то нет никакой гарантии, когда переменная будет записана в основную память, чтоб вновь измененное значение переменной увидел второй поток. Эта проблема решается путем объявления переменной counter как volatile (см. Листинг 18).

Листинг 18:

public class SharedObject {
    public volatile int counter = 0;
}

бъявление переменной как volatile гарантирует, что любое чтение и любая запись в эту переменную сразу будет попадать в главную память. Объявления переменной counter как volatile достаточно, когда один поток изменяет переменную, а другой поток читает ее значение. Если два потока изменяют общую переменную, то использования ключевого слова volatile недостаточно — будет race condition. Ключевое слово volatile гарантирует следующее:

  • Если первый поток записывает в volatile переменную, а затем второй поток читает значение из этой переменной, тогда все переменные, видимые потоку A перед записью в переменную volatile, также будут видны потоку B, после того как он прочитал переменную volatile.
  • Если поток A считывает переменную volatile, то все переменные, видимые потоку A при чтении переменной volatile, также будут перечитаны из основной памяти. Пример в Листинге 19.

Листинг 19:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

При записи значения в volatile переменную days гарантируется, что запись остальных переменных years и months тоже будет произведена в главную память. Чтение можно выполнить следующим способом (см. Листинг 20).

Листинг 20:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

В Листинге 20 в методе totalDays сначала производится чтение volatile переменной days, а затем производится чтение остальных переменных. Это чтение производится с главной памяти программы. 

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

Листинг 21:

int a = 1;
int b = 2;
a++;
b++;
//changes to
int a = 1;
a++;
int b = 2;
b++;

Рассмотрим модифицированный код из Листинга 22.

Листинг 22:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

В Листинге 22 изменен порядок записи в volatile переменную и в обычные переменные по сравнению с примером из листинга 20. В Java есть решение проблемы перестановки инструкций, которое будет рассмотрено в следующем пункте.

В программах, которые используют многопоточность, встречаются ситуации, когда использование ключевого слова volatile недостаточно для корректной работы программы в целом. Например, есть два потока, которые одновременно изменяют общий счетчик. Необходимо прочитать значение из переменной, увеличить значение переменной, а затем записать значение в общую память. Предположим, что два потока прочитали одно и то же значение, допустим, равное единице. Каждый поток увеличил значение на 1, и первый поток записал значение 2 в главную память, а затем и второй поток записал значение 2 в общую память. Однако после записи второго потока в общую память значение должно быть 3. Такая логика приведет к race condition и некорректному поведению программы. В этом случае надо использовать ключевое слово synchronized, либо атомарные переменные, которые будут рассмотрены в последующей статье.

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

2.7 Отношение happens-before

Отношение happens-before гарантирует, что результаты операции в одном потоке будут видны в другом действии в другом потоке. Отношение happens-before определяет частичное упорядочение всех действий внутри программы. Чтобы гарантировать, что поток, выполняющий действие Y, может видеть результаты действия X (независимо от того, происходят ли X и Y в разных потоках), между X и Y должно существовать отношение happens-before. При отсутствии отношения happens-before между двумя действиями JVM может переставить операции как угодно, это происходит за счёт оптимизации компилятора JVM.

Рис 3. Отношение happens-before

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

Отношение happens-before возможно в следующих случаях.

  • Отношение happens-before в одном потоке — действие, выполняемое в одном потоке всегда happens-before другого действия, которое выполняется позже в этом же потоке.

Рис 4. Отношение happens-before в одном потоке. 

  • Захват и освобождение монитора — отпускание монитора (выход из синхронизированного метода или выход из блока) всегда находится в отношении happens-before к захвату этого же монитора.

Рис 5. Отношение happens-before при захвате и отображения монитора.

  • Чтение/запись переменных не могут быть перемещены и поставлены до чтения volatile поля, если изначально они находились после него. При этом есть возможность переместить чтение переменных, которые находились до чтения volatile поля, чтобы они произошли после него.
  • Запуск потока — вызов метода start() всегда в отношении happens-before к первой операции в запускаемом потоке.

Рис 6. Отношение happens-before при запуске потока.

  • Выполнение happens-before для метода join():

Рис 7. Отношение happens-before при использовании метода join.

2.8 Заключение

В этой статье мы рассмотрели основы многопоточных программ: создание и запуск потока, свойства потока, синхронизация потоков, состояния, в которых может находится поток. Приведенные примеры демонстрируют, как корректно использовать ключевое слово synchronized и к каким последствиям может привести их неправильное использование. В конце рассказали о volatile переменных и отношении happens-before. Такие знания должны стать хорошей основой для дальнейшего изучения, понимания и написания многопоточных программ.