Неустойчиво дорого?
После прочтения Поваренной книги JSR-133 для авторов компиляторов о реализации volatile, особенно в разделе "Взаимодействие с атомарными инструкциями", я предполагаю, что для чтения изменяемой переменной без ее обновления необходим барьер LoadLoad или LoadStore. Далее по странице я вижу, что LoadLoad и LoadStore фактически не работают на процессорах X86. Означает ли это, что операции чтения volatile могут выполняться без явного аннулирования кэша на x86, и это так же быстро, как чтение обычной переменной (не учитывая ограничения на изменение порядка volatile)?
Я считаю, что я не понимаю это правильно. Может кто-то хочет просветить меня?
РЕДАКТИРОВАТЬ: Интересно, есть ли различия в многопроцессорных средах. В однопроцессорных системах ЦП может смотреть на свои собственные кэши потоков, как утверждает Джон В., но в многопроцессорных системах должна быть некоторая опция конфигурации для ЦПУ, что этого недостаточно, и необходимо задействовать основную память, делая энергозависимую медленнее в системах с несколькими процессорами, верно?
PS: На своем пути, чтобы узнать больше об этом, я наткнулся на следующие замечательные статьи, и, поскольку этот вопрос может быть интересен для других, я поделюсь своими ссылками здесь:
Ответы
Ответ 1
На Intel неспокойное нестабильное чтение довольно дешево. Если мы рассмотрим следующий простой случай:
public static long l;
public static void run() {
if (l == -1)
System.exit(-1);
if (l == -2)
System.exit(-1);
}
С помощью возможности Java 7 для печати ассемблера метод запуска выглядит примерно так:
# {method} 'run2' '()V' in 'Test2'
# [sp+0x10] (sp of caller)
0xb396ce80: mov %eax,-0x3000(%esp)
0xb396ce87: push %ebp
0xb396ce88: sub $0x8,%esp ;*synchronization entry
; - Test2::[email protected] (line 33)
0xb396ce8e: mov $0xffffffff,%ecx
0xb396ce93: mov $0xffffffff,%ebx
0xb396ce98: mov $0x6fa2b2f0,%esi ; {oop('Test2')}
0xb396ce9d: mov 0x150(%esi),%ebp
0xb396cea3: mov 0x154(%esi),%edi ;*getstatic l
; - Test2::[email protected] (line 33)
0xb396cea9: cmp %ecx,%ebp
0xb396ceab: jne 0xb396ceaf
0xb396cead: cmp %ebx,%edi
0xb396ceaf: je 0xb396cece ;*getstatic l
; - Test2::[email protected] (line 37)
0xb396ceb1: mov $0xfffffffe,%ecx
0xb396ceb6: mov $0xffffffff,%ebx
0xb396cebb: cmp %ecx,%ebp
0xb396cebd: jne 0xb396cec1
0xb396cebf: cmp %ebx,%edi
0xb396cec1: je 0xb396ceeb ;*return
; - Test2::[email protected] (line 40)
0xb396cec3: add $0x8,%esp
0xb396cec6: pop %ebp
0xb396cec7: test %eax,0xb7732000 ; {poll_return}
;... lines removed
Если вы посмотрите на 2 ссылки на getstatic, первая включает в себя загрузку из памяти, вторая пропускает нагрузку, поскольку значение повторно используется из регистра (ов), в который он уже загружен (длинный бит 64 бит и мой 32-битный ноутбук использует 2 регистра).
Если переменная l изменена, результирующая сборка отличается.
# {method} 'run2' '()V' in 'Test2'
# [sp+0x10] (sp of caller)
0xb3ab9340: mov %eax,-0x3000(%esp)
0xb3ab9347: push %ebp
0xb3ab9348: sub $0x8,%esp ;*synchronization entry
; - Test2::[email protected] (line 32)
0xb3ab934e: mov $0xffffffff,%ecx
0xb3ab9353: mov $0xffffffff,%ebx
0xb3ab9358: mov $0x150,%ebp
0xb3ab935d: movsd 0x6fb7b2f0(%ebp),%xmm0 ; {oop('Test2')}
0xb3ab9365: movd %xmm0,%eax
0xb3ab9369: psrlq $0x20,%xmm0
0xb3ab936e: movd %xmm0,%edx ;*getstatic l
; - Test2::[email protected] (line 32)
0xb3ab9372: cmp %ecx,%eax
0xb3ab9374: jne 0xb3ab9378
0xb3ab9376: cmp %ebx,%edx
0xb3ab9378: je 0xb3ab93ac
0xb3ab937a: mov $0xfffffffe,%ecx
0xb3ab937f: mov $0xffffffff,%ebx
0xb3ab9384: movsd 0x6fb7b2f0(%ebp),%xmm0 ; {oop('Test2')}
0xb3ab938c: movd %xmm0,%ebp
0xb3ab9390: psrlq $0x20,%xmm0
0xb3ab9395: movd %xmm0,%edi ;*getstatic l
; - Test2::[email protected] (line 36)
0xb3ab9399: cmp %ecx,%ebp
0xb3ab939b: jne 0xb3ab939f
0xb3ab939d: cmp %ebx,%edi
0xb3ab939f: je 0xb3ab93ba ;*return
;... lines removed
В этом случае обе ссылки getstatic на переменную l включают в себя нагрузку из памяти, то есть значение не может храниться в регистре через несколько изменчивых чтений. Чтобы обеспечить чтение атома, значение считывается из основной памяти в регистр MMX movsd 0x6fb7b2f0(%ebp),%xmm0
, делая операцию чтения одной инструкцией (из предыдущего примера мы увидели, что для 64-битного значения обычно требуется два 32-битных чтения в 32-битной системе).
Таким образом, общая стоимость волатильного чтения будет примерно эквивалентна нагрузке на память и может быть столь же дешевой, как доступ к кеш-памяти L1. Однако, если другое ядро записывает переменную volatile, кэш-строка будет недействительной, если требуется основная память или, возможно, доступ к кэшу L3. Фактическая стоимость будет в значительной степени зависеть от архитектуры процессора. Даже между Intel и AMD протоколы когерентности кэша отличаются.
Ответ 2
Вообще говоря, на большинстве современных процессоров энергозависимая нагрузка сравнима с нормальной нагрузкой. Нестабильное хранилище составляет около 1/3 времени входа/выхода монитора. Это видно по системам, которые являются когерентными.
Чтобы ответить на вопрос OP, изменчивые записи являются дорогостоящими, в то время как чтение обычно не являются.
Означает ли это, что операции чтения volatile могут выполняться без явного аннулирования кэша на x86, и это так же быстро, как и обычное чтение переменных (не учитывая ограничения на изменение порядка volatile)?
Да, иногда при проверке поля процессор может даже не попадать в основную память, а шпионить за другими кешами потоков и получать оттуда значение (очень общее объяснение).
Тем не менее, я высказал предположение Нейла, что если у вас есть поле, к которому обращаются несколько потоков, вы должны обернуть его в AtomicReference. Будучи AtomicReference, он выполняет примерно одинаковую пропускную способность для чтения/записи, но также более очевидно, что поле будет доступно и изменено несколькими потоками.
Изменить, чтобы ответить OP edit:
Когерентность кэша представляет собой сложный протокол, но вкратце: CPU будет использовать общую строку кэша, которая подключена к основной памяти. Если процессор загружает память, и у него нет другого процессора, он будет считать, что это наиболее актуальное значение. Если другой ЦП попытается загрузить ту же ячейку памяти, то уже загруженный ЦП будет знать об этом и фактически разделит кешированную ссылку на запрашивающий ЦП - теперь ЦП запроса имеет копию этой памяти в своем кеше ЦП. (Никогда не приходилось искать в основной памяти ссылки)
Здесь задействовано немного больше протокола, но это дает представление о том, что происходит. Также, чтобы ответить на ваш другой вопрос, при отсутствии нескольких процессоров, энергозависимые операции чтения/записи могут фактически быть быстрее, чем с несколькими процессорами. Есть некоторые приложения, которые фактически работают быстрее одновременно с одним процессором, а не с несколькими.
Ответ 3
В словах модели памяти Java (как определено для Java 5+ в JSR 133) любая операция - чтение или запись - в переменной volatile
создает произойдет-до отношения по отношению к любой другой операции по одной и той же переменной. Это означает, что компилятор и JIT вынуждены избегать определенных оптимизаций, таких как инструкции по переупорядочению в потоке или выполнение операций только в локальном кеше.
Поскольку некоторые оптимизации недоступны, полученный код обязательно медленнее, чем это было бы, хотя, вероятно, не очень.
Тем не менее вы не должны создавать переменную volatile
, если не знаете, что к ней будут доступны несколько потоков вне блоков synchronized
. Даже тогда вам следует подумать, является ли volatile лучшим выбором по сравнению с synchronized
, AtomicReference
и его друзьями, явными классами Lock
и т.д.
Ответ 4
Доступ к изменчивой переменной во многом аналогичен обходу доступа к обычной переменной в синхронизированном блоке. Например, доступ к переменной volatile запрещает CPU переупорядочивать инструкции до и после доступа, и это обычно замедляет выполнение (хотя я не могу сказать, сколько).
В общем, в многопроцессорной системе я не вижу, как можно сделать доступ к изменчивой переменной без штрафа - должен быть какой-то способ гарантировать, что запись на процессоре A будет синхронизирована с процессором чтения на процессоре Б.