JavaRush/Java блог/Java Developer/Управление потоками. Ключевое слово volatile и метод yiel...
Автор
Владимир Портянко
Java-разработчик в Playtika

Управление потоками. Ключевое слово volatile и метод yield()

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

Ключевое слово 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-переменные происходит раньше, чем чтение из них.
Комментарии (163)
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
28 октября 2023, 16:43
Почему вы написали Thread.start() happens before Thread.run()? Это ведь не статические методы
Евгений N
Уровень 23
15 августа 2023, 13:53
ситуация, при которой поток B выведет в консоль 0, исключена. Запись в volatile-переменные происходит раньше, чем чтение из них. не очень понял... если поток В будет читать ДО присвоения, разве он не получит 0 ?
volatile int z;.  //  что получит поток B ,читая z в этот момент?
z= 555;
Grock
Уровень 44
21 августа 2023, 09:58
Тоже такой вопрос возник. UPD. Ответ есть ниже в сообщении Михаила "Кладовщик с кучей времени" 10 декабря 2021, 18:06 в ответах. Поэтому, чтобы этого избежать поможет synchronized, а не volatile.
Zhandos
Уровень 30
28 февраля, 08:34
как я понял volatile переменные не будет кэшироваться чтение и запись будет происходить из общей памяти
Rustam
Уровень 35
Student
11 августа 2023, 10:17
Классная статья!
No Name
Уровень 32
1 июля 2023, 04:12
+ статья в копилке
Ислам
Уровень 33
26 июня 2023, 13:35
Nice
10 июня 2023, 07:55
Вот четыре основных правила "happens-before" в Java: Правило захвата монитора (Monitor Lock Rule): Если поток A захватывает монитор объекта X, а затем выполняет операцию, а поток B должен захватить тот же монитор X, чтобы увидеть результаты изменений, выполненных потоком A. Другими словами, все операции, выполненные потоком A до освобождения монитора X, будут видны потоку B после захвата того же монитора X.
// Поток A
synchronized (lock) {
    sharedVariable = 10;
}

// Поток B
synchronized (lock) {
    int value = sharedVariable; // Гарантируется, что значение 10 будет видно потоку B
}
10 июня 2023, 07:55
Правило передачи между потоками (Thread Start Rule): Если поток A запускает поток B с использованием метода start(), то все операции, выполненные перед запуском потока B в потоке A, будут видны в потоке B после его запуска.
// Поток A
sharedVariable = 10;
Thread threadB = new Thread(() -> {
    int value = sharedVariable; // Гарантируется, что значение 10 будет видно потоку B
});
threadB.start();
10 июня 2023, 07:56
Правило завершения потока (Thread Termination Rule): Если поток A завершается (завершает свою работу) до того, как поток B вызывает метод join() для потока A, то все операции, выполненные в потоке A, будут видны в потоке B после вызова метода join().
// Поток A
sharedVariable = 10;

// Поток B
Thread threadB = new Thread(() -> {
    // Выполняется работа потока B
});
threadB.start();
threadB.join();
int value = sharedVariable; // Гарантируется, что значение 10 будет видно потоку B после вызова join()
10 июня 2023, 07:56
Правило передачи через volatile-переменные (Volatile Variable Rule): Если поток A записывает значение в volatile-переменную, а поток B читает это значение из той же volatile-переменной, то все операции, выполненные потоком A перед записью в volatile-переменную, будут видны потоку B после чтения из нее.
// Поток A
sharedVolatileVariable = 10;

// Поток B
int value = sharedVolatileVariable; // Гарантируется, что значение 10 будет видно потоку B
10 июня 2023, 07:39
Даже на 64-битных платформах, использование volatile с 64-битными переменными, такими как long и double, может быть недостаточным для обеспечения атомарности сложных операций. Например, если два потока пытаются одновременно выполнить операцию инкремента на volatile long переменной, может возникнуть состояние гонки, потому что инкремент состоит из нескольких операций чтения, модификации и записи. В таких случаях для обеспечения атомарности операций или синхронизации между потоками следует использовать средства синхронизации, такие как synchronized блоки или классы из пакета java.util.concurrent.atomic, которые предоставляют атомарные операции над переменными, включая 64-битные переменные.
xxxx
Уровень 23
10 июня 2023, 08:33
бро, поделись ссылкой
25 марта 2023, 14:03
зачем вплетать проблему атомарности операции над long и double к работе volatile !?
16 февраля 2023, 21:30
volatile в априори не может создавать атомарное представление переменной, он лишь отменяет ее кэширование, что косвенно делает ее атомарной, но volatile != 100% атомарность
Alexandr
Уровень 20
5 января 2023, 12:43
Правило 4 "Запись в volatile переменную happens-before чтение из той же переменной" Само собой это не происходит, мы сами это регулируем. Если мы запустим чтение/запись с разных потоков, какой поток и когда прочитает переменную c volatile зависит от самих потоков, и их конкуренции. Сдесь вывод будет разный, но в основном по первому потоку который был запущен.
public class Main {

	public volatile static String message = "No changes";

	public static void main(String[] args) throws InterruptedException {
		new FreeThread().start();
		new MyThread().start();
	}

	public static class MyThread extends Thread {
		@Override
		public void run() {
			message = "Message was changed";
		}
	}

	public static class FreeThread extends Thread {
		@Override
		public void run() {
			System.out.println(message);
		}
	}
}
Первые 3 правила очевидны, но четвертое от нас зависит.