В этой статье мы обсуждаем, как ЦП реагирует на каждую операцию Java. Как ЦП обрабатывает различные операции оптимальным образом. В этой статье вы получите ясное представление о таких терминах, как ЦП, кэш ЦП, кеши L1, L2 и L3, и о том, как они помогают выполнять операции ЦП.

Прежде чем перейти к этой статье, надеюсь, вы знаете об инструменте тестирования производительности Java JMH. В этой статье мы проводим несколько экспериментов с производительностью Java с использованием JMH. Если нет, то просто вернитесь к моей статье о JMH и разберитесь в ней.



Представьте, что у вас есть очень большой массив целых чисел и вы хотите выполнить две операции над этим массивом.

  1. Первая операция - умножить все числа на целое 5.
  2. Вторая операция - умножить все 15-е и кратные числа на целое 30.

Можете ли вы сказать, какая операция самая быстрая и насколько быстро она выполняется?

Обычно, если вы думаете, что метод doFifteenthMultiply() становится в 15 раз быстрее, чем doAllMultiply(), потому что doFifteenthMultiply() изменяет только 15-й элемент в массиве, а doAllMultiply() изменяет каждый элемент в массиве.

Но удивительная вещь из приведенного выше фрагмента кода заключается в том, что для выполнения обоих циклов требуется примерно одинаковое время. Чтобы узнать, как это происходит, давайте узнаем, как работает процессор.

Как работает процессор

Давайте разберемся, как работают процессор и память, на примере выше.

Массив хранится в основной памяти, и ЦП считывает элемент за элементом, умножает каждый элемент на три и записывает результат обратно. Это поведение общего процессора

Здесь следует его графическое изображение.

На приведенном выше изображении объясняется, что ЦП считывает каждый элемент из основной памяти и умножает результат на 3, а затем возвращает результат в то же место.

Вы знаете, что в наши дни процессоры сверхбыстрые. Поэтому, если обработка массива работает так, как показано на изображении выше, это было бы ужасно неэффективно, потому что сверхбыстрый ЦП большую часть времени будет ждать, пока гораздо более медленная основная память доставит следующий элемент.

Почему кэш процессора

Чтобы преодолеть это узкое место, разработчики процессоров добавили небольшой кэш-память между процессором и основной памятью. Кэш - это гораздо более быстрый модуль памяти, вся цель которого - уменьшить разрыв в производительности.

Программы, как правило, обращаются к одним и тем же данным и коду несколько раз за короткий период (временная локальность), а доступ к памяти часто ограничивается небольшими регионами (пространственной локальностью). Это означает, что если вы загрузите все данные, которые вы используете, в кеш, высока вероятность, что они вам понадобятся позже. Поскольку в следующий раз, когда вам понадобятся данные, которые, возможно, уже находятся в кеше, производительность ваших программ значительно возрастет.

Теперь вы можете подумать: если между процессором и основной памятью можно разместить более быструю память, почему нельзя сделать быстрее всю память компьютера? Есть две основные причины:

  • Основная память намного больше, чем кеш, и просто требуется больше времени, чтобы найти правильный адрес в пределах 16 ГБ (типичный размер основной памяти на момент написания этой статьи), чем найти правильный адрес в пределах 8 КБ (типичный размер Кэш уровня 1 [L1]).
  • Электронные компоненты кэш-памяти намного дороже, чем те, что используются в основной памяти, с точки зрения тепла и места. Тепло и пространство являются ограничивающими факторами в дизайне современных микросхем и, по сути, в дизайне современных компьютеров в целом.

ЦП загружает всю строку кэша из основной памяти в кэш и напрямую изменяет элементы в кэше. Первый цикл изменяет все элементы в строке кэша, а второй цикл изменяет только один элемент (16 целых чисел, каждые 4 байта).

Далее следует диграматическое объяснение того, как работает кеш, на основе нашего примера.

Давайте немного подробнее о кеше

Различные уровни кеширования

Кэш обычно не представляет собой единый блок, а состоит из нескольких иерархических уровней с разными размерами и временем доступа. кэш L1 - самый маленький и быстрый. L2 больше и намного медленнее. L3 еще больше и медленнее, но все же значительно быстрее, чем основная память.

Каждое плато на графике соответствует уровню иерархии кеша. Пока массив умещается в кешах L1 и L2, время доступа очень мало. Но как только массив становится слишком большим и его приходится читать из кеша L3, время доступа заметно увеличивается. И то же самое происходит снова, как только массив не помещается в кеш L3 и должен быть прочитан из основной памяти. Если присмотреться, можно даже увидеть небольшой скачок между кешем L1 и L2.

Размер имеет значение. Несмотря на то, что память дешевле, чем когда-либо прежде, старайтесь не тратить ее впустую. Чем меньший объем данных вы используете для определенной структуры данных, тем выше вероятность того, что они поместятся в кеш, что может привести к значительному повышению производительности.

Неустойчивая эффективность ключевого слова

Затем давайте выясним, как ключевое слово volatile работает в том же сценарии. Ключевое слово volatile является особенным, потому что ключевое слово volatile не кэширует значение переменной и всегда считывает переменную из основной памяти. Это означает, что он не использует кэш ЦП, потому что это обеспечивает безопасность потоков. Итак, можно сказать, что ключевое слово volatile работает медленнее, чем обычное ключевое слово… не так ли?

Давайте рассмотрим это на примере.

В приведенном выше примере объясняется метод doMultiply() вставки элементов в обычный массив и метод doVolatileMultiply() вставки элементов в изменчивый массив. Сравнивая скорости обоих методов, мы видим, что метод doVolatileMultiply() работает медленнее, чем метод doMultiply(), потому что ключевое слово volatile не использует кэш ЦП для хранения элементов. Он берет элементы прямо из основной памяти и предназначен для обеспечения безопасности потоков.