Статьи

Понимание Volatile через пример

Мы провели последние пару месяцев, стабилизируя  функциональность обнаружения блокировки в Plumbr . Во время этого мы столкнулись со многими сложными проблемами параллелизма. Многие из проблем являются уникальными, но один конкретный тип проблем постоянно появляется.

Вы могли догадаться — неправильное использование  ключевого слова volatile  . Мы обнаружили и решили множество проблем, в результате которых интенсивное использование энергозависимых компонентов делало произвольные части приложения медленнее, увеличивало время удержания блокировок и, в конечном итоге, приводило JVM на колени. Или наоборот — предоставление слишком либеральной политики доступа вызвало некоторые неприятные проблемы параллелизма.

Я предполагаю, что каждый Java-разработчик вспоминает первые шаги в языке. Дни и дни, проведенные с учебниками и учебными пособиями. Все эти учебники содержали список ключевых слов, среди которых  изменчивое было одним из самых страшных. Шли дни, и все больше и больше кода было написано без использования этого ключевого слова, многие из нас забыли о существовании  volatile . Пока производственные системы не начали либо портить данные, либо умирать непредсказуемым образом. Отладка таких случаев вынудила некоторых из нас понять концепцию. Но держу пари, что это был не очень приятный урок, поэтому, может быть, я смогу спасти некоторых из вас, пролив свет на концепцию на простом примере.

Пример изменчивости в действии

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

Первый из двух потоков реализован как  CustomerInLine.  Этот поток ничего не делает, кроме как ожидает, пока значение в  NEXT_IN_LINE не совпадет с заявкой  клиента. Номер билета жестко закодирован, чтобы быть № 4. Когда приходит время ( NEXT_IN_LINE> = 4), поток  объявляет, что ожидание закончено и заканчивается. Это моделирует клиента, прибывающего в офис с некоторыми клиентами, уже находящимися в очереди.

Реализация очереди находится в   классе Queue, который запускает цикл, вызывающий следующего клиента, а затем моделирует работу с клиентом, спя 200 мс для каждого клиента. После вызова следующего клиента значение, сохраненное в переменной класса  NEXT_IN_LINE  , увеличивается на единицу.

public class Volatility {

	static int NEXT_IN_LINE = 0;

	public static void main(String[] args) throws Exception {
		new CustomerInLine().start();
		new Queue().start();
	}

	static class CustomerInLine extends Thread {
		@Override
		public void run() {
			while (true) {
				if (NEXT_IN_LINE >= 4) {
					break;
				}
			}
			System.out.format("Great, finally #%d was called, now it is my turn\n",NEXT_IN_LINE);
		}
	}

	static class Queue extends Thread {
		@Override
		public void run() {
			while (NEXT_IN_LINE < 11) {
				System.out.format("Calling for the customer #%d\n", NEXT_IN_LINE++);
				try {
					Thread.sleep(200);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
}

Таким образом, при запуске этой простой программы вы можете ожидать, что результат программы будет похож на следующее:

Calling for the customer #1
Calling for the customer #2
Calling for the customer #3
Calling for the customer #4
Great, finally #4 was called, now it is my turn
Calling for the customer #5
Calling for the customer #6
Calling for the customer #7
Calling for the customer #8
Calling for the customer #9
Calling for the customer #10

Как оказалось, предположение неверно. Вместо этого вы увидите   обработку очереди в списке из 10 клиентов, и несчастный поток, имитирующий клиента № 4, никогда не предупредит, что он видел приглашение. Что случилось и почему клиент все еще сидит там и ждет бесконечно?

Анализируя результат

Здесь вы видите оптимизацию JIT, применяемую к коду, который кэширует доступ к  переменной NEXT_IN_LINE . Оба потока получают свою собственную локальную копию, и   поток CustomerInLine никогда не видит  очередь,  фактически увеличивающую значение потока. Если вы теперь думаете, что это какая-то ужасная ошибка в JVM, то вы не совсем правы — компиляторы могут делать это, чтобы не перечитывать значение каждый раз. Таким образом, вы получаете повышение производительности, но за счет затрат — если другие потоки изменяют состояние, поток, кэширующий копию, не знает об этом и работает с использованием устаревшего значения.

Это как раз тот случай, когда  летучий . С этим ключевым словом компилятор предупрежден, что определенное состояние является изменчивым, и код вынужден перечитывать значение каждый раз, когда выполняется цикл. Благодаря этим знаниям у нас есть простое решение — просто измените объявление  NEXT_IN_LINE  на следующее, и ваши клиенты не останутся в очереди навсегда:

static volatile int NEXT_IN_LINE = 0;

Для тех, кто доволен только пониманием варианта использования  volatile , вам пора. Просто помните о дополнительных затратах — когда вы начинаете объявлять все  нестабильными,  вы заставляете ЦП забывать о локальных кешах и переходить прямо в основную память, замедляя код и забивая шину памяти.

Летучий под капотом

Для тех, кто хочет разобраться в проблеме более подробно, оставайтесь со мной. Чтобы увидеть, что происходит внизу, давайте включим отладку, чтобы увидеть код сборки, сгенерированный из байт-кода JIT. Это достигается указанием следующих параметров JVM:

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

Запуск программы с включенными этими параметрами и   включением и отключением volatile дает нам следующее важное понимание:

Выполнение кода  без   ключевого слова volatile показывает, что по инструкции 0x00000001085c1c5a мы сравниваем два значения. Если сравнение не удается, мы продолжаем с 0x00000001085c1c60 до 0x00000001085c1c66, который возвращается к 0x00000001085c1c60, и возникает бесконечный цикл.

  0x00000001085c1c56: mov    0x70(%r10),%r11d
  0x00000001085c1c5a: cmp    $0x4,%r11d
  0x00000001085c1c5e: jge    0x00000001085c1c68  ; OopMap{off=64}
                                                ;*if_icmplt
                                                ; - Volatility$CustomerInLine::run@4 (line 14)
  0x00000001085c1c60: test   %eax,-0x1c6ac66(%rip)        # 0x0000000106957000
                                                ;*if_icmplt
                                                ; - Volatility$CustomerInLine::run@4 (line 14)
                                                ;   {poll}
  0x00000001085c1c66: jmp    0x00000001085c1c60  ;*getstatic NEXT_IN_LINE
                                                ; - Volatility$CustomerInLine::run@0 (line 14)
  0x00000001085c1c68: mov    $0xffffff86,%esi

Имея  ключевое слово volatile  , мы можем видеть, что по инструкции 0x000000010a5c1c40 мы загружаем значение в регистр, в 0x000000010a5c1c4a сравниваем его с нашим защитным значением 4. Если сравнение не удается, мы возвращаемся с 0x0000010a5c1c4e к 0x000000010a5c1c40, снова загружая значение проверить. Это гарантирует, что мы увидим измененное значение   переменной NEXT_IN_LINE .

  0x000000010a5c1c36: data32 nopw 0x0(%rax,%rax,1)
  0x000000010a5c1c40: mov    0x70(%r10),%r8d    ; OopMap{r10=Oop off=68}
                                                ;*if_icmplt
                                                ; - Volatility$CustomerInLine::run@4 (line 14)
  0x000000010a5c1c44: test   %eax,-0x1c1cc4a(%rip)        # 0x00000001089a5000
                                                ;   {poll}
  0x000000010a5c1c4a: cmp    $0x4,%r8d
  0x000000010a5c1c4e: jl     0x000000010a5c1c40  ;*if_icmplt
                                                ; - Volatility$CustomerInLine::run@4 (line 14)
  0x000000010a5c1c50: mov    $0x15,%esi

Теперь, надеюсь, объяснение спасет вас от пары неприятных ошибок. Если вам понравился контент, подпишитесь на нашу ленту Twitter, чтобы узнать  больше об оптимизации производительности. Или, если вы заинтересованы в устранении утечек памяти, неоптимального поведения GC или проблем с блокировкой,  отправляйтесь на пробную версию Plumbr .