JavaRush /Java блог /Архив info.javarush /Управление непостоянством (volatility)
lexmirnov
29 уровень
Москва

Управление непостоянством (volatility)

Статья из группы Архив info.javarush

Указания по использованию volatile-переменных

Автор Брайан Гётц, 19 июня 2007 года. Оригинал: Managing Volatility Volatile-переменные в Java можно назвать "synchronized-лайт"; для их использования нужно меньше кода, чем для synchronized-блоков, часто они выполняются быстрее, но при этом могут делать лишь часть из того, что делают synchronized. В этой статье представлено несколько паттернов эффективного использования volatile - и несколько предупреждений о том, где их использовать не надо. У локов (locks) есть две основные черты: взаимное исключение (mutual exclusion, mutex) и видимость. Взаимное исключение означает, что лок может быть захвачен только одной нитью в отдельный момент времени, и это свойство можно использовать для реализации протоколов управления доступом к общедоступным ресурсам, так что только одна нить будет их использовать в отдельный момент времени. Видимость - вопрос более тонкий, ее задача обеспечить, что изменения, сделанные в общедоступных ресурсах до освобождения замка будут видимы следующей нити, захватившей этот замок. Если бы синхронизация не гарантировала видимость, нити могли бы получать устаревшие или неверные значения общедоступных переменных, что привело бы к целому ряду серьезных проблем.
Volatile-переменные
Volatile-переменные обладают свойствами видимости, присущими synchronized, но лишены их атомарности. Это означает, что нити автоматически будут использовать самые актуальные значения volatile-переменных. Их можно использовать для нитебезопасности (thread safety, чаще переводится как потокобезопасности), но в очень ограниченном наборе случаев: тех, что не вводят связи между несколькими переменными или между текущими и будущими значениями переменной. Таким образом, одной volatile недостаточно для реализации счетчика, мьютекса или любого класса, чьи неизменные части связаны с несколькими переменными (например, "start <=end"). Предпочесть volatile локам можно по одной из двух основных причин: простоте или масштабируемости. Некоторые языковые конструкции легче записать в виде программного кода, а в дальнейшем - прочесть и разобраться, когда они используют volatile-переменных вместо локов. Кроме того, в отличие от локов, они не могут заблокировать нить, и поэтому менее чреваты проблемами масштабируемости. В ситуациях, когда операций чтения гораздо больше, чем записи, volatile-переменные могут дать выигрыш в производительности по сравнению с локами.
Условия правильного использования volatile
Заменить локи на volatile можно в ограниченном числе обстоятельств. Для нитебезопасности необходимо, чтобы выполнялись оба критерия:
  1. То, что записывается в переменную, не зависит от ее текущего значения.
  2. Переменная не участвует в инвариантах с другими переменными.
Проще говоря, эти условия означают, что корректные значения, которые могут быть записаны в volatile-переменную, не зависят от любого другого состояния программы, включая текущее состояние переменной. Первое условие исключает использование volatile-переменных как нитебезопасных счетчиков. Хотя инкремент (x++) выглядит как одна операция, в действительности это целая последовательность операций чтения-изменения-записи, которая должна выполняться атомарно, чего volatile не обеспечивает. Корректная операция требовала бы, чтобы значение x оставалось неизменным в течение всей операции, чего нельзя добиться с помощью volatile. (Однако если вам удастся обеспечить, что значение будет записываться только из одной нити, первое условие можно опустить). В большинстве ситуаций будет нарушено либо первое, либо второе условия, что делает volatile-переменные менее используемым подходом к достижению нитебезопасности, чем synchronized. В листинге 1 показан не-нитебезопасный класс с диапазоном чисел. Он содержит инвариант - нижняя граница всегда меньше или равна верхней. @NotThreadSafe public class NumberRange { private int lower, upper; public int getLower() { return lower; } public int getUpper() { return upper; } public void setLower(int value) { if (value > upper) throw new IllegalArgumentException(...); lower = value; } public void setUpper(int value) { if (value < lower) throw new IllegalArgumentException(...); upper = value; } } Поскольку переменные состояния диапазона ограничены таким образом, будет недостаточным сделать поля lower и upper volatile, чтобы обеспечить нитебезопасность класса; по-прежнему будет нужна синхронизация. Иначе рано или поздно не повезет и две нити, выполнившие setLower() и setUpper() с неподходящими значениями могут привести диапазон в противоречивое состояние. Например, если начальное значение (0, 5), нить A вызывает setLower(4), и в то же время нить B вызывает setUpper(3), эти перемежающиеся операции приведут к ошибке, хотя обе пройдут проверку, которая должна защищать инвариант. В итоге диапазон будет (4, 3) - неверные значения. Нам нужно сделать setLower() и setUpper() атомарными по отношению к другим операциям над диапазоном - и присвоение полям volatile этого не сделает.
Соображения производительности
Первая причина использования volatile - простота. В некоторых ситуациях, использовать такую переменную попросту проще, чем относящийся к ней лок. Вторая причина - производительность, иногда volatile будут работать быстрее, чем локи. Чрезвычайно трудно сделать точные всеобъемлющие заявления вида "X всегда быстрее, чем Y," особенно когда речь идет о внутренних операциях виртуальной машины Java. (Например, JVM может полностью снять блокировку в некоторых ситуациях, что затрудняет абстрактное обсуждение затрат на volatile по отношению к синхронизации). Тем не менее, на большинстве современных процессорных архитектур затраты на чтение volatile мало отличаются от затрат на чтение обычных переменных. Затраты на запись volatile значительно больше, чем на запись обычных переменных, из-за ограждения памяти, необходимого для обеспечения видимости, но в целом дешевле, чем установка локов.
Паттерны для правильного использования volatile
Многие эксперты по параллелизму склонны избегать использования volatile-переменных вообще, потому что их труднее использовать правильно, чем локи. Однако существуют некоторые четко определенные паттерны, которые, если следовать им внимательно, могут безопасно использоваться в самых разных ситуациях. Всегда учитывайте ограничения volatile - используйте только volatile, которые никак не зависят от всего остального в программе, и это должно не позволить вам залезть с этими паттернами на опасную территорию.
Паттерн №1: флаги состояния
Возможно, каноническое использование изменчивых переменных - это простые булевы флаги состояния, указывающие на то, что произошло важное одноразовое событие жизненного цикла, такое как завершение инициализации или запрос на завершение работы. Многие приложения включают конструкцию управления формы: «Пока мы не готовы выключиться, продолжаем работать», как показано в листинге 2: volatile boolean shutdownRequested; ... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } } Вероятно, метод shutdown () будет вызываться откуда-то извне цикла - в другой нити - поэтому требуется синхронизация для обеспечения правильной видимости переменной shutdownRequested. (Он может быть вызван из слушателя JMX, слушателя действий в нити событий GUI, через RMI, через веб-службу и т. д.). Однако цикл с синхронизированными блоками будет гораздо громоздким, чем цикл с volatile-флагом состояния как в листинге 2. Поскольку volatile упрощает написание кода, а флаг состояния не зависит от какого-либо другого состояния программы, это пример хорошего использования volatile. Для таких флагов статуса характерно то, что обычно существует только один переход состояния; флаг shutdownRequested переходит из false в true, а затем программа выключается. Этот паттерн можно расширить до флагов состояния, которые могут изменяться туда и обратно, но только если цикл перехода (от false до true to false) будет происходить без внешних вмешательств. В противном случае необходим какой-то атомарный механизм перехода, такой как атомарные переменные.
Паттерн № 2: одноразовая безопасная публикация
Ошибки видимости, которые возможны при отсутствии синхронизации, могут стать еще более сложным вопросом, когда происходит запись ссылок на объекты вместо примитивных значений. Без синхронизации можно увидеть актуальное значение для ссылки на объект, которое было записано другой нитью, и по-прежнему видеть устаревшие значения состояния этого объекта. (Эта угроза лежит в корне проблемы с печально известной блокировкой с двойной проверкой, где ссылка на объект считывается без синхронизации, и вы рискуете увидеть актуальную ссылку, но получить через нее частично сконструированный объект.) Один из способов безопасной публикации объекта состоит в том, чтобы сделать ссылку на объект volatile. В листинге 3 показан пример, где во время запуска фоновый поток загружает некие данные из базы данных. Другой код, когда может попытаться использовать эти данные, проверяет, был ли он опубликован, прежде чем пытаться его использовать. public class BackgroundFloobleLoader { public volatile Flooble theFlooble; public void initInBackground() { // делаем много всякого theFlooble = new Flooble(); // единственная запись в theFlooble } } public class SomeOtherClass { public void doWork() { while (true) { // чё-то там делаем... // используем theFolooble, но только если она готова if (floobleLoader.theFlooble != null) doSomething(floobleLoader.theFlooble); } } } Если бы ссылка на theFlooble не была volatile, код в doWork() рисковал бы увидеть частично сконструированный Flooble при попытке обратиться по theFlooble. Ключевым требованием для этого паттерна является то, что публикуемый объект должен быть либо нитебезопасным, либо по факту неизменным (фактически неизменный означает, что его состояние никогда не изменяется после его публикации). Volatile-ссылка может гарантировать видимость объекта в его опубликованной форме, но если состояние объекта будет изменяться после публикации, то требуется дополнительная синхронизация.
Паттерн № 3: независимые наблюдения
Другой простой пример безопасного применения volatile - ситуация, когда наблюдения периодически “публикуются” для использования в рамках программы. Например, есть датчик окружающей среды, который определяет текущую температуру. Фоновая нить может считывать показания этого датчика с периодом в несколько секунд и обновлять volatile-переменную, содержащую текущую температуру. Затем другие нити могут считывать эту переменную, зная, что значение в ней всегда самое актуальное. Еще одно использование этого паттерна - сбор статистики о программе. В листинге 4 показано, как механизм аутентификации может запоминать имя последнего залогинившегося пользователя. Ссылка lastUser будет повторно использоваться для публикации значения для использования остальной частью программы. public class UserManager { public volatile String lastUser; public boolean authenticate(String user, String password) { boolean valid = passwordIsValid(user, password); if (valid) { User u = new User(); activeUsers.add(u); lastUser = user; } return valid; } } Этот паттерн расширяет предыдущий; значение публикуется для использования где-то еще в программе, но публикация не одноразовое событие, а серия независимых. Этот паттерн требует, чтобы опубликованное значение было фактически неизменным - что его состояние после публикации не менялось . Код, использующий значение, должен знать, что оно может в любой момент измениться.
Паттерн № 4: паттерн «volatile bean»
Паттерн “volatile bean” применим во фреймворках, использующих JavaBeans как “glorified structs”. В паттерне “volatile bean” JavaBean используется как контейнер для группы независимых свойств с геттерами и/или сеттерами. Обоснованием необходимости паттерна “volatile bean” является то, что многие фреймворки предоставляют контейнеры для изменяемых держателей данных (например, HttpSession), но объекты, помещенные в эти контейнеры, должны быть нитебезопасными. В патттерне volatile bean все элементы данных JavaBean являются volatile, а геттеры и сеттеры должны быть тривиальными - они не должны содержать никакой логики, кроме получения или установки соответствующего свойства. Кроме того, для членов данных, которые являются объектными ссылками, упомянутые объекты должны быть эффективно неизменными. (Это запрещает наличие полей-ссылок на массивы, так как когда ссылка массива объявлена volatile, только эта ссылка, а не сами элементы, имеет свойство volatile.) Как и в любой volatile-переменной, не может быть никаких инвариантов или ограничений, связанных с свойствами JavaBeans. Пример JavaBean, написанного по паттерну “volatile bean”, показан в листинге 5: @ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } }
Более сложные volatile-паттерны
Паттерны в предыдущем разделе охватывают большинство типичных случаев, когда использование volatile оправданно и очевидно. В этом разделе рассматривается более сложный паттерн, в котором volatile может обеспечить преимущество производительности или масштабируемости. Более продвинутые паттерны использования volatile могут быть чрезвычайно хрупкими. Крайне важно, чтобы ваши предположения были тщательно задокументированы, а эти паттерны сильно инкапсулироваными, потому что даже мельчайшие изменения могут сломать ваш код! Кроме того, учитывая, что основной причиной для более сложных вариантов использования volatile является производительность, убедитесь, что у вас действительно есть выраженная потребность в предполагаемом усилении производительности, прежде чем применять их. Эти паттерны являются компромиссами, которые жертвуют читабельностью или легкостью поддержки ради возможного повышения производительности - если вам не требуется повышение производительности (или вы не можете доказать, что вам это нужно, с помощью строгой программы измерения), то это, вероятно, плохая сделка, потому что вы отказываетесь от чего-то ценного и получаете что-то меньшее взамен.
Паттерн № 5: дешевый лок чтения-записи
Сейчас вы должны уже хорошо понимать, что volatile слишком слаба для реализации счетчика. Поскольку ++ x по факту сокращение трех операций (чтение, добавление, хранение), при неудачном стечении обстоятельств вы потеряете обновленное значение, если несколько потоков попытаются одновременно увеличить volatile-счетчик. Однако, если операций чтения значительно больше, чем изменения, вы можете объединить встроенную блокировку и volatile-переменные, чтобы снизить затраты на общий путь кода. В листинге 6 показан нитебезопасный счетчик, который использует synchronized, чтобы гарантировать, что операция приращения является атомарной, и использует volatile, чтобы гарантировать видимость текущего результата. Если обновления нечасты, этот подход может улучшить производительность, поскольку расходы на чтение ограничены чтением volatile, которое, как правило, дешевле, чем получение неконфликтующего лока. @ThreadSafe public class CheesyCounter { // Employs the cheap read-write lock trick // All mutative operations MUST be done with the 'this' lock held @GuardedBy("this") private volatile int value; public int getValue() { return value; } public synchronized int increment() { return value++; } } Причина, по которой этот метод называется «дешевым локом чтения-записи», заключается в том, что вы используете разные механизмы синхронизации для чтения и записи. Поскольку операции записи в этом случае нарушают первое условие использования volatile, вы не можете использовать volatile для безопасной реализации счетчика - вы должны использовать блокировку. Однако вы можете использовать volatile для обеспечения видимости текущего значения при чтении, поэтому вы используете блокировку для всех операций изменения и volatile для операций read-only. Если лок позволяет только одной нити за раз получать доступ к значению, volatile-чтения допускают более одного, поэтому, когда вы используете volatile для защиты чтения, вы получаете более высокий уровень обмена, чем если бы вы использовали блокировку для всего кода: и чтения, и записи. Однако имейте в виду хрупкость этого паттерна: с двумя конкурирующими механизмами синхронизации он может стать очень сложным, если вы выйдете за пределы самого базового приложения этого паттерна.
Резюме
Volatile-переменные - это более простая, но более слабая форма синхронизации, чем блокировка, которая в некоторых случаях обеспечивает лучшую производительность или масштабируемость, чем встроенная блокировка. Если вы соблюдаете условия безопасного использования volatile - переменная действительно независима и от других переменных, и от своих собственных предыдущих значений - иногда вы можете упростить код, заменив synchronized на volatile. Однако код с использованием volatile часто бывает более хрупким, чем код с блокировкой. Предлагаемые здесь паттерны охватывают наиболее распространенные случаи, когда волатильность - разумная альтернатива синхронизации. Следуя этим паттернам - и заботясь о том, чтобы не вытеснять их за их собственные пределы - вы сможете безопасно использовать volatile в тех случаях, когда они дают выигрыш.
Комментарии (2)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
fatfaggy Уровень 26
30 октября 2017
хорошая статья и неплохой перевод)

есть, конечно, пару моментов, типа
когда использование летучих оправданно и очевидно.
Некоторые языковые конструкции легче облечь в код
но только если цикл перехода (от false до true to false) может позволить себе быть не обнаруженным


вот только не очень понял что за второе условие правильного использования volatile-переменной?
Переменная не участвует в инвариантах с другими переменными.
это как?