Spring Boot — де-факто стандарт backend на JVM, и работает он одинаково из Java и Kotlin. Проблема не в том, чтобы «запустить», а в том, чтобы понимать, что именно автоконфигурация сделала за вас — иначе отладка превращается в гадание.
Эта статья — про Spring Boot без магии: контейнер и DI, автоконфигурация и стартеры, конфигурация и actuator на уровне понимания, а не копипасты. А там, где эта магия имеет измеримую цену — реальные числа с одного стенда: один и тот же минимальный REST-сервис, собранный на Spring Boot 3.5.3 и на Quarkus 3.22.3, на одном базовом JRE-образе.
Вводная статья серии уже давала ориентировочный диапазон Spring Boot vs Quarkus «на пальцах» (2–5 с / 200–400 МБ против 0.8–1.5 с / 80–150 МБ). Здесь — тот же вопрос вглубь: откуда берётся эта цена внутри Spring Boot, и измеренные числа конкретно на этой паре фреймворков.
В статье
- Контейнер и DI
- Автоконфигурация и стартеры
- Конфигурация: профили и внешние настройки
- Actuator и наблюдаемость
- Spring Boot vs Quarkus: startup и память на одном стенде
- GraalVM native: что уже показано, а что нет
- Когда Spring избыточен
Контейнер и DI
Spring Framework под Spring Boot — это прежде всего IoC-контейнер: объекты (bean’ы) создаёт и связывает не код приложения, а контейнер, по описанию зависимостей. У bean’а есть область видимости (scope) — по умолчанию singleton (один экземпляр на контекст), реже prototype (новый экземпляр на каждый запрос bean’а) или web-scoped (request, session) — и жизненный цикл: создание, внедрение зависимостей, @PostConstruct, использование, @PreDestroy при остановке контекста.
Внедрение зависимостей возможно тремя способами — через конструктор, через @Autowired-поле, через setter — но constructor injection де-факто дефолт с середины 2010-х, и не по вкусовой причине. Конструктор с обязательными параметрами делает невозможным создание объекта в невалидном состоянии (нет билдера-с-null’ами), позволяет объявлять зависимости final и явно палит циклические зависимости на этапе старта контекста — Spring бросает BeanCurrentlyInCreationException, а не создаёт proxy-заглушку, которая маскирует проблему до первого реального вызова. Field injection (@Autowired private Foo foo;) — устоявшийся антипаттерн: объект временно существует в невалидном состоянии до внедрения полей, конструктор без параметров не запрещает создать bean вручную мимо контейнера, а модульный тест без Spring-контекста не может подставить mock без рефлексии.
// Дефолт: constructor injection, зависимость обязательна и final
@Service
public class OrderService {
private final OrderRepository repository;
private final PaymentClient paymentClient;
public OrderService(OrderRepository repository, PaymentClient paymentClient) {
this.repository = repository;
this.paymentClient = paymentClient;
}
}Component scanning (@ComponentScan, включён неявно через @SpringBootApplication) находит классы с @Component/@Service/@Repository/@Controller по пакетам и регистрирует их как bean’ы автоматически. Альтернатива — явная @Configuration-конфигурация с @Bean-методами: полезна там, где создание объекта требует логики (сторонняя библиотека без Spring-аннотаций, выбор реализации по условию), а не там, где достаточно пометить свой класс аннотацией.
Демо-сервис стенда к этой статье (см. раздел про бенчмарк ниже) — намеренно однокласснный REST-контроллер без внедряемых зависимостей: DI ему демонстрировать нечего, весь код — один @RestController. Механика DI выше — про Spring Boot вообще, а не про конкретно этот минимальный стенд.
Автоконфигурация и стартеры
@SpringBootApplication — это три аннотации сразу: @Configuration (класс сам может объявлять bean’ы), @ComponentScan (сканирование пакета) и @EnableAutoConfiguration — именно она и есть источник «магии». Механизм в JDK 25 работает через META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports — Spring Boot при старте вычитывает этот файл из каждого JAR на classpath и подключает перечисленные @AutoConfiguration-классы. Каждый из них решает сам, применяться ему или нет, через condition-аннотации:
// Упрощённо: так устроены автоконфигурации Spring Boot внутри
@AutoConfiguration
@ConditionalOnClass(DataSource.class) // класс есть на classpath
@ConditionalOnMissingBean(DataSource.class) // bean ещё не создан вручную
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean
public DataSource dataSource(DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}Отсюда практическое следствие: автоконфигурация никогда не переопределяет явно объявленный bean того же типа — @ConditionalOnMissingBean уступает место коду приложения. Это и превращает «магию» в предсказуемый механизм: если нужен нестандартный DataSource, достаточно объявить свой @Bean — автоконфигурация молча отступит, а не будет конфликтовать.
Стартеры (spring-boot-starter-web, spring-boot-starter-actuator и подобные) — не код, а units зависимостей: POM-артефакт без собственных классов, который тянет за собой согласованный набор библиотек нужной версии через dependencyManagement родительского BOM. Стенд к этой статье использует ровно два стартера на Spring-стороне:
<!-- spring-vs-quarkus/spring/pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>spring-boot-starter-web подтягивает встроенный Tomcat, Jackson для JSON, spring-webmvc — и с ними приходит десяток автоконфигураций (ServletWebServerFactoryAutoConfiguration, DispatcherServletAutoConfiguration, JacksonAutoConfiguration и другие), каждая из которых что-то решает за вас условно. Именно это накопление автоконфигураций — прямая причина того, почему Spring Boot и «стартует не мгновенно, и весит не 10 МБ»: числа в разделе про бенчмарк ниже — не абстракция, а измеренное следствие ровно этого механизма.
Что реально сработало на старте — не нужно гадать: флаг --debug (или debug=true в конфигурации) печатает Condition Evaluation Report — построчный список, какие автоконфигурации применились (Positive matches), какие пропущены и почему (Negative matches, с точной причиной вроде DataSourceAutoConfiguration did not match: required class ... not found). Actuator даёт то же самое в рантайме, эндпоинтом — об этом ниже.
java -jar app.jar --debug
# ищите в выводе секцию:
# CONDITIONS EVALUATION REPORT
# Positive matches:
# DispatcherServletAutoConfiguration matched
# - @ConditionalOnClass found required class 'jakarta.servlet....' (OnClassCondition)
# Negative matches:
# DataSourceAutoConfiguration did not match
# - @ConditionalOnClass did not find required class 'javax.sql.DataSource' (OnClassCondition)Конфигурация: профили и внешние настройки
Внешняя конфигурация в Spring Boot — иерархия источников с чётким приоритетом: command-line аргументы перекрывают env-переменные, те — application-{profile}.yml, а он — базовый application.yml. Профили (spring.profiles.active=prod) переключают набор bean’ов и свойств без пересборки — @Profile("prod") на конфигурации или отдельный файл application-prod.yml поверх базового. Тот же принцип, что общий для всей платформы khorost.tech: секреты и environment-специфичные значения — через env vars, а не в файле, закоммиченном в git.
@ConfigurationProperties даёт типобезопасный доступ к сгруппированным настройкам вместо разрозненных @Value("${...}"):
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(
Duration timeout,
int retryAttempts,
URI gatewayUrl
) {}
// application.yml:
// app.payment.timeout: 5s
// app.payment.retry-attempts: 3
// app.payment.gateway-url: https://gateway.internal/payВ Spring Boot 3.x record-based @ConfigurationProperties сам по себе не сканируется component scan — классу нужен либо @EnableConfigurationProperties(PaymentProperties.class) на конфигурации, либо @ConfigurationPropertiesScan на классе приложения, иначе bean просто не создастся (иллюстративный сниппет, на стенде к статье не используется).
Fail-fast на старте — не отдельная фича, а следствие этой же типизации: если app.payment.timeout не парсится как Duration или обязательное поле отсутствует, контекст Spring не поднимется вовсе, с понятной ошибкой биндинга — а не полезет в код с null посреди обработки запроса.
На стенде к этой статье внешняя конфигурация сведена к минимуму намеренно — application.properties только переопределяет порт и путь health-эндпоинта (см. следующий раздел), профили не задействованы: сервис слишком мал, чтобы профили что-то демонстрировали содержательно.
Actuator и наблюдаемость
spring-boot-starter-actuator добавляет управляющие HTTP-эндпоинты поверх приложения: /actuator/health (готовность/живость), /actuator/metrics (через Micrometer — счётчики, таймеры, гистограммы, экспортируемые в Prometheus/OTEL), /actuator/env, /actuator/beans (список зарегистрированных bean’ов — тот же ответ на вопрос «что сконфигурировалось», что и Condition Evaluation Report, но в рантайме, а не только на старте). По умолчанию actuator живёт под префиксом /actuator/, а из всех эндпоинтов наружу торчит только /health — остальное нужно явно включать через management.endpoints.web.exposure.include, потому что /beans или /env в проде без аутентификации — прямая утечка внутреннего устройства сервиса.
Стенд к этой статье ремаппит health-эндпоинт с дефолтного /actuator/health на голый /health — не ради вкуса, а чтобы один и тот же скрипт замера опрашивал Spring и Quarkus по одинаковому пути:
# spring-vs-quarkus/spring/src/main/resources/application.properties
server.port=8080
# Выносим actuator health на /health (вместо дефолтного /actuator/health) --
# единый путь опроса для bench-inside.sh у обоих фреймворков (Quarkus сторона
# ремаппит /q/health -> /health тем же приёмом).
management.endpoints.web.base-path=/
management.endpoints.web.exposure.include=health
management.endpoint.health.enabled=true
# Меньше шума в логе при замере startup из bench-inside.sh.
logging.level.root=WARNHealth в Spring Boot составной: если в контексте есть bean DataSource (именно bean, а не просто класс на classpath — плюс JDBC-условия автоконфигурации), регистрируется DataSourceHealthIndicator, который реально пингует БД, и общий статус /health становится агрегатом всех активных индикаторов — тот же принцип автоконфигурации, что и везде в Spring Boot, применённый к диагностике. Kubernetes-специфичные livenessState/readinessState (раздельные пробы вместо одного общего /health) и graceful shutdown под оркестратором разобраны отдельно в статье «JVM в production» этой же серии — actuator даёт для них строительный блок, но не решает жизненный цикл пода целиком сам по себе.
Spring Boot vs Quarkus: startup и память на одном стенде
Дальше — не «на пальцах», а измерено. Стенд — два эквивалентных REST-сервиса: GET /hello отдаёт hello, GET /health отдаёт статус фреймворка, у обоих один и тот же путь /health (см. выше). Spring-сторона — это ровно тот же App.java, что и в разделе про DI:
// spring-vs-quarkus/spring/.../App.java
@SpringBootApplication
@RestController
public class App {
@GetMapping("/hello")
public String hello() {
return "hello";
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}Quarkus-сторона — эквивалентный JAX-RS ресурс на quarkus-rest + quarkus-smallrye-health:
// spring-vs-quarkus/quarkus/.../HelloResource.java
@Path("/hello")
public class HelloResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "hello";
}
}Ключевое условие честности сравнения — оба контейнера собраны на одном и том же базовом рантайм-образе, eclipse-temurin:25-jre. Разница в числах ниже приходится только на фреймворк, а не на разную ОС или JVM-сборку:
# spring-vs-quarkus/spring/Dockerfile
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]# spring-vs-quarkus/quarkus/Dockerfile — тот же базовый образ
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/quarkus-app/ /app/
CMD ["java", "-jar", "/app/quarkus-run.jar"]Startup и peak RSS измерялись внутри контейнера — тот же приём, что и в статье про сборку и упаковку этой серии: port-forwarder Docker Desktop на Windows штрафует первый коннект на десятки секунд, что для JVM, стартующей за секунды, полностью топит числа.
# bench-inside.sh — суть измерения
t0=$(date +%s%N)
eval "$START_CMD & echo \$! > /tmp/svc.pid"
PID=$(cat /tmp/svc.pid)
for _ in $(seq 1 4000); do
if exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null; then
printf 'GET /health HTTP/1.0\r\n\r\n' >&3
resp=$(head -1 <&3 2>/dev/null || true)
case "$resp" in *200*) ok=1; break;; esac
fi
sleep 0.002
done
t1=$(date +%s%N)
startup_ms=$(( (t1 - t0) / 1000000 ))
# peak RSS — VmHWM (историчный пик резидентной памяти) из /proc/$PID/status
rss_kb=$(awk '/VmHWM/{print $2}' "/proc/$PID/status")Результаты — характерный прогон, 10 запусков на фреймворк (канонические 5 + 5 подтверждающих):
| Фреймворк | startup (мс) | peak RSS (МБ) | размер образа (МБ) |
|---|---|---|---|
| Spring Boot 3.5.3 (JVM) | 2830–3858 (~3350 типично; выброс 6206 исключён) | 306–344 (~326 среднее) | 131.5 |
| Quarkus 3.22.3 (JVM) | 1168–1442 (~1270 типично; выброс 4019 исключён) | 140–142 (~141 типично) | 125.1 |
Quarkus стартует примерно в 2.6 раза быстрее и держит примерно в 2.3 раза меньше peak RSS, чем Spring Boot, в JVM-режиме — ассерт подтверждён, и оба числа не выдуманы под ожидание, а получены с одного и того же стенда.
Про аудируемость среднего RSS Spring Boot. ~326 МБ — не абстрактное «типично», а среднее ровно из пяти показанных значений канонического прогона: 306.2 / 317.7 / 329.7 / 330.9 / 343.8 МБ. Это намеренно проверяемое число: пересчитать его может кто угодно, сложив пять цифр и поделив на пять, а не поверить на слово.
Про выбросы — оба не спрятаны. У каждого фреймворка — по одному выбросу startup: Spring Boot дал 6206 мс, Quarkus дал 4019 мс. Оба встретились по одному разу и не повторились в пяти дополнительных прогонах на каждый фреймворк. Для выброса Quarkus RSS отдельно проверен и остался в обычном диапазоне (142.2 МБ) — аномалия только во времени старта, не в памяти. Оба значения — похоже на разовый шум планировщика Docker Desktop на конкретном хосте, а не системная особенность фреймворка, но они честно исключены из «типичных» цифр таблицы, а не подогнаны задним числом.
Про верхнюю границу диапазона Quarkus. Вводная статья серии даёт ориентировочный диапазон Quarkus JVM 80–150 МБ — измеренные ~141 МБ формально внутри, но у самой верхней границы. Это не повод разочароваться в «лёгкости» Quarkus: даже минимальный GET /hello + health-эндпоинт уже тянет за собой quarkus-rest (RESTEasy Reactive поверх Vert.x) и quarkus-smallrye-health — Vert.x, Netty и SmallRye на classpath стоят памяти сами по себе. Голый Quarkus без REST-стека был бы ближе к нижней границе диапазона; сервис с реальным HTTP-эндпоинтом — нет, и это честная картина, а не недостаток замера.
GraalVM native: что уже показано, а что нет
Естественный следующий вопрос — а что с GraalVM native image конкретно для Spring Boot и для Quarkus? В этом прогоне ответа нет: native-сборка обеих сторон сознательно не выполнялась — по объёму работы это отдельная задача, не довесок к JVM-сравнению. Причины пропуска асимметричны для двух фреймворков. Quarkus native технически проще (quarkus.native.container-build=true без локального GraalVM), но добавляет ещё один build в несколько минут поверх уже собранного JVM-стенда. Spring Boot native заметно тяжелее: требует AOT-обработки (spring-boot:process-aot) и либо buildpacks, либо ручной GraalVM native-image plugin с риском упереться в reflection-конфиги для spring-boot-starter-web и actuator — замкнутый мир классов Substrate VM плохо совместим с автоконфигурацией, построенной на условной логике и рефлексии по умолчанию.
Общий эффект AOT-компиляции GraalVM — не выдуманный, а измеренный, просто на другом сервисе: в статье «Сборка и упаковка JVM» этой серии тот же тулчейн (ghcr.io/graalvm/native-image-community:25) даёт на минимальном plain-Java HTTP-сервисе (com.sun.net.httpserver, без Spring и без Quarkus) ~12 мс startup и ~16 МБ RSS против ~150 мс/~65 МБ у обычного fat-jar на том же eclipse-temurin:25-jre. Порядок эффекта — единицы-десятки миллисекунд и единицы-десятки мегабайт вместо сотен — это то, ради чего Spring Boot и Quarkus вообще поддерживают native-режим. Но конкретные числа Spring Boot native и Quarkus native здесь не приведены, потому что не измерены на этом стенде — переносить цифры с голого HTTP-сервиса на полноценный фреймворк с автоконфигурацией и DI было бы нечестно: у обоих фреймворков в native-режиме к самому AOT-эффекту добавляется собственная цена (build-time DI Quarkus работает иначе, чем JVM-режим; Spring Boot AOT генерирует дополнительный код), и без прямого замера эта разница осталась бы гаданием.
Когда Spring избыточен
Спрашивать «Spring Boot или Quarkus» имеет смысл только вместе с вопросом «зачем вообще этот стек». Spring Boot оправдан, когда экосистема — не опция, а требование: Spring Data, Spring Security, Spring Cloud, десятки готовых интеграций, огромный рынок найма и answered-вопросов под любую задачу. Цена — измеренная выше: больше времени на старт, больше памяти под простаивающий процесс, больше поверхности «магии», которую нужно уметь читать через Condition Evaluation Report и actuator, а не принимать на веру.
Quarkus (или более минималистичный Ktor на Kotlin — сравнение подходов «полновесный фреймворк vs минимальное ядро» на разных языках разбирает статья «Веб-фреймворки в разных экосистемах»Скоро) уместен, когда цена Spring Boot ощутима практически: много мелких сервисов с ограниченным лимитом памяти на под, serverless с оплатой по cold start, команда, которая готова обменять часть экосистемы на build-time DI и меньший рантайм-footprint. Между «полновесно и привычно» и «минимально и быстро» нет универсально правильного ответа — есть конкретная цена, которую в этой статье наконец можно посчитать, а не оценить на глаз.
Документация и первоисточники
- Spring Boot Reference
- Spring Framework
- Spring Boot: Auto-configuration
- Spring Boot Actuator
- Micrometer
- Quarkus Guides
- Quarkus SmallRye Health
- Стенд:
digital-cookbook/java/deep-dive/spring-vs-quarkus(Spring Boot 3.5.3 + Quarkus 3.22.3,bench.sh/bench-inside.sh)
Комментарии