Производительность JVM
JIT-компиляция, чтение байткода, виртуальная и статическая диспетчеризация, escape-анализ, AOT-кеширование классов и профилирование работающей JVM.
9 вопросов
JuniorТеорияЧастоЧем различаются JIT-компиляторы C1 и C2 и как многоуровневая компиляция использует оба?
Чем различаются JIT-компиляторы C1 и C2 и как многоуровневая компиляция использует оба?
C1 компилирует быстро, с лёгкими оптимизациями, и вставляет счётчики профилирования; C2 компилирует медленно, но агрессивно — инлайнинг, раскрутка циклов, escape-анализ — опираясь на этот профиль. Многоуровневая компиляция, включённая по умолчанию, задействует оба: метод стартует в интерпретаторе, поднимается в C1, когда счётчики переходят порог, и перекомпилируется C2, когда становится горячим.
Типичные ошибки
- ✗Думать, что
-client/-serverдо сих пор выбирает один компилятор на весь процесс, а не что многоуровневая компиляция запускает оба - ✗Полагать, что C2 оптимизирует только по байткоду и игнорирует профиль, собранный C1
- ✗Считать, что метод компилируется один раз и не может быть перекомпилирован или деоптимизирован обратно в интерпретатор
Уточняющие вопросы
- →Что заставляет JVM деоптимизировать скомпилированный C2 метод обратно в интерпретатор?
- →Почему бенчмарку нужна фаза прогрева, прежде чем его числа что-то значат?
MiddleДебаггингЧастоCPU-профиль работающего сервиса отдаёт 72% одному методу JDK — определите причину
CPU-профиль работающего сервиса отдаёт 72% одному методу JDK — определите причину
Pattern.compile доминирует в профиле и стоит прямо под SkuValidator.isValid, значит регулярка компилируется на каждый запрос: код зовёт Pattern.compile либо String.matches/split прямо в пути запроса. Компиляция разбирает шаблон в дерево узлов и стоит куда дороже сопоставления готовым. Поднимите его в static final Pattern, а на запрос зовите только matcher(input).
Типичные ошибки
- ✗Читать горячий кадр JDK как проблему JDK, вместо того чтобы смотреть на кадр приложения прямо под ним
- ✗Путать стоимость компиляции
Patternсо стоимостью сопоставления по готовому шаблону - ✗Считать
String.matchesиString.splitдешёвыми — каждый из них компилирует новыйPatternпри каждом вызове
Уточняющие вопросы
- →Почему
String.matches(regex)внутри цикла обходится так же дорого, как вызовPattern.compileтам же? - →Что покажет профиль по календарному времени, чего не видно в профиле по CPU?
JuniorКодИногдаВосстановите сигнатуру метода по дескриптору, который печатает javap
Восстановите сигнатуру метода по дескриптору, который печатает javap
Дескриптор перечисляет параметры по порядку внутри скобок, а возвращаемый тип ставит после них, кодируя I как int, J как long, ведущую [ как массив и Lcom/foo/Bar; как ссылочный тип. Значит, здесь это public static List<String> pick(String s, int[] a, long n), а параметр дженерика стёрт. Флаг javap -s печатает дескрипторы, -p добавляет приватные члены.
Типичные ошибки
- ✗Читать возвращаемый тип до скобок, а не после них
- ✗Ждать, что аргументы дженериков появятся в дескрипторе, — они стираются
- ✗Забывать, что
javapскрывает приватные члены, пока не передан-p
Уточняющие вопросы
- →Что
javap -cпоказывает о методе такого, чего не показываетjavap -s? - →Где внутри class-файла выживает обобщённый тип
List<String>, если не в дескрипторе?
MiddleПроизводительностьИногдаКак опережающая загрузка и связывание классов (JEP 483) сокращает время старта JVM?
Как опережающая загрузка и связывание классов (JEP 483) сокращает время старта JVM?
Пробный прогон записывает, какие классы загружает приложение, а -XX:AOTMode=create пишет AOT-кеш, где они уже разобраны, проверены и связаны. Следующий старт отображает кеш вместо загрузки и связывания каждого class-файла, и приложение с массой классов стартует заметно быстрее. Вышло в Java 24, расширяет AppCDS и в машинный код ничего не компилирует.
Типичные ошибки
- ✗Думать, что JEP 483 компилирует методы в машинный код, а не кеширует загруженные и связанные классы
- ✗Называть сам Project Leyden вышедшей возможностью — Leyden это зонтичный проект, а поставленный JEP это JEP 483
- ✗Ждать, что один кеш останется валидным после обновления JDK или смены classpath
Уточняющие вопросы
- →Чем AOT-кеш отличается от архива AppCDS?
- →Что происходит на старте, если AOT-кеш не соответствует classpath, с которым его записывали?
MiddleПроизводительностьИногдаЧто escape-анализ в C2 позволяет сделать с короткоживущим объектом и что ему мешает?
Что escape-анализ в C2 позволяет сделать с короткоживущим объектом и что ему мешает?
Escape-анализ доказывает, что аллокация не покидает свой метод или поток. Тогда C2 применяет скалярную замену: объект разбирается на поля в регистрах, поэтому аллокации в куче не происходит — на стек HotSpot его буквально не кладёт, — а блокировки на нём устраняются. Срабатывает он только после инлайнинга: возврат ссылки, запись в поле или незаинлайненный вызов дают настоящую аллокацию.
Типичные ошибки
- ✗Верить, что HotSpot буквально размещает объект на стеке, вместо скалярной замены на поля в регистрах
- ✗Ждать срабатывания escape-анализа там, где потребляющий метод так и не был заинлайнен
- ✗Считать это возможностью сборщика мусора, а не оптимизацией C2
Уточняющие вопросы
- →Как деоптимизация восстанавливает объект, который скалярная замена уже устранила?
- →Почему очень большой метод теряет escape-анализ, который сохраняется у метода поменьше?
MiddleТеорияИногдаКакие байткоды invoke* отмечают статическую и виртуальную диспетчеризацию и когда выбирается цель?
Какие байткоды invoke* отмечают статическую и виртуальную диспетчеризацию и когда выбирается цель?
javac выпускает invokestatic для static-методов и invokespecial для конструкторов, private-методов и вызовов super. — все привязаны к одной цели при компиляции. Вызовы экземпляра дают invokevirtual или invokeinterface: компилятор фиксирует лишь имя и дескриптор, а JVM выбирает переопределение по фактическому классу получателя в рантайме. C2 девиртуализует мономорфный вызов.
Типичные ошибки
- ✗Путать разрешение перегрузки, которое происходит при компиляции, с выбором переопределения, который происходит в рантайме
- ✗Полагать, что
private- иfinal-методы экземпляра компилируются вinvokestatic - ✗Считать, что виртуальный вызов всегда стоит поиска по таблице, даже когда JIT доказал мономорфность точки вызова
Уточняющие вопросы
- →Почему лямбда компилируется в
invokedynamic, а не в обычный вызов метода? - →Что позволяет C2 инлайнить точку вызова, которая видела два разных класса получателя?
SeniorПроизводительностьИногдаПочему System.out.println в горячем цикле куда дороже вызова логгера с параметрами?
Почему System.out.println в горячем цикле куда дороже вызова логгера с параметрами?
System.out — это PrintStream, созданный с включённым autoflush, а его методы synchronized: каждый println берёт монитор потока вывода, сбрасывает буфер и делает системный вызов write, поэтому N итераций стоят N системных вызовов, а потоки ждут на одном мониторе. Вызов логгера с параметрами ничего не форматирует при выключенном уровне и копит записи в буферизованном или асинхронном аппендере.
Типичные ошибки
- ✗Полагать, что
System.outбуферизован без autoflush, и цикл изprintlnстоит одного системного вызова - ✗Забывать, что методы
PrintStreamобъявленыsynchronized, поэтому параллельные потоки выстраиваются на одном мониторе - ✗Передавать логгеру уже склеенное сообщение, обесценивая выигрыш, который дала бы проверка уровня
Уточняющие вопросы
- →Почему
log.debug("id={}", id)почти ничего не стоит, когда настроен уровеньINFO? - →Чем расплачивается асинхронный аппендер за то, что уносит запись с вызывающего потока?
SeniorДебаггингИногдаВ проде летит Too many open files — определите причину по трейсу и lsof
В проде летит Too many open files — определите причину по трейсу и lsof
Это утечка файловых дескрипторов, а не заниженный лимит: 3968 из 4092 хендлов — это CSV-отчёты, которые процесс уже дописал. Принудительный GC роняет счётчик до 231, а значит потоки закрываются только своим Cleaner при сборке — writeReport не вызывает close(). Оберните его в try-with-resources; поднятие ulimit -n лишь отодвинет падение.
Типичные ошибки
- ✗Поднять
ulimit -nи считать дело закрытым, пока счётчик хендлов снова доползает до нового потолка - ✗Игнорировать подсказку, что принудительный GC освобождает хендлы, — почерк потока, закрываемого только своим cleaner
- ✗Полагать, что
FileOutputStreamзакрывается, когда его переменная выходит из области видимости
Уточняющие вопросы
- →Почему try-with-resources всё равно закрывает поток, если
writeReportпадает на середине? - →Какая метрика уровня JDK показала бы рост числа хендлов ещё до первого падения?
SeniorДизайнРедкоВаш рекомендательный сервис оценивает кандидатов косинусной близостью по эмбеддингам float[1024]. CPU-профиль отдаёт циклу скалярного произведения 38% процессорного времени сервиса, а p99-задержка — главная жалоба команды. Инженер предлагает переписать этот цикл на Vector API — модуль jdk.incubator.vector, который раскладывает цикл на SIMD-инструкции процессора, — и показывает ускорение в 3 раза в микробенчмарке на своём ноутбуке. Сервис работает на текущей LTS-версии JDK, релизится еженедельно, а платформенная команда обновляет JDK примерно раз в год. Vector API до сих пор инкубируется: нужен --add-modules, в рантайме сыплется предупреждение, и он останется в инкубации, пока не выйдут value-типы из Project Valhalla, так что его API может несовместимо измениться в любом релизе. Решение на дизайн-ревью за вами. Как вы определите, выигрывает ли эта нагрузка на самом деле, и как приняли бы или отвергли предложение?
Ваш рекомендательный сервис оценивает кандидатов косинусной близостью по эмбеддингам float[1024]. CPU-профиль отдаёт циклу скалярного произведения 38% процессорного времени сервиса, а p99-задержка — главная жалоба команды. Инженер предлагает переписать этот цикл на Vector API — модуль jdk.incubator.vector, который раскладывает цикл на SIMD-инструкции процессора, — и показывает ускорение в 3 раза в микробенчмарке на своём ноутбуке. Сервис работает на текущей LTS-версии JDK, релизится еженедельно, а платформенная команда обновляет JDK примерно раз в год. Vector API до сих пор инкубируется: нужен --add-modules, в рантайме сыплется предупреждение, и он останется в инкубации, пока не выйдут value-типы из Project Valhalla, так что его API может несовместимо измениться в любом релизе. Решение на дизайн-ревью за вами. Как вы определите, выигрывает ли эта нагрузка на самом деле, и как приняли бы или отвергли предложение?
Сначала почините базу сравнения: прогоните текущий цикл через JMH и проверьте, не векторизует ли его C2 уже сам, — трёхкратное ускорение на ноутбуке против неоптимизированного скалярного кода не доказывает ничего. Выигрыш будет, только если цикл упирается в CPU, распараллеливается по данным над непрерывными примитивами и свободен от ветвлений. Затем оцените цену инкубации и внедряйте только за скалярным запасным путём.
Типичные ошибки
- ✗Сравнивать с неоптимизированным скалярным циклом вместо того, что автовекторизатор C2 уже выдаёт
- ✗Считать инкубационный модуль стабильным API и пропускать перепроверку на каждом обновлении JDK
- ✗Ждать ускорения от цикла, упирающегося в пропускную способность памяти, лишь потому что инструкции стали шире
Уточняющие вопросы
- →Как по прогону бенчмарка доказать, что JIT уже выпускает SIMD-инструкции для скалярного цикла?
- →Во что обходится в рантайме прятать векторный код за интерфейсом со скалярным запасным путём?