Привет! Мы продолжаем изучение многопоточности, и сегодня познакомимся с новым ключевым словом — volatile и методом yield(). Давай разберемся, что это такое :)
Управление потоками. Ключевое слово volatile и метод yield() - 1

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

При создании многопоточных приложений мы можем столкнуться с двумя серьезными проблемами. Во-первых, в процессе работы многопоточного приложения разные потоки могут кэшировать значения переменных (мы уже говорили об этом в лекции «Применение volatile»). Возможна ситуация, когда один поток изменил значение переменной, а второй не увидел этого изменения, потому что работал со своей, кэшированной копией переменной. Естественно, последствия могут быть серьезными. Представь, что это не просто какая-то «переменная», а, например, баланс твоей банковской карты, который вдруг начал рандомно скакать туда-сюда :) Не очень приятно, да? Во-вторых, в Java операции чтения и записи полей всех типов, кроме long и double, являются атомарными. Что такое атомарность? Ну, например, если ты в одном потоке меняешь значение переменной int, а в другом потоке читаешь значение этой переменной, ты получишь либо ее старое значение, либо новое — то, которое получилось после изменения в потоке 1. Никаких «промежуточных вариантов» там появиться не может. Однако с long и double это не работает. Почему? Из-за кроссплатформенности. Помнишь, мы еще на первых уровнях говорили, что принцип Java — «написано однажды — работает везде»? Это и есть кроссплатформенность. То есть Java-приложение запускается на абсолютно разных платформах. Например, на операционных системах Windows, разных вариантах Linux или MacOS, и везде это приложение будет стабильно работать. Long и double — самые «тяжеловесные» примитивы в Java: они весят по 64 бита. И в некоторых 32-битных платформах просто не реализована атомарность чтения и записи 64-битных переменных. Такие переменные читаются и записываются в две операции. Сначала в переменную записываются первые 32 бита, потом еще 32. Соответственно, в этих случаях может возникнуть проблема. Один поток записывает какое-то 64-битное значение в переменную Х, и делает он это «в два захода». В то же время второй поток пытается прочитать значение этой переменной, причем делает это как раз посередине, когда первые 32 бита уже записаны, а вторые — еще нет. В результате он читает промежуточное, некорректное значение, и получается ошибка. Например, если на такой платформе мы попытаемся записать в переменную число — 9223372036854775809 — оно будет занимать 64 бита. В двоичной форме оно будет выглядеть так: 1000000000000000000000000000000000000000000000000000000000000001 Первый поток начнет запись этого числа в переменную, и сначала запишет первые 32 бита: 10000000000000000000000000000000 а потом вторые 32: 0000000000000000000000000000001 И в этот промежуток может вклиниться второй поток, и прочитать промежуточное значение переменной — 10000000000000000000000000000000, первые 32 бита, которые уже были записаны. В десятичной системе это число равняется 2147483648. То есть мы всего лишь хотели записать число 9223372036854775809 в переменную, но из-за того, что эта операция на некоторых платформах является не атомарной, у нас из ниоткуда возникло «левое», ненужное нам число 2147483648, и неизвестно как оно повлияет на работу программы. Второй поток просто прочитал значение переменной до того, как оно окончательно записалось, то есть первые 32 бита он увидел, а вторые 32 бита — нет. Эти проблемы, конечно, возникли не вчера, и в Java они решаются с помощью всего одного ключевого слова — volatile. Если мы объявляем в нашей программе какую-то переменную, со словом volatile…
public class Main {

   public volatile long x = 2222222222222222222L;

   public static void main(String[] args) {

   }
}
…это означает, что:
  1. Она всегда будет атомарно читаться и записываться. Даже если это 64-битные double или long.
  2. Java-машина не будет помещать ее в кэш. Так что ситуация, когда 10 потоков работают со своими локальными копиями исключена.
Вот так две очень серьезные проблемы решаются одним словом :)

Метод yield()

Мы рассмотрели уже много методов класса Thread, но есть один важный, который будет для тебя новым. Это метод yield(). С английского переводится как «уступать». И это ровно то, что метод делает! Управление потоками. Ключевое слово volatile и метод yield() - 2Когда мы вызываем метод yield у потока, он фактически говорит другим потокам: «Так, ребята, я никуда особо не тороплюсь, так что если кому-то из вас важно получить время процессора — берите, мне не срочно». Вот простой пример того, как это работает:
public class ThreadExample extends Thread {

   public ThreadExample() {
       this.start();
   }

   public void run() {

       System.out.println(Thread.currentThread().getName() + " уступает свое место другим");
       Thread.yield();
       System.out.println(Thread.currentThread().getName() + " has finished executing.");
   }

   public static void main(String[] args) {
       new ThreadExample();
       new ThreadExample();
       new ThreadExample();
   }
}
Мы последовательно создаем и запускаем три потока — Thread-0, Thread-1 и Thread-2. Thread-0 запускается первым и сразу уступает место другим. После него запускается Thread-1, и тоже уступает. После — запускается Thread-2, который тоже уступает. Больше потоков у нас нет, и после того, как Thread-2 последним уступил свое место, планировщик потоков смотрит: «Так, новых потоков больше нет, кто у нас там в очереди? Кто уступал свое место последним, перед Thread-2? Кажется, это был Thread-1? Окей, значит пусть он и выполняется». Thread-1 выполняет свою работу до конца, после чего планировщик потоков продолжает координацию: «Окей, Thread-1 выполнился. Есть у нас кто-то еще в очереди?». В очереди есть Thread-0: он уступал свое место сразу до Thread-1. Теперь дело дошло до него, и он выполняется до конца. После чего планировщик заканчивает координацию потоков: «Ладно, Thread-2, ты уступил место другим потокам, они все уже отработали. Ты уступал место последним, так что теперь твоя очередь». После этого отрабатывает до конца поток Thread-2. Вывод в консоль будет выглядеть так: Thread-0 уступает свое место другим Thread-1 уступает свое место другим Thread-2 уступает свое место другим Thread-1 закончил выполнение. Thread-0 закончил выполнение. Thread-2 закончил выполнение. Планировщик потоков, конечно, может запустить потоки в другом порядке (например, 2-1-0 вместо 0-1-2), но сам принцип неизменный.

Правила «happens-before»

Последнее, чего мы коснемся сегодня, это принципы «happens before». Как ты уже знаешь, в Java основную часть работы по выделению времени и ресурсов потокам для выполнения их задач выполняет планировщик потоков. Также ты не раз видел, как потоки выполняются в произвольном порядке, и чаще всего предсказать его невозможно. Да и вообще, после «последовательного» программирования, которым мы занимались до этого, многопоточность выглядит рандомной штукой. Как ты уже убедился, ход работы многопоточной программы можно контролировать при помощи целого набора методов. Но в дополнение к этому в многопоточности Java существует еще один «островок стабильности» — 4 правила под названием «happens-before». Дословно с английского это переводится как «происходит перед», или «происходит раньше, чем». Понять смысл этих правил достаточно просто. Представь, что у нас есть два потока — A и B. Каждый из этих потоков может выполнять операции 1 и 2. И когда в каждом из правил мы говорим «A happens-before B», это означает, что все изменения, выполненные потоком A до момента операции 1 и изменения, которые повлекла эта операция, видны потоку B в момент выполнения операции 2 и после выполнения этой операции. Каждое из этих правил дает гарантию, что при написании многопоточной программы одни события в 100% случаев будут происходить раньше, чем другие, и что поток B в момент выполнения операции 2 всегда будет в курсе изменений, которые поток А сделал во время операции 1. Давай рассмотрим их.

Правило 1.

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

Правило 2.

Метод Thread.start() happens before Thread.run(). Тоже ничего сложного. Ты уже знаешь: чтобы начал выполняться код внутри метода run(), необходимо вызвать у потока метод start(). Именно его, а не сам метод run()! Это правило гарантирует, что установленные до запуска Thread.start() значения всех переменных будут видны внутри начавшего выполнение метода run().

Правило 3.

Завершение метода run() happens before выход из метода join(). Вернемся к нашим двум потокам — А и B. Мы вызываем метод join() таким образом, чтобы поток B обязательно дождался завершения A, прежде чем выполнять свою работу. Это означает, что метод run() объекта A обязательно отработает до самого конца. И все изменения в данных, которые произойдут в методе run() потока A стопроцентно будут видны в потоке B, когда он дождется завершения A и начнет работу сам.

Правило 4.

Запись в volatile переменную happens-before чтение из той же переменной. При использовании ключевого слова volatile мы, фактически, всегда будем получать актуальное значение. Даже в случае с long и double, о проблемах с которыми говорилось ранее. Как ты уже понял, изменения, сделанные в одних потоках, далеко не всегда видны другим потокам. Но, конечно, очень часто встречаются ситуации, когда подобное поведение программы нас не устраивает. Допустим, в потоке A мы присвоили значение переменной:
int z;.

z= 555;
Если наш поток B должен вывести значение переменной z на консоль, он запросто может вывести 0, потому что не знает о присвоенном ей значении. Так вот, Правило 4 гарантирует нам: если объявить переменную z как volatile, изменения ее значений в одном потоке всегда будут видны в другом потоке. Если мы добавим в предыдущий код слово volatile...
volatile int z;.

z= 555;
...ситуация, при которой поток B выведет в консоль 0, исключена. Запись в volatile-переменные происходит раньше, чем чтение из них.