Java против Go в 2026: бенчмарк через шесть лет показал другую картину

от автора

Шесть лет назад я и Питер Наги задали вопрос, который был достаточно простым, чтобы быть забавным, и достаточно занудным, чтобы быть полезным: могут ли микросервисы на Java быть такими же быстрыми, как микросервисы на Go? Речь не шла о войне языков — такие споры обычно субъективны, а хуже того, отбивают желание разбираться дальше. Практический вопрос был куда у́же: если взять небольшой HTTP-сервис, аккуратно реализовать его на Go и на Java и запустить на одном железе, окажутся ли результаты в одном диапазоне производительности?

В 2020 году ответ был «да» — для небольшой нагрузки. Тогда я заметил закономерность, которую хотел проверить снова: Java становилась интереснее по мере роста нагрузки и мощности машины. Поэтому вопрос 2026 года не «проиграл ли Go?» и не «решила ли Java все свои проблемы?». Вопрос звучит иначе: для этого сервиса, на этой машине, с текущими рантаймами — что происходит при росте нагрузки и уровня конкурентности?

Репозиторий с материалами к статье: markxnelson/go-java-go-2026. В нём код сервисов, скрипты бенчмарков, исходные результаты, сводные таблицы и скрипт построения графиков.

Исходные условия

Для этого прогона использовались:

  • Go 1.26.3

  • Oracle JDK 26.0.1

  • Helidon SE 4.4.1

  • Linux на x86_64

  • Intel Xeon W-11855M, 6 ядер / 12 потоков

  • 128 ГиБ RAM

Go-сервис использует стандартный net/http-сервер из стандартной библиотеки. Без фреймворка, без middleware-стека.

Java-сервис использует Helidon SE WebServer. Helidon 4 обрабатывает запросы через virtual threads, и health-эндпоинт подтвердил, что обработка запросов действительно шла на virtual threads.

Для Java-стороны измерялись два варианта рантайма:

  • Oracle JDK JVM

  • Oracle JDK с AOT-кэшем Leyden

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

Если вдруг решите повторить эксперимент из статьи, то удобнее всего это сделать в OpenIDE. В OpenIDE реализована первоклассная поддержка Java, Go и других самых популярных языков программирования. А поддержка Docker и 300+ плагинов в маркетплейсе доступны абсолютно бесплатно.

Сервис

Оба сервиса экспоузят одинаковые эндпоинты:

GET /health  GET /ready  GET /api/strings/{value}  GET /api/generated/{size}

Эндпоинт strings полезен для простых функциональных проверок. Эндпоинт generated — тот, который использовался в матрице бенчмарка.

Это различие важно.

В одном из ранних прогонов я тестировал 2 КБ входных данных, передавая 2-килобайтную строку прямо в URL-пути. Это в основном показало, как каждый роутер обрабатывает странный path-параметр. Возможно, интересно, но не то, что я хотел измерить. В финальном полном прогоне используется /api/generated/{size}, поэтому URL остаётся коротким, а нужный размер входных данных генерируется внутри обработчика.

Каждый запрос выполняет одинаковый небольшой объём работы:

  • перевод входных данных в верхний регистр

  • перевод входных данных в нижний регистр

  • разворот строки

  • вычисление CRC32-хэша

  • повторение дополнительной CRC-работы согласно WORK_FACTOR

  • возврат JSON с результатом и метаданными рантайма

Для бенчмарка WORK_FACTOR=10. Логирование запросов было отключено.

Это всё ещё небольшой синтетический сервис. Не корзина покупок, не антифрод-система и не платёжный API. У него нет базы данных, TLS, очереди, парсера JSON на входе и внешних зависимостей. Это сделано намеренно: цель — сделать горячий путь достаточно маленьким, чтобы было видно поведение рантайма и сервера.

Конфигурация бенчмарка

Позвольте мне в этой статье использовать термин «бенчмарк» немного вольно.

Раннер бенчмарка запускает один сервис, прогоняет полную матрицу, останавливает его, затем запускает следующий сервис. Go и Java не работают одновременно, поэтому они не конкурируют друг с другом за CPU или память.

В прогоне использовались следующие параметры:

payload sizes:      7, 128, 2048, 8192 bytes  concurrency levels: 1, 6, 12, 24, 48, 96, 192  repeats per cell:   2  warmup per cell:    2 seconds  measurement window: 5 seconds  work factor:        10

Настройки рантайма были заданы явно:

Go:    GOMAXPROCS=12    GOMEMLIMIT=off  Java JVM variants:    -XX:ActiveProcessorCount=12    -XX:MaxRAMPercentage=75  With Leyden:    -XX:+UnlockDiagnosticVMOptions    -XX:-AOTRecordTraining    -XX:-AOTReplayTraining

Объединённый набор результатов, использованный в статье, лежит здесь:

results/sequential_generated_leyden_feedback_full_20260608_0700432/

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

Маленькая настройка, которая изменила результат Java

Перед основным прогоном бенчмарка я наткнулся на странный результат.

Сервис на Helidon выглядел нормально для маленьких ответов, но при более крупных сгенерированных ответах появлялся подозрительный нижний предел задержки около 44–48 мс — когда Go-драйвер нагрузки повторно использовал персистентные HTTP/1.1-соединения. Обычный запрос через curl после прогрева такого поведения не показывал. Это было больше похоже на поведение пакетов, чем на проблему в коде приложения.

Решение было таким:

WebServer server = WebServer.builder()          .port(port)          .connectionOptions(socket -> socket.tcpNoDelay(true))          .routing(routing -> routing                  .get("/health", (req, res) -> health(res))                  .get("/ready", (req, res) -> ready(res))                  .get("/api/strings/{value}", (req, res) -> strings(req, res, logRequests, workFactor))                  .get("/api/generated/{size}", (req, res) -> generated(req, res, logRequests, workFactor)))          .build()          .start();

После включения tcpNoDelay(true) кейс с 2 КБ и персистентным соединением перешёл из категории «явно сломанный бенчмарк» в категорию «нормальный сервер». Именно поэтому такие тесты стоит прогонять перед тем, как писать статью: одна пропущенная настройка способна превратиться в уверенный, но неверный вывод.

Оба сервиса также явно выставляли Content-Length для JSON-ответов известного размера.

Что получилось

Короткая версия: для этого сервиса, на этой машине, Java не была просто «не хуже Go». Как только тест выходил за пределы самого маленького случая, реализация на Java часто масштабировалась лучше.

В прогоне раннер использовал 10-секундный прогрев сервиса после его запуска, плюс 10-секундный прогрев перед каждой измеряемой ячейкой. Также прогонялся Leyden-replay с диагностическими опциями, отключающими запись и replay-тренировку во время измерения.

На самом маленьком сгенерированном payload все три варианта были в одном диапазоне при низкой конкурентности. С одним воркером и payload в 7 байт Go достиг около 3 200 запросов в секунду. Обычный Oracle JDK — около 2 722 запросов в секунду, а Leyden AOT — около 3 561.

Именно такой результат люди обычно запоминают из старых споров Java против Go: Go стартует быстро, код компактный, и при низкой конкурентности всё выглядит прекрасно.

Но картина менялась с ростом конкурентности.

При 192 одновременных воркерах и том же payload в 7 байт Go достиг около 59 173 запросов в секунду. Обычный Oracle JDK — около 74 044. Leyden AOT — около 99 099.

На 128 байтах Java-варианты вышли вперёд при более высокой конкурентности. При 192 воркерах Go достиг около 40 928 запросов в секунду, обычный Oracle JDK — около 62 433, Leyden AOT — около 91 124.

На 2 КБ разрыв стал больше. Пик Go — около 16 971 запроса в секунду. Пик обычного Oracle JDK — около 39 532, Leyden AOT — около 41 604.

На 8 КБ оба варианта Java заметно опережали Go в этом локальном прогоне. Пик Go — около 6 815 запросов в секунду. Пик обычного Oracle JDK — около 15 025, Leyden AOT — около 15 493.

Таблица пиковой пропускной способности из этого прогона выглядит так:

Данные с высокой конкурентностью, на которых строится основной вывод статьи:

Это и есть интересная версия истории: кривая.

Для самого маленького кейса сервисы находятся в одном диапазоне при низкой конкурентности, но Leyden AOT отрывается на высоком конце конкурентности. По мере роста сгенерированного payload преимущество Java проявляется раньше и сильнее.

Это не значит «Java быстрее Go». Это значит, что данная реализация на Java, на этом JDK, с обработкой запросов через virtual threads в Helidon и правильной настройкой сокета, масштабировалась лучше, чем данная реализация на Go в этой конкретной локальной среде.

В этом предложении много существительных. И все они нужны.

Что дал Leyden AOT

Leyden AOT не просто ускорил каждую конфигурацию запуска — но с отключёнными опциями replay-тренировки во время измерения он существенно изменил итоговый результат.

У него была лучшая пиковая пропускная способность для каждого payload в этом прогоне. На 7 байтах пик Leyden составил около 99 099 запросов в секунду при конкурентности 192, с p95 около 6,0 мс и p99 около 9,1 мс. На 128 байтах пик — около 91 124. На 2 КБ — около 41 604. На 8 КБ — около 15 493.

Это не значит, что Leyden выигрывал постоянно. Leyden AOT показал наивысшую пропускную способность в 20 из 28 вариантах payload/конкурентность, а обычный Oracle JDK JVM выиграл оставшиеся 8. Go не выиграл ни в одной из комбинаций в финальной матрице, хотя держался близко на самых маленьких кейсах с низкой конкурентностью. Общая картина пиковой пропускной способности сместилась: Leyden AOT оказался вариантом рантайма с наивысшим пиком для каждого payload в этой матрице.

Это не разочаровывает — это полезно. Leyden AOT не магический переключатель «сделать результаты бенчмарка лучше». Он меняет поведение запуска, прогрева и рантайма так, что это нужно измерять применительно к конкретной нагрузке, которая вас интересует.

Честный итог для этой статьи такой:

Leyden AOT оказался сильнейшим в разрезе пиковой пропускной способности после того, как прогон измерения аккуратнее отделил прогрев и отключил запись/replay-тренировку Leyden во время replay. Запуск и footprint всё ещё заслуживают отдельного рассмотрения.

Что значат эти результаты

Старый простой аргумент звучал так: Go — очевидный выбор для маленьких сетевых сервисов, потому что Java слишком тяжёлая.

Этот аргумент разбивается о результаты данной статьи.

Go остаётся прекрасным выбором для маленьких сервисов. Реализация компактна. Тулчейн прост. Стандартный HTTP-сервер вполне способен. История с деплоем единым бинарником всё ещё очень привлекательна.

Современная Java тоже отлично подходит для маленьких сервисов, и у неё совсем другой набор сильных сторон. У JVM зрелый оптимизатор, богатые инструменты наблюдаемости, отличная инженерия GC и теперь массовая модель virtual threads, которая делает блокирующий серверный код заметно дешевле, чем раньше.

Helidon SE удерживает Java-сторону достаточно компактной, чтобы это сравнение не превращалось в «минимальный Go против огромного Java-фреймворка». Это компактный Java-сервис на компактном Java-сервере.

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

Практический вывод из этой статьи такой:

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

Что я измерил бы дальше

Пропускная способность — лишь часть истории.

Следующий проход должен добавить:

  • время запуска

  • использование RSS и heap

  • утилизацию CPU

  • GC-логи

  • Java Flight Recorder

  • async-profiler

  • более длинные прогоны

  • больше повторов на ячейку

  • изолированный хост для генератора нагрузки

  • лимиты контейнера

  • TLS

  • логирование запросов включённым и выключенным

  • Spring Boot

  • хотя бы одну настоящую зависимость, например вызов базы данных

Я бы также оставил урок с tcpNoDelay в чек-листе бенчмарка. Это не эффектно, но и быть неправым на 40 миллисекунд — тоже не эффектно.

Как повторить этот прогон

Собрать Java-сервис:

cd helidon-service  JAVA_HOME=/home/mark/jdk-26.0.1 \  PATH=/home/mark/jdk-26.0.1/bin:/home/mark/apache-maven-3.9.12/bin:$PATH \  mvn -B -DskipTests package

Запустить последовательную матрицу:

RESULTS_DIR=/home/mark/redstack/go-java-go-2026/results/sequential_generated_$(date +%Y%m%d_%H%M%S) \  GO_PORT=25081 \  JAVA_PORT=25082 \  CONCURRENCY_LEVELS="1 6 12 24 48 96 192" \  PAYLOAD_SIZES="7 128 2048 8192" \  REPEATS=2 \  DURATION=5s \  WARMUP_DURATION=2s \  JAVA_VARIANTS="oracle-jdk-jvm oracle-jdk-leyden-aot" \  WORK_FACTOR=10 \  ENDPOINT_MODE=generated \  scripts/run-sequential-matrix.sh

Раннер автоматически записывает исходные и сводные таблицы.

В чём я всё ещё уверен

Оригинальная статья не закрыла этот вопрос навсегда. Она и не должна была.

Производительность — это не только свойство языка.

Это также свойство:

  • формы железа

  • версии рантайма

  • выбора фреймворка

  • прогрева

  • логирования

  • сериализации

  • настроек сокета

  • лимитов контейнера

  • поведения GC

  • дизайна драйвера нагрузки

  • продолжительности измерения

  • «шумных соседей»

  • частей сервиса, которые не входят в ваш бенчмарк

Это было верно в 2020-м и остаётся верным в 2026-м.

Так могут ли микросервисы на Java быть такими же быстрыми, как на Go?

Для этого сервиса, на этой машине, с этими версиями — да. И по мере роста payload и конкурентности реализация на Java часто оказывалась быстрее.

Полезный следующий вопрос «с какой формой рантайма вы хотите оперировать, наблюдать, тюнить, деплоить и жить в продакшене?». Он даёт вам что измерить, что улучшить и, в удачный день, что-то, в чём стоит поменять мнение.

Уже сейчас OpenIDE позволяет разрабатывать проекты на Java, Spring, Python, Go, PHP, JavaScript и TypeScript! А поддержка Docker и 300+ плагинов доступны абсолютно бесплатно в маркетплейсе. Пробуйте российскую IDE в деле и подписывайтесь на нас в Telegram или Max, чтобы не пропустить свежие обновления и полезные материалы.

ссылка на оригинал статьи https://habr.com/ru/articles/1051930/