Java vs Node.js: кто сколько ест памяти
Если запустить простейший 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, библиотеки типа NettyByteBuf) — 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:
- Высокий и стабильный RPS. JVM с разогретым JIT обрабатывает запрос в 3–10 раз быстрее Node.js на CPU-bound задачах. Один Java-инстанс заменяет 3–5 Node-инстансов — суммарная память кластера может оказаться меньше.
- Долгоживущие соединения / WebSocket-фармы. JVM better справляется с долгоживущими сессиями и большими инстансами.
- Тяжёлые 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.