Volatile java что это такое
Применение volatile
— Хочу рассказать тебе о модификаторе volatile. Знаешь, что это такое?
— Вроде что-то связанное с нитями. Не помню точно.
— Тогда слушай. Вот тебе немного технических деталей:
В компьютере есть два вида памяти – глобальная (обычная) и встроенная в процессор. Встроенная в процессор делится на регистры, затем кэш первого уровня (L1), кэш второго уровня (L2) и третьего уровня (L3).
Эти виды памяти отличаются по скорости работы. Самая быстрая и самая маленькая память – это регистры, затем идет кэш процессора (L1, L2, L3) и, наконец, глобальная память (самая медленная).
Скорость работы глобальной памяти и кэша процессора сильно отличаются, поэтому Java-машина позволяет каждой нити хранить самые часто используемые переменные в локальной памяти нити (в кэше процессора).
— А можно как-то управлять этим процессом?
— Практически никак – всю работу делает Java-машина, она очень интеллектуальная в плане оптимизации скорости работы.
Но я тебе это рассказываю вот зачем. Есть одна маленькая проблемка. Когда две нити работают с одной и той же переменной, каждая из них может сохранить ее копию в своем внутреннем локальном кэше. И тогда может получится такая ситуация, что одна нить переменную меняет, а вторая не видит этого изменения, т.к. по-прежнему работает со своей копией переменной.
— На этот случай разработчики Java предусмотрели специальное ключевое слово – volatile. Если есть переменная, к которой обращаются из разных нитей, ее нужно пометить модификатором volatile, чтобы Java-машина не помещала ее в кэш. Вот как это обычно выглядит:
— О, вспомнил. Ты же уже про это рассказывала. Я же это уже знаю.
— Ага, знаешь. А вспомнил, только когда я рассказала.
— Э, ну забыл немного.
— Повторение – мать учения.
Вот тебе несколько новых фактов работы модификатора volatile. Модификатор volatile гарантирует только безопасное чтение/запись переменной, но не ее изменение.
— Вот смотри. Как изменяется переменная:
Код | Что происходит на самом деле: | Описание |
---|---|---|
Этап 1. Значение переменной count копируется из глобальной памяти в регистровую память процессора. Этап 2 Этап 3 |
— Ого! Так что, изменение любой переменной происходит только в процессоре?
— И значения копируются туда-сюда: из памяти в процессор и обратно?
Так вот, модификатор volatile, гарантирует, что при обращении к переменной count она будет прочитана из памяти (этап 1). А если какая-то нить захочет присвоить ей новое значение, то оно обязательно окажется в глобальной памяти (этап 3).
Но Java-машина не гарантирует, что не будет переключения нитей между этапами 1 и 3.
— Т.е. увеличение переменной на 1 – это фактически три операции?
— И если две нити одновременно захотят исполнить count++, то они могут помешать друг другу?
Нить 1 | Нить 2 | Результат |
---|
— Т.е. обращаться к переменной можно, а изменять рискованно все равно?
Java Blog
Ключевое слово volatile в Java
Пример использования volatile:
Мы лениво создаем экземпляр, когда приходит первый запрос.
Если мы не сделаем переменную instance volatile, тогда поток, который создает экземпляр Singleton, не сможет взаимодействовать с другим потоком. Таким образом, если поток A создает экземпляр Singleton и сразу после создания ЦП повреждается и т. д., все другие потоки не смогут увидеть значение instance как ненулевое, и они будут полагать, что ему по-прежнему присвоено значение null.
Почему это происходит? Поскольку потоки чтения не выполняют никаких блокировок и пока поток записи не выйдет из синхронизированного блока, память не будет синхронизироваться, и значение instance не будет обновляться в основной памяти. С ключевым словом volatile в Java это обрабатывается самой Java, и такие обновления будут видны всем потокам чтения.
Заключение: ключевое слово volatile используется для передачи содержимого памяти между потоками.
Пример использования без volatile (не потоко-безопасное):
Приведенный выше код не является потокобезопасным. Хотя он еще раз проверяет значение экземпляра в синхронизированном блоке (по соображениям производительности), JIT-компилятор может изменить байт-код таким образом, чтобы ссылка на экземпляр была установлена до того, как конструктор завершил свое выполнение. Это означает, что метод getInstance() возвращает объект, который, возможно, не был полностью инициализирован. Чтобы сделать код потокобезопасным, можно использовать ключевое слово volatile, начиная с Java 5 для переменной экземпляра. Переменные, помеченные как volatile, становятся видимыми для других потоков только после того, как конструктор объекта полностью завершит свое выполнение.
Использование volatile в Java:
Отказоустойчивые итераторы обычно реализуются с использованием volatile счетчика в объекте списка.
Реализация отказоустойчивых итераторов обычно легка. Обычно они полагаются на свойства структур данных конкретной реализации списка.
volatile очень полезен для остановки потоков.
Не то чтобы вам следовало писать свои собственные потоки, в Java 1.6 есть много хороших пулов потоков. Но если вы уверены, что вам нужен поток, вам нужно знать, как его остановить.
В приведенном выше примере кода поток, читающий close в цикле while, отличается от того, который вызывает close(). Без volatile поток, выполняющий цикл, может никогда не увидеть изменения для close.
Обратите внимание, что нет необходимости в синхронизации.
5 вещей, которых вы не знали о многопоточности
Хоть от многопоточности и библиотек, которые её поддерживают, отказываются немногие Java-программисты, но тех, кто нашёл время изучить вопрос в глубину ещё меньше. Вместо этого мы узнаём о потоках только столько, сколько нам требуется для конкретной задачи, добавляя новые приёмы в свой инструментарий лишь тогда, когда это необходимо. Так можно создавать и запускать достойные приложения, но можно делать и лучше. Понимание особенностей компилятора и виртуальной машины Java поможет вам писать более эффективный, производительный код.
В этом выпуске серии «5 вещей …», я представлю некоторые из тонких аспектов многопоточного программирования, в том числе synchronized-методы, volatile переменные и атомарные классы. Речь пойдет в особенности о том, как некоторые из этих конструкций взаимодействуют с JVM и Java-компилятором, и как различные взаимодействия могут повлиять на производительность приложений.
Примечание переводчика: я как раз из тех людей, которые не знали этих пяти вещей о многопоточном программировании, поэтому посчитала, что эта статья стоит того, чтобы её обнародовать здесь, но и поэтому же могла допустить некоторые ошибки в переводе, так что поправки приветствуются с энтузиазмом.
Примечание переводчика2: в комментариях знающие люди делятся ссылками и информацией по теме, не менее интересными, чем содержание статьи)
1. Synchronized-метод или synchronized-блок?
Вы, возможно, уже задумывались о том, объявлять ли синхронизированным весь метод или только ту его часть, которую необходимо обезопасить. В таких ситуациях, полезно знать, что когда компилятор Java преобразует исходный код в байт-код, он работает с synchronized-методами и synchronized-блоками очень по-разному.
Когда JVM выполняет synchronized-метод, выполняющийся поток определяет, что в method_info этого метода проставлен флаг ACC_SYNCHRONIZED. Тогда он автоматически устанавливает блокировку на объект, вызывает метод и снимает блокировку. Если вылетает исключение, поток автоматически снимает блокировку.
С другой стороны, synchronized-блок обходит встроенную в JVM поддержку запросов блокировок объекта и обработку исключений, так что это необходимо описывать явно в байт-коде. Если вы посмотрите на байт-код для блока, увидите в нём кучу дополнительных операций в сравнении с методом. Листинг 1 показывает вызов и того, и другого.
Листинг 1. Два подхода к синхронизации.
package com.geekcap ;
public class SynchronizationExample <
private int i ;
public synchronized int synchronizedMethodGet ( ) <
return i ;
>
public int synchronizedBlockGet ( ) <
synchronized ( this ) <
return i ;
>
>
>
Метод synchronizedMethodGet() method генерирует следующий байт-код:
А вот байт-код для метода synchronizedBlockGet():
Создание synchronized-блока выдало 16 строк байт-кода, тогда как synchronized-метода – только 5.
2. «Внутрипоточные» (ThreadLocal) переменные.
Если вы хотите сохранить один экземпляр переменной для всех экземпляров класса, вы используете статические переменные класса. Если вы хотите сохранить экземпляр переменной для каждого потока, используйте внутрипоточные (ThreadLocal) переменные. ThreadLocal переменные отличаются от обычных переменных тем, что у каждого потока свой собственный, индивидуально инициализируемый экземпляр переменной, доступ к которой он получает через методы get() или set().
Предположим, вы разрабатываете многопоточный трассировщик кода, чьей целью является однозначное определение пути каждого потока через ваш код. Проблема в том, что вам необходимо скоординировать несколько методов в нескольких классах через несколько потоков. Без ThreadLocal это было бы трудноразрешимо. Когда поток начинал бы выполняться, было бы необходимо сгенерировать уникальный маркер для идентификации его трассировщиком, а потом передавать этот маркер каждому методу при трассировке.
С ThreadLocal это проще. Поток инициализирует ThreadLocal переменную в начале выполнения, а затем обращается к нему из каждого метода в каждом классе, и переменная при этом будет хранить трассировочную информацию только для исполняемого в данный момент времени потока. Когда его выполнение завершится, поток может передать свою индивидуальную запись о трассировке объекту управления, ответственному за поддержание всех записей.
Использование ThreadLocal имеет смысл, когда вам необходимо хранить экземпляры переменной для каждого потока.
3. Volatile переменные.
По моим оценкам, лишь половина всех разработчиков Java знает, что в Java есть ключевое слово volatile. Из них лишь около 10 процентов знают, что оно значит, и еще меньше знают, как эффективно его использовать. Короче говоря, определение переменной с ключевым словом volatile(«изменчивый») означает, что значение переменной будет изменяться разными потоками. Чтобы полностью понять, что значит volatile, во-первых, нужно понять, как потоки оперируют с обычными, не-volatile, переменными.
В целях повышения эффективности работы, спецификации языка Java позволяет JRE сохранять локальную копию переменной в каждом потоке, который ссылается на нее. Можно считать эти «внутрипоточные» копии переменных похожими на кэш, помогающий избежать проверки главной памяти каждый раз, когда требуется доступ к значению переменной.
Но представьте, что произойдёт в следующем случае: запустятся два потока, и первый прочитает переменную А как 5, тогда как второй – как 10. Если переменная А изменились от 5 до 10, то первый поток не будет знать об изменении, так что будет иметь неправильное значение А. Однако если переменная А будет помечена как volatile, то то в любое время, когда поток обращается к её значению, он будет получать копию А и считывать её текущее значение.
Если переменные в вашем приложении не меняются, то внутрипоточный кэш имеет смысл. В противном случае, очень полезно знать, что может сделать для вас ключевое слово volatile.
4. Volatile против synchronized.
int temp = 0 ;
synchronize ( myVolatileVar ) <
temp = myVolatileVar ;
>
synchronize ( myVolatileVar ) <
myVolatileVar = temp ;
>
Другими словами, если volatile переменная обновляется неявно, то есть значение читается, измененяется, а затем присваивается как новое, результат будет не-потокобезопасным между двумя синхронными операциями. Вы можете выбирать, следует ли использовать синхронизацию или рассчитывать на поддержку JRE автоматической синхронизации volatile переменных. Наилучший подход зависит от вашего случая: если присвоенное значение volatile переменной зависит от её текущего значения (например, во время операции инкремента), то нужно использовать синхронизацию, если вы хотите, чтобы операция была потокобезопасной.
5. Обновления атомарных полей.
Когда вам требуется примитивный тип, выполняющий операции инкремента и декремента, гораздо лучше выбрать его среди новых атомарных классов в пакете java.util.concurrent.atomic, чем писать synchronized блок самому. Атомарные классы гарантируют, что определённые операции будут выполняться потокобезопасно, например операции инкремента и декремента, обновления и добавления(add) значения. Список атомных классов включает AtomicInteger, AtomicBoolean, AtomicLong, AtomicIntegerArray, и так далее.
Своеобразным вызовом программисту в использовании атомарных классов является то, что все операции класса, включая get, set и семейство операций get-set тоже атомарные. Это значит, что операции чтения и записи, которые не изменяют значения атомарной переменной, синхронизированы, а не только важные операции чтения-обновления-записи. Если вы хотите более детального контроля над развертыванием синхронизированного кода, то обходной путь заключается в использовании атомарного апдейтера поля.
Использование атомарного апдейтера.
Атомарные апдейтеры типа AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, и AtomicReferenceFieldUpdater по существу оболочки применяющиеся к volatile полям. Внутри, библиотеки классов Java используют их. Хотя они не часто используются в коде приложений, но у вас нет причин не начать облегчать свою жизнь с их помощью.
Листинг 2 демонстрирует пример класса, который использует атомарные обновления для изменения книги, которую кто-то читает:
Листинг 2. Класс Book.
public class Book
<
private String name ;
public String getName ( )
<
return name ;
>
Класс Book – просто POJO (plain old Java object – незамысловатый старый Java объект), у которого есть только одно поле: name.
Листинг 3. Класс MyObject.
/**
*
* @author shaines
*/
public class MyObject
<
private volatile Book whatImReading ;
public Book getWhatImReading ( )
<
return whatImReading ;
>
Класс MyObject в листинге 3 представляет, как и можно было ожидать, get и set методы, но метод set делает кое-что иное. Вместо того, чтобы просто предоставить свою внутреннюю ссылку на указанную книгу (что было бы выполнено закомментированным кодом в листинге 3), он использует AtomicReferenceFieldUpdater.
AtomicReferenceFieldUpdater
Javadoc определяет AtomicReferenceFieldUpdater так:
A reflection-based utility that enables atomic updates to designated volatile reference fields of designated classes. This class is designed for use in atomic data structures in which several reference fields of the same node are independently subject to atomic updates.
(Основанная на отражении утилита, которая разрешает атомарные обновления назначенным volatile ссылочным полям назначенных классов. Этот класс предназначен для использования в атомарных структурах данных, в которых несколько ссылочных полей одной и той же записи являются независимыми субъектами для атомарных обновлений)убейте меня, я не знаю, как это нормально перевести
В листинге 3 AtomicReferenceFieldUpdater создан через вызов метода newUpdater, который принимает три параметра.
• класс объекта, содержащего поле (в данном случае, MyObject)
• класс объекта, который будет обновляться атомарно (в данном случае, Book)
• имя поля для атомарного обновления
Значимым здесь является то, что метод getWhatImReading выполняется без синхронизации любого рода, в то время как setWhatImReading выполняется как атомарная операция.
В листинге 4 показано, как использовать setWhatImReading () и доказывается, что переменная изменяется правильно:
Листинг 4. Тест-кейс атомарного апдейтера.
import org.junit.Assert ;
import org.junit.Before ;
import org.junit.Test ;
public class AtomicExampleTest
<
private MyObject obj ;
@Before
public void setUp ( )
<
obj = new MyObject ( ) ;
obj. setWhatImReading ( new Book ( «Java 2 From Scratch» ) ) ;
>
Проблема многопоточности — локальный кэш. Volatile
— Привет, Амиго! Помнишь, Элли тебе рассказывала про проблемы при одновременном доступе нескольких нитей к общему (разделяемому) ресурсу?
— Так вот – это еще не все. Есть еще небольшая проблема.
Как ты знаешь, в компьютере есть память, где хранятся данные и команды (код), а также процессор, который исполняет эти команды и работает с данными. Процессор считывает данные из памяти, изменяет и записывает их обратно в память. Чтобы ускорить работу процессора в него встроили свою «быструю» память – кэш.
Чтобы ускорить свою работу, процессор копирует самые часто используемые переменные из области памяти в свой кэш и все изменения с ними производит в этой быстрой памяти. А после – копирует обратно в «медленную» память. Медленная память все это время содержит старые(!) (неизмененные) значения переменных.
Вспомним вчерашний пример:
Код | Описание |
---|---|
Нить «не знает» о существовании других нитей. В методе run переменная isCancel при первом использовании будет помещена в кэш дочерней нити. Эта операция эквивалентна коду: Вызов метода cancel из другой нити поменяет значение переменной isCancel в обычной (медленной) памяти, но не в кэше остальных нитей. |
— Ничего себе! А для этой проблемы тоже придумали красивое решение, как в случае с synchronized?
Сначала думали отключить работу с кэшем, но потом оказалось, что из-за этого программы работают в разы медленнее. Тогда придумали другое решение.
Вот как нужно исправить наше решение, чтобы все стало отлично работать:
Модель памяти в примерах и не только
В продолжение серии топиков под названием «фундаментальные вещи о Java, которые стоит знать, но которые многие не знают». Предыдущий топик: Бинарная совместимость в примерах и не только
Модель памяти Java — нечто, что оказывает влияние на то, как работает код любого java-разработчика. Тем не менее, довольно многие пренебрегают знанием этой важной темы, и порой наталкиваются на совершенно неожиданное поведение их приложений, которое объясняется именно особенностями устройства JMM. Возьмём для примера весьма распространённую и некорректную реализацию паттерна Double-checked locking:
Люди, пишущие подобный код, пытаются добиться улучшения производительности, избегая блокировки, если значение уже было присвоено. К сожалению, эти люди не учитывают многих факторов, в результате проявления которых может случиться зомби-апокалипсис. Под катом я расскажу теорию и приведу примеры того, как что-то может пойти не так. Кроме того, как говорили в одном индийском фильме, «Мало знать, что не так. Нужно знать, как сделать так, чтобы было так». Потому и рецепты успеха вы также сможете найти дальше.
Немножко истории
Потому в 2004 году в Java 5 появилась JSR 133, в которой были устранены недостатки первоначальной модели. О том, что получилось, мы и будем говорить.
Atomicity
Хотя многие это знают, считаю необходимым напомнить, что на некоторых платформах некоторые операции записи могут оказаться неатомарными. То есть, пока идёт запись значения одним потоком, другой поток может увидеть какое-то промежуточное состояние. За примером далеко ходить не нужно — записи тех же long и double, если они не объявлены как volatile, не обязаны быть атомарными и на многих платформах записываются в две операции: старшие и младшие 32 бита отдельно. (см. стандарт)
Visibility
В старой JMM у каждого из запущенных потоков был свой кеш (working memory), в котором хранились некоторые состояния объектов, которыми этот поток манипулировал. При некоторых условиях кеш синхронизировался с основной памятью (main memory), но тем не менее существенную часть времени значения в основной памяти и в кеше могли расходиться.
В новой модели памяти от такой концепции отказались, потому что то, где именно хранится значение, вообще никому не интересно. Важно лишь то, при каких условиях один поток видит изменения, выполненные другим потоком. Кроме того, железо и без того достаточно умно, чтобы что-то кешировать, складывать в регистры и вытворять прочие операции.
Важно отметить, что, в отличие от того же C++, «из воздуха» (out-of-thin-air) значения никогда не берутся: для любой переменной справедливо, что значение, наблюдаемое потоком, либо было ранее ей присвоено, либо является значением по умолчанию.
Reordering
Но и это, как говорится, ещё не всё. Если вы сделаете заказ прямо сейчас, то ваши инструкции переставят местами совершенно бесплатно! Процессоры проявляют невероятную проворность в оптимизации исполнения инструкций. В этом им также помогает компилятор и JIT. Одним из примечательных эффектов может оказаться то, что действия, выполненные одним потоком, другой поток увидит в другом порядке. Эту фразу довольно сложно понять, просто прочитав, потому приведу пример. Пусть есть такой код:
Хотя внутри одного потока об этом можно не беспокоиться, в многопоточной среде результаты операций, произведённых другими потоками, могут наблюдаться не в том порядке. Чтобы не быть голословным, я хотел добиться того, чтобы на моей машине сработал assertion, но мне это не удавалось настолько долго ( нет, я не забыл указать при запуске ключ -ea ), что, отчаявшись, я обратился с вопросом «а как же всё-таки спровоцировать reordering» к небезызвестным перформанс-инженерам. Так на мой вопрос ответил Сергей Куксенко:
На машинах с TSO (к коим относится x86) довольно сложно показать
ломающий reordering. Это можно показать на каком-нибудь ARM’е или
PowerPC. Еще можно сослаться на Альфу — процессор с самыми слабыми правилами ордеринга. Альфа — это был ночной кошмар разработчиков компиляторов и ядер операционной системы. Счастье, что он таки умер. В сети можно найти массы историй об этом.
Классический пример:
(пример аналогичен приведённому выше — прим. автора)
… на x86 будет отрабатывать корректно всегда, ибо если вы увидели
стор в «b», то увидите и стор в «a».
А когда я сказал, что хотел бы для каждого рассмотренного в статье аспекта найти работающую демонстрацию того, что происходит, Сергей порадовал меня, сказав что необходимо долго и усердно читать мануалы по соответствующему железу. Какое-то время я подумывал попробовать добиться эффекта на телефоне, но в итоге решил, что это не настолько важно. К тому же, я всё-таки специализируюсь вовсе не на особенностях каких-то конкретных платформ.
Итак, вернёмся к нашему изначальному примеру и поймём, как может его испортить reordering. Пусть наш класс Data в конструкторе выполняет какие-то не очень тривиальные вычисления и, главное, записывает какие-то значения в не final поля:
Happens-before
Определение
Пусть есть поток X и поток Y (не обязательно отличающийся от потока X). И пусть есть операции A (выполняющаяся в потоке X) и B (выполняющаяся в потоке Y).
В таком случае, A happens-before B означает, что все изменения, выполненные потоком X до момента операции A и изменения, которые повлекла эта операция, видны потоку Y в момент выполнения операции B и после выполнения этой операции.
На словах такое определение, возможно, воспринимается не очень хорошо, потому немного поясню. Начнём с самого простого случая, когда поток только один, то есть X и Y — одно и то же. Внутри одного потока, как мы уже говорили, никаких проблем нет, потому операции имеют по отношению к друг другу happens-before в соответствии с тем порядком, в котором они указаны в исходном коде (program order). Для многопоточного случая всё несколько сложнее, и тут без… картинки не разобраться. А вот и она:
Здесь слева зелёным помечены те операции, которые гарантированно увидит поток Y, а красным — те, что может и не увидеть. Справа красным помечены те операции, при исполнении которых ещё могут быть не видны результаты выполнения зелёных операций слева, а зелёным — те, при исполнении которых уже всё будет видно. Важно заметить, что отношение happens-before транзитивно, то есть если A happens-before B и B happens-before C, то A happens-before C.
Операции, связанные отношением happens-before
Посмотрим теперь, что же именно за ограничения на reordering есть в JMM. Глубокое и подробное описание можно найти, например, в The JSR-133 Cookbook, я же приведу всё на несколько более поверхностном уровне и, возможно, пропущу некоторые из ограничений. Начнём с самого простого и известного: блокировок.
1. Освобождение (releasing) монитора happens-before заполучение (acquiring) того же самого монитора. Обратите внимание: именно освобождение, а не выход, то есть за безопасность при использовании wait можно не беспокоиться.
Посмотрим, как это знание поможет нам исправить наш пример. В данном случае всё очень просто: достаточно убрать внешнюю проверку и оставить синхронизацию как есть. Теперь второй поток гарантированно увидит все изменения, потому что он получит монитор только после того, как другой поток его отпустит. А так как он его не отпустит, пока всё не проинициализирует, мы увидем все изменения сразу, а не по отдельности:
2. Запись в volatile переменную happens-before чтение из той же самой переменной.
То изменение, которое мы внесли, конечно, исправляет некорректность, но возвращает того, кто написал изначальный код, туда, откуда он пришёл — к блокировке каждый раз. Спасти может ключевое слово volatile. Фактически, рассматриваемое утверждение (2) значит, что при чтении всего, что объявлено volatile, мы всегда будем получать актуальное значение. Кроме того, как я говорил раньше, для volatile полей запись всегда (в т.ч. long и double) является атомарной операцией. Ещё один важный момент: если у вас есть volatile сущность, имеющая ссылки на другие сущности (например, массив, List или какой-нибудь ещё класс), то всегда «свежей» будет только ссылка на саму сущность, но не на всё, в неё входящее.
Итак, обратно к нашим Double-locking баранам. С использованием volatile исправить ситуацию можно так:
Кроме того, тут используется интересное предположение, которое стоит проверить: volatile store + read быстрее, чем блокировка. Однако, как неустанно повторяют нам всё те же инженеры производительности, микробенчмарки имеют мало отношения с реальностью, особенно если вы не знаете, как устроено то, что вы пытаетесь измерить. Более того, если вы думаете, что знаете, как оно устроено, то вы, скорее всего, ошибаетесь и не учитываете какие-нибудь важные факторы. У меня нет достаточной уверенности в глубине своих познаний, чтобы производить свои бенчмарки, поэтому таких замеров тут не будет. Впрочем, некоторая информация по производительности volatile есть в этой презентации начиная со слайда #54 (хотя я настойчиво рекомендую прочитать всё). UPD: есть интересный комментарий, в котором говорят, что volatile существенно быстрее синхронизации, by design.
Только в том-то и соль, что заработает оно именно магически, и человек, который не знает о вашем хитроумном приёме, может вас не понять. Да и вы тоже можете о таком довольно быстро позабыть. Есть, конечно же, вариант добавить горделивый комментарий типа «neat trick here!», описывающий, что же тут происходит, но мне это почему-то кажется не очень хорошей практикой.
UPD: Это неправда. В комментариях описано, почему. UPD2: По результатам обсуждения вопроса Руслан написал статью.
Кроме того, важно помнить, что поля бывают ещё и статические, а что инициализацию классов JVM гарантированно выполняет лишь один раз при первом обращении. В таком случае, тот же синглетон ( не будем в рамках данной статьи называть его ни паттерном, ни антипаттерном. Статья ведь совсем не об этом 😉 ) можно реализовать вот так:
Это, конечно, не является советом к тому, как нужно реализовывать синглетон, поскольку все, читавшие Effective Java, знают, что если вы совершенно неожидано по какой-то причине вдруг зачем-то решили его написать, то лучше всего использовать enum и получить из коробки решение всех проблем и с многопоточностью, и с сериализацией, и с клонированием. UPD: По поводу того, как лучше реализовать singleton, можно почитать этот топик.
Кстати, тем, кто знает, что final-поля можно изменить через Reflection и заинтересовавшимся, как такие изменения будут видны, могу сказать вот что: «всё, кажется, будет хорошо, только непонятно, почему, и непонятно, действительно ли всё и действительно ли хорошо». Есть несколько топиков на эту тему, наиболее интерен этот. Если кто-нибудь расскажет в комментариях, как оно на самом деле, я буду крайне рад. Впрочем, если никто не расскажет, то я и сам выясню и обязательно расскажу. UPD: В комментариях рассказали.
Наверняка есть ещё какие-то операции, связанные отношением happens-before, которые я не осветил в данной статье, но они уже гораздо более специфичны, и при наличии интереса вы можете их найти сами в стандарте или где-то ещё, а затем поделиться со всеми в комментариях.
Credits, links and stuff
В первую очередь хотелось бы поблагодарить за некоторые консультации и предварительную проверку статьи на содержание клинического бреда упомянутых ранее инженеров производительности: Алексея TheShade Шипилёва и Сергея Walrus Куксенко.