Выпуск 8 · подкаст «Тысяча фичей»

#8: Медленная Java: GraalVM, Mockito

42:32
↓ скачать mp3

Почему Java стартует медленно — класс-лоадинг, интерпретация, прогрев JIT — и как GraalVM native image ускорил command-line-приложение Александра с 3,5 секунд до 40 миллисекунд: closed-world assumption, инициализация классов в build time и цена в виде ручных метаданных для рефлексии. Плюс обзор альтернатив — AppCDS и CRaC от Azul. Во второй части — расследование флэки-тестов: как Mockito записывает вызовы в глобальный shared-state стек внутри when/thenReturn, почему это ломается в многопоточности и когда вместо мока нужен стаб, фейк или интеграционный тест. Полезняшка — «Распределённые данные» Алекса Петрова.

Главное

  • Java-старт дорог из-за класс-лоадинга и интерпретации до прогрева JIT: Hello World — около 800 мс на Java 17 против 5 мс у скомпилированного Go.
  • GraalVM native image переносит класс-лоадинг и компиляцию в build time (closed-world assumption) — старт CLI-приложения ускорился с 3,5 секунд до 40 миллисекунд, на два порядка.
  • Graal меняет привычную семантику: статические блоки исполняются при сборке (значение env, прочитанное в build time, «зашьётся» и в проде), а рефлексия, ресурсы и динамическая загрузка требуют явных метаданных — иначе ClassNotFound в рантайме.
  • Альтернативы: AppCDS — дамп загруженных классов приложения (примерно вдвое быстрее старт), и CRaC от Azul — восстановление уже прогретого приложения за десятки миллисекунд, но с очень узкой нишей.
  • Всегда спрашивайте, является ли проблема проблемой: микросервису за лоад-балансером с хелс-чеками скорость старта почти не важна, а вот для CLI-утилиты на Java это фактически бизнес-требование — идеальная ниша GraalVM.
  • Флэки-тесты от Mockito: конструкция when(...).thenReturn(...) пишет вызовы в глобальный shared-state стек, и конкурентный вызов мока из другого потока вклинивается между push и pop — отсюда «несовпадение типов».
  • Моки — для тестирования поведения (Фаулер, «Mocks Aren't Stubs»); если мок — просто затычка для конструктора из семи сервисов, это запах: подойдут стаб, фейк или дамми, а лучше — интеграционный тест с реальным кодом.
  • Мок оправдан на крайней точке системы — например, mock-сервер для вендорного REST API, к которому нет тестовых серверов.
Расшифровка

[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это восьмой выпуск подкаста «Тысяча фичей», и он снова немного необычный. Сегодня мы не будем обсуждать прочитанное, потому что читать было некогда — перед Новым годом, как обычно, сраки горят. Но это не повод не записывать подкаст: работа у нас интересная, и всегда есть что обсудить. В этом выпуске поговорим, почему Java стартует медленно и что с этим делать. В конце — интересная история о том, как выстрелить себе в ногу с помощью Mockito. Поехали!

[01:01] Существует мнение, что Java медленная — особенно время старта приложения и прогрев. В этом выпуске я попробую разобраться, правда ли это, как с этим бороться — и нужно ли вообще. Для начала затравочка. На этот выпуск меня сподвигла оптимизация, которую недавно удалось сделать: время старта command-line-приложения на Java 11 я уменьшил с 3,5 секунд до 40 миллисекунд. Дальше разберёмся, как так получилось, что Java-приложение стартует 3,5 секунды, и как старт можно ускорить на два порядка.

[01:57] Но прежде — небольшой обзор базы: как оно вообще работает и что мы оптимизируем. Возьмём обычный Hello World: Java-файл с методом main и System.out.println("Hello World"). Начиная, по-моему, с Java 11, файл с расширением .java можно передать прямо в команду java — раньше, в Java 8, нужно было сначала прогнать javac, получить class-файл и уже его передавать в JVM. Сейчас JVM сама скомпилирует и запустит. Так вот: сколько занимает напечатать Hello World? На Java 11 — примерно секунду, на Java 17 — около 800 миллисекунд. Один Java-файл, ничего больше. Что происходит? Сначала компиляция в class-файл. Затем стартует JVM и запускается механизм класс-лоадинга: загружаются все классы — не только наш, но и системные: System, коллекции, всё, что используется стандартной библиотекой. И только после загрузки — выполнение, execution. Он тоже непрост. Java знаменита своим JIT-компилятором, но прежде чем что-то компилировать just in time… Что значит «скомпилировать»? Class-файл — это не нативные процессорные инструкции («возьми этот регистр, перемножь, положи туда»), это байткод, который при достаточном опыте можно прочитать глазами — чего не скажешь о машинном коде. Сначала исполняются — интерпретируются — байткод-инструкции; через какое-то время интерпретация сменяется скомпилированным кодом. Он бывает двух видов: C1 JIT-компилятор быстро компилирует машинные инструкции на ходу — работает, но не максимально эффективно; а по истечении десятков секунд код, который по собранной статистике оказался «горячим» — какой-нибудь event loop или диспетчер, работающий почти всегда, — перекомпилируется C2-компилятором с жёсткими оптимизациями и отбрасыванием бранчей. Именно он работает основное время жизни серверного приложения. Тогда говорят, что JVM «прогрелась» и приложение перформит на всю. От java start до этой точки проходит уйма времени — в среднем около минуты, по опыту (точных замеров у меня нет). В нашем Hello World никакого JIT, конечно, нет — одна интерпретация, и всё; но даже до неё — сотни миллисекунд, в нашем случае 800, просто чтобы напечатать строчку. А ведь есть ещё библиотеки, которые тоже нужно загрузить (время класс-лоадинга) и интерпретировать: чем больше кода, тем дольше. В моём случае вполне нормальное, не тривиальное command-line-приложение стартовало 3,5 секунды.

[06:21] Чтобы отвлечься от Java, посмотрим в мир Go: сколько занимает аналогичный Hello World? У Go есть go run, принимающая исходник — аналог запуска java-файла: компилирует и запускает. Это около 200 миллисекунд против 800 у Java. Но это вместе с компиляцией, а в продакшн мы запускаем собранный код. Если собрать go build и запустить нативный executable — 5 миллисекунд. Справедливости ради, 800 мс у Java — тоже с компиляцией; если скомпилировать заранее и замерять только запуск class-файла, получится около 60 миллисекунд против 5 у Go. Все замеры примерные — просто чтобы знать порядки; я не претендую на точность, и у вас цифры могут выйти другими.

[07:58] Возвращаемся к нашим баранам: у меня есть command-line-приложение, стартующее 3,5 секунды — поднимается Micronaut-контекст, отрабатывает dependency injection. Думаю, все знают такую технологию, как GraalVM — Substrate VM. Это штука, которая делает ряд очень агрессивных оптимизаций на уровне build time и производит native image — по сути, машинный код под конкретную архитектуру и процессор, готовый к запуску. JVM как таковая уже не нужна: чтобы запустить скомпилированный нативный образ, на компьютере не нужна установленная Java. Какие оптимизации позволяют превратить 3,5 секунды в 40 миллисекунд? Во-первых, Graal исходит из того, что всё, что нужно загрузить в память, известно в build time, — так называемое closed-world assumption. Предполагается, что все классы, которые мы загружаем и используем, известны во время сборки. Всякие загрузки ресурсов и динамические класс-лоадеры Graal отметает: если ты явно про них не сказал — их не существует. Во время сборки происходит фаза статического анализа: по исходному коду строится граф достижимости всех классов, и все они загружаются. «Загружаются» — это значит, что фаза класс-лоадинга, которая обычно происходит на старте приложения, выполняется в build time. А что исполняется во время класс-лоадинга? Статические блоки, инициализация статических переменных. Вся эта машинерия исполнится при сборке. Пример: у меня в build time переменная окружения env равна dev, и её значение кладётся в статическую переменную. Если я скомпилирую у себя на машине, а запущу в проде, где env равна prod, — в коде она будет равна dev: класс загрузился и скомпилировался во время сборки на моей машине, сдампился в файл, и этот файл просто поднялся на проде. Значение статической переменной будет таким, каким оно было в build time. Это супернеочевидная, мегаагрессивная оптимизация, которая, вообще говоря, меняет семантику — не самого языка, а того, как мы привыкли, что работает наш код. Обойти можно — явно указать, что инициализировать в рантайме, — но из коробки оно так, и это надо иметь в виду. Во-вторых, все классы не только загружаются в build time — они компилируются, и скомпилированные версии дампятся в файл, так называемый Graal heap, который и есть native image. Потом мы запускаем его у пользователя или на сервере — и всё, что сделано при сборке, моментально поднимается в память: не интерпретация, а сразу работающий машинный код. Соответственно, никакого C2 JIT здесь нет — код уже скомпилирован, перекомпилировать по статистике нечем. Этих двух шагов — загрузка и компиляция в build time — достаточно, чтобы приложение стартовало настолько быстрее обычного HotSpot: прирост более чем на два порядка. Но отмечу: это быстро для Java, а не вообще — Go стартует за 5 миллисекунд против наших 40. Сопоставимо, но скорее традиционная Java супермедленная, чем Graal супербыстрый.

[13:03] Теперь практика: это не одна команда, после которой у вас нативный образ, — нужен ряд приседаний, и технология достаточно сырая. Сначала ставится Graal Updater — что-то среднее между тулбоксом и пакетным менеджером для установки компонентов; там целый зоопарк технологий. Я использовал native image — модуль в стеке Graal, который компилирует Java, билдит нативное приложение и выполняет все эти build-time-загрузки. Ему на вход нужны метаданные. Например, приложение использует JDBC — а это динамический класс-лоадинг драйвера. Если в build time не сказать Graal, что грузится такой динамический класс, в рантайме вы получите ClassNotFound: класса нет в native image, потому что статический анализ несовершенен — стопроцентный граф достижимости он строить пока не умеет. Поэтому явно говорим: чувак, у меня вот такой JDBC-драйвер, вот такой ресурс. Загрузить application.properties из ресурсов вы тоже не сможете — это динамическая загрузка: не сказали — в native image её не будет. А вот тут — рефлексия, которой в Graal из коробки практически нет. Все DI-фреймворки — Spring, Micronaut — используют рефлексию для сеттинга полей и вызова конструкторов; не передадите метаданные — в рантайме всё упадёт. Что у меня и случилось: я использую Micronaut, и рефлексия там есть — хотя пишут, что Micronaut её «не использует», в стектрейсе прямо виден вызов reflection при инжекте полей. Пришлось подключить дополнительную зависимость и аннотировать все классы с field injection как требующие рефлексии — благо это просто аннотация Micronaut, из которой генерируются метаданные, которые потом «хавает» native image. Логгер тоже отвалился — он грузится динамическим класс-лоадером; приложение, слава богу, не упало, просто весь output полетел в терминал — решаемо. В общем, день на эти приседания в первый раз потратить можно, дальше плюс-минус понятно, где в коде рефлексия и что чем аннотировать; можно даже завести мета-аннотацию. Технология сыровата, и информации немного: материалы двухлетней давности устарели настолько, что неприменимы — например, официальная документация Picocli описывает ряд команд и зависимостей для генерации метаданных, а сейчас этого делать вообще не надо: GraalVM просто понимает сам.

[17:52] Есть и приколюха для тех, кто не хочет ничего размечать: у Graal есть агент. Запускаете под ним обычное Java-приложение, работаете с ним — выполняете команды, гоняете тесты, — и агент проксирует и записывает все вызовы рефлексии, класс-лоадинги, загрузки ресурсов, а потом дампит метаданные в нужном Graal формате. Скармливаете их native image — и он компилирует образ. Как по мне, практика странная: под агентом легко протыкать не все пути исполнения, где-то продолбаться — нужно реально стопроцентное покрытие. Кажется, это временный костыль, потому что анализатор пока недостаточно смышлёный. Очень надеюсь, что в будущем GraalVM дорастёт до технологии, которой пользуешься просто: подключил Gradle-плагин, появилась таска nativeCompile (это и сейчас так), указал платформы — macOS, Linux, Windows — и получил три executable, без ручной разметки. Сейчас так не работает: собрав текущим плагином, вы, скорее всего, получите NullPointerException, ClassNotFound или ещё какую-нибудь дичь. Но в любом случае звучит и выглядит очень круто.

[20:06] Пока я всё это ресерчил, посмотрел, какие ещё есть способы оптимизации старта — Graal всё-таки крайний случай, и не все могут его использовать (со Spring, насколько я знаю, сейчас нельзя). Есть старая фича — class data sharing, появилась ещё примерно в Java 5: дампим загруженные классы — это оптимизация именно времени класс-лоадинга — и при следующем запуске передаём дамп, из которого всё сразу поднимается в память, класс-лоадеру работать не нужно. Но это только про системные классы стандартной библиотеки. А нормальные большие приложения — это не столько стандартная библиотека, сколько спринги, томкаты и вот это всё, и на них эта фича не экономит. Но в последних версиях — по-моему, с Java 10 — появился application class data sharing: теперь можно шерить не только классы JDK, но и классы приложения. Делаем тестовый запуск, при котором загружаются все спринговые классы, наши классы и библиотеки, — и дальше используем дамп для быстрого старта. Я попробовал у себя: правда, упало — на Java 17 OkHttp, написанный на Kotlin, свалился из-за несовместимости, — но приложение поднялось, контекст почти сбилдился, и это заняло секунду. То есть AppCDS даёт примерно двукратное ускорение старта среднего приложения, что тоже достаточно круто. Если вы собираете приложение в контейнеры, это можно сделать прямо в контейнере — звучит прикольно, я бы такую фичу использовал на серверах.

[22:33] Ещё я набрёл на проект CRaC от ребят из Azul. Это тоже суперагрессивная оптимизация — не только времени загрузки классов, но и работы JIT-компилятора. Идея: один раз загрузим приложение, дадим ему нагрузку, получим прогретое приложение, где всё скомпилировано C2-компилятором и супероптимизировано, — и сдампим это состояние на диск. Перед этим скажем JVM: «чувак, мы тебя дампим — закрой файловые дескрипторы и все ресурсы». А в следующий раз поднимем из дампа — и приложение встаёт за 30–40 миллисекунд, уже скомпилированное JIT-компилятором. Это важно: если Graal компилирует ahead-of-time — статическим компилятором, который не знает статистику и горячие места, — то приложение из снэпшота скомпилировано «горячим» компилятором и, как правило, перформит лучше. Звучит круто, но сфера применения, честно говоря, мегаузкая: что делать, если классы поменялись? Самая частая причина перезапуска контейнеров в микросервисах — деплой нового кода; как восстанавливаться из чекпоинта, когда код изменился, — непонятно: нужно как-то фоллбэчиться, трекать… Кажется, это технология для очень малого количества приложений, в то время как Graal работает везде, где ограничена рефлексия и фреймворки знают о его существовании.

[25:02] На этом про способы оптимизации старта со стороны самой Java (не говоря про ленивые загрузки бинов и прочее) — всё, чем хотел поделиться. И последняя мысль в тему: технологии и инициативы прикольные, но они решают конкретную проблему — и нужно определить, является ли она проблемой для вас. Если я делаю обычные микросервисы — какая мне, глобально, разница, стартует приложение 30 миллисекунд или 5 секунд? У меня лоад-балансеры и хелс-чеки; если это Kubernetes — нагрузка на приложение просто не пойдёт, пока оно не поднялось: отработает другой под со старой версией. Для пользователя ничего не меняется, и жёсткая оптимизация старта лишь экономит какие-то ресурсы и доставляет нам, инженерам, удовольствие — это правда. Но бывают случаи, где это критично. Как у меня сейчас: command-line-приложение, которое я поставляю пользователям, и --help отрабатывает больше трёх секунд. С точки зрения инженера это ужасно, а пользователь — ждёт. Когда вы используете ls, cat и любые линуксовые команды, они отрабатывают моментально: они скомпилированы под конкретную архитектуру и написаны не на Java. И в том же контексте Java-приложение, работающее три секунды, реально ощущается медленным. Это ухудшение user experience — и здесь увеличение скорости старта становится фактически бизнес-требованием, а не инженерной хотелкой. Мне кажется, идеальная ниша Graal — именно command-line-приложения на Java (хотя сами они говорят больше про микросервисные миры). Так что посыл такой: всегда смотрите, нужно ли это вам, является ли проблема, решаемая технологией, проблемой для вас — сейчас или в будущем. Если нет — лучше деливерить больше фичей и делать код гибче и менее связанным, чем упарываться по технологиям, которые вам не очень-то нужны.

[27:58] Во второй части, как и обещал, — наброс на моки. Контекст: перед новогодними праздниками на CI, на Jenkins, тесты начали время от времени падать со странными ошибками — флэки-тесты, которые локально, естественно, работают. Ошибки были вида: Mockito говорит, что вы не можете замокать метод, возвращающий список стрингов, интеджером. То есть: «ты пытаешься в рантайме сказать, что метод, возвращающий List<String>, вернёт Integer». Ошибка несоответствия типов между тем, что реально возвращает интерфейс, и тем, что мы пытаемся подсунуть через конструкцию when(вызов метода).thenReturn(что вернуть) — достаточно популярную конструкцию Mockito. Но смотришь в код, на который ругается Mockito, — а там всё нормально по типам: где лист — возвращаешь лист, где интеджер — интеджер. Совершенно непонятно: выглядит, будто Mockito просто снесло голову.

[29:22] Потратив немного времени (сразу скажу: раньше я с таким не сталкивался, так что находка меня прилично удивила), вот что я нашёл. Дано: приложение многопоточное, и некоторые моки, которые мы суём в объект, потом используются в другом потоке. Например, есть сервис, у которого какой-то демон в цикле постоянно делает getList — крутится: get, get, get. И пока он это делает, мы в тесте конфигурируем мок: «когда у тебя вызывают такой метод — верни такое». Стандартный код: создали мок в beforeEach, передали его в DI-механизм Micronaut, там он используется в другом потоке, в тесте донастраиваем, запускаем бизнес-логику, валидируем результат. Такие тесты пишут все — я видел их везде, где работал. Так вот: когда мы отдали мок наружу и его начали использовать, ребята из Mockito говорят — всё, донастраивать его уже нельзя; если делаете — «ваши тесты плохие» (что с ними делать, не говорят, но словами такими разбрасываться любят). А проблема вот в чём. Каждый вызов метода на моке записывается — но не во внутренний стейт самого мока, а в статическую переменную, глобальный контейнер, что-то типа стека. И записываются туда вызовы не только когда кто-то реально вызывает мок в коде, но и когда мы конфигурируем мок. Теперь представьте картину — будет сложновато, но попытайтесь. Конструкция: when(myService.getValue()).thenReturn(object). Как этот код исполняется? Прежде чем вызвать метод when, Java должна вычислить его параметры. А параметр — это вызов myService.getValue() на моке. Он реально вызывается — и во время вызова происходит запись в глобальный shared state: «у этого мока вызвали этот метод». Положили в стек. Затем вызывается when — и он делает pop последнего вызова из глобального стека, формируя объект, у которого мы вызываем thenReturn. И если кто-то в другом потоке в этот момент просто вызывает у мока методы — между нашим push и pop конкурентно вклинивается другой вызов. Мы берём верхний элемент стека — а там типы не совпадают: логично, там вообще другой метод, вызванный не нами и из другого потока.

[33:56] Когда понимаешь, что DSL when(…).thenReturn(…) работает через такой механизм, становится немного не по себе: из DSL и из документации это вообще не очевидно — настолько, что я поражён. Я считаю, конкретно этот синтаксис надо бы задепрекейтить: если использовать перевёрнутый вариант — doReturn(value).when(…) — он, насколько мне известно, этот shared state уже не использует. А так — выглядит и ведёт себя как полная дичь. Тот же WireMock, где явно пишешь startRecording, делаешь вызовы, потом stopRecording, выглядит страшновато — но хотя бы выглядит так, как работает. Пофиксили мы это тем, что мок больше не используется из другого потока во время конфигурации; способов было много, но проблема была флэки, и времени на неё ушло прилично.

[34:57] В связи с чем — мысли, которые есть у меня достаточно давно: моки — говно. Какие бы они ни были — Mockito, WireMock, другие. Когда мы используем их в тестах, это запах: явный индикатор того, что что-то в архитектуре, в тестах, в майндсете не так, и стоит пересмотреть, как мы используем код. Проблема в том, что моки используют везде и повсюду, и когда приходишь в готовый код — правило разбитых окон, про которое я говорил: окна уже разбиты, и ты либо делаешь так же, либо не делаешь ничего. Я тоже так делаю, понимаю. Но если речь о новом коде, отдельной функциональности, — каждый раз, когда сам начинаешь заваривать эту кашу с моками, стоит спросить: а почему я сейчас использую мок? Что такое мок как концепция: мы записываем поведение сторонней системы («на такой запрос отвечаешь вот так»), а в конце проверяем, что именно этот запрос был задан, сколько раз и что вернулось. Мы тестируем поведение. Та самая знаменитая статья Фаулера — «Mocks Aren’t Stubs» — как раз об этом: моки — это behavior testing. Нужно протестировать поведение — используем моки. А всякий раз, когда поведение тестировать не надо, а мок суётся, потому что, блин, у нас конструктор из семи объектов и сервисов, а протестировать нужно один метод, — вот тут всегда стоит подумать. Я и до того, как узнал про неочевидный shared state, всегда спрашивал себя: зачем у меня здесь мок, могу ли я без него?

[37:24] А как без моков? Я вообще топлю за интеграционные тесты — за то, чтобы использовать тот код, который реально работает в продакшене. Если такой тест написать сложно — архитектура кода немного страдает, и, если вы не тестируете поведение, а делаете затычки, её, вероятно, стоит пересмотреть. Но пересмотреть возможно не всегда. Что делать со страшным сервисом без моков? Фаулер говорит: есть другие виды заглушек. Ближайшие — стабы: объекты, которые просто возвращают что-то. Отличие от моков: моки тестируют поведение и записывают в себя вызовы — та самая проблема с многопоточной записью; стабы вызовы не записывают, у них просто нет такой функциональности. Мы конфигурируем поведение на стадии создания стаба — «на этот метод возвращай это значение» — и он всегда так себя ведёт. Проще, тупее — и не доставляет проблем, которые, как мы теперь знаем, может доставить мок. Ещё есть фейковые объекты — типа H2, in-memory database: когда нужна какая-то база, а настоящая она или нет — неважно; файлик или хешмапа в памяти — пофиг, какое хранилище. Можно использовать, хотя штука спорная. И дамми-объекты — написанный руками наследник нашего сервиса: ничего не возвращай, кидай исключения, возвращай налы — мне нужно тебя просто сунуть, потому что конструктор тебя требует, а на самом деле ты мне не нужен. Когда понимаешь, что есть и другие способы, начинаешь смотреть шире — и каждый раз задаёшь себе вопрос «а зачем мне это здесь?», что само по себе плюс: это заставляет думать. И в целом, когда это в голове есть, код начинает проектироваться с меньшим числом лишних зависимостей, меньшей связанностью: объект проще создать, проще протестировать, он следует single responsibility — знает только то, что ему нужно, и делает только то, что должен. Код чище, архитектура лучше, тестировать проще. В коде, который я сейчас пишу с нуля (не в легаси), я вообще не использую Mockito. Единственное, что реально понадобилось, — mock-сервер в интеграции REST-клиента, чтобы мокать ответы сервера: эта часть не под моим контролем, это вендорная штука, и тестовых серверов они не предоставляют. Я стартую свой и говорю: на такой запрос возвращай такой JSON. Это та самая крайняя точка системы, которую я действительно мокаю, потому что выбора нет. А как правило, во всех других случаях выбор есть. Если слишком долго или занудно — уж простите, наболело.

[41:32] Полезняшка! В этот раз делюсь книгой, которую недавно дочитал: «Распределённые данные. Алгоритмы работы современных систем хранения информации», автор — Алекс Петров. У книги отличный перевод на русский, так что необязательно читать в оригинале. Первая половина посвящена алгоритмам хранения: и про B-деревья, и про LSM-деревья, и про всевозможные их модификации. Вторая часть — полностью про распределённые алгоритмы. Если вы уже прочитали книгу с кабанчиком и хотите ещё — вам точно зайдёт. Ну а на этом всё. Услышимся!