Отчёт с Java Virtual Machine Language Summit 2019

nhxkcfgwiztaulvnbccwhxgcpwe.png

Сегодня закончился двенадцатый саммит JVM LS. Как обычно, это было хардкорное мероприятие с техническими докладами, посвящёнными виртуальным машинам и языкам, которые на них работают. Как обычно, саммит проходил в Санта-Кларе, в кампусе компании Оракл. Как обычно, желающих попасть сюда существенно больше, чем мест: количество участников не превышает 120. Как обычно, не было никакого маркетинга, только потроха.

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

Если вы не попали на саммит, не беда. Большинство докладов выкладывают на YouTube практически сразу послед саммита. Собственно они уже доступны. Чтобы было проще сориентироваться, я опишу здесь вкратце все доклады и воркшопы, на которых удалось поприсутствовать.

Это не про особенности компиляции Future в языке Clojure, как многие подумали, а просто о развитии языка, тонкостях кодогенерации и проблемах, с которыми при этом сталкиваются. Например, оказалось, что в Clojure принципиально занулять локальные переменные после последнего использования, потому что если в локальной переменной голова списка, который лениво генерируется, то при его обходе узлы, которые уже обошли, могут не собираться сборщиком мусора, и программа может упасть с OutOfMemory. Вообще JIT-компилятор C2 сам отпускает переменные после последнего использования, но стандарт этого не гарантирует и, скажем, интерпретатор HotSpot этого не делает.

Также интересно было узнать о реализации динамической диспетчеризации вызовов функций. Ещё я узнал, что до недавнего времени Clojure таргетировался на JVM 6 и только недавно перешёл на JVM 8. Теперь авторы компилятора поглядывают на invokedynamic.

Проект Loom — это легковесные потоки (fibers) для Java. Год назад Алан и Рон уже рассказывали про этот проект, и тогда создалось впечатление, что всё идёт весьма хорошо и вот-вот будет готово. Тем не менее, официально в Java этот проект пока не вошёл и разрабатывается до сих пор в отдельном форке репозитория. Конечно, оказалось, что надо утрясти много деталей.

Многие стандартные API от ReentrantLock.lock до Socket.accept уже адаптированы к файберам: если такой вызов выполнится внутри файбера, то состояние исполнения будет сохранено, стек размотан и поток операционной системы освободится для других задач, пока не произойдёт событие, пробуждающее файбер (например, ReentrantLock.unlock). Однако, например, старый добрый synchronized-блок до сих пор не работает и, кажется, там не обойтись без серьёзного рефакторинга всей поддержки синхронизации в JVM. Ещё размотка стека не сработает, если между стартом файбера и точкой останова в стеке есть нативные фреймы. В обоих этих случаях ничего не взорвётся, но файбер не освободит поток.

Много вопросов относительно того, как Fiber соотносится со старым классом java.lang.Thread. Год назад была идея сделать Fiber подклассом Thread. Сейчас от этого отказались и делают его независимой сущностью, потому что эмулировать в каждом файбере всё поведение обычного потока довольно дорого. При этом Thread.currentThread () внутри файбера вернёт сгенерированную обманку, а не настоящий поток, в котором всё выполняется. Но обманка будет довольно хорошо себя вести (хотя может замедлить работу). Важная мысль в том, чтобы ни при каких обстоятельствах внутри файбера не выдать настоящий поток-носитель, на котором файбер выполняется. Это может быть опасно, так как файбер может легко переехать на другой поток. Обманка же сохранится.

Любопытно, что участники проекта уже пропихнули несколько подготовительных изменений в основной репозиторий JDK, чтобы облегчить себе жизнь. Например, в Java 13 метод doPrivileged переписали с нативного кода полностью на Java, получив примерно 50-кратный прирост производительности. Зачем это проекту Loom? Дело в том, что именно этот метод очень часто появляется в середине стека, а пока он был нативным, файберы с таким стеком не останавливались. Так или иначе, проект уже приносит пользу.

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

Параллельно шёл воркшоп про проект Loom, но я пошёл на Amber. Здесь обсудили вкратце цели проекта и основные JEP’ы, в которых идёт работа — Pattern matching, Records и Sealed types (сейчас черновик почему-то в закрытом доступе). Затем всё обсуждение свалилось в частный вопрос скоупинга. Я рассказывал об этом на конференции Joker в прошлом году, в принципе ничего сильно нового не прозвучало. Я пытался протолкнуть идею с неявными типами-объединениями вроде if(obj instanceof Integer x || obj instanceof Long x) use(x.longValue()), но энтузиазма не увидел.

Во всех отношениях замечательный проект от компании Google для поиска гонок по данным в виде чтения и записи одного и того же неволатильного поля или элемента массива из разных потоков без установки отношения happens-before. Проект изначально написан как модуль LLVM для нативного кода, а теперь его адаптировали для HotSpot. Это официальный проект OpenJDK со своим списком рассылки и репозиторием.

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

Основная проблема — производительность. Сейчас для джава-кода инструментируется только интерпретатор, соответственно, JIT-компиляция полностью отключена, а интерпретатор, который и без того медленный, замедляется ещё в несколько раз. Но если у вас достаточно ресурсов (у Google их, конечно, достаточно), вы можете время от времени гонять свои наборы тестов с помощью TSan. Добавить инструментацию в JIT тоже планируется, но это гораздо более серьёзное вмешательство в JVM.

Кто-то спросил, не влияет ли на результат отключение JIT-компиляции, ведь какие-то гонки могут не проявляться на интерпретаторе. Докладчик не исключил такую возможность, но сказал, что они и так нашли огромное количество гонок, которые разгребать придётся очень долго. Так что будьте осторожны, запуская ваш проект под TSan: вы можете узнать неприятную правду.

Все ждут value-типов в джаве, но никто не знает, когда они появятся. Впрочем, движения всё более серьёзные. Уже сейчас имеются тестовые бинарные сборки с текущим майлстоуном L2. В текущих планах полная Вальгалла наступит на майлстоуне L100, но авторы всё же полны оптимизма и считают, что сделано более двух процентов.

Итак, с точки зрения языка мы имеем классы с модификатором inline, которые особым образом обрабатываются виртуальной машиной. Экземпляры таких классов могут встраиваться в другие объекты, а также возможны плоские массивы, соержащие экземпляры инлайн-классов. У экземпляра нет заголовка, а значит, нет идентичности, хэшкод считается по полям, == тоже по полям, попытка синхронизации или Object.wait() на таком классе вызовет IllegalMonitorStateException. Записать null в переменную такого типа, конечно, не получится. Впрочем, авторы предлагают альтернативу: если у вас объявлен inline-класс Point, то можно объявить поле или переменную типа (сюрприз-сюрприз!) Point?, и тогда будет полноценный объект в куче (вроде боксинга) с заголовком, идентичностью, и null туда впишется.

Серьёзными открытыми вопросами остаётся специализация дженериков и миграция существующих классов (например, Optional) в inline-класс, чтобы не сломать существующий код (да-да, люди записывают null в переменные типа Optional). Тем не менее картина вырисовывается, и просвет виден.

Для меня было сюрпризом, что тот самый Нил Гафтер, соавтор оригинальных Java-паззлеров, теперь работает в Микрософте над рантаймом .Net. Также сюрпризом было увидеть доклад про CLR (так называется рантайм .Net) на JVM LS. Но познакомиться с опытом коллег из других миров всегда полезно. В докладе рассказывается про разновидности ссылок и указателей в CLR, про инструкции байткода, используемые для value-типов, про то как красиво специализируются обобщённые функции вроде reduce. Интересно было узнать, что одна из целей value-типов в .Net — интероп с нативным кодом. Из-за этого расположение полей в value-типах строго фиксировано и может быть спроецировано на сишную структуру без преобразований. В JVM такой задачи никогда не стояло, а что делать с нативным интеропом — смотрите ниже.

Опять же обновление прошлогоднего доклада. Опять же вопрос, почему до сих пор ничего не выпустили, если ещё год назад всё выглядело вполне неплохо.

Вектор представляет собой совокупность из нескольких чисел, которая в железе может быть представима одним векторным регистром вроде zmm0 для AVX512. В векторы можно загружать данные из массивов, выполнять над ними операции вроде поэлементного умножения и закидывать назад. Все операции, для которых есть инструкции процессора, интринсифицируются JIT-компилятором в эти инструкции. Количество операций просто огромно. Если чего-то нет, используется альтернативная медленная реализация. Промежуточные объекты Vector в идеале не создаются, работает escape-анализ. Всякие стандартные вычислительные алгоритмы векторизуются на ура, используя всю мощь вашего процессора.

К сожалению, авторам тяжело без вальгаллы: escape-анализ хрупок и может легко не сработать. Эти векторы просто обязаны быть инлайн-классами, тогда все проблемы исчезнут. Непонятно, могут ли вообще выпустить это API до первой версии вальгаллы. Оно кажется существенно более готовым. Ещё в числе проблем называют трудности с поддержкой кода. Там много повторяющихся кусков для разных размеров регистров и разных типов данных, поэтому большая часть кода генерируется из шаблонов и поддерживать это больно.

Использование пока тоже неидеально. В Java нет перегрузки операторов, поэтому выглядит математика уродливо: вместо max(va-vb*42, 0) приходится писать va.lanewise(SUB, vb.lanewise(MUL, 42)).lanewise(MAX, 0). Было бы красиво иметь доступ к AST лямбд как в C#. Тогда бы можно было генерировать кастомную операцию по лямбде вроде MYOP = binOp((va, vb) -> max(va-vb*42, 0)) и использовать её.

Второй день проходил под флагом компиляции.

Сотрудник IBM, участник проекта JVM OpenJ9 рассказывает про их опыт JIT и AOT-компиляции. Проблемы есть всегда: JIT — это медленный стартап, потому что разогрев; затраты CPU на компиляцию. AOT — неоптимальная производительность из-за отсутствия профиля (профилировать можно, но нетривиально и не всегда профиль при компиляции совпадает с профилем при исполнении), сложнее использование, привязка к целевой платформе, к ОС, к сборщику мусора. Часть проблем можно решить, объединив подходы: начав с AOT-компилированного кода и добивая потом JIT-ом. Хорошая альтернатива всему этому — кэширующий JIT. Если у вас много виртуальных машин (привет, микросервисы), они все обращаются к отдельному сервису — JIT-компилятору (да-да, JITaaS), где всё по-взрослому, оркестрация, балансировка нагрузки. Этот сервис и компилирует. Весьма часто он может отдать готовый код определённого метода, потому что этот метод уже компилировался на другой JVM. Это сильно улучшает разогрев, убирает потребление ресурсов с вашего JVM-сервиса и вообще снижает суммарное потребление ресурсов.

В общем, JITaaS может стать следующим баззвордом в мире JVM. К сожалению, не уловил, можно ли этим поиграться прямо сейчас или пока ещё это закрытая разработка.

GraalVM Native Image — это Java-приложение, скомпилированное в нативный код, который выполняется без JVM (в отличие от модулей, собранных с помощью AOT-компилятора вроде jaotc). Точнее это не совсем Java-приложение. Для корректной работы ему нужен закрытый мир, то есть весь код должен быть виден на этапе компиляции, никаких Class.forName. Можно рефлекшн и метод-хэндлы, но вам придётся при компиляции конкретно рассказать, какие классы и методы будут использоваться через рефлекшн.

Ещё забавная штука — инициализация классов. Многие классы инициализируются в процессе компиляции. То есть, скажем, ваши статические поля будут по умолчанию вычисляться компилятором и результат будет записан в собранный образ, а при запуске приложения просто считан. Это требуется, чтобы достичь лучшего качества компиляции: можно делать всякий constant folding, если значения статических полей известны компилятору. У JIT-то всё хорошо, статическую инициализацию выполняет интерпретатор, а потом зная константы, можно компилировать. А при сборке нативного приложения приходится ухищряться. Это, конечно, приводит к весёлым психоделическим эффектам. Так классы обычно инициализируются в порядке обращения к ним, а во время компиляции этот порядок неизвестен и возможна инициализация в другом. При наличии циклических ссылок между инициализаторами классов можно увидеть разницу в поведении кода на JVM и в нативном образе.

Разбиралась всякая боль, связанная со сборщиками мусора. Я, к сожалению, большую часть прослушал. Помню, что обсуждался возврат памяти ОС, в том числе всем опостылевший Xmx. Здесь есть хорошая новость: в Java 13 добавляется новая опция -XX: SoftMaxHeapSize. Пока она поддерживается только коллектором ZGC, но G1 тоже может подтянуться. Она задаёт лимит размера кучи, который не надо превышать за исключением экстренных ситуаций, когда по-другому не получается. Таким образом можно задать большой Xmx (скажем, равный размеру всей оперативной памяти) и какой-то разумный SoftMaxHeapSize. Тогда JVM будет держать себя в рамках большую часть времени, но при пиковой нагрузке всё же не кинет OutOfMemoryError, а возьмёт ещё памяти у ОС. Когда нагрузка упадёт, память вернётся.

Мэй-Чин Цай из Микрософт рассказала об особенностях JIT и AOT компиляции в CLR. AOT-компиляция у них развивается давно, но изначально (ngen.exe) велась на целевой платформе, вроде как при первом запуске (если у вас винда, поищите файлы *.ni.dll в папке Windows). Файлы получаются зависимые от версии локальной винды и даже от других DLL-ек. Соответственно если обновляется зависимость, все нативные модули надо перекомпилировать. Во втором поколении (crossgen) появилась предкомпиляция авторами приложения и модули относительно независимые от железа и версии ОС и зависимостей. Это замедлило код, потому что вызовы к зависимостям теперь пришлось делать честно виртуальными. Данную проблему решили, подключая JIT и перекомпилируя горячий код в процессе работы приложения. Далее разговоры шли про многоуровневую (tiered)-компиляцию (кажется, в CLR это в зачаточном уровне, в то время как в джаве уже не меньше десяти лет развивается) и про будущие планы сделать AOT по-настоящему кроссплатформенным.

Коллеги из Alibaba представили свой подход к проблеме разогрева JVM. Они используют JVM для множества веб-сервисов. В принципе сильно быстрый стартап не так важен, потому что балансировщик всегда может подождать, пока машина загрузится и только потом начать передавать ей запросы. Однако проблема в том, что без запросов машина не прогревается: код, описывающий логику обработки запросов, не вызывается, а значит, не компилируется. Компилироваться он будет, когда придут первые запросы, то есть сколько бы балансировщик ни выжидал, будет провал производительности на первых запросах. Раньше они пытались это решить, закидывая поднявшийся сервис поддельными запросами до того как отправлять на него реальные запросы. Подход интересный, но довольно сложно сгенерировать такой поддельный поток, который бы вызвал компиляцию всего нужного кода.

Отдельная проблема — деоптимизация. В первой тысяче запросов один if всегда шёл по первой ветке, JIT-компилятор вообще выкинул вторую, вставив туда деоптимизационную ловушку, чтобы уменьшить размер кода. Но 1001-й запрос пошёл во вторую ветку, сработала деоптимизация и весь метод ушёл в интерпретатор. Пока снова наберётся статистика, пока метод скомпилируется компилятором C1, потом по полному профилю компилятором C2, пользователи будут испытывать замедление. А потом в том же методе может деоптимизироваться другой if, и всё пойдёт по новой.

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

Стоит заметить, что здесь не решается проблема быстрого запуска. Запуск может получиться даже медленнее, потому что компилируется много методов, некоторые из которых может потребуются только через минуты после запуска. Зато лог получается переиспользуемый: в отличие от AOT можно поднять сервис на другой архитектуре или с другим сборщиком мусора и переиспользовать предыдущий лог.

Авторы довольно давно пытаются просунуть JWarmUp в OpenJDK. Пока безуспешно, но работа движется. Главное, что полноценный патч вполне себе доступен на сервере Code Review, поэтому вы можете легко применить его к исходникам HotSpot и собрать сами JVM с JWarmUp.

Это исследовательская работа из Манчестера, но авторы утверждают, что проект уже кое-где внедрён. Тоже надстройка над OpenJDK, которая позволяет довольно легко перекидывать определённый Java-код на GPU, iGPU, FPGA или просто распараллелить на ядра своего процессора. Для компиляции на GPU они используют GraalVM в который встроили свой бэкенд — TornadoJIT. Правильно написанный Java-метод совершенно прозрачно уходит на соответствующее устройство. Правда, говорят, компиляция на FPGA может занимать несколько часов, но если ваша задача считается месяц, то почему бы и нет. Некоторые бенчмарки (например, дискретное преобразование Фурье) по сравнению с голой джавой ускоряются более чем в сто раз, что в принципе ожидаемо. Проект полностью выложен на GitHub, там же можно ознакомиться с научными публикациями по теме.

Всё та же песня — давний проект, каждый саммит презентация, год назад всё выглядело довольно готово, но релиза всё нет. Оказалось, что с тех пор сместился фокус.

Идея проекта в улучшенном интеропе с нативным кодом. Все знают, насколько больно пользоваться JNI. Очень больно. Проект Panama сводит эту боль на нет: с помощью jextract по *.h-файлам нативной библиотеки генерируются Java-классы, которыми вполне удобно пользоваться, вызывая нативные методы. На стороне Си/Си++ вообще не надо ни строчки писать. Вдобавок всё стало куда быстрее: оверхед на вызовы Java→native и native→Java упал в разы. Чего ещё желать?

Осталась проблема, которая стоит довольно давно — передача массивов данных в нативный код. До сих пор рекомендуемый способ — это DirectByteBuffer, с которым множество проблем. Одна из самых серьёзных — неуправляемое время жизни (буфер исчезнет, когда сборщик мусора подберёт соответствующий Java-объект). Из-за этой и других проблем люди используют Unsafe, который при должном старании может легко положить всю виртуальную машину.

Это значит, нужен новый нормальный доступ к памяти за пределами Java-кучи. Аллокация, структурированные аксессоры, явное удаление. Структурированные аксессоры — это чтобы вам не пришлось самим считать смещения, если вам нужно записать, например, struct { byte x; int y; }[5]. Вместо этого вы один раз описываете лэйаут этой структуры и затем делаете, например, VarHandle, который может прочитать все x, перепрыгивая через y. При этом, разумеется, всегда должна быть проверка границ, как в обычных Java-массивах. Кроме того, должен быть запрет на обращение к уже закрытой области. А это оказывается нетривиальная задача, если мы хотим сохранить производительность на уровне Unsafe и разрешить доступ из нескольких потоков. Короче, смотрите видео, очень интересно.

Проект Метрополис объединяет все попытки переписать части JVM на Java. Основная его часть сегодня — компилятор Graal. За последние годы он развился очень неплохо и уже реально ходят разговоры о полноценной замене устаревающему C2. Раньше была проблема бутстрапа: грааль медленно стартовал, потому что его самого надо было JIT-компилировать или интерпретировать. Тогда появилась AOT-компиляция (да, основная цель проекта по AOT-компиляции — это бутстрап самого грааля). Но с АОТ грааль отъедает приличную часть кучи Java-приложения, которое может не очень хочет делиться своей кучей. Сейчас научились превращать грааль в нативную библиотеку с помощью Graal Native Image, что в итоге позволило изолировать компилятор от общей кучи. С пиковой производительностью кода, собранного граалем, есть ещё проблемы на некоторых бенчмарках. Например, грааль отстаёт от C2 по интринсикам и векторизации. Однако благодаря очень мощному инлайнингу и escape-анализу он просто рвёт C2 на функциональном коде, где создаётся много неизменяемых объектов и много маленьких функций. Если вы пишете на Скале и всё ещё не используете грааль, бегом использовать. Тем более, что в последних версиях JDK это совсем тривиально делается парой ключиков, всё уже есть в комплекте.

Кевин объявил о новом проекте, но просил не рассказывать публично и не выкладывать запись его выступления на YouTube. Поэтому извините. Скажу только, что официальный анонс должен состояться в этом году.

Дмитрий рассказывал о системе Sorbet для языка (внезапно!) Ruby, которая позволяет выводить типы для методов Ruby и проверять их. Он утверждает, что у них в Stripe чуть ли не самая большая кодовая база на Ruby в мире и, конечно, хочется поддерживать её качество. Честно говоря, в детали я не вник.

Микро-доклады по пять минут без видеозаписи. В первом Remi Forax очень коротко и убедительно сказал, что джаве следует отказаться от проверяемых исключений, что это зло. Прошу прощения у всех, кому мой твит внушил ложные надежды:

Это был просто крик души Реми, а не какие-то планы Оракла на ближайший релиз, как многие подумали.

И последний очень живой доклад с весёлыми слайдами про то что скоро ML и AI будут делать за нас всю работу, и программисты станут не нужны. Там были некоторые примеры того, что они в Facebook уже делают — автоматическое исправление ошибок в коде с помощью getafix, супер-авто-дополнение, которое может целый цикл за вас написать, и так далее. Вторая половина доклада была кратким описанием математических основ глубинного обучения. Дуальные числа, автоматическое дифференцирование, вот это вот всё. К сожалению, доклад вышел немного скомканым, потому что он явно рассчитан на большее время.

На этом саммит закончился. Получилось просто прекрасно. Завтра же начинается OpenJDK Committer Workshop.

© Habrahabr.ru