РозробкаБудь-який рівень

Java

Java вже тридцять років є типовою мовою великих бекенд-систем і досі та, яку бачиш, коли банк, авіакомпанія чи телеком відкриває своє дерево коду. Java робить собою не так синтаксис, як машина, на якій вона працює: байт-код замість машинного коду, компілятор, що оптимізує під час роботи програми, і збирач сміття, який налаштовують, а не замінюють. На цій сторінці — JVM, система типів, Spring, паралельність і те, що змінили віртуальні потоки.

Артефакт
Байт-код, не машинний
Пам’ять
Збирається, не звільняється
Релізи
Кожні 6 місяців, LTS раз на 2 роки

Машина під сподом

Java не компілюється в машинний код. Вона компілюється в байт-код — інструкції абстрактної машини, — а JVM під вашу платформу перетворює їх на щось зрозуміле процесору. Ця опосередкованість і породила старе гасло про «написати раз і запускати всюди», і саме через неї той самий артефакт збірки без змін працює і на ноутбуці розробника, і на Linux-сервері.

Java компілюється в байт-код, а не в машинний код, тож артефакт усюди однаковий, а різниться саме JVM під кожну платформу. Усередині інтерпретатор виконує кожен метод, а JIT перекомпільовує в машинний код те, що стало гарячим, — тому сервіс повільний на перших тисячах запитів і швидкий далі. Об’єкти лягають у купу, якою керує середовище виконання: більшість помирає молодими й збирається дешево, ті, що вижили, переходять у старе покоління, і саме його збирання дає паузи.

Усередині JVM спершу інтерпретує цей байт-код — це повільно — і водночас рахує, як часто виконується кожен метод. Коли метод переходить поріг, JIT-компілятор перекладає його в машинний код, і, оскільки він компілює програму, за якою може спостерігати, він оптимізує за фактами, яких статичний компілятор не має ніколи: яку гілку насправді обирають, який тип реально приходить у це місце виклику, який цикл гарячий. Тому Java-сервіс помітно повільніший на перших тисячах запитів, а потім вирівнюється, — і тому бенчмарк на дві секунди не каже про прод нічого.

Друга робота JVM — це пам’ять. Об’єкти виділяються в купі й звільняються збирачем сміття, а не вами, і це прибирає весь клас помилок use-after-free та подвійного звільнення, що панують у C і C++. Збирач спирається на одне сильне спостереження: більшість об’єктів гине майже одразу. Тому купу поділено на покоління: нові об’єкти потрапляють у молоде покоління, яке збирають часто й дуже дешево, а ті нечисленні, що вижили, переходять у старе покоління, яке збирають рідко й дорого. Довгі паузи йдуть саме зі старого покоління, і тому «зменшити сміття» зазвичай означає «перестати підвищувати об’єкти, які мали померти молодими».

Два наслідки визначають, як Java розгортають сьогодні. Старт не миттєвий — JVM має завантажити класи, перевірити їх і прогрітися, — що незручно для функції, яка масштабується до нуля, і байдуже для сервісу, що працює тижнями. А коли старт таки важить, GraalVM native image компілює заздалегідь у справжній виконуваний файл, який стартує за мілісекунди, ціною втрати оптимізацій JIT під час роботи й потреби конфігурувати все, що користується рефлексією.

Мова і скільки її вам треба

Java статично типізована й компільована, тож помилка в назві методу чи не той аргумент — це збій збірки, а не сторінка, що ламається в одного користувача в п’ятницю. Система типів іменна й класова: усе живе в класі, спадкування одиничне, а множинне успадкування поведінки дають інтерфейси, чого класи не дають. Ця багатослівність — угода, на яку мова йшла завжди, і сучасні версії тихо забрали з неї чимало.

  • Записи

    Носій даних в одному рядку: поля, конструктор, аксесори, equals, hashCode і toString згенеровано, і він незмінний за побудовою.

  • Запечатані типи

    Ієрархія, що називає власні підтипи, тож компілятор знає, що список закритий, і може перевірити switch на повноту.

  • Зіставлення зі зразком

    Перевірити тип і зв’язати його одним кроком, у instanceof і в switch, — заміна приведенню після кожної перевірки.

  • Стріми

    Декларативна обробка колекцій: filter, map, collect. Читабельно для конвеєра й гірше за цикл для двох операцій.

  • Optional

    Явний тип повернення «може бути відсутнім». Беріть для повернень; не беріть для полів і параметрів — там він лише шум.

  • var

    Локальне виведення типу. Скорочує оголошення, тип якого очевидний із правого боку, і ховає його, коли неочевидний.

Слово про Kotlin, бо це питання виникає завжди. Він працює на тій самій JVM, напряму викликає Java-бібліотеки й виправляє дві головні претензії до Java: можливість null живе в системі типів, а не у вашій голові, і шаблонного коду немає. Для Android це типовий вибір, для нового сервісу — розумний. Це не привід переписувати робочу Java-базу, а змішування обох в одному модулі — витрата на конфігурацію збірки, на яку варто йти свідомо.

Spring і що тут робить фреймворк

Spring на сервері майже універсальний, і по суті він дає впровадження залежностей: ваші класи оголошують у конструкторах, що їм потрібно, а контейнер це постачає. Звучить бюрократично, доки не помічаєш наслідку: класу, який отримує своїх співробітників, у тесті можна дати підроблених, — і бізнес-логіка стає тестованою без бази даних, мережі й запущеного сервера. Spring Boot додає до цього автоконфігурацію з думкою й вбудований сервер, тож застосунок є виконуваним файлом, а не чимось, що розгортають у контейнер сервера застосунків.

ШарЩо робитьДе кусає
Контейнер DIБудує й зв’язує ваші об’єкти, керує їхнім життєвим циклом і областю.Ін’єкція в поле ховає залежності; просіть їх у конструкторі.
Spring MVC / WebFluxМапить HTTP на методи. MVC блокує на запит, WebFlux реактивний.WebFlux окупається, лише якщо неблокуючий увесь ланцюг, разом із драйвером.
Spring Data / JPAМапить об’єкти на таблиці й виводить запити з імен методів.Ліниве завантаження перетворює один виклик на N запитів; чесний свідок — лише лог SQL.
ТранзакціїАнотація відкриває й фіксує транзакцію навколо методу.Виклик зсередини того самого класу оминає проксі — і анотація не робить нічого.

Потоки і що змінили віртуальні

Потік у Java завжди був справжнім потоком операційної системи. Це дає справжню паралельність по ядрах — на відміну від PHP, Node чи CPython, Java може завантажити всі ядра в одному процесі — і коштує близько мегабайта стека на потік, тому сервери тримають їх у пулі. Класичний Java-вебсервер дає потік на запит, і межа його паралельності — це розмір пулу: двісті потоків означають двісті запитів у польоті, хоч би більшість із них лише чекала на базу даних.

Віртуальні потоки, стандартні від Java 21, прибирають цю стелю. Ними керує JVM, розкладаючи їх на невелику кількість справжніх потоків; коштують вони кілобайти замість мегабайтів і — ось суть — коли один блокується на вводі-виводі, JVM паркує його й використовує носійний потік для чогось іншого. У підсумку звичайний блокуючий код, написаний прямолінійно, як усі й уміють, масштабується до сотень тисяч одночасних запитів. Тому реактивний стиль, що купував ту саму масштабованість ціною нечитабельних стектрейсів і повністю неблокуючого ланцюга, тепер потрібен значно рідше.

Як це виявляється в реальній поставці

Справжня перевага Java на довгому проєкті — не продуктивність, а те, що інструментарій виходить із припущення, що код переживе тих, хто його написав. Збірка відтворювана, граф залежностей явний, рефакторинг в IDE механічний і безпечний на мільйоні рядків, а профайлер постачається разом із JDK. Flight Recorder і Mission Control покажуть, де на робочому процесі виділення пам’яті й паузи, за ціною, достатньо низькою, щоб лишити їх увімкненими.

Дві операційні нотатки, які варто мати до першого інциденту в проді. Запускайте LTS-реліз — 17 або 21, — бо вони отримують роки безпекових оновлень, тоді як звичайний реліз шість місяців. І задавайте купу явно в контейнері: JVM, яка вважає, що має пам’ять хоста, розрахує купу під хост і буде вбита оркестратором — це виглядає як випадковий перезапуск і аж ніяк не випадкове.

Де це вироджується

  • Ліниві зв’язки JPA, завантажені всередині циклу, перетворюють один ендпоінт на сотні запитів.
  • Метод із @Transactional, викликаний із того самого класу: проксі оминають — і ніщо не відкочується.
  • Винятки, спіймані й залоговані на кожному шарі: один збій дає п’ять стектрейсів і жодного рішення.
  • Абстракція, додана під другу реалізацію, яка так і не з’явилася: інтерфейс і фабрика на кожен клас.
  • Реактивний стек, узятий заради масштабованості, тоді як один блокуючий драйвер у ланцюгу все серіалізує.
  • Розмір купи, лишений типовим усередині контейнера, закінчується вбивством за пам’яттю від оркестратора.
  • Бенчмарки, зняті до прогріву JIT, вимірюють інтерпретатор і не доводять нічого.

Коли це застосовувати

Застосовуйте, коли

  • Довгоживучі системи зі складними доменами, де статичні типи й безпечний рефакторинг окупаються щороку.
  • Сервіси, що мають використати всі ядра в одному процесі: справжні потоки роблять це без додаткових процесів.
  • API з високою паралельністю на Java 21: віртуальні потоки дають реактивний масштаб зі звичайним блокуючим кодом.
  • Регульовані середовища, де LTS-реліз із роками безпекових патчів — це вимога, а не вподобання.

Уникайте, коли

  • Короткоживучі функції, що масштабуються до нуля, — хіба що ви готові будувати й підтримувати native image.
  • Маленькі скрипти й разові інструменти, де церемонія коштує більше, ніж уся задача варта.
  • Реактивні фреймворки, обрані заради масштабу на Java 21, — віртуальні потоки дають те саме значно простіше.
  • Будь-яке середовище, де пауза збирача в мілісекунди справді неприйнятна: це не про це середовище виконання.

Було корисно?

Поділіться з тим, хто працює над тією ж задачею.