openjdk.ruOpenJDK на русском

JEP draft: New Invoke Bindings

Новые привязки для байткодов invoke

ОтветственныйErik Österlund
ТипFeature
ОбластьImplementation
СтатусDraft
Компонентhotspot / compiler
Обсуждениеhotspot dash dev at openjdk dot java dot net
ТрудоёмкостьS
ДлительностьS
РецензентыVladimir Kozlov
Создан2019/04/02 10:30
Обновлён2022/06/13 13:36
Задача8221828

Аннотация

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

Цели

Основная цель этого JEP — упростить сопровождение кода в долгосрочной перспективе. Дополнительная цель — сделать мегаморфные вызовы быстрыми.

Что не является целью

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

Критерии успеха

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

Мотивация

Наши базовые мегаморфные вызовы неэффективны. Чтобы скрыть накладные расходы мегаморфных вызовов, механизм спекуляции типов, называемый inline-кэшами, рассчитывает на то, что места вызова динамически мономорфны. Если нам везёт и спекуляция типов успешна, можно выполнить более эффективный мономорфный вызов. Но динамически мегаморфные вызовы по-прежнему медленные, и характеристики производительности становятся непредсказуемыми, поскольку мономорфные и мегаморфные вызовы значительно различаются. Эта логика спекуляции типов к тому же очень сложна и за многие годы стала причиной множества ошибок. Сложный жизненный цикл машинного кода, скомпилированного Just-In-Time (JIT), — следствие inline-кэшей. Кроме того, различные гонки, которые приносят inline-кэши, значительно усложняют выгрузку классов.

Описание

Чтобы заменить inline-кэши со спекуляцией типов, базовые мегаморфные вызовы предлагается оптимизировать так, чтобы они были почти так же быстры, как спекулятивно мономорфные вызовы. Тогда механизм спекуляции (~10000 строк подверженного гонкам низкоуровневого кода, включая подверженное гонкам изменение машинного кода) можно будет удалить.

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

Для вызовов invokeinterface предлагается присвоить каждому интерфейсному методу уникальный номер, называемый «селектором» (selector), и создать хэш-таблицу в стиле кукушкиного хэширования (cuckoo), отображающую селекторы на указатели на код конкретных реализаций интерфейсного метода. Таблица встраивается непосредственно в нативный класс. В большинстве случаев это позволяет выполнять диспетчеризацию одним косвенным и одним прямым переходом (сегодня для динамически мономорфных вызовов нужны два прямых перехода).

Что касается прямых вызовов, сегодня, если целевой метод скомпилирован, они вызывают непосредственно скомпилированный код цели, а при переходе в режим интерпретации проходят через заглушку (stub), которая заполняет регистр метода. Так исключаются затраты на установку регистра при вызове скомпилированного кода, но возникает много гонок и значительная сложность кода, включая подверженное гонкам изменение машинного кода. Затраты на безусловную установку этого регистра и удаление кода заглушки считаются минимальными по сравнению с выгрузкой регистров в стек (spilling) и проверкой стека (stack banging), которые мы обычно выполняем при вызове метода.

Одна из непростых вещей, с которыми приходится иметь дело этому JEP, — байткоды invokeinterface. В частности, сегодня JVM не может быть уверена, что получатель реализует данный интерфейс, и вместо этого должна решать задачу динамической проверкой типа, которая может выбросить IncompatibleClassChangeError, если получатель не реализует интерфейс. В частности, в JVMS сказано:

«При присваиваниях интерфейсы рассматриваются как Object». — JVMS14 4.10.1.2.

Это ослабление системы типов верификатора означает, что мы ничего не знаем о переменных интерфейсных типов. Они могут быть чем угодно. Влияние на invokeinterface таково:

«Иначе, если класс objectref не реализует разрешённый интерфейс, invokeinterface выбрасывает IncompatibleClassChangeError». — JVMS 14 6.5

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

Это создаёт проблемы по нескольким причинам.

Проблема 1: производительность.

Текущая архитектура полагается на то, что мономорфные inline-кэши неявно выполняют и эту проверку типа. Мегаморфная реализация медленная. Поэтому, раз идея этого JEP — оптимизировать мегаморфные вызовы так, чтобы мономорфные вызовы по мегаморфному пути выполнения были не хуже спекулятивного inline-кэша, нужно особенно тщательно решить, что делать с этой динамической проверкой типа. Наивное решение — выполнять проверку типа REFC для каждого invokeinterface — даёт неподходящие характеристики производительности: наблюдаются заметные регрессии.

Проблема 2: сложность.

Байткод invokeinterface может привести к диспетчеризации через itable, через vtable (для методов по умолчанию) или к прямым вызовам (когда это доказуемо с помощью CHA и т. п.). Поэтому все эти виды вызовов должны выполнять проверку REFC, если исходный байткод — invokeinterface. Это приводит к некоторым неприятным усложнениям.

Проблема 3: целостность типов.

Позволять байткодам передавать экземпляры, не являющиеся Foo, в переменные типа Foo, довольно скверно. Помимо того что это вызывает ошибки в JVM, поскольку мы не можем доверять собственной системе типов в отношении интерфейсов, и заставляет явно отказываться от оптимизаций, поскольку мы не доверяем системе типов, это создаёт проблемы и для пользователей Java. Java-программисты могли не читать JVMS, поэтому они могут не знать, что байткод invokeinterface может выбросить ICCE. С точки зрения языка Java такое событие кажется невозможным. Но с точки зрения JVM оно вполне возможно. Такое расхождение ожиданий в отношении целостности типов может скрывать уязвимости в Java-коде: злоумышленник может изменить поток управления так, как большинство Java-программистов даже не считают возможным и как, пожалуй, не должно быть возможно.

Текущее решение в рамках этого JEP — любой ценой избегать проверки REFC. Изначально это было сделано так: у верификатора два режима, нормальный (sane) и ненормальный (insane). В нормальном режиме верификатор проверяет, что типобезопасность интерфейсов обеспечена. Пока верификатор находится в нормальном режиме (что верно практически для всех разумных программ, кроме пограничных тестов JVMS), проверки REFC исключаются из всех байткодов invokeinterface, скомпилированных JIT. В маловероятном случае, когда связывается класс с ненормальными байткодами, нарушающими базовую типобезопасность, запускается деоптимизация. Это радикальная мера: она деоптимизирует все скомпилированные JIT методы в процессе, и верификатор переходит в ненормальный режим. В результате новые компиляции восстановят динамическую проверку REFC, чтобы мы следовали JVMS.

Однако JVMS была специфицирована таким образом отчасти для того, чтобы можно было не загружать интерфейсы во время верификации; это оптимизация. Но именно загрузку интерфейсов я и выполняю, чтобы исключить проверку REFC из invokeinterface; это тоже оптимизация, причём гораздо более важная. Иначе говоря, в JVMS проверка типов ослаблена, чтобы мы могли оптимизировать. Но если мы действительно хотим оптимизировать то, что важно, мы не можем воспользоваться этой оптимизацией верификатора. В результате приходится поддерживать два режима: нормальный и дополнительный ненормальный, который используется только для соответствия JVMS. Если смотреть на это так, у JVMS остаётся мало причин навязывать верификатору более слабую систему типов и запрещать нам делать интуитивно понятную вещь. Поэтому я предлагаю изменить JVMS так, чтобы как минимум позволить JVM реализовывать в верификаторе полноценные проверки типов для интерфейсов. Это даёт следующие преимущества:

Преимущество 1: производительность

Теперь invokeinterface никогда не нужно выполнять проверку REFC, потому что верификатор уже доказал, что ненормальных байткодов нет. Благодаря этому мегаморфные вызовы с предложенным мной механизмом диспетчеризации через itable могут быть очень быстрыми.

Преимущество 2: сопровождение

Компиляторам не нужно явно не доверять собственной системе типов и отказываться от различных оптимизаций или сталкиваться с ошибками из-за нарушенной системы типов. Код вроде DirectMethodHandles (на Java) тоже может избавиться от таких проверок.

Преимущество 3: целостность типов

Пользователи вдруг получают возможность доверять тому, что написанный ими Java-код делает нечто более близкое к тому, чего они от него ожидают. Более строгая система типов, применяемая верификатором, не оставляет злоумышленникам возможностей использовать ненормальные потоки управления, которые не должны быть возможны с точки зрения Java-программиста.

Главный риск этого предложения по изменению JVMS — обратная совместимость. Хотя это маловероятно, теоретически возможно, что у какого-то пользователя есть собственноручно написанные байткоды (не от javac), которые намеренно нарушают базовую типобезопасность и даже намеренно выбрасывают ICCE (что приводит к деоптимизациям). Я считаю этот риск небольшим по сравнению с, возможно, более серьёзным риском того, что более реалистичный Java-код окажется подвержен странным ситуациям во время выполнения, которые никогда не должны возникать.

В заключение: хотя изменять JVMS не обязательно и вместо этого можно поддерживать и нормальный, и ненормальный режимы верификатора для совместимости с JVMS, я считаю, что правильно изменить JVMS так, чтобы разрешить использование только нормального режима и полностью отказаться от ненормального. Более вероятно, что такие байткоды — серьёзная ошибка в программе, которую следует исправить, чем то, что их намеренно используют таким образом.

Альтернативы

Альтернативный механизм диспетчеризации invokeinterface выполняет раскраску графа интерфейсных методов, чтобы ещё сильнее оптимизировать вызов. Сейчас он не рассматривается, поскольку основная цель этого JEP — упростить код и сделать его более удобным в сопровождении.

Тестирование

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

Риски и допущения

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