[Перевод] Почему каждая JVM в вашей инфраструктуре прогревается отдельно

Мы живём в мире распределённых систем. Чтобы обрабатывать больше трафика, мы просто добавляем новые экземпляры, или реплики, одного и того же сервиса, а затем распределяем между ними нагрузку.
В результате сервис работает на десятках, сотнях или тысячах JVM. Один и тот же JAR, одна и та же JDK, одинаковый тип инстанса, одинаковый характер трафика. Первый экземпляр запускается холодным, в первые минуты выясняет, какие из нескольких тысяч его методов используются наиболее активно, и компилирует их, оптимизируя под наблюдаемый трафик. Тысячный экземпляр, запущенный на пару часов позже, делает ровно то же самое. С нуля. В тысячный раз.

Это не ошибка. Так устроена JVM, и на то есть веская причина: JIT-компилятор компилирует то, что наблюдает, в той JVM, где он это наблюдает. Но повторная работа никуда не исчезает, и от очевидного вопроса трудно отделаться. Первый экземпляр уже всё это сделал. Почему тысячный не может просто воспользоваться готовым результатом?
Наивный подход мог бы выглядеть так: сохранить где-нибудь скомпилированный код каждого метода, а при следующем запуске передать это где-нибудь JVM и восстановить уже скомпилированный код вместо компиляции на лету. Например, просто сопоставить методы по полному имени. В конце концов, это копия того же JAR, то же приложение, тот же трафик. Что здесь может быть сложного?
В этой статье разберёмся, почему всё далеко не так просто. Мы рассмотрим:
Какие предположения на самом деле сделал компилятор. Машинный код, который выдаёт JIT, — это не просто перевод байт-кода. Это ставка на состояние одной конкретной JVM в один конкретный момент. Именно эта ставка и обеспечивает скорость.
Три проблемы, которые возникают, как только Вы пытаетесь перенести этот код.
Идентификация. О каких именно классах, полях и методах этой JVM вообще идёт речь в поступившем коде? Имя — вовсе не тот уникальный идентификатор, которым вы, вероятно, его считаете.
Корректность. На один метод приходятся несколько тысяч зафиксированных предположений. Каждое из них должно выполняться, а проверить их все нужно гораздо быстрее, чем заняла бы самостоятельная компиляция метода.
Целесообразность. Код может быть доказуемо корректным и при этом не подходить конкретному экземпляру.
Этому и посвящена статья.
Скомпилированный код — артефакт, действующий при определённых условиях
Чтобы не потеряться в этом разделе, сразу раскроем главное: просто сохранить код, скомпилированный JIT, а затем попытаться сопоставить его с теми же методами по именам не получится. Такой код корректен лишь вместе с предположениями, на которых он основан. При восстановлении кода эти предположения необходимо проверить. И их очень много. Разберём конкретные примеры.
Начнём с того, что на самом деле делает JIT-компилятор. «Переводит байт-код в машинный код» — лишь небольшая часть его работы, причём в нашем случае наименее важная.
JIT-компилятор наблюдает за выполнением программы, а затем делает ставку на увиденное. Возьмём такой код:
Codec c = registry.lookup(name);
return c.decode(buf);Если на момент компиляции загружена ровно одна реализация Codec, нет смысла генерировать виртуальную диспетчеризацию — то есть выбирать вызываемый метод в зависимости от фактически используемой реализации. Компилятор девиртуализирует вызов, встраивает тело decode непосредственно в вызывающий метод, а затем применяет оптимизации, которым раньше мешала граница вызова: свёртку констант, удаление недостижимых ветвей, распределение регистров с учётом встроенного кода и другие. В результате диспетчеризации в коде нет, а от самого decode вполне может не остаться никаких узнаваемых следов.
Суть в том, что код работает быстро именно благодаря наблюдениям или предположениям — в данном случае тому, что обнаружена только одна реализация Codec, — а не вопреки им.
Теперь дадим приложению поработать ещё немного. Приходит запрос в формате, который раньше никто не использовал, реестр загружает вторую реализацию Codec, и предположение перестаёт выполняться. Машинный код, рассчитанный на единственную реализацию, теперь не просто неоптимален — он некорректен. Причём эта некорректность проявляется аварийным завершением, а не изменением графика задержек.
JVM справляется с этим каждый день, а Вы даже не замечаете. При компиляции метода она зафиксировала предположение. Поэтому, когда загружается второй класс, JVM находит затронутый код, отбрасывает его и возвращается к интерпретатору, восстанавливая кадры интерпретатора для всех потоков, которые в этот момент выполняют этот код. Затем она компилирует его заново — на этот раз с диспетчеризацией. Это и есть деоптимизация. Весь этот механизм существует именно потому, что оптимизированный код по своей природе зависит от определённых условий.
Но вот что важно: Ваша JVM уже ведёт весь этот учёт. Прямо сейчас, в рабочей среде, для каждого установленного оптимизированного метода. Именно благодаря этому деоптимизация вообще возможна, а стоящая за ней система отслеживания зависимостей весьма сложна. Поэтому необходимость «записывать предположения компилятора» — не самая трудная часть задачи и не новая идея. JVM занимается этим всё время.
Когда код переносится в другую JVM, меняется не то, что вдруг появляется необходимость вести такие записи. Меняется их назначение: они перестают быть внутренними заметками для самой JVM. В пределах одной JVM эти предположения привязаны к среде, которую она сама контролирует, и отслеживаются с учётом изменений, которые она сама же вносит. Перенесите их куда-нибудь ещё — и каждое станет утверждением о процессе, о котором эта JVM ничего не знает.
Что же содержится в этих записях? Утверждения примерно такого рода:
связи в иерархии классов;
смещения и типы полей;
байт-код и флаги доступа методов;
значения констант;
целевые методы разрешённых вызовов.
Длина списка отчасти зависит от выбранного подхода. Детальная запись даёт множество небольших элементов: в реализации, которую мы рассмотрим во второй части, их несколько тысяч на один метод. Более крупные элементы позволили бы сократить их количество. Но само число здесь не самое интересное.
Интереснее другое: если посмотреть на этот список, большая его часть кажется статической. Какие интерфейсы реализует класс, где внутри объекта находится поле, какой байт-код содержит метод — если оба экземпляра загрузили побайтово одинаковый файл класса, всё это гарантированно совпадёт. Проверка таких сведений начинает выглядеть паранойей.
Так бы и было, если бы не одно обстоятельство. Ничто не сообщает принимающей JVM, что оба экземпляра действительно загрузили один и тот же файл. «Это одно и то же приложение» — знаете Вы, благодаря процессам сборки и развёртывания. У JVM доступа к этим сведениям нет. Вычислив цифровой отпечаток файла класса, можно было бы установить его идентичность один раз и больше не проверять каждый факт по отдельности.
Это вполне реальный вариант, который, по крайней мере в принципе, решает статическую часть проблемы. Поэтому посмотрим на то, что остаётся.
Значение константы не обязано совпадать между двумя запусками. Поле static final, инициализированное текущими датой и временем, для компилятора, которому доступно его значение, является константой времени компиляции. Но увиденное им значение отличается от значения в текущем экземпляре.
Есть и то, чего в этом списке вообще нет, поскольку это свойство не кода, а JVM. Самый наглядный пример — состояние инициализации класса. Если класс уже инициализирован, в обращающемся к нему коде проверка инициализации не нужна, поэтому компилятор её не генерирует. Возьмите такой код и установите его в JVM, где класс ещё не инициализирован, — и программа будет работать неправильно.
Тот же JAR, та же JDK, тот же тип машины, тот же характер трафика. Всё это не помогает. В принимающем экземпляре класс не инициализирован, потому что путь выполнения, который должен был его инициализировать, там ещё не был пройден. Этот процесс просто запущен позже. Одинаковые входные данные не означают одинакового состояния во время выполнения, и никакой цифровой отпечаток этого не изменит.
Именно эта часть проблемы никуда не исчезает. А от каждого элемента записи, статического или нет, зависит корректность фрагмента кода, из которого уже удалена проверка, способная обнаружить проблему.
Таким образом, наивный подход с сопоставлением скомпилированного кода по полному имени метода оказывается несостоятельным ещё до сохранения чего-либо в файловую систему. Дамп кеша кода сохраняет ответы и выбрасывает вопросы. Машинный код без записи положенных в его основу предположений — это не элемент кеша, а авария, которая только и ждёт появления загрузчика классов.
Подход должен измениться. Переносить нужно не просто скомпилированный код, а код вместе с несколькими тысячами утверждений об окружающей среде. И принимающая JVM — то есть новый экземпляр, запускающийся где-то в инфраструктуре, а не перезапуск того, который создал код, — должна проверить каждое из этих утверждений применительно к себе, прежде чем выполнять хоть что-нибудь из этого кода. Это подводит нас к вопросу, который кажется формальностью, но оказывается ещё одной сложной задачей: прежде чем проверить утверждение о поле, нужно понять, о каком именно поле идёт речь.
Идентификация: о чём вообще это утверждение?
Вся эта проверка распадается на три вопроса, и их стоит обозначить заранее. Может ли JVM понять, о чём вообще идёт речь в утверждении? Верно ли оно здесь? И если верно, действительно ли связанный с ним код подходит этому экземпляру? Первый вопрос — тот самый, в котором никто не ожидает подвоха.
Один элемент записи выглядит примерно так: поле buffer класса com.example.Codec находится по смещению 40 и имеет тип byte[]. Чтобы это проверить, JVM должна найти собственный com.example.Codec и посмотреть на него. Поиск класса по имени — один вызов метода. Это очевидный ход. И он неверен.
Имя класса не является уникальным идентификатором в работающей JVM. Класс определяется своим именем и загрузчиком классов, который его определил. Два загрузчика могут определить по классу с именем com.example.Codec, и это будут два совершенно разных класса: с разными полями по разным смещениям и разными телами методов. Если Вы когда-нибудь выпускали систему плагинов, запускали приложение на сервере приложений или использовали два модуля, каждый со своей версией одной библиотеки, в Вашей JVM уже сосуществовали одноимённые классы. Вы этого не замечали, потому что загрузчики изолировали их друг от друга.
Поиск по имени не отвечает на вопрос, а угадывает ответ. Он легко вернёт не тот Codec. Мы проверим утверждение на классе, не имеющем отношения к тому, который видел компилятор, и каждое смещение поля в машинном коде будет указывать в правдоподобное, но неверное место.
Значит, имена не подходят. Что остаётся?
Нужно перестать искать сущности и начать выводить их идентичность из уже известных связей.
Всё, о чём говорится в записи, получает собственный номер, имеющий смысл только в её пределах. Класс 24. Поле 100. Метод 201. Эти номера не являются адресами в какой-либо JVM. Это локальные идентификаторы, а задача принимающей JVM — последовательно установить, какой реальной сущности в этом процессе соответствует каждый из них.
Небольшой набор сущностей однозначно известен с самого начала: метод, для которого запрошен код, и класс, в котором этот метод объявлен. Это исходные данные. До всего остального нужно добраться от уже идентифицированных сущностей.
Вот что позволяет этому работать: проверка утверждения и установление идентичности происходят одновременно. Допустим, запись утверждает, что класс 24 реализует три интерфейса с номерами 153, 173 и 69. Чтобы это проверить, нужно уже знать, какой класс в работающей JVM соответствует номеру 24. Вы обращаетесь к этому классу, смотрите, какие интерфейсы он действительно реализует, и сравниваете. Если всё совпало, одновременно произошло два события: утверждение проверено, а Вы теперь знаете, каким интерфейсам в работающей JVM соответствуют номера 153, 173 и 69. Следующее утверждение может ссылаться на 153, и Вы будете понимать, что это значит.
Каждая проверка приносит дополнительную пользу, устанавливая идентичность новых сущностей. Запись упорядочена так, чтобы это работало на каждом шаге: ни одна сущность не упоминается раньше, чем один из предыдущих элементов однозначно её определит. Принимающая JVM проходит список один раз, от начала до конца.
Имена во всём этом тоже присутствуют и выполняют полезную работу. Интерфейсы сопоставляются по именам. Но обратите внимание, где именно используется имя: не для поиска в JVM класса под названием Constable — эта операция неоднозначна, — а для выбора нужного интерфейса среди интерфейсов уже идентифицированного класса. Структура приводит Вас в нужный район, и только после этого имя указывает на нужную дверь.
Так и стоит представлять всю схему. Само по себе имя — это почтовый адрес, который встречается в сотне городов. Запись же даёт маршрут: начните от двери, перед которой уже стоите, и каждый следующий шаг будет привязан к предыдущему.
Поэтому всё выполняется за один проход: без поиска, перебора вариантов и возвратов назад. Дело не в изяществе решения. Это чрезвычайно важно, и следующий раздел объясняет почему: на всю операцию отведено очень мало времени.
Корректность: всё или ничего при жёстких ограничениях по времени
Установить, к чему относится утверждение, — лишь половина работы. Проверка делится на два отдельных этапа: сначала разрешение ссылок — всё то, что описано в предыдущем разделе, — а затем верификация, которая начинается, когда нужная сущность уже найдена.
С концептуальной точки зрения верификация — скучная половина. Вы нашли поле. В записи указаны смещение 40 и тип byte[]. Смотрите на собственное поле: смещение 40, тип byte[]? Значит, утверждение верно. Переходим к следующему.
Интересны требования, которые к ней предъявляются. Критерий принятия абсолютен: прежде чем результат компиляции можно будет применить, необходимо разрешить каждую зависимость и убедиться, что каждая разрешённая зависимость соответствует записи. Не большинство. Не те, которые кажутся важными. Все.
Это звучит как паранойя, пока не вспомнишь сказанное ранее. Нарушенное предположение в оптимизированном коде — не снижение производительности, а ошибка корректности, причём компилятор уже удалил проверки, которые могли бы её обнаружить. «Почти корректного» скомпилированного метода не существует. Нельзя установить 90% метода, а остальное интерпретировать. Либо весь метод целиком, либо ничего. Поэтому один не прошедший проверку элемент из нескольких тысяч заставляет отбросить весь результат компиляции.
Теперь оценим объём работы. Несколько тысяч элементов на один метод. Запускающемуся экземпляру нужно много методов — в этом и состоит вся затея. Значит, принимающая JVM выполняет миллионы отдельных проверок. И делает это во время запуска — и без того самого напряжённого и неудобного момента в жизни процесса: загружаются классы, выполняется инициализация приложения, а ограничения на использование процессора ощущаются острее, чем когда-либо ещё.
И вот ограничение, определяющее всю архитектуру: всё это имеет смысл, только если проверка метода намного дешевле его локальной компиляции. Если затраты сопоставимы, Вы создали более медленный JIT. Поэтому проверка должна быть не просто корректной — затраты на неё должны быть пренебрежимо малы по сравнению с оптимизирующей компиляцией.
Именно поэтому схема определения сущностей из предыдущего раздела устроена таким образом. Никакого поиска классов по имени, обращений к хеш-таблицам, перебора вариантов, повторных попыток или возвратов назад. Записи поступают в порядке зависимостей, JVM проходит их один раз, от начала до конца, и каждый шаг либо однозначно определяет одну сущность, либо заканчивается неудачей. Решение, которому пришлось бы искать нужный класс, могло бы быть совершенно корректным и совершенно бесполезным.
Неудача тоже должна обходиться дёшево. Если запись метода не прошла проверку полностью, результат компиляции просто отбрасывается, а JVM компилирует метод обычным способом. Именно поэтому механизм можно безопасно включать: в худшем случае не будет ни аварии, ни перехода на какой-то медленный путь — останется обычная JVM, выполняющая обычную JIT-компиляцию. Вы потеряете выигрыш на старте для одного метода, и только.
До сих пор речь шла о том, как избежать ошибок. Но остаётся вопрос, который оказывается самым тонким: код может пройти проверку полностью, каждое утверждение может подтвердиться, и всё же устанавливать этот код будет не лучшим решением.
Целесообразность: корректный код, который всё равно не подходит
До сих пор речь шла о том, как избежать ошибок. Верификация отвечает на вопрос «сломается ли что-нибудь из-за этого кода?» и даёт однозначный ответ: либо каждое утверждение выполняется, либо код отправляется в корзину.
Но есть второй вопрос, на который она вообще не отвечает. Будет ли этот код быстрым здесь?
Кажется, что это один и тот же вопрос, но это не так: скомпилированный код несёт в себе два совершенно разных вида предположений. Одни — факты о структуре JVM: это поле находится по такому смещению, у этого класса такой родитель, у этого метода такой байт-код. Всё это можно проверить. Вы смотрите и получаете ответ: да или нет.
Другие — не факты о структуре, а статистика поведения. Эта ветвь выполнялась в 98% случаев. В этой точке вызова встречался только один тип объекта-получателя. Этот цикл обычно выполнялся 12 раз. Этот путь вообще ни разу не выполнялся, поэтому не будем тратить силы на его оптимизацию. Компилятор использовал эти наблюдения столь же активно, как и структурные сведения, и скорость полученного кода зависит от них ничуть не меньше.
И вот неприятный момент: сверять их не с чем. У запускающейся JVM нет профиля выполнения. Она ещё ничего не выполняла. Именно поэтому ей и нужен чужой скомпилированный код. Можно проверить все структурные утверждения до последнего элемента и всё равно не иметь никакой возможности узнать, выполняются ли предположения о поведении. Ведь ответ может дать только то выполнение, которое Вы пытаетесь пропустить.
Возьмём точку вызова из предыдущего примера. В экземпляре, где выполнялась компиляция, метод decode вызывался только для объектов одного типа. Поэтому компилятор создал быстрый путь для этого типа с защитной проверкой и медленный запасной путь для всех остальных. Теперь установим этот код в экземпляр, первые запросы которого случайно обращаются к другому кодеку. Каждый вызов не проходит защитную проверку и попадает на запасной путь. Код полностью корректен. Но он медленнее того, который обычный JIT создал бы для этого экземпляра: обычный JIT наблюдал бы за этим трафиком и оптимизировал код под то, что действительно увидел.
Вы можете вполне обоснованно возразить, что одинаковые экземпляры, обслуживающие одинаковый трафик, должны формировать одинаковые профили. В установившемся режиме это в основном так. Но целесообразность оценивается в тот период, когда экземпляр ещё не вышел на установившийся режим. Первые запросы нового экземпляра — это проверки работоспособности, установка соединений, наполнение кешей, загрузка схемы и всё, что балансировщик случайно направит ему вначале. Профиль экземпляра, создавшего код, сформировался за час реального трафика. Первые секунды принимающего экземпляра не являются репрезентативной выборкой этого часа. А ведь именно в эти секунды установленный код и должен приносить пользу.
Кроме того, цена ошибки здесь несимметрична. Утверждение, которое не прошло проверку, почти ничего не стоит: отбросить код, скомпилировать обычным способом и потерять только преимущество на старте. А код, который прошёл проверку, но просто не подходит, уже установлен и выполняется. И будет выполняться, пока какой-нибудь механизм не заметит проблему и не заменит его.
OpenJDK прямо говорит об этом в JEP 544. Там отмечается, что AOT-код и JIT-код могут различаться, поскольку обучающие и рабочие запуски могут отличаться друг от друга. Завершается эта мысль фразой, которую стоит запомнить: во время выполнения компиляторы могут генерировать JIT-код взамен AOT-кода, «который со временем перестаёт быть удачным». Заявленное предположение состоит в том, что кешированный код приносит пользу при похожих рабочих запусках, при этом не вредит отличающимся запускам: в них JIT-компиляция может создать другой код.
Это разумный довод, и он опирается на механизмы, которые у JVM были всегда. Но он не говорит, сколько времени пройдёт между установкой неподходящего кода и началом выполнения подходящего.
Поэтому проблема заключается не только в вопросе «корректен ли здесь этот код?». Она ещё и в вопросе «какой из доступных вариантов кода вообще стоит отправлять?». Это уже задача прогнозирования, а не проверки. И в отличие от проверки, у прогноза нет абсолютного критерия принятия, за которым можно укрыться.
Что дальше
Так выглядит честная постановка задачи: три вопроса, и лишь у двух из них есть ответы, которые можно проверить.
Во второй части мы подробно рассмотрим два выхода. Сначала — Project Leyden, который обходит большую часть этих проблем, перенося работу на более ранний этап, а не на другую машину, и расплачивается за это качеством генерируемого кода. Затем — наше решение, которое идёт противоположным путём: потоковая передача скомпилированного кода из множества JVM, прямо сейчас обслуживающих рабочий трафик, в экземпляры, которые ещё запускаются. При таком подходе нельзя исключить из рассмотрения ни одну из трёх проблем. Именно поэтому на его создание ушло так много времени. Мы разберём, как он решает каждую из них и что это даёт.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.