Java: обзор языка, платформы JVM и дорожная карта курса
Java — редкий случай, когда язык проще всего объяснить через задачу, которую он решал.
В начале 1990-х Sun пыталась писать софт для бытовой электроники: приставки, пульты,
терминалы. Железо у каждого производителя своё, компилятор C++ у каждого свой, размер
int зависит от платформы, а половина багов — обращение к освобождённой памяти. Команда
Джеймса Гослинга сделала ход, который в тот момент выглядел расточительно: зафиксировать
не железо, а виртуальную машину. Программа компилируется не в машинный код, а в байткод
абстрактного процессора; этот процессор эмулируется на любой платформе; память освобождает
сборщик мусора; семантика описана в спецификации до последнего бита.
Ставка сыграла не в приставках, а в серверах. Тридцать лет спустя на JVM работают Kafka, Spark, Flink, Cassandra, Elasticsearch, большая часть банковских бэкендов и половина российского энтерпрайза. Поэтому первое, что нужно понять про Java: вы учите не язык, вы въезжаете в платформу. Язык — это верхушка; ценность внизу.
Три разные вещи, которые называют словом «Java»
Путаница здесь стоит новичкам недель, поэтому разведём термины сразу.
- Язык Java — синтаксис и статическая семантика, описанные в
Java Language Specification. Это то, что
проверяет компилятор
javac. - Платформа JVM — формат файла
.class, набор байткодовых инструкций и правила исполнения, описанные в Java Virtual Machine Specification. JVM ничего не знает про язык Java: она исполняет байткод. Поэтому на ней живут Kotlin, Scala, Clojure, Groovy — и их классы свободно вызывают друг друга. - JDK — конкретная реализация: компилятор, JVM (обычно HotSpot), стандартная
библиотека, инструменты (
javap,jcmd,jfr,jlink). Реализаций много: Eclipse Temurin, Amazon Corretto, Azul Zulu, BellSoft Liberica, Oracle JDK, GraalVM, Red Hat build. Все они собраны из одного исходника OpenJDK и проходят один и тот же набор тестов совместимости (TCK). Практический вывод: выбор дистрибутива — вопрос лицензии и поддержки, а не поведения кода.
Отдельного JRE («только для запуска») с Java 11 не существует — минимальный рантайм под
своё приложение вы собираете сами через jlink. Об этом подробно в статье про
деплой и наблюдаемость.
Путь от исходника до машинного кода
Этот конвейер стоит один раз рассмотреть целиком: почти всё «странное» поведение Java —
время прогрева, внезапное ускорение под нагрузкой, ClassNotFoundException в рантайме —
объясняется именно им.
исходник"] -->|javac| CLS["Hello.class
байткод + constant pool"] CLS --> CL["ClassLoader
загрузка, связывание,
верификация байткода"] CL --> INT["Интерпретатор
+ счётчики вызовов"] INT -->|горячий метод| C1["C1 (client)
быстрая компиляция,
сбор профиля"] C1 -->|очень горячий| C2["C2 (server)
агрессивные оптимизации
по профилю"] C2 -->|профиль соврал| INT C2 --> MC["Машинный код
в Code Cache"] CLS -.->|GraalVM native-image| NAT["Нативный бинарник
без прогрева, без JIT"]
Разберём шаги, потому что каждый даёт практическое следствие.
Компиляция. javac почти не оптимизирует. Он проверяет типы, разворачивает синтаксический
сахар (for-each, автобоксинг, строковую конкатенацию, лямбды) и пишет байткод. Отсюда
важное: оптимизирует не компилятор, а рантайм. Читать «сгенерированный ассемблер» после
javac бесполезно — там честный стековый байткод.
Загрузка классов. Классы подгружаются лениво, в момент первого использования, иерархией
загрузчиков. Отсюда весь класс ошибок, невозможных в C или Go: NoClassDefFoundError,
ClassNotFoundException, конфликт версий одной библиотеки в двух местах classpath. Отсюда
же вся мощь Spring, Hibernate и агентов профилирования: класс можно найти, сгенерировать и
переписать во время работы.
Верификация. Прежде чем исполнить, JVM доказывает, что байткод типобезопасен: не
переполнит стек операндов, не приведёт Object к String без проверки. Это фундамент
модели безопасности и причина, по которой «испорченный .class» не уронит процесс.
Интерпретация → JIT. Сначала байткод интерпретируется, а JVM считает вызовы и ветвления.
Когда метод становится «горячим», включается многоуровневая компиляция: C1 даёт быстрый код
с профилированием, C2 — агрессивно оптимизированный. C2 умеет то, чего статический компилятор
не может в принципе: инлайнить виртуальные вызовы по факту, увидев, что за интерфейсом
PaymentService в этой сборке всегда стоит один класс. Если предположение нарушится
(загрузился второй класс), произойдёт деоптимизация: код выбрасывается, исполнение
возвращается в интерпретатор. Отсюда знаменитое «Java медленно стартует, но быстро работает».
Механику JIT-компиляции подробно разбирает трек компиляторов —
JIT-компиляция.
Первая программа: сколько её осталось от учебника 2005 года
Каноническая версия, которую все помнят:
public class Hello {
public static void main(String[] args) {
System.out.println("Привет, JVM!");
}
}
Современная (JEP 512, финализирован в Java 25) — файл Hello.java целиком:
void main() {
IO.println("Привет, JVM!"); // компактный исходник: без класса, без static, без System.out
}
Запускается это в обоих случаях одинаково — прямо из исходника, без промежуточной компиляции (JEP 330, с Java 11):
java Hello.java
# Привет, JVM!
Мелочь, но показательная: церемонию из Java убирают, а не добавляют. Пять слов
public static void main(String[]), которые тридцать лет были первым барьером для новичка,
наконец необязательны.
Посмотрим, во что превращается код. Инструмент javap — самый недооценённый в JDK:
String greet(String name) {
return "Привет, " + name;
}
int sum(int a, int b) {
return a + b;
}
javac Greeter.java && javap -c -p Greeter.class
java.lang.String greet(java.lang.String);
Code:
0: aload_1
1: invokedynamic #7, 0 // makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
6: areturn
int sum(int, int);
Code:
0: iload_1
1: iload_2
2: iadd
3: ireturn
Здесь видно две вещи. Первая: JVM — стековая машина, у неё нет регистров, операнды
кладутся на стек (iload), инструкция их снимает (iadd), результат кладёт обратно.
Вторая: конкатенация строк с Java 9 компилируется не в StringBuilder, а в invokedynamic —
рантайм сам решает, какую стратегию склейки применить. Это тот же механизм, на котором
работают лямбды: javac не создаёт анонимный класс, он оставляет «заявку», которую
LambdaMetafactory исполняет при первом вызове.
Где живут объекты
Второй фундамент после байткода — память. Понимание раскладки экономит потом дни на разборе
OutOfMemoryError и странного RSS в Kubernetes.
Ключевые следствия из картинки:
- Стек хранит ссылку, куча — объект. Все объекты Java живут в куче, локальные переменные примитивных типов — на стеке. Аллокация в куче почти бесплатна: это сдвиг указателя внутри TLAB (thread-local allocation buffer), персонального куска Eden у каждого потока. Дорого не создать объект, а пережить его.
-Xmx— это не вся память процесса. RSS складывается из кучи, Metaspace, Code Cache, стеков потоков и off-heap-буферов. Классическая продовая ошибка: лимит пода 1 ГБ,-Xmx1g, и под убивает OOM Killer — потому что о трёх остальных областях никто не подумал. Разбираем в JVM изнутри.- Утечка в Java — это не «забыли free», а «объект всё ещё достижим, хотя больше не нужен».
Классика: статический
Map, растущий кэш без вытеснения,ThreadLocalв пуле потоков, слушатель, которого не отписали.
Жизненный цикл объекта и сборка мусора
Вся поколенческая сборка держится на одной эмпирической гипотезе: подавляющее большинство объектов умирает молодыми. Ответ на HTTP-запрос, DTO, промежуточный список в стриме живут миллисекунды. Значит, дёшево обходить только «молодую» область и копировать оттуда редких выживших, вместо того чтобы каждый раз обходить всю кучу.
что объект не убегает Scalar --> [*]: объекта нет вовсе,
поля живут в регистрах TLAB --> Survivor: пережил minor GC,
копирование, age = 1 Survivor --> Survivor: каждая молодая сборка,
age++ Survivor --> Old: age достиг порога
(MaxTenuringThreshold) TLAB --> Old: объект слишком велик
для Eden, аллоцирован сразу Survivor --> [*]: стал недостижим —
просто не копируется Old --> [*]: mixed / full GC Old --> Leak: остался достижим
по забытой ссылке Leak --> Old: живёт вечно,
Old пухнет Leak --> [*]: OutOfMemoryError
Обратите внимание на асимметрию: мёртвые объекты в молодом поколении не стоят ничего — их не «удаляют», их просто не копируют, а Eden затирается целиком. Стоимость молодой сборки пропорциональна объёму выживших, а не объёму мусора. Отсюда контринтуитивный вывод: сделать приложение быстрее часто помогает не «меньше аллоцировать», а «аллоцировать так, чтобы объекты умирали внутри одного запроса и не переползали в Old».
Сборщиков в JDK несколько, и выбор — это осознанный компромисс между пропускной способностью, паузами и накладными расходами:
| Сборщик | Когда брать | Цена |
|---|---|---|
| Serial | контейнер с 1 vCPU, короткоживущие джобы, CLI | останавливает мир целиком |
| Parallel | пакетная обработка, важна пропускная способность | паузы в сотни мс |
| G1 (по умолчанию) | 90 % серверных приложений | паузы десятки мс, настраивается MaxGCPauseMillis |
| ZGC (поколенческий) | большие кучи, жёсткие требования к латентности | паузы < 1 мс, +10–15 % CPU и памяти |
| Shenandoah | то же, альтернативная реализация | аналогично |
| Epsilon | бенчмарки и тесты: «не собирать вообще» | падает по OOM, и это фича |
Общая теория сборки мусора — в статье Сборка мусора; конкретные флаги, логи и настройка — в JVM изнутри и Производительности.
Цена объекта: почему «всё есть объект» иногда больно
Java не даёт объявить свой тип-значение (пока — проект Valhalla в работе). У каждого объекта есть заголовок, а у каждой коллекции — косвенность. На горячем пути это видно.
Практические следствия, о которых стоит помнить с первого дня:
List<Integer>из миллиона элементов — это ~20 МБ и миллион промахов кэша;int[]— 4 МБ подряд. В горячем цикле разница десятикратная.- Пустой
HashMapуже стоит под сотню байт; сотня тысяч мелких мап — заметная часть кучи. - Заголовок в 12 байт — причина, по которой «микрооптимизация полей» бессмысленна, а вот выбор между объектом и примитивом осмыслен.
- Мерить это нужно инструментом, а не глазами: JOL показывает точную раскладку, JFR — реальный профиль аллокаций. Тема раскрывается в Производительности и перекликается с треком Производительность: память.
Как Java менялась и почему учебники устарели
Если ваше представление о Java сформировано книгой десятилетней давности — оно неверно примерно наполовину. Ключевой перелом произошёл дважды: в 8-й версии (лямбды и стримы) и в 21-й (записи, sealed-типы, сопоставление с образцом, виртуальные потоки).
С 2017 года релизы выходят каждые полгода, LTS — раз в два года (11, 17, 21, 25). Правило для продакшена простое: сидите на актуальной LTS, обновляйтесь на следующую в течение её жизненного цикла. Обратная совместимость в Java почти религиозна: код, написанный в 2005-м, как правило компилируется и работает сегодня. Это одновременно главное преимущество платформы и источник её многословности — выкинуть неудачное решение нельзя почти никогда.
Моделирование данных: records и sealed вместо мешка геттеров
Двадцать лет каноничная Java-модель выглядела как класс на сто строк с полями, конструктором,
геттерами, equals, hashCode, toString — и Lombok, чтобы это спрятать. Сегодня это одна
строка, а иерархия закрывается sealed, что делает switch исчерпывающим:
// sealed фиксирует полный список наследников — компилятор знает их все
public sealed interface Payment permits Card, Cash, Transfer {}
// record: неизменяемые поля, конструктор, equals/hashCode/toString — сгенерированы
public record Card(String maskedPan, long amountMinor) implements Payment {}
public record Cash(long amountMinor) implements Payment {}
public record Transfer(String iban, long amountMinor, boolean urgent) implements Payment {}
static String describe(Payment p) {
// default не нужен: компилятор доказал, что варианты покрыты все
return switch (p) {
case Card c when c.amountMinor() > 100_000 -> "карта, крупный платёж " + c.maskedPan();
case Card c -> "карта " + c.maskedPan();
case Cash _ -> "наличные"; // _ — безымянный шаблон, Java 22+
case Transfer(String iban, var amt, true) -> "срочный перевод " + amt + " на " + iban;
case Transfer t -> "перевод на " + t.iban();
};
}
Последняя ветка использует деконструирующий шаблон: Transfer(String iban, var amt, true)
одновременно проверяет тип, сопоставляет литерал true в третьей позиции и распаковывает поля
в переменные. Это уже не ООП-полиморфизм — это алгебраические типы данных, приехавшие в Java
из функциональных языков (см. АТД и сопоставление с образцом).
Ключевая ценность здесь не в краткости, а в том, что добавление нового варианта ломает
компиляцию во всех местах, где вы забыли его обработать. Ровно это отличает надёжную
доменную модель от иерархии с default: throw new IllegalStateException(). Подробно — в
ООП в Java.
Виртуальные потоки: главное изменение в модели исполнения за 20 лет
До Java 21 у серверного разработчика был неприятный выбор. Либо «поток на запрос» — просто,
читаемо, отлаживаемо, но платформенный поток стоит мегабайт стека и переключение в ядре, и
десять тысяч одновременных соединений вас убьют. Либо реактивщина (CompletableFuture,
Reactor, RxJava) — держит нагрузку, но код выворачивается наизнанку, стектрейсы перестают
что-либо значить, а отладчик показывает пустоту.
Виртуальные потоки (JEP 444) убирают выбор. Это потоки, которыми управляет JVM, а не ОС: их стек — обычная структура в куче, растущая по требованию, а на блокирующем вызове поток отцепляется от несущего платформенного потока, освобождая его.
ForkJoinPool participant OS as Несущий поток ОС participant IO as Сеть / БД App->>VT: httpClient.send(request) VT->>OS: исполняется на несущем потоке OS->>IO: неблокирующий системный вызов Note over VT,OS: unmount: стек виртуального потока
копируется в кучу OS-->>Sch: несущий поток свободен Sch->>OS: берёт другой виртуальный поток IO-->>Sch: данные пришли Sch->>OS: mount: стек возвращается на стек ОС OS-->>App: send() возвращает ответ Note over App: код всё это время выглядел
как обычный блокирующий вызов
На практике это выглядит скучно — и в этом весь смысл:
// Десять тысяч одновременных задач. На платформенных потоках это ~10 ГБ стеков;
// на виртуальных — десятки мегабайт в куче.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // блокирует задачу, но не поток ОС
return id;
});
}
} // close() дожидается завершения всех задач — try-with-resources как структурная конкурентность
Сравните с моделью C#: там асинхронность потребовала «окрасить» функции в async/await
и продублировать половину библиотек. Java выбрала другой путь — сохранить блокирующий стиль
и изменить рантайм. Цена: старый код с synchronized в горячих местах может «пришпилить»
(pin) виртуальный поток к несущему, а пулы потоков, которые всю жизнь были оптимизацией,
внезапно стали антипаттерном. Всё это — в Конкурентности.
Грабли, на которые наступают именно в Java
Список не для заучивания — просто узнайте эти ситуации в лицо, чтобы потом не терять на них часы.
Автобоксинг и кэш Integer. Классика собеседований, которая иногда прорывается в прод:
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true — оба из кэша Integer для -128..127
System.out.println(c == d); // false — два разных объекта
System.out.println(c.equals(d)); // true — сравнивать боксы надо только так
Хуже другое: Long total = 0L; for (...) total += x; создаёт объект на каждой итерации и
работает в разы медленнее, чем long. Ещё хуже — Map<String, Integer> и map.get(k) == 0,
которое ломается на значениях больше 127. И совсем плохо — int x = map.get(k), когда ключа
нет: тихий NullPointerException на распаковке null.
Сравнение строк через ==. Литералы интернируются, поэтому == иногда «работает» и
создаёт ложное ощущение правильности:
String s1 = "java";
String s2 = "ja" + "va"; // склеено компилятором → та же константа
String s3 = new String("java");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1.equals(s3)); // true — единственный правильный способ
Арифметика. int переполняется молча, а double не умеет считать деньги:
System.out.println(Integer.MAX_VALUE + 1); // -2147483648, без единого предупреждения
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(new BigDecimal(0.1)); // 0.1000000000000000055511151231257827...
System.out.println(BigDecimal.valueOf(0.1)); // 0.1 — valueOf идёт через строку
В деньгах — либо BigDecimal.valueOf / new BigDecimal("0.1"), либо (что чаще правильнее)
целые копейки в long. Math.addExact бросит исключение вместо тихого переполнения.
Перегрузка, которая маскируется под индекс. Одна из самых злых ловушек стандартной библиотеки:
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // remove(int index) → удалит 20!
list.remove(Integer.valueOf(30)); // remove(Object) → удалит 30
Три разных «неизменяемых» списка. Arrays.asList — фиксированного размера, но элементы
менять можно, и он смотрит на исходный массив. Collections.unmodifiableList — обёртка,
которая меняется, если поменять оригинал. List.of — по-настоящему неизменяемый, но бросает
NullPointerException на null-элементе. Подробно — в
Коллекциях и дженериках.
Стирание типов. Дженерики в Java существуют только на этапе компиляции:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true — в рантайме оба просто ArrayList
Отсюда: нельзя new T[], нельзя instanceof List<String>, нельзя перегрузить метод по
List<String> и List<Integer>. В C#/.NET дженерики реифицированы, и это одно из самых
заметных различий двух похожих платформ.
Изменяемый ключ в HashMap. Положили объект ключом, потом поменяли поле, участвующее в
hashCode — и объект «потерялся» в мапе навсегда: он лежит в корзине по старому хэшу.
Ключи должны быть неизменяемыми, поэтому record — почти идеальный ключ.
NullPointerException как способ жизни. Java не различает на уровне типов «может быть
null» и «не может» (в отличие от C# с nullable reference types или Kotlin). Спасают
дисциплина, Optional в возвращаемых значениях (но не в полях и не в параметрах!) и
подробные сообщения JVM начиная с Java 14:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.length()" because the return value of "Order.comment()" is null
Проглоченные исключения. catch (Exception e) {} — самый дорогой три-символьный блок в
индустрии. Обратная сторона: checked-исключения не дружат с лямбдами, что толкает людей
оборачивать всё в RuntimeException без контекста. Как жить правильно — в
Идиоматике и обработке ошибок.
Потокобезопасность по невнимательности. SimpleDateFormat не потокобезопасен (используйте
java.time.format.DateTimeFormatter), HashMap под конкурентной записью теряет данные,
double-checked locking без volatile не работает, а synchronized внутри виртуального
потока может его пришпилить.
Контейнеры. До JDK 10 JVM не видела cgroup-лимиты и определяла память по хосту. Сейчас
UseContainerSupport включён по умолчанию, но по-прежнему стоит явно задавать
-XX:MaxRAMPercentage вместо -Xmx, чтобы образ переезжал между окружениями без правок.
Честно: где Java выигрывает, а где нет
Обзорная статья обязана быть честной, иначе она реклама.
Java уверенно выигрывает там, где:
- процесс живёт долго — сервисы, брокеры, ETL. Прогрев JIT амортизируется, и в стационарном режиме код по скорости сопоставим с C++ на типичной бизнес-логике;
- нужны большие данные: Kafka, Spark, Flink, Cassandra, Elasticsearch/Lucene, Hadoop, Trino — всё это JVM. Войти в data-инфраструктуру, не зная JVM, практически нельзя (см. трек Data Engineering);
- важна долгая жизнь кодовой базы: обратная совместимость беспрецедентна, а стоимость найма разработчиков предсказуема;
- нужна наблюдаемость в проде: JFR, JMX, heap dump, async-profiler,
jcmdдают уровень интроспекции живого процесса, которого нет почти нигде; - задача — интеграция и энтерпрайз: транзакции, очереди, SOAP/REST, ORM, batch — здесь экосистема просто зрелее всех остальных.
Java проигрывает там, где:
- важен холодный старт и маленький footprint: CLI-утилиты, короткие Lambda-функции, скрипты. Стандартный старт JVM — сотни миллисекунд, базовый RSS — десятки мегабайт. Лечится GraalVM native-image, AppCDS, проектом Leyden и CRaC, но это отдельная работа, а не «из коробки»;
- нужен полный контроль над памятью и предсказуемость до микросекунды: ядра ОС, драйверы, hard real-time. Здесь C, C++, Rust (трек Системное программирование);
- задача — исследования в ML/DS: экосистема Python вне конкуренции, а DJL и Tribuo нишевые;
- нужен фронтенд в браузере — это территория TypeScript (трек TypeScript);
- команда мелкая, а домен простой: Java требует больше церемонии, чем Go или Python. Иногда честный ответ — «нам не нужна JVM».
Куда Java не стоит тащить принципиально. В короткоживущие процессы, вызываемые тысячи раз в секунду (каждый запуск — заново прогрев). В embedded с несколькими мегабайтами памяти. В задачи, где вся логика — обвязка вокруг вызова внешнего инструмента: там скрипт честнее.
Соседство с C#/.NET: близко, но не одно и то же
На портале есть трек C#/.NET — ниша та же: управляемый рантайм, JIT, GC, статическая типизация, корпоративный бэкенд. Различия при этом глубже, чем синтаксис.
| Аспект | Java | C#/.NET |
|---|---|---|
| Дженерики | стирание типов, List<T> в рантайме — просто List |
реифицированы, typeof(List<int>) существует |
| Типы-значения | пока нет своих (проект Valhalla) | struct, record struct, Span<T> |
| Асинхронность | виртуальные потоки: блокирующий код без окраски функций | async/await: явная окраска, две версии API |
| Работа с данными | Stream API, коллекторы | LINQ + деревья выражений (провайдеры к БД) |
| Null-безопасность | нет на уровне типов, спасает дисциплина и Optional |
nullable reference types в компиляторе |
| Управление платформой | OpenJDK, много независимых вендоров | преимущественно Microsoft |
| Пакеты | Maven Central, координаты groupId:artifactId:version |
NuGet |
| AOT | внешний GraalVM native-image | встроен в SDK |
| Десктоп/игры | слабое место | WPF, MAUI, Unity |
| Big data | доминирует | почти отсутствует |
Практический вывод: если вы пришли из C#, синтаксис вы прочитаете сразу, а спотыкаться будете
на стирании типов, отсутствии структур, модели конкурентности и на культуре сборки (Maven и
Gradle устроены совсем не как dotnet build).
Экосистема одним взглядом
Обратите внимание на нижнюю ветку: знание JVM переносимо. Освоив память, GC и байткод, вы получаете доступ к Kotlin и Scala почти бесплатно — меняется синтаксис, но не модель исполнения.
Дорожная карта курса
Курс устроен как лестница: от установки JDK до профилирования живого сервиса под нагрузкой. Каждая статья опирается на предыдущие, поэтому лучше идти по порядку.
Фундамент
- Установка и инструментарий — выбор дистрибутива JDK, переключение версий через SDKMAN, Maven против Gradle, структура проекта, IDE, форматтеры.
- Основы Java — примитивы и ссылки, копирование по
значению ссылки, строки и их неизменяемость,
var, управление потоком, массивы. - ООП в Java — классы, интерфейсы с default-методами, композиция
против наследования,
record,sealed, сопоставление с образцом,equals/hashCode. - Коллекции и дженерики — внутреннее
устройство
ArrayListиHashMap, выбор структуры под задачу, стирание типов,? extends/? superи правило PECS.
Идиоматика
- Идиоматика и обработка ошибок — checked против
unchecked,
try-with-resources, честное применениеOptional, неизменяемость по умолчанию. - Stream API и функциональный стиль — лямбды и ссылки на методы, ленивость, коллекторы, когда стрим уместен и когда цикл честнее.
- Конкурентность — модель памяти Java,
synchronizedиvolatile, executors,CompletableFuture, виртуальные потоки и структурная конкурентность.
Платформа
- JVM изнутри — байткод и
javap, загрузчики классов, области памяти, поколенческий GC, выбор и настройка сборщика, многоуровневый JIT.
Инженерная практика
- Тестирование — JUnit 5, параметризованные тесты, AssertJ, Mockito без злоупотреблений, Testcontainers и честные интеграционные тесты.
- Spring и Spring Boot — внедрение зависимостей, контекст, автоконфигурация, web-слой, что именно делает стартер под капотом.
- Работа с данными — JDBC, пул соединений, JPA и Hibernate, транзакции и их границы, проблема N+1, ленивые загрузки и миграции.
- Архитектура прод-приложений — слои и модули, конфигурация и профили, идемпотентность, таймауты, retry, circuit breaker.
Эксплуатация
- Производительность — методика измерения, JFR и async-profiler, микробенчмарки на JMH и их ловушки, настройка GC, поиск утечек.
- Деплой и наблюдаемость — fat jar против
слоёных образов,
jlinkи native-image, метрики Micrometer, логи, трейсинг OpenTelemetry. - Java в SDLC — CI/CD, управление зависимостями и уязвимостями, статический анализ, обновление версий и курированный список ресурсов.
Как проходить этот курс
- Держите открытый scratch-проект. Всё, что видите в статьях, набирайте руками и запускайте.
Для одиночных примеров хватит
jshell(REPL, встроен в JDK с версии 9) илиjava Файл.java. - Смотрите под капот при каждом удобном случае. Не понимаете, во что превратилась
конструкция —
javap -c. Не понимаете, куда ушла память — heap dump и JFR. Java уникально прозрачна в рантайме, и это её главный обучающий бонус. - Читайте спецификацию, а не пересказы. JLS и JVMS написаны сухо, но однозначно. Один раз найдя ответ там, вы перестанете спорить о поведении языка.
- Не верьте бенчмаркам без JMH. Из-за JIT наивный замер
System.nanoTime()вокруг цикла в Java врёт систематически: код успевает соптимизироваться, а иногда и вовсе исчезнуть. - Сначала LTS, потом новинки. Учитесь на Java 21 или 25: там уже есть всё современное, и это то, что вы встретите в вакансиях.
- Не пропускайте главы про JVM и конкурентность, даже если пришли опытным разработчиком из другого языка. Именно там прячется специфика, на которой спотыкаются сеньоры.
Сквозной пример курса
Чтобы фичи не висели в воздухе, через статьи проходит один пример — сервис платежей
(payments). Мы будем возвращаться к нему в разных разрезах: смоделируем домен записями и
sealed-типами, разложим ошибки на ожидаемые и исключительные, соберём отчёты стримами,
распараллелим обращения к внешним провайдерам виртуальными потоками, покроем тестами с
реальной базой в Testcontainers, разложим по слоям, положим за Spring Boot, а затем
профилируем и выкатим в контейнере с метриками и трейсами.
Мини-итог
- Java — это в первую очередь платформа: спецификация языка, спецификация JVM и экосистема. Язык можно выучить за неделю, платформу изучают годами.
- Компилятор почти не оптимизирует; всю тяжёлую работу делает рантайм — многоуровневый JIT, инлайнинг по профилю, деоптимизация. Отсюда прогрев и высокая пиковая скорость.
- Память делится на кучу с поколениями, Metaspace, Code Cache, стеки и off-heap.
-Xmx— не весь процесс, и это самая частая причина падений в Kubernetes. - Утечка в Java — это достижимый, но ненужный объект. GC избавляет от
free, но не от ответственности за граф ссылок. - Современная Java (17/21/25) — это записи, sealed-типы, сопоставление с образцом и виртуальные потоки. Учебники десятилетней давности описывают другой язык.
- Ниша честная: долгоживущие серверные процессы, интеграции и большие данные. Для CLI, embedded и ML есть инструменты лучше.
Источники
- Java Language Specification и JVM Specification — первоисточник.
- docs.oracle.com/en/java — API и руководства JDK.
- openjdk.org/jeps/0 — индекс JEP: почему каждая фича появилась.
- inside.java и dev.java — материалы команды OpenJDK.
- Joshua Bloch, «Effective Java», 3-е издание — канон идиоматики.
- Brian Goetz et al., «Java Concurrency in Practice» — до сих пор лучшая книга о модели памяти.
- Benjamin Evans, James Gough, Chris Newland, «Optimizing Java» — JIT, GC и измерения.
- Aleksey Shipilëv — тексты и бенчмарки по JMM, GC и JMH.
- Java Almanac — что именно изменилось между любыми двумя версиями.
Что дальше
Установка и инструментарий: JDK, Maven, Gradle, структура проекта — выберем дистрибутив JDK, научимся держать несколько версий одновременно, разберёмся, чем Maven отличается от Gradle, и соберём проект, который не стыдно показать команде.