Можно ли изменить порядок доступа к объекту с помощью последнего доступа к полю этого объекта в Java?
Пример кода ниже взят из JLS 17.5 "final Field Semantics":
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x; // guaranteed to see 3
int j = f.y; // could see 0
}
}
}
Поскольку экземпляр FinalFieldExample
публикуется через гонку данных, возможно ли, что f != null
проверка оценивается успешно, но последующие f.x
разыменование видит f
как null
?
Другими словами, можно ли получить NullPointerException
в строке с комментарием "гарантированно увидим 3"?
2 ответа
Хорошо, вот мой собственный взгляд на это, основанный на довольно подробном разговоре (на русском языке) об окончательной семантике, данном Владимиром Ситниковым, и последующем пересмотре JLS 17.5.1.
Конечная семантика поля
В спецификации указано:
Даны запись w, замораживание f, действие a (которое не является чтением последнего поля), чтение r1 последнего поля, замороженного с помощью f, и чтение r2 такое, что hb(w, f), hb(f, a), mc(a, r1) и разыменования (r1, r2), то при определении того, какие значения могут видеть r2, мы рассматриваем hb(w, r2).
Другими словами, мы гарантированно увидим запись в финальное поле, если будет построена следующая цепочка отношений:
hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)
1. hb(ш, ж)
w - запись в последнее поле:x = 3
f - действие "замораживания" (выходFinalFieldExample
конструктор):
Пусть o будет объектом, а c будет конструктором для o, в котором записано последнее поле f. Замораживание последнего поля f из o происходит, когда c выходит, как обычно, так и внезапно.
Поскольку запись поля происходит до завершения конструктора в порядке выполнения программы, мы можем предположить, что hb(w, f)
:
Если x и y являются действиями одного и того же потока, а x стоит перед y в программном порядке, тогда hb(x, y)
2. hb(е, а)
Определение дано в описании действительно расплывчато ("действие, которое не является чтение окончательного поля")
Можно предположить, что публикует ссылку на объект (f = new FinalFieldExample()
), поскольку это предположение не противоречит спецификации (это действие, а не чтение последнего поля).
Поскольку завершающий конструктор выполняется до записи ссылки в программном порядке, эти две операции упорядочиваются отношением происходит до:hb(f, a)
3. mc(a, r1)
В нашем случае r1 - это "чтение последнего поля, замороженного f" (f.x
)
И тут начинается самое интересное. mc (Цепочка памяти) - один из двух дополнительных частичных порядков, представленных в разделе "Семантика конечных полей":
Есть несколько ограничений на порядок цепочки памяти:
- Если r - это чтение, которое видит запись w, тогда это должно быть так, что mc(w, r).
- Если r и a - действия, такие, что разыменование (r, a), то должно быть так, что mc(r, a).
- Если w - это запись адреса объекта o потоком t, который не инициализировал o, тогда должно существовать некоторое чтение r потоком t, которое видит адрес o, такое, что mc(r, w).
В рассматриваемом простом примере нас действительно интересует только первый пункт, так как два других необходимы, чтобы рассуждать о более сложных случаях.
Ниже приведена часть, которая на самом деле объясняет, почему можно получить NPE:
- обратите внимание на жирную часть в спецификации:
mc(a, r1)
отношение существует, только если чтение поля видит запись в общую ссылку f != null
а такжеf.x
две различные операции чтения с точки зрения JMM- в спецификации нет ничего, что говорило бы, что
mc
отношения транзитивны по отношению к программному порядку или случаются до - поэтому если
f != null
видит запись, выполненную другим потоком, нет никаких гарантий, чтоf.x
тоже видит это
Я не буду вдаваться в подробности ограничений цепочки разыменования, поскольку они нужны только для рассуждений о более длинных цепочках ссылок (например, когда последнее поле ссылается на объект, который, в свою очередь, ссылается на другой объект).
Для нашего простого примера достаточно сказать, что JLS утверждает, что "порядок разыменования рефлексивен, и r1 может быть таким же, как r2" (что в точности соответствует нашему случаю).
Безопасный способ борьбы с небезопасной публикацией
Ниже приведена модифицированная версия кода, которая гарантированно не генерирует NPE:
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
FinalFieldExample local = f;
if (local != null) {
int i = local.x; // guaranteed to see 3
int j = local.y; // could see 0
}
}
}
Важным отличием здесь является чтение общей ссылки в локальную переменную. Как заявляет JLS:
Локальные переменные... никогда не используются совместно между потоками и не зависят от модели памяти.
Следовательно, с точки зрения JMM из общего состояния выполняется только одно чтение.
Если при этом чтении происходит запись, выполненная другим потоком, это будет означать, что две операции связаны цепочкой памяти (mc
) отношения. Более того,local = f
а также i = local.x
связаны отношениями цепочки разыменования, что дает нам всю цепочку, упомянутую в начале:
hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)
Ваш анализ прекрасен (1+), если бы я мог проголосовать дважды - я бы. Вот еще одна ссылка на одной и той же проблемы с "независимо гласит: " вот, к примеру.
Я также попытался подойти к этой проблеме в другом ответе.
Я думаю, что если мы введем здесь ту же концепцию, то это тоже можно будет доказать. Возьмем этот метод и немного изменим его:
static void reader() {
FinalFieldExample instance1 = f;
if (instance1 != null) {
FinalFieldExample instance2 = f;
int i = instance2.x;
FinalFieldExample instance3 = f;
int j = instance3.y;
}
}
И компилятор может теперь сделать некоторые хотят читает (переместить те читает перед темif statement
):
static void reader() {
FinalFieldExample instance1 = f;
FinalFieldExample instance2 = f;
FinalFieldExample instance3 = f;
if (instance1 != null) {
int i = instance2.x;
int j = instance3.y;
}
}
Эти чтения могут быть дополнительно переупорядочены между ними:
static void reader() {
FinalFieldExample instance2 = f;
FinalFieldExample instance1 = f;
FinalFieldExample instance3 = f;
if (instance1 != null) {
int i = instance2.x;
int j = instance3.y;
}
}
Отсюда все должно быть тривиально: ThreadA
читает FinalFieldExample instance2 = f;
быть null
, прежде чем он выполнит следующее чтение:FinalFieldExample instance1 = f;
некоторые ThreadB
звонки writer
(как таковой f != null
) и часть:
FinalFieldExample instance1 = f;
решено non-null
.