счет за волкаПожалуйста, указывайте первоисточник при перепечатке, спасибо!
предисловие
Популярное в Java ключевое слово volatile часто упоминается в интервью и часто обсуждается в различных группах по техническому обмену, но кажется, что обсуждение не может дать идеального результата. , Реорганизация с точки зрения компиляции.
Две основные особенности volatile: запрет на переупорядочивание, видимость памяти, эти две концепции, студенты, которые не очень ясны, могут прочитать эту статью ->Головоломки с ключевыми словами Java
Концепция понятна, но все еще очень запутана,Как именно они реализуются?
Эта статья будет содержать некоторый компиляционный контент, и если вы прочтете ее несколько раз, вы сможете ее понять.
изменение порядка
Чтобы понять переупорядочение, давайте рассмотрим простой фрагмент кода.
public class VolatileTest {
int a = 0;
int b = 0;
public void set() {
a = 1;
b = 1;
}
public void loop() {
while (b == 0) continue;
if (a == 1) {
System.out.println("i'm here");
} else {
System.out.println("what's wrong");
}
}
}
Класс VolatileTest имеет два метода: set() и loop().Предполагая, что поток B выполняет метод цикла, а поток A выполняет метод set, каков будет результат?
Ответ не уверен, потому что он включает переупорядочение компилятора и переупорядочение инструкций ЦП.
Переупорядочивание компилятора
При условии, что однопоточная семантика не меняется, компилятор может изменить порядок инструкций байт-кода, чтобы повысить скорость работы программы, поэтому порядок присваивания a и b в коде может стать первым для установки b после компиляции. ., а затем установите a.
Потому что для потока A то, какой из них установлен первым, не влияет на его собственный результат.
Переупорядочивание инструкций ЦП
Как насчет переупорядочения инструкций ЦП? Прежде чем углубляться, давайте взглянем на структуру кэша процессора x86.
1. Для хранения локальных переменных и параметров функций используются различные регистры, однократное обращение к которым занимает 1 такт и занимает менее 1 нс; 2. Кэш L1, кеш первого уровня, локальный основной кеш, разделенный на 32 КБ кэш данных L1d и 32 КБ кэш инструкций L1i, доступ к L1 требует 3 тактов и занимает около 1 нс; 3. Кэш L2, кеш L2, локальный основной кеш, разработан как буфер между кешем L1 и общим кешем L3, размером 256 КБ, доступ к L2 требует 12 циклов и занимает около 3 нс; 4. Кэш L3, кеш L3.Все ядра в одном слоте совместно используют кеш L3 и разделены на несколько сегментов по 2M.Для доступа к L3 требуется 38 циклов и около 12 нс;
Конечно, есть и хорошо известная DRAM, которая обычно занимает 65 нс для доступа к памяти, поэтому однократный доступ ЦП к памяти очень медленный по сравнению с кешем.
Для процессоров в разных сокетах данные L1 и L2 не являются общими.Как правило, согласованность кеша гарантируется протоколом MESI, но за определенную плату.
В протоколе MESI каждая строка Cache имеет 4 состояния, а именно:
1. М (модифицированный) Эта строка данных действительна, но была изменена, что несовместимо с данными в памяти, а данные существуют только в этом кэше.
2. Е (Эксклюзив) Эта строка данных действительна и согласуется с данными в памяти, а данные существуют только в этом кэше.
3. S (общий) Эта строка данных действительна, согласуется с данными в памяти, и данные распределены по многим кешам.
4. Я (недействительно) Эта строка данных недействительна
Контроллер кэша каждого Ядра не только знает свои собственные операции чтения и записи, но и отслеживает операции чтения и записи других Кэшей.Если Ядер 4: 1. Core1 загружает переменную X из памяти, и значение равно 10. В это время состояние строки кэша переменной X кэша в Core1 равно E; 2. Core2 также загружает из памяти переменную X. В это время состояние строки кэша Core1 и переменной X кэша Core2 преобразуется в S; 3. Core3 также загружает переменную X из памяти, а затем устанавливает X равным 20. В это время состояние строки кэша переменной X кэша в Core3 преобразуется в M, а строки кэша, соответствующие другим Ядрам, становятся I ( недействителен).
Конечно, внутренние детали разных процессоров также различаются. Например, процессор Intel Core i7 использует протокол MESIF, эволюционировавший из MESI, а F (Forward) эволюционирует из Share.Если строка кэша находится в состоянии F, данные могут передаваться напрямую другим ядрам, поэтому здесь нет запутанности.
Процессор блокируется во время перехода состояния строки кэша.После длительной оптимизации между регистром и кэшем L1 добавляются LoadBuffer и StoreBuffer для сокращения времени блокировки.LoadBuffer и StoreBuffer вместе называются Memoryordering Buffers (MOB), длина буфера загрузки равна 64, а длина буфера сохранения равна 36. Когда буфер и L1 передают данные, ЦП не нужно ждать.
1. Когда CPU выполняет данные чтения нагрузки, поместите запрос на чтение на LoadBuffer, поэтому вам не нужно ждать других ответов CPU, Advance Row, а затем обрабатывает результат этого запроса на чтение. 2, когда CPU выполняет хранилище записи данных, напишите данные в Storebuffer, чтобы быть подходящей точкой времени, щетрите данные магазина в основную память.
Из-за наличия StoreBuffer, когда CPU записывает данные, реальные данные не сразу отображаются в памяти, поэтому они невидимы для других CPU, по той же причине запрос в LoadBuffer не может получить самые свежие данные, установленные другими CPU ;
Поскольку StoreBuffer и LoadBuffer выполняются асинхронно, извне нет строгого фиксированного порядка: сначала писать, а потом читать, или сначала читать, а потом записывать.
Как реализована видимость памяти
Из приведенного выше анализа видно, что на самом деле это асинхронность, когда ЦП выполняет загрузку и сохранение данных, что делает память между различными ЦП невидимой.Так как же ЦП может получать самые последние данные при загрузке?
установить изменчивую переменную
Напишите простой код Java, объявите volatile переменную и присвойте значение
public class VolatileTest {
static volatile int i;
public static void main(String[] args){
i = 10;
}
}
Сам по себе этот код не имеет смысла, я просто хочу посмотреть, в чем разница между скомпилированным байткодом после добавления volatile, выполнитьjavap -verbose VolatileTest
После этого результат следующий:
Очень огорчает отсутствие инструкции байткода (monitorenter,monitorexit) после компиляции с ключевым словом synchronize, и инструкция присваивания putstatic после компиляции volatile ничем не отличается, разница только в том, что флаги модификации переменной i имеют на один большеACC_VOLATILE
логотип.
Тем не менее, я думаю, мы можем начать с этого логотипа и сначала искать по всему миру.ACC_VOLATILE
, если вы не знаете, с чего начать, сначала посмотрите, где используются ключевые слова, и вы можете найти похожее имя в файле accessFlags.hpp.
пройти черезis_volatile()
Вы можете определить, изменяется ли переменная с помощью volatile, а затем выполнить глобальный поиск, где используется «is_volatile», и, наконец, вbytecodeInterpreter.cpp
В файле найдите реализацию интерпретатора инструкции байт-кода putstatic, которая содержитis_volatile()
метод.
Конечно, при обычном выполнении эта логика не будет соблюдаться, и будут выполняться инструкции машинного кода, соответствующие байт-коду.Этот код можно использовать во время отладки, но окончательная логика та же.
Переменная кеша является экземпляром переменной i в кеше пула констант в коде Java, потому что переменная i изменяется с помощью volatile, поэтомуcache->is_volatile()
верно, присвоение переменной i задается выражениемrelease_int_field_put
реализация метода.
посмотри сноваrelease_int_field_put
метод
Внутреннее действие присваивания заключено в слой,OrderAccess::release_store
В конце концов, волшебство сделано, позволяя другим потокам читать последнее значение переменной i.
Странно, но в реализации OrderAccess::release_store к первому параметру принудительно добавляют volatile, очевидно, это ключевое слово c/c++.
Ключевое слово volatile в c/c++ используется для изменения переменных, обычно используемых на уровне языка.memory barrier, в «Языке программирования C++» описание volatile выглядит следующим образом:
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.
Volatile — это модификатор типа. Переменная, объявленная volatile, означает, что она может измениться в любое время. Каждый раз, когда она используется, она должна считываться из адреса памяти, соответствующего переменной i. Компилятор больше не будет оптимизировать код, который работает Следующее Напишите два простых кода c/c++ для проверки
#include <iostream>
int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
Переменная i в коде на самом деле недействительна, выполнитьg++ -S -O2 main.cpp
Скомпилированный ассемблерный код выглядит следующим образом:
Можно обнаружить, что в сгенерированном ассемблерном коде были оптимизированы некоторые недопустимые ответственные операции для переменной а. Если добавить volatile при объявлении переменной а
#include <iostream>
int foo = 10;
volatile int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
Ассемблерный код генерируется снова следующим образом:
По сравнению с первым сравнением имеются следующие отличия:
1. Оператор присваивания 2 переменной a также сохранен, хотя это и недопустимое действие, поэтому ключевое слово volatile может запрещать оптимизацию инструкций, фактически играет роль барьера компилятора;
Барьеры компилятора позволяют избежать проблемы неупорядоченного доступа к памяти, вызванной оптимизацией компилятора. Вы также можете вручную вставить барьеры компилятора в код. Например, следующий код имеет тот же эффект, что и добавление ключевого слова volatile.
#include <iostream>
int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
__asm__ volatile ("" : : : "memory"); //编译器屏障
a = foo + 10;
__asm__ volatile ("" : : : "memory");
int b = a + 20;
return b;
}
После компиляции он аналогичен приведенному выше
2. Среди них_a(%rip)
является адресом переменной a каждый раз,movl $2, _a(%rip)
Вы можете установить память, в которой находится переменная a, равной 2. Что касается RIP, вы можете просмотретьНовый режим адресации для PIC под x64: относительная адресация RIP
Поэтому каждое присваивание переменной а будет записываться в память, при каждом чтении переменной она будет перезагружаться из памяти.
Чувствуя себя немного не так, давайте вернемся к коду JVM.
После выполнения операции присваивания выполнитеOrderAccess::storeload()
, что это?
На самом деле, это барьер памяти, о котором часто говорят, раньше я умел только читать, но не знал, как это реализовать. Из анализа структуры кеша ЦП было известно: операция загрузки должна войти в LoadBuffer, а затем перейти в память для загрузки; операция сохранения должна войти в StoreBuffer, а затем записать в кеш, эти две операции являются асинхронными, что приведет к неправильному изменению порядка инструкций, поэтому в JVM определен ряд барьеров памяти, чтобы указать порядок выполнения инструкций.
Барьеры памяти, определенные в JVM, следующие: реализация JDK1.7
1. Грузозащитный барьер (груз1, грузгруз, груз2) 2. Барьер Loadstore (загрузить, загрузить, хранить)
Оба барьера проходятacquire()
реализация метода
в__asm__
, указывающий на начало ассемблерного кода.
volatile, как анализировалось ранее, не позволяет компилятору оптимизировать код.
Собрав эту инструкцию, я обнаружил, что не понимаю ее... Последняя "память" - это функция барьера компилятора.
Вставьте барьер в LoadBuffer, очистите операцию загрузки перед барьером, а затем выполните операцию после барьера, что может гарантировать, что данные операции загрузки будут готовы до следующей инструкции сохранения.
3. Барьер магазин-магазин (магазин1, магазин-магазин, магазин2) Через метод «релиз()»:
Вставьте барьер в StoreBuffer, очистите операцию сохранения перед барьером, а затем выполните операцию сохранения после барьера, чтобы гарантировать, что данные, записанные store1, будут видны другим ЦП при выполнении store2.
4. Барьер загрузки магазина (хранить, хранить, загружать) После присваивания volatile переменной в java вставляется этот барьер, который реализуется методом "fence()":
Вы рады видеть это?
пройти черезos::is_MP()
Сначала оцените, многоядерный ли он, если ЦП только один, этих проблем не существует.
Барьер загрузки хранилища полностью реализуется следующими инструкциями.
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
Чтобы проверить использование этих инструкций, давайте напишем код на C++ и скомпилируем его.
#include <iostream>
int foo = 10;
int main(int argc, const char * argv[]) {
// insert code here...
volatile int a = foo + 10;
// __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
volatile int b = foo + 20;
return 0;
}
Для того, чтобы переменные a и b не оптимизировались компилятором, здесь для украшения используется volatile.Скомпилированные инструкции по сборке выглядят следующим образом:
Из скомпилированного кода видно, что при втором использовании переменной foo она не перезагружается из памяти и используется значение регистра.
Пучок__asm__ volatile ***
Перекомпилировать после добавления инструкции
По сравнению с предыдущими есть еще две инструкции, одна блокировка и одна доп. Назначение инструкции блокировки: когда выполняется инструкция, следующая за блокировкой, будет установлен сигнал LOCK# процессора (этот сигнал блокирует шину, предотвращая доступ других ЦП к памяти через шину до выполнения этих инструкций). заканчивается), выполнение этой инструкции становится атомарной операцией, предыдущие запросы на чтение и запись не могут быть переупорядочены за пределами инструкции блокировки, что эквивалентно барьеру памяти.
Есть еще одно: когда переменная foo используется во второй раз, она перезагружается из памяти, чтобы убедиться, что можно получить самое последнее значение переменной foo, что достигается следующими инструкциями
__asm__ volatile ( : : : "cc", "memory");
Это также барьер компилятора, который информирует компилятор о повторной генерации инструкции загрузки (которая не может быть извлечена из кэш-регистра).
читать изменчивую переменную
Также вbytecodeInterpreter.cpp
файл, найдите реализацию интерпретатора инструкции getstatic bytecode.
пройти черезobj->obj_field_acquire(field_offset)
получить значение переменной
наконец прошлоOrderAccess::load_acquire
выполнить
inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
Нижний уровень основан на реализации volatile в C++.Поскольку volatile поставляется с функцией барьера компилятора, он всегда может получить самое последнее значение в памяти.