Языки

Java vs Node.js: кто сколько ест памяти

5 мин чтения
  • #java
  • #nodejs
  • #jvm
  • #v8
  • #memory
  • #gc

Если запустить простейший HTTP-сервер на Java (Spring Boot) и такой же — на Node.js (Express), разница в потреблении памяти будет не 10–20%, а в разы. Java-процесс после старта спокойно держит 250–400 МБ RSS, Node.js на том же запросе — 40–70 МБ. И это не баг, а фундаментальное отличие двух сред. Разберёмся, откуда берутся эти цифры и когда «дорогая» JVM на самом деле оказывается выгоднее.

Что вообще считаем: RSS, heap и resident

Прежде чем сравнивать, важно уточнить, что мы измеряем. Память процесса — это не одно число:

  • RSS (Resident Set Size) — сколько физической оперативки процесс реально занимает прямо сейчас. То, что вы видите в top / htop / Task Manager.
  • Heap — управляемая средой память для объектов (-Xmx в Java, V8 heap в Node).
  • Off-heap / native — всё остальное: метаданные классов, потоки, JIT-кеш, внутренние буферы.

В Java heap — это только часть истории. В Node.js V8 heap — почти вся история (плюс libuv-буферы под I/O).

Холодный старт: почему Java сразу «тяжёлая»

Java-приложение при старте поднимает JVM — это огромная инфраструктура:

  • Class metadata в Metaspace — загруженные байткоды классов Spring, Hibernate, Jackson. Один только Spring Boot тащит тысячи классов, это десятки мегабайт.
  • JIT-компилятор (C1/C2 в HotSpot) строит кеши скомпилированного нативного кода.
  • Потоки GC, финализаторы, JIT-потоки — каждый со своим стеком.
  • Heap по умолчанию разогревается: JVM резервирует до 1/4 физической памяти (но не обязательно использует).

Итог: даже пустой hello world на Spring Boot весит в RSS 250+ МБ.

Node.js стартует иначе: V8 — это движок под один скрипт, без тысяч классов и отдельного JIT-инфраструктурного слоя. Cold start Express-сервера — 30–50 МБ.

Главное правило: сравнивать память одного процесса ничего не значит без контекста нагрузки. Стартовый RSS — это база, а не потолок.

Под нагрузкой: где всё меняется

Вот тут начинается самое интересное. На холодном старте Node выглядит победителем, но по мере роста нагрузки картина размывается.

1. Heap растёт по-разному

Java-heap управляется поколенческим GC (G1, ZGC, Parallel). Объекты быстро умирают в young generation, выжившие переезжают в old. Память освобождается предсказуемо, но heap «раздувается» до -Xmx и держит этот объём — освобождать страницы обратно ОС JVM не спешит (это нормально, так и задумано).

V8 в Node тоже использует поколенческий GC, но heap ограничен (по умолчанию ~1.5–4 ГБ в зависимости от версии и битности). Под интенсивной аллокацией Node может упереться в лимит и начать агрессивно собирать мусор — паузы и рост latency.

2. Тяжёлые payload'ы

Типичный паттерн: сервер парсит большой JSON или держит в памяти пайплайн данных.

  • Java: можно уйти в off-heap (ByteBuffer, sun.misc.Unsafe, библиотеки типа Netty ByteBuf) — GC такую память не видит, аллокации не триггерят сборку.
  • Node.js: Buffer — это тоже вне V8 heap, но экосистема завязана на JS-объекты. Любой JSON.parse большого документа создаёт гору объектов в V8 heap, которые GC будет убирать.

На больших JSON-payload'ах Java-сервис стабильно держит память, Node — начинает «дышать» (рост → GC → просадка → снова рост).

3. Много одновременных соединений

Каждое соединение — это буферы. Здесь включается разница моделей:

  • Java (Spring MVC / Tomcat) — thread-per-request: N потоков, каждый со стеком (~1 МБ по умолчанию). 1000 соединений = ~1 ГБ только на стеки. Spring WebFlux (Netty) решает это, переходя на event-loop как у Node.
  • Node.js — event-loop, один поток обрабатывает тысячи соединений. Память растёт линейно от количества активных одновременных операций, а не от числа соединений.

Сводная таблица

ПараметрJava (Spring Boot)Node.js (Express)
RSS на холодном старте250–400 МБ30–70 МБ
Дефолтный heap-лимит~25% RAM (-Xmx)~1.5–4 ГБ (V8)
Управление памятьюJVM + GC (G1/ZGC)V8 + GC + libuv
Off-heapДа, нативноДа, через Buffer
Стек на запрос~1 МБ (thread-per-request)общий event-loop
Возврат памяти ОСнеохотноохотнее
Cold start latencyвыше (JIT разогрев)ниже
Пиковая RSS под RPSстабильно высокаярастёт, потом GC-паузы

Когда выбирают Java, несмотря на «прожорливость»

Цифры на старте — это ещё не TCO (total cost of ownership). Несколько сценариев, где Java выигрывает в деньгах, несмотря на больший baseline:

  1. Высокий и стабильный RPS. JVM с разогретым JIT обрабатывает запрос в 3–10 раз быстрее Node.js на CPU-bound задачах. Один Java-инстанс заменяет 3–5 Node-инстансов — суммарная память кластера может оказаться меньше.
  2. Долгоживущие соединения / WebSocket-фармы. JVM better справляется с долгоживущими сессиями и большими инстансами.
  3. Тяжёлые domain-модели (банкинг, биллинг). Богатая типизация, JPA/Hibernate, transactional-кеш — всё это съедает память, но экономит недели разработки.

Когда Node.js реально выгоднее:

  • Serverless / FaaS (Lambda, Cloud Functions) — холодный старт и низкий baseline критичны, платите за миллисекунды.
  • Прототипы и MVP — быстро, дёшево, памяти мало.
  • I/O-bound сервисы — gateway'и, BFF, прокси. Event-loop показывает себя отлично, CPU почти не нужен.

Практические лимиты, которые стоит знать

Java:

# Ограничить heap и метаспейс для контейнера (типичные дефолты для Docker)
java -Xmx512m -Xms256m -XX:MaxMetaspaceSize=128m -jar app.jar
  • -XX:+UseZGC (или G1) — современные GC почти не дают пауз на heap'ах до терабайта.
  • -XX:MaxRAMPercentage=75 в контейнерах вместо старого -Xmx.

Node.js:

// Лимит old-space V8 (по умолчанию ~1.5–4 ГБ в зависимости от версии)
node --max-old-space-size=512 server.js
  • Мониторьте process.memoryUsage()heapUsed, rss, external.
  • Для больших потоков используйте стримы (stream.Readable), не грузите всё в память.

Вывод

«Java ест больше памяти» — это правда только на холодном старте и в baseline. Под реальной нагрузкой сравнение становится зависимым от workload:

  • Если у вас много маленьких инстансов с холодными стартами (serverless, edge) — Node.js дешевле.
  • Если у вас несколько жирных долгоживущих сервисов под высоким RPS — Java часто выходит дешевле в пересчёте на запрос, потому что один инстанс делает работу нескольких.

Выбирайте по характеру нагрузки, а не по RSS после npm start и mvn spring-boot:run.