Нативна Java: кога да, кога не и кое е подходящо в cloud native среда
От Dorian Chávez · основател на Hábil и архитект по интеграция ·
GraalVM, Quarkus, Spring Boot, кеш за стартиране на Java 25 и CRaC: какво печели и какво струва всяка опция и коя е подходяща за Вашата услуга и облак.
Почти във всеки разговор за микроуслуги на Java се появява един и същ въпрос: да ги компилираме ли като нативни? Обещанието звучи неустоимо: услуги, които стартират за част от времето и ползват част от паметта. И то е вярно, но с дребен шрифт, който почти никога не се чете: това, което се печели при стартирането и паметта, се плаща с производителност при обработка, с време за компилация и със сложност при експлоатация.
Тази статия е за онзи, който решава как се изграждат и как работят Java услугите на една банка, една застрахователна компания, една търговска верига или всяка друга регулирана компания. Тя отговаря на три въпроса: кога е подходяща нативната компилация и кога не, какво да направите, ако услугата Ви е нова или вече работи на Spring Boot, и кое е подходящо за cloud native архитектура.
Първо, какво означава «нативна»
Обикновена Java услуга работи върху JVM, виртуалната машина на Java. Тя стартира, зарежда своите класове и, докато обслужва заявки, вътрешен компилатор (JIT) оптимизира кода, който се използва най-често. Затова една Java услуга се «загрява»: първите секунди е бавна, а после е много бърза.
Компилирането до нативен код —с GraalVM Native Image— върши цялата тази работа предварително, при изграждането. Резултатът е изпълним файл, който вече не се нуждае от JVM: стартира много по-бързо и ползва много по-малко памет. В замяна компилаторът трябва да знае предварително всичко, което програмата може да прави (това се нарича «затворен свят»), и губи способността да продължава да оптимизира според реалното натоварване.
Между тези две крайности има междинни варианти, които си струва да се сравнят, преди да се реши:
- Кешът за стартиране на Java (проект Leyden). Java 24 въведе предварителното зареждане и свързване на класове, а Java 25 добави ергономията и профилите; с тях JVM може да запише във файл работата по зареждане и свързване на класове и профилите от едно тренировъчно изпълнение, и да я използва повторно при стартиране. Кешът зависи от приложението, JDK, операционната система и архитектурата. Това си остава обичайната JVM с нейния JIT; стартира по-бързо и в лабораторията на Quarkus ползва и по-малко памет [1][2][3].
- Запазване и възстановяване на вече загрят процес. CRaC, проект на OpenJDK, наличен в някои дистрибуции на Java, и AWS Lambda SnapStart правят «снимка» на вече стартираното приложение и я възстановяват. CRaC може да възстановява много бързо; SnapStart може да свали студения старт на Lambda под една секунда при благоприятни условия (данни на AWS; зависят от приложението, платформата и натоварването), със своите изисквания към състоянието, връзките и идентификационните данни [4][5].
Какво показват измерванията
Най-пълното публично сравнение на трите модалности го публикува самият проект Quarkus в своето официално ръководство, с една тестова услуга [6]. Това е лаборатория на доставчик и с конкретно натоварване, затова се чете като порядък на величините, а не като обещание:
| Модалност | Време до първата заявка | Пиков капацитет (заявки в секунда) | Резидентна памет (RSS) |
|---|---|---|---|
| Обикновена JVM | ~4,4 с | ~13 300 | ~304 MiB |
| JVM с кеш за стартиране (Leyden) | ~1,9 с | ~12 400 | ~240 MiB |
| Нативна (GraalVM) | ~0,6 с | ~5 400 | ~95 MiB |
Данни от лабораторията на проекта Quarkus (доставчик): изпълнение от 21 април 2026 г. с Quarkus 3.34.3, JDK 25.0.2, GraalVM 25.0.2, 4 процесора и `-Xmx512m`. Резидентната памет е RAM, която процесът държи заета; пиковият капацитет е максималният капацитет при това натоварване, а не този на Вашата продукционна среда; а времето до първата заявка не е времето за внедряване, нито латентността, която усеща Вашият клиент.
Три извода, които са по-важни от числата:
- В тази лаборатория нативната версия стигна до първата заявка около седем пъти по-рано и ползва около една трета от паметта.
- Но обработи около 40 % от заявките в секунда, които обработи JVM. При дългоживееща услуга с постоянно натоварване JVM може да даде по-висока устойчива производителност, защото нейният JIT оптимизира според това, което наистина се случва в продукция; Oracle GraalVM предлага оптимизация, водена от профили (PGO), за да скъси тази разлика, но тя не е налична в редакцията Community: потвърдете редакцията и лиценза, защото тази лаборатория не я е измервала [6][7]. Резултатът зависи от натоварването, процесора, събирача на памет и конфигурацията.
- Кешът за стартиране запазва голяма част от предимството, срещу малка цена: стартиране над два пъти по-бързо, почти същата производителност и, тук, памет от 304 на 240 MiB (ефектът може да варира, а кешът добавя около 198 MB към артефакта). OpenJDK ползва примерното приложение на Spring (PetClinic) като казус за кеша; никой казус не заменя измерването на Вашата услуга [1][3].
И една празнина, която е честно да се каже: не намерихме нито едно публично и независимо измерване, което да сравнява трите модалности по латентността в най-лошия случай (p99: латентността, под която попадат 99 % от заявките; показва опашката, а не абсолютния максимум). Съществуващите са на доставчици, с техните натоварвания. Единственият сериозен начин да се реши е да се измери с Вашето.
Времената, които имат значение: компилиране, стартиране, готовност и загряване
Когато се говори за «бързо», се смесват четири различни времена и е добре да се разделят:
| Време | Обикновена JVM | JVM с кеш за стартиране | Нативна |
|---|---|---|---|
| Компилиране на услугата | секунди (около 30 с в примера на Quarkus) | секунди, плюс едно тренировъчно изпълнение за създаване на кеша | от 3 до 10 минути и от 4 до 8 GB памет [6] (данни на доставчик; зависят от приложението, платформата и натоварването) |
| Стартиране до обслужване на първата заявка | ~4,4 с | ~1,9 с | ~0,6 с [6] |
| Готовност за приемане на трафик | колкото отнеме стартирането и свързването със зависимостите | същото, но стартира по-рано | същото, но стартира по-рано |
| Загряване до най-добрата производителност | докато JIT оптимизира според реалното натоварване | част от загряването вече е в кеша [2] | няма загряване: стига директно до своята производителност, която в тази лаборатория е по-ниска [6] |
Две практически последици. Времето за компилиране го плаща Вашият екип при всяка промяна и всяка корекция, не клиентът; а «готовността» почти никога не зависи само от стартирането на Java: свързването с базата данни, с брокера на съобщения или с доставчика на идентичност обикновено тежи колкото него или повече.
Пробите на Kubernetes: където стартирането се превръща в реален проблем
Kubernetes решава дали една услуга е жива и дали може да приема трафик с три проби [17]:
- Startup (приключило ли е стартирането?). Докато не мине, другите две не се оценяват. Времето, което се допуска за стартиране, е броят на опитите по интервала между тях (
failureThreshold × periodSeconds). Тази проба съществува точно за услугите, които стартират бавно. - Liveness (жив ли е още процесът?). Ако се провали, Kubernetes рестартира контейнера.
- Readiness (може ли да приема трафик в момента?). Ако се провали, Kubernetes спира да му изпраща заявки, без да го рестартира.
С разширението SmallRye Health Quarkus ги предоставя на /q/health/live, /q/health/ready и /q/health/started [19]; Spring Boot — на /actuator/health/liveness и /actuator/health/readiness, които се активират автоматично при внедряване в Kubernetes [20].
Илюстративен пример за услуга на JVM, която стартира за около 5 секунди:
startupProbe:
httpGet: { path: /q/health/started, port: 8080 }
periodSeconds: 2
failureThreshold: 30 # допуска до 60 s за стартиране
livenessProbe:
httpGet: { path: /q/health/live, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /q/health/ready, port: 8080 }
periodSeconds: 5При нативна услуга, с половин секунда стартиране, пробата за startup почти не чака; при JVM тя е онази, която не позволява на клъстера да «убие» услугата по средата на качването. Една услуга, която стартира бавно, няма нужда от нативна компилация, за да преживее внедряването: нужна ѝ е добре поставена проба за startup. Тази проба предотвратява преждевременните рестартирания, но не скъсява времето до готовност: ако целта за мащабиране изисква готовност по-рано, кешът за стартиране или нативната компилация остават варианти за измерване.
Двете грешки, които виждаме най-често:
- Проба за liveness, която проверява базата данни или външна услуга. Ако тази зависимост падне, Kubernetes рестартира всички инстанции едновременно и превръща външен проблем в собствен срив. Документацията на Spring Boot предупреждава за това с тези думи [20]. Пробата за liveness трябва да отговаря само «процесът е жив».
- Липса на проба за startup, компенсирана с фиксирано забавяне при liveness. Ако един ден стартирането се забави — претоварен възел, бавна зависимост — услугата влиза в цикъл от рестартирания.
Пробата за readiness може да взема предвид зависимостите, но с преценка: ако без базата данни услугата не може да отговори нищо полезно, е добре да се изведе от трафика; ако може да отговаря частично, по-добре да остане и грешката да се обработи по-нагоре [20].
Колко памет иска една Java услуга в контейнер и защо
Обичайно е да се срещат услуги на Spring Boot, които искат около 500 MB на контейнер, макар логиката им да е малка. Това не е недостатък на Spring: това е сборът от онова, което JVM се нуждае, за да живее:
- Heap-ът, където живеят обектите. Ако не е конфигуриран, JVM взема най-много една четвърт от паметта, която открива [22], а в контейнер тя открива лимита на контейнера [23].
- Заредените класове (metaspace). Рамка с много функции зарежда хиляди класове.
- Кодът, вече оптимизиран от JIT (code cache).
- Стек за всяка нишка. Уеб сървър с голям пул от нишки натрупва памет, дори когато тези нишки чакат.
- Буфери и самият garbage collector.
Затова в Kubernetes значение има не размерът на heap-а, а общата памет на процеса (онова, което системата вижда като RSS), и лимитът на контейнера трябва да я покрива с резерв. Ако не, Kubernetes убива контейнера заради липса на памет, дори heap-ът да изглежда здрав.
Преди да се премине към нативна компилация, има настройки, които намаляват паметта, без да се сменя моделът: фиксиране на процента памет, който взема heap-ът, оразмеряване на пуловете от нишки според това, което услугата наистина обслужва, и избор на garbage collector според размера на контейнера. Кешът за стартиране, напротив, ускорява стартирането и в лабораторията на Quarkus свали паметта от 304 на 240 MiB (~21 %); ефектът може да варира, а кешът увеличава размера на артефакта [24].
Там, където нативната компилация променя сметките, е плътността. Илюстративна сметка: на възел с 16 GB, налични за услуги, се побират около 32 контейнера по 500 MB или около 160 по 100 MB. Ако Вашата сметка за облак или Вашите собствени възли се определят от паметта и имате десетки малки услуги, тази разлика плаща нативната компилация. Ако имате малко големи услуги с постоянно натоварване, не я плаща.
Какво струва нативната компилация и почти никой не пише в предложението
- Изграждането отнема време и тежи. Ръководството на Quarkus оценява от 3 до 10 минути и от 4 до 8 GB памет за нативна компилация (данни на доставчик; зависят от приложението, платформата и натоварването), срещу няколко секунди при обикновената [6]. Това се повтаря при всяка промяна, всяка корекция на Java и всяка нова зависимост и си личи в pipeline-а.
- «Затвореният свят» чупи неща безшумно. Всичко, което програмата открива по време на изпълнение —рефлексия, прокси обекти, сериализация, динамично зареждане на класове, ресурси— трябва да бъде декларирано. Иначе двоичният файл може да се компилира и да се провали при изпълнение на динамичен път, който никой не е тествал; затова нативният пилот трябва да включва интеграционни тестове на двоичния файл, а не само на кода [8].
- При Spring Boot, когато се включи обработката AOT или нативният режим, някои решения се замразяват при компилацията. Профилите и свойствата, които променят кои компоненти се създават, вече не могат да се сменят при стартиране; идентификационните данни и адресите могат [9].
- Диагностиката е различна. JFR, записът на събития, който един екип ползва ежедневно в JVM, съществува и в нативния режим, но е изключен и трябва да се включи при компилацията [10]; същото важи и за други диагностични инструменти. Проверете предварително дали Вашата експлоатация има всичко необходимо, за да разследва инцидент.
- В регулирана компания нативната компилация добавя неща, които трябва да се управляват: компилаторът и неговата версия, метаданните, инвентарът на компонентите (SBOM) и тестовете на двоичния файл — всичко проследимо по версия. А при уязвимост в зависимост или в JDK не е достатъчно да се актуализира: трябва двоичният файл да се прекомпилира, да се тества отново и да се пусне отново. Тя не намалява работата по сигурността; сменя ѝ мястото.
Ако услугата Ви е нова: Quarkus?
За нова услуга правилният въпрос не е «Quarkus или Spring?», а «какъв вид услуга е това?».
- Quarkus е роден за това. Решава почти всичко при компилацията и неговите разширения декларират дали са съвместими с нативния режим [11]; въпреки това съвместимостта се проверява зависимост по зависимост. Ако услугата е малка, ще се мащабира до нула или живее с малко памет, нативният Quarkus може да намали стартирането и паметта, когато необходимите разширения са съвместими; ръководството на Quarkus препоръчва да се започне с JVM и да се премине към нативна компилация при конкретна нужда. А ако после се окаже, че е по-удобна JVM, Quarkus работи много добре и върху нея.
- Spring Boot си остава чудесна опция, когато екипът вече го владее, когато услугата зависи от библиотеки от неговата екосистема или когато логиката е сложна и дългоживееща. От версия 3 поддържа нативния режим официално, а днес предлага и кеша за стартиране на Java 25 [9][12].
- Micronaut и Helidon също предлагат нативни пътища; съвместимостта се проверява по зависимости и по случай на употреба [13][14].
Ако екипът Ви знае Spring Boot: кога му е изгодно да премине към Quarkus?
Това е най-честият въпрос и честният отговор започва с онова, което не се вижда в едно сравнение: цената на това да имате две рамки. Два начина за конфигуриране, за тестване, за мониторинг и за наемане на хора. Екип, който владее Spring Boot, вече е продуктивен, а тази продуктивност струва повече от няколко секунди стартиране при услуга, която живее седмици без рестартиране.
Останете на Spring Boot, когато:
- Услугата живее дълго с постоянно натоварване: там JVM може да е по-производителна и стартирането тежи малко; проверете го с Вашия профил на трафика.
- Зависи от библиотеки от екосистемата на Spring, които нямат пряк еквивалент.
- Кешът за стартиране на Java 25 вече Ви решава проблема с времената [12].
Преминаването към Quarkus е изгодно, когато:
- Има нови услуги, които ще се мащабират до нула, ще работят като функции или ще живеят с много малко памет, и нативната компилация наистина си плаща цената.
- Плътността на клъстера е реален проблем за разходите: много малки услуги, които не се побират във възлите.
- Екипът има възможност да учи и се започва с една пилотна услуга, а не с миграция.
Какво трябва да се научи наново, казано от опит. Преминаването към Quarkus не е смяна на анотации. Променя се начинът на инжектиране на зависимости (CDI вместо контейнера на Spring), начинът на достъп до данни (Panache, със свой стил на entity класове и хранилища, вместо Spring Data), REST слоят, конфигурацията и цикълът на разработка. На нас ни се наложи да научим наново голяма част от онова, което смятахме за известно. Заслужава си, когато видът на услугата го изисква; не заслужава си за всичко.
Какво улеснява прехода и какво не. Quarkus включва слой за съвместимост с най-използваните анотации на Spring —инжектиране на зависимости, уеб контролери, Spring Data JPA, свойства, транзакции— за да бъде един екип на Spring продуктивен още от първия ден [21]. Но това е мост, а не копие: не поддържа @Conditional и @ComponentScan, защото Quarkus разрешава зависимостите при компилацията, а самото ръководство препоръчва с времето да се премине към стандартните анотации на CDI [21]. Голяма услуга на Spring не се «преобразува» в Quarkus; тя се пренаписва спокойно или остава където е.
Правилото, което прилагаме: не се сменя рамка по мода или заради един бенчмарк. Сменя се, когато определен вид услуга го оправдава с числа, и се започва с една.
Ако вече имате Spring Boot: не прескачайте направо към нативна компилация
Директното прехвърляне към нативен режим на работеща услуга на Spring, «защото стартира по-бързо», добавя риск, когато съвместимостта на нейните зависимости не е проверена. Редът, който препоръчваме:
- Актуализирайте първо. Java 25 LTS —или по-нова версия, която Вашата организация е валидирала— и текущата версия на Spring Boot могат да донесат подобрения в стартирането и паметта; проверете ги с функционални и експлоатационни тестове.
- Включете кеша за стартиране. Spring Boot го документира като препоръчана опция от Java 25 нататък [12]. Изисква поток за тренировка и валидиране на артефакта (същото приложение и същата версия на Java). Това е промяна с по-нисък риск от нативната и при много услуги може да е достатъчна.
- Оценете CRaC само ако платформата Ви го поддържа и екипът Ви може да се грижи за онова, което изисква: затваряне и повторно отваряне на връзките, обновяване на идентификационните данни и непоставяне на тайни вътре в снимката [4][15].
- Преминете към нативен режим само с измерен пилот в услугата, където паметта или стартирането наистина струват пари.
Кое е подходящо в cloud native среда
«Cloud native» не означава «нативен». Означава проектиране на услуги, които се мащабират, възстановяват и внедряват автоматично. Кое е подходящо, зависи от начина, по който живее всяка услуга:
| Вид услуга | Кое е подходящо | Защо |
|---|---|---|
| Функции и услуги, които се мащабират до нула | Нативна, или SnapStart, ако работите в AWS Lambda и проверите ограниченията му | Стартирането се плаща при всяка студена заявка [5][16] |
| Дългоживеещи услуги с постоянно натоварване | JVM, с кеш за стартиране | JIT дава по-висока устойчива производителност [6] |
| Непредвидими пикове на трафика | Нативна за инстанциите, които се добавят в пика | Могат да скъсят времето до готовност; измерете го, включително връзките и зависимостите |
| Много малки услуги в един клъстер | Нативна, ако паметта е онова, което ограничава плътността | Във всеки възел се побират повече услуги |
| Инструменти от командния ред и кратки процеси | Нативна | Няма време за загряване |
В Kubernetes има един практически детайл: услуга, която стартира бавно, се нуждае от проба за стартиране с достатъчно време; иначе клъстерът я рестартира, преди да е приключила да се вдигне [17]. Кешът за стартиране и нативният режим намаляват този проблем; проба за startup, която покрива най-лошото стартиране, избягва рестартиранията, макар да не превръща бавната услуга в готова.
Какво не се поддържа
- «Нативната винаги е по-бърза.» Стартира по-рано; при устойчиво натоварване JVM обикновено обработва повече.
- «Нативната решава латентността в най-лошия случай.» Опашката на латентността продължават да определят garbage collector-ът, мрежата, пуловете от връзки и външните зависимости.
- «Компилира се без промени.» Само ако всичките ѝ зависимости вече са подготвени за затворения свят.
- «По-малко памет е по-малко разходи.» Трябва да се добавят процесорното време на заявка, pipeline-ът, размерът на артефакта и експлоатацията.
- «Leyden вече заменя нативната.» Още не: запазва JVM, а предварителната компилация на код (JEP 544) е в състояние на кандидат, не е налична във вече пусната версия на JDK [18].
От къде да започнете
- Класифицирайте услугите си според начина, по който живеят: функции, дългоживеещи услуги, пикове, инструменти.
- Измервайте, преди да решавате: стартиране, памет, заявки в секунда и латентност в най-лошия случай, с Вашето реално натоварване.
- Първо опитайте евтиното: актуализирайте Java и включете кеша за стартиране.
- Направете нативен пилот само там, където стартирането или паметта струват пари, с реалните си зависимости и своя pipeline.
- Определете критерия за излизане, преди да започнете: ако пилотът не подобри метриката, която Ви боли (памет, стартиране или разходи) достатъчно, за да плати своята сложност, услугата остава на JVM.
- Решавайте с числата в ръка и оставете записано защо.
Диагностиката не започва с миграция. Класифицираме кандидат-услугите, измерваме стартиране, памет, капацитет и латентност в най-лошия случай с Вашето натоварване, преглеждаме зависимостите и контролите за експлоатация и Ви предаваме проследимо решение за всяка услуга: къде е подходяща JVM, кешът за стартиране, CRaC или нативната компилация, какъв риск остава и какъв би бил минималният пилот. Така решавате с доказателства, преди да обвържете платформата си.
Referencias
- OpenJDK, JEP 483, Ahead-of-Time Class Loading & Linking (JDK 24). https://openjdk.org/jeps/483
- OpenJDK, JEP 514 и JEP 515 (JDK 25). https://openjdk.org/jeps/514 · https://openjdk.org/jeps/515
- OpenJDK, Project Leyden. https://openjdk.org/projects/leyden/
- OpenJDK, CRaC. https://github.com/openjdk/crac
- AWS, Lambda SnapStart. https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html
- Quarkus, ръководство Building a Native Executable (версия 3.40), сравнение на JVM, AOT кеш и нативна версия от лабораторията на проекта. https://quarkus.io/version/3.40/guides/building-native-image/
- GraalVM, Optimizations and Performance. https://www.graalvm.org/jdk25/reference-manual/native-image/optimizations-and-performance/
- GraalVM, Dynamic Features (рефлексия, прокси обекти, ресурси). https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/
- Spring Boot, Ahead-of-Time Processing и GraalVM Native Images. https://docs.spring.io/spring-boot/reference/packaging/aot.html · https://docs.spring.io/spring-boot/reference/packaging/native-image/introducing-graalvm-native-images.html
- GraalVM, JFR in Native Image. https://www.graalvm.org/latest/reference-manual/native-image/debugging-and-diagnostics/JFR/
- Quarkus, версии и поддръжка (3.40 LTS). https://quarkus.io/blog/quarkus-3-40-released/
- Spring Boot, AOT Cache. https://docs.spring.io/spring-boot/reference/packaging/aot-cache.html
- Micronaut, документация (5.2). https://docs.micronaut.io/5.2.x/core/
- Helidon, Native Image. https://helidon.io/docs/v4/mp/guides/native-image
- Spring Boot, Checkpoint and Restore. https://docs.spring.io/spring-boot/reference/packaging/checkpoint-restore.html
- AWS, Reducing Java cold starts on AWS Lambda functions with SnapStart. https://aws.amazon.com/blogs/compute/reducing-java-cold-starts-on-aws-lambda-functions-with-snapstart/
- Kubernetes, Configure Liveness, Readiness and Startup Probes. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- OpenJDK, JEP 544, Ahead-of-Time Code Compilation (предложен). https://openjdk.org/jeps/544
- Quarkus, SmallRye Health. https://quarkus.io/guides/smallrye-health
- Spring Boot, Actuator: Kubernetes Probes. https://docs.spring.io/spring-boot/reference/actuator/endpoints.html
- Quarkus, Quarkus Extension for Spring DI API и ръководства за съвместимост със Spring. https://quarkus.io/guides/spring-di
- Oracle, Java SE 25 GC Tuning Guide: Ergonomics (максимален heap по подразбиране, 1/4 от физическата памет). https://docs.oracle.com/en/java/javase/25/gctuning/ergonomics.html
- Oracle, The java Command (откриване на контейнери,
UseContainerSupport). https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html - Quarkus Performance Lab, изпълнения на JVM, AOT кеш и нативна версия (паметта е от 304 на 240 MiB с AOT кеш); вж. [6].
- Облак и инфраструктура →
- Модернизиране на основната система без спиране на бизнеса →
- Пускайте версии без страх в собствената си инфраструктура →
Вашата дейност среща ли тези предизвикателства?
Предпочитате имейл? Пишете ни на hola@habil.mx