Приветствую, это текстовая версия моего доклада на JPoint-2021. Как обычно я сделал упор на случаи из жизни и на повседневные вещи, используемые каждым разработчиком. Под кат приглашаются интересующиеся производительностью и им сочувствующие. Если верить Большому энциклопедическому словарю, то нюанс (франц. nuance) - это оттенок, тонкое различие, едва заметный переход (в цвете, мысли, звуке и т. п.). Очень пошлое (и вместе с тем очень верное) объяснение определения дано в виде анекдота тут. Действительно: почему отнюдь не всегда глядя на код мы можем с полной уверенностью сделать вывод о том, насколько быстро он будет работать? Краткий ответ: потому что Java многослойна. Развёрнутый ответ: потому что проделывая путь от исходного кода до машинных команд ваша программа может претерпеть настолько существенные изменения, что её поведение будет отличаться от ожидаемого. В общих чертах исполнение программы выглядит так: компилятор превращает исходный код в байт-код байт-код исполняется виртуальной машиной оптимизирующий компилятор (многоуровневый) меняет код на лету по мере его исполнения процессор исполняет инструкции, полученные от компилятора Нюансы существуют на каждом из этапов, где-то они более очевидны, где-то неочевидны вовсе. нюансы реализации/алгоритма нюансы исполнения нюансы измерения Начнём, как это ни странно, с конца. Все знают, что неправильно написанный бенчмарк даёт совершенно неверные результаты. Из этого наблюдения часто делается ложный и, по моему мнению, опасный вывод: если бенчмарк написан правильно, то его результаты можно использовать как неопровержимое доказательство очевидной полезности тех или иных изменений в коде. Предположим, мы хотим узнать, какой из способов перебора массива будет быстрее: Казалось бы: Оба способа делают одно и то же, тем более, что for-eachявляется по сути синтаксическим сахаром для for-i. Но есть нюанс :) Этот мопед не мой, ниже я дам ссылку на автора, а пока давайте посмотрим на прогон: Впечатляет, правда? Использование синтаксического сахара дало почти двукратный прирост производительности на пустом месте! Нужно срочно переписывать код своего проекта! Так думает начинающий разработчик и энтузиаст производительности (так когда-то думал и я). Более опытные не торопятся с выводами, ведь бенчмарк бенчмарку рознь. Быть может в наши исследования вкралась методологическая ошибка? К счастью для нас, создатели JMH описали примеры на все случаи жизни, среди которых находим нужный: Получается, всё сделано как в лучших домах Потсдама и данные сами по себе верны, но из них сделан неправильный вывод. А правильный вывод сделал Ницан Вакарт в статье The volatile read surprise: Внутри метода Blackhole::consume происходит два чтения из волатильных полей, которые создают эффект чёрного ящика, не давая компилятору выбросить источник аргумента b, и вынуждая исполнение отказаться от оптимизации доступа к полю bunn и каждый раз делать честное чтение из памяти, а не из кэша. Поэтому простое считывание поля в переменную и проход по ней волшебным образом выравнивают показатели: Метод goodOldLoopReturnsработает так же быстро, как и for-each Таким образом, for-each выигрывал лишь из-за неявной записи поля в переменную: после сборки превращается в Мораль сей басни выражена в послесловии к выводу каждого бенчмарка: REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts. Do not assume the numbers tell you what you want them to tell. На языке родных осин это означает, что правильное толкование полученных данных столь же важно, как и написание правильного бенчмарка. Цифры сами по себе ничто, понимание - всё. Начиная с версии 1.3 и до 1.28 класс Blackhole был переписан, чтобы уменьшить вредное воздействие волатильного доступа: Теперь разница стала заметно скромнее: Тем не менее, это ещё не предел (в пределе разницы быть вообще не должно). Начиная с версии 1.29 разработчики добавили возможность реализации чёрного ящика с помощью компиляторных хитростей: Критик наверняка скажет: "Но это выдуманный пример!". На деле, не такой уж и выдуманный: Да-да, наш метод как две капли воды похож на реализацию Iterable.forEach(Consumer), который есть у любой коллекции. Выше мы убедились, что в 99 случаях из 100 способ перебора не важен, но на всякий сотый случай все коллекции на основе массива используют либо for-each, либо выполняют явную синхронизацию на стеке: На этом  примере хорошо виден круговорот очевидностей и нюансов в природе: бенчмарк показал очевидную разницу между for-i и for-each нюанс: причина в инфраструктуре JMH очевидно, что в 99 случаях из 100 способ перебора массива не влияет на производительность нюанс: есть редкие случаи, когда всё же влияет Рассмотрим метод  o.s.util.StringUtils.uriDecode(String, Charset). Он нужен для превращения кодированной ссылки вроде в более удобочитаемую Метод довольно громоздкий, а нас интересует последняя строка: А в ней - использование метода ByteArrayOutputStream.toByteArray(): Этот метод возвращает копию массива, хранящегося внутри ByteArrayOutputStream: Всё логично: строка создаётся из копии массива. Теперь взглянем на соседний метод ByteArrayOutputStream.toString(String charsetName): Он делает то же самое, что и new String(bos.toByteArray(), charset), только без создания промежуточной копии. Поскольку и ByteArrayOutputStream.toString(), и ByteArrayOutputStream.toByteArray() синхронизированы, а внутри строки в любом случае создаётся новый byte[], то выражение new String(bos.toByteArray(), charset) можно заменить выражением bos.toString(charset), обработав при этом сопутствующее исключение. Здравый смысл подсказывают, что избавившись от создания промежуточного массива мы получим прирост производительности. Бенчмарк подтверждает догадку: Наш расчёт оправдался: прирост есть как по времени, так и по памяти. Теперь проверим, улучшилась ли производительность StringUtils.uriDecode(String, Charset): Время исполнения уменьшилось на 6%, а потребление памяти - на 12,75%. Вроде бы мелочь, но приятно, тем более что замену можно автоматизировать. Этот же подход можно применить к прочим подобным классам, например, к java.io.CharArrayWriter: Проверим, насколько метод toString() выигрывает у промежуточного массива: В JDK есть даже подопытный компонент - java.net.URLEncoder, использующий CharArrayWriter в методе encode() схожим образом: Получается, здесь возможна аналогичная замена: Соберём JDK и измерим производительность: И вдруг: Теперь мы потребляем меньше памяти, но, время выполнения выросло почти на четверть! Почему в одном случае всё отработало хорошо, а в похожем как две капли воды мы не только не получили прироста, но и наоборот - производительность ухудшилась? С этим вопросом я пошел в hotspot-compiler-dev, т. к. был уверен, что речь идёт об ошибке оптимизирующего компилятора. Так выглядит лог вклеивания до моих изменений: А таким он стал после (внимание на вторую и седьмую строки): Мы видим, что CharArrayWriter::toString и StringCoding::encodeUTF8_UTF16 не были встроены в точку вызова из-за своего размера (хотя оба скомпилированы полностью). Также в логе компиляции появилась такая запись (внимание на последнюю строку): Таким образом, в скомпилированном коде появилась дыра, где нам нужно сделать честный вызов метода, что ломает многие оптимизации, ведь компилятор не знает, что нас ждёт внутри чёрного ящика (точно так же Blackhole ломал оптимизации в первом примере). Увеличение объёма кода вызвано использованием другого конструктора: Очевидно, что преобразование char[]-> byte[] выполняется внутри последнего конструктора, а единственная разница заключается в проверке границ в скромном методе rangeCheck(char[], int, int): но этого оказалось достаточно. Выяснилось, что C2 uses a bunch of of heuristics. Here, it's detected that CharArrayWriter::toStringis large and has already been compiled so there's no sense inlining another copy of it. This isn't necessarily true, but it's a good guess. Try playing with InlineSmallCode start with = 1000, and increases it from there to see if it helps. Определение -XX:InlineSmallCode звучит так: Inline a previously compiled method only if its generated native code size is less than this. The default value varies with the platform on which the JVM is running. Более развёрнутое (и более простое, как мне кажется) определение дано в статье "Java HotSpot JIT компилятор — устройство, мониторинг и настройка (часть 2)", которая категорически рекомендуется к прочтению: Встраиванию не подлежат скомпилированные на последнем уровне методы, занимающие более 1000 байт при выключенной многоуровневой компиляции и 2000 байт при включенной Несмотря на всё вышесказанное я создал ПР и протолкнул свои изменения: Сделать это мне удалось благодаря другому ПР, на первый взгляд никак не связанному с моим: 8254913: Increase InlineSmallCode default from 2000 to 2500 for x64. После него удалось получить желаемый прирост: Возникает закономерный вопрос: а почему бы не вклеить вообще всё и получить цельный кусок ассемблера? Ответ: If you never share code you ends up with several giga bytes of assembly codes which destroy your perf because you start to have a lot of cache miss on the instructions.   So there is a trade off between a theoritical fully inlined program and a never inlined program. Действительно, давайте представим себе полностью вклеенную программу: у нас есть цельный кусок ассемблера. Получается, что если раньше мы использовали повторно тело какого-нибудь String.isEmpty(), то теперь тело этого метода будет вклеено в каждую точку обращения к нему. В этом случает объём скомпилированного кода из-за отсутствия переиспользования вырастет лавинообразно.  Поместится ли такой кусок в кэш целиком? Вряд ли, что приведёт к промахам при обращении к кэшу (cache-miss), а это будет бить по производительности, ведь процессору будет необходимо постоянно подгружать нужный код из ОЗУ в кэш.  Ввиду сказанного выше разработчики ВМ и компилятора пришли к компромиссу между встраиванием и повторным использованием.  Вывод для разработчика: улучшения "под копирку" работают не всегда после улучшения всегда проверяйте конечный сценарий Работающие со вводом-выводом знают, что буферизация - это эффективное средство улучшить производительность ведь чтение и/или запись отдельных байтов стоят дорого: Метод readFromFile()считывает содержимое файла байт за байтом: Попробуем буферизацию: Отлично, считывание ускорилось в скромных 20 раз: Это стало возможным за счёт использования промежуточного массива, куда считывается сразу пачка данных, сильно удешевляя тем самым чтение отдельного байта. Разумеется, бесплатно ничего не достаётся, о чём свидетельствует взрывной рост потребления памяти. И всё же общая производительность существенно улучшается, что показывает прогон с измерением пропускной способности: Даже выросшее потребление памяти (и соответственно, нагрузка на GC) не мешает буферизированному чтению более чем на порядок превосходить обычное. Вроде всё просто и очевидно. Где же нюанс? А нюанс возникает, когда буферизацией начинают злоупотреблять: Этот бенчмарк полностью повторяет предыдущий за одним исключением: здесь читаем не из файла напрямую, а из ссылки на него. Получается так: Здесь буферизация не ускорила чтение, но потребление памяти выросло почти в 2 раза. Причина в реализации FileURLConnection.connect(), который вызывается из FileURLConnection.getInputStream(): Здесь возвращается уже буферизированный поток чтения, поэтому дополнительное оборачивание приводит к переливанию из пустого в порожнее, что увеличивает потребление памяти и снижает пропускную способность: Обратите внимание на существенно выросшее количество сборок мусора и затраченное на них время. На мой взгляд, это - большая недоработка, корнями уходящая в 1996 год с медленным железом и файловыми системами, из-за чего разработчики JDK решили буферизировать поток ввода из файла по умолчанию. В 2021 году это скорее вредит: Этот бенчмарк использует новый метод InputStream.readAllBytes()для считывания всего файла (именно это нужно обычному разработчику в 9 случаях из 10): Смотрите, как любопытно получается: при чтении файла из локальной файловой системы буферизация не только не помогает, но и мешает. Попробуем сделать то же для большого (относительно размера буфера) файла (см. закомментированный код выше): Здесь отрицательное влияние буферизации почти незаметно. Причина замедления в обоих случаях одна и та же: промежуточный буфер внутри BufferedInputStream: Метод fill()использует InputStream.read(byte[], int, int)для чтения большой пачки данных, но ровно это же напрямую делает InputStream.readNBytes(int): Мне кажется, что изначально разработчикам вместо явной буферизации (которую теперь уже не выпилить) стоило бы указать в документации, что возвращаемый поток не буферизирован, и при необходимости клиент должен сделать это самостоятельно. Именно это делает Files.newInputStream(Path): Грамотные разработчики библиотек тоже указывают наличие/отсутствие буферизации потока ввода/вывода, как это сделано в org.apache.commons.io.IOUtils: В наличии есть 2 бенчмарка, которые делают одно и то же, но разными способами. Первый бенчмарк неопровержимо доказывает пользу буферизации, второй - столько же неопровержимо доказывает её вред. Какому бенчмарку верить? Оба написаны и работают правильно, а для принятия решения в конкретной ситуации необходимо вникнуть в контекст. Мне кажется, что привычка буферизировать любой поток ввода-вывода вредна, см. https://github.com/openjdk/jdk/pull/2992/files https://bugs.openjdk.java.net/browse/JDK-8263599 Рассмотрим часть кода класса o.s.core.type.classreading.SimpleMetadataReader,  использующегося для считывания метаданных загруженных классов в "Спринге": Здесь resource.getInputStream() оборачивается в BufferedInputStream и вы, вероятно, уже догадываетесь, куда ветер дует: Также буферизированное чтение происходит внутри класса o.s.asm.ClassReader: Вновь делается двойная работа, бесполезность которой позволяет оценить этот бенчмарк: Запустив его дважды (с оборачиванием и без него) получаем: Таким образом, просто выбросив ненужную буферизацию мы получили неплохую экономию памяти и небольшое снижение времени выполнения. Будет ли от этого толк в масштабе приложения? Чтобы проверить это можно собрать Спринг с указанным изменением и воспользоваться бенчмарком: Здесь мы измеряем время, необходимое для запуска приложения на Спринг-Буте: Пока не особо впечатляет, но даже такое простое изменение даёт прирост. Критически настроенный читатель посчитает это изменение чересчур резким, ведь у интерфейса Resource есть 44 (!) реализации: Возможно, нам стоит использовать более щадящий подход а-ля Котлин: Раз всё дело в избыточном заворачивании в BufferedInputStream, то ничто не мешает нам соорудить что-то вроде: и использовать его в SimpleMetadataReader-е. Но и тут есть нюансы, ведь у InputStreama есть множество реализаций, например, ByteArrayInputStream, который не является подклассом BufferedInputStream-а, а значит он будет обёрнут: Уже поверхностное рассмотрение кода показывается бессмысленность использования BufferedInputStream-а, что подтверждается бенчмарком: дающим совершенно сногсшибательные результаты: Обратите внимание на огромный перерасход памяти: 17 килобайт использовано для чтения 1. Всё очень и очень празднично. Хорошо, немного поправим наше решение: Теперь найдём наиболее часто используемые реализации. Вот один из примеров: Но и для этого класса буферизация оказывается вредной.  Собрав статистику выкатываем итоговую версию: И сразу появляется неудобство: Злоключения на этом не заканчиваются, ведь кодовая база "Спринга" всё ещё использует "восьмёрку", но рано или поздно настанет день обновления, и тогда у нас появится ещё одна ошибка: В "девятке" пакет sun.nio.ch не экспортируется модулем java.base, поэтому использовать его больше не получится. Если бы мы жили в идеальном мире, то InputStream был бы интерфейсом, в который было бы достаточно добавить один метод: и использовать его вот так: Теперь вернёмся к org.springframework.asm.ClassReader, возможно из него можно выжать ещё что-нибудь. Большой недостаток этого метода в выделении буфера постоянного размера: Обратите внимание: для чтения 421 байта выделено 4 кБ - в 10 раз больше, чем необходимо. Также в том случае, когда метод InputStream.read(byte[], int, int)смог считать все данные сразу в буфер нужного размера, вызов OutputStream.toByteArray()стал бы ненужен, ведь массив data уже содержит все данные, а значит можно вернуть его из метода избежав копирования. Зайдём в лоб и воспользуемся InputStream.available():  И тут же облом: Мы не можем полагаться на expectedLength == os.size(), т. к.  возможен случай, в котором InputStream.available()вернёт 3 кБ, но при чтении будет получено только 2, из-за чего мы пойдём на второй проход по циклу. В итоге условие expectedLength == os.size()будет выполнено, но data будет содержать только 1 кБ из 3, что закончится падением контекста. Что ж, изменим код чтобы явно отслеживать количество чтений: Это вполне рабочий вариант, но он всё ещё таит скрытую угрозу: Оказывается, Линукс допускает существование файлов, которые притворяются пустыми, но в действительности содержат данные. Конечно, если classpath вашего приложения расположен в /proc, то вы более чем странный человек, но такую возможность исключать нельзя, поэтому перестрахуемся: И это даже приносит пользу! Окрылённый успехом я создал ПР Improve performance of ASM's ClassReader.readStream() , забыв, что ClassReader целиком скопирован из библиотек ASM, поэтому правильнее всё-таки создать ПР туда, что я и сделал. Однако, разработчикам ASM-а идея показалась слишком смелой, поэтому пока изменения лежат без движения. Нужно отметить, что ASM - это не совсем обычная библиотека, многие её используют путём копирования исходников в свой проект (кроме Спринга так же сделали в JDK). В любом случае, похожий финт ушами в можете делать в своих проектах самостоятельно. производительность ввода-вывода - это "страна, где много-много диких обезьян" и всё в ней делается на свой страх и риск ни один из рецептов не должен быть догмой, даже такая очевидно полезная вещь, как буферизация нередко вредит указывайте в документации ваших методов необходимость буферизации или её отсутствия. Да, это детали реализации, но часто они очень важны  правильно истолковать цифры бенчмарка столь же важно, как и правильно написать этот самый бенчмарк без понимания природы цифр сами по себе они бесполезны измерять нужно именно конечный сценарий, улучшение частного может ухудшить целое изменения под копирку работают не всегда вникайте в контекст, производительность – это немного про археологию в производительности изучайте реализации, а не только интерфейс, а также не доверяйте слепа красотам/ужасам документации З.Ы. Бенчмарки для IO лежат тут. Разработчик