Продвинутое программирование на Java
От «код работает» к «система понятна, проверена и выдерживает сбои». Архитектура, типы, HTTP, многопоточность, тесты и внутренняя работа JVM.
try (var executor = Executors .newVirtualThreadPerTaskExecutor()) { var result = executor.submit( () -> probe.check(endpoint) ).get(); System.out.println(result); } > UP · 200 · 184 ms
Это продолжение, а не повторение базового курса
Здесь мы перестаем оценивать программу только по фразе «она запускается». Продвинутый разработчик думает о контрактах, границах модулей, ошибках, конкурентном доступе, тестируемости, наблюдаемости и стоимости решений.
Что уже нужно уметь
- уверенно использовать условия, циклы, методы, классы и интерфейсы;
- работать с
List,Set,Map, файлами и исключениями; - понимать наследование, композицию, generics и базовый Stream API;
- читать stack trace и самостоятельно исправлять простые ошибки;
- собрать небольшое консольное приложение из нескольких классов.
Входная проверка на 60–90 минут
Задача диагностика
Напишите каталог заказов. У заказа есть ID, клиент, сумма и статус. Программа должна добавлять заказ, менять статус, искать по ID, группировать заказы по статусу и сохранять их в файл.
Критерий готовности: вы можете объяснить структуру решения, проверить неверный ввод и написать минимум три тестовых сценария без готового ответа.
Что должен проверить наставник
- поля модели закрыты и не меняются произвольно;
- ID не сравниваются через случайные индексы списка;
- для статуса используется
enum, а не набор строк; - ошибка файла не проглатывается;
- методы имеют одну ответственность;
- стажер понимает каждую строку, даже если использовал ИИ.
Как проходить Advanced-курс
- Сформулируйте контракт. До кода запишите вход, выход, ошибки и инварианты.
- Напишите самый простой корректный вариант. Не оптимизируйте предположения.
- Покройте поведение тестами. Особенно границы и отрицательные сценарии.
- Измерьте спорное место. Профилировщик важнее догадок о производительности.
- Проведите ревью. Объясните компромиссы и только затем улучшайте код.
От работающего кода к профессиональному проекту
Рабочий код решает один известный пример. Профессиональный код сохраняет понятное поведение при неверных данных, изменениях требований и работе другого разработчика.
Четыре уровня готовности
| Уровень | Главный вопрос | Признак |
|---|---|---|
| Компилируется | Понимает ли код Java? | Нет ошибок компиляции. |
| Работает | Решен ли основной сценарий? | Получен ожидаемый результат. |
| Надежно | Что произойдет на границах? | Ошибки предсказуемы и проверены. |
| Поддерживаемо | Можно ли безопасно изменить? | Контракты, тесты и границы ясны. |
Начинаем не с класса, а с контракта
Операция: зарегистрировать платеж
Вход:
- orderId — существующий положительный ID
- amount — положительная сумма в валюте заказа
Результат:
- создан уникальный paymentId
- заказ получает статус PAID
Ошибки:
- заказ не найден
- валюта не совпадает
- заказ уже оплачен
Инвариант:
- один заказ нельзя успешно оплатить дважды
Такой список не является бюрократией. Он заранее показывает места, где понадобятся проверки, типы ошибок, идемпотентность и тесты.
Пакет выражает ответственность
ru.nerosky.payments
├── api // публичные команды и результаты
├── domain // правила предметной области
├── application // сценарии использования
└── infrastructure
├── file // файловая реализация
└── http // интеграция по HTTP
Минимальный публичный API
package ru.nerosky.payments.api;
public interface PaymentService {
PaymentReceipt pay(PaymentCommand command);
}
package ru.nerosky.payments.api;
import java.math.BigDecimal;
import java.util.Currency;
public record PaymentCommand(
long orderId,
BigDecimal amount,
Currency currency
) {
}
Клиенту не нужно видеть внутренние репозитории и вспомогательные классы. Чем меньше публичная поверхность, тем меньше обещаний придется поддерживать.
public
Если тип используется только внутри пакета, оставьте package-private доступ.
Скрытый внутренний код можно менять без нарушения внешнего контракта.
Definition of Done для учебной задачи
Практика архитектура
- Возьмите итоговый проект базового курса и выпишите контракты всех команд.
- Разделите классы на API, домен, приложение и инфраструктуру.
- Найдите типы, которые не обязаны быть публичными.
- Составьте Definition of Done для новой команды «изменить срок задачи».
Пример контракта команды «изменить срок»
- вход: существующий ID и дата не раньше сегодняшней;
- результат: срок изменен и сохранен;
- ошибки: задача не найдена, дата в прошлом, хранилище недоступно;
- инвариант: завершенную задачу изменять нельзя;
- тесты: обычная дата, сегодняшняя дата, прошлое, неизвестный ID, завершенная задача.
Maven, пакеты и модули
IDE умеет запустить почти любой набор файлов. Команда и CI должны получать тот же результат без ручных настроек разработчика — для этого нужна описанная сборка.
Стандартная структура
advanced-app/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/ru/nerosky/app/
│ │ └── resources/
│ └── test/
│ ├── java/ru/nerosky/app/
│ └── resources/
└── target/ ← результат сборки, не хранится в Git
Минимальный pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.nerosky.training</groupId>
<artifactId>advanced-app</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.0</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
</project>
Свойство maven.compiler.release задает одновременно синтаксис,
формат class-файлов и доступный публичный API выбранного релиза.
Главные команды
mvn clean test # чистая компиляция и тесты
mvn clean package # создать JAR в target/
mvn dependency:tree # показать дерево зависимостей
mvn help:effective-pom # итоговая конфигурация после наследования
Scopes зависимостей
| Scope | Компиляция | Запуск | Пример |
|---|---|---|---|
compile | да | да | Основная библиотека приложения |
runtime | нет | да | Драйвер, нужный только при запуске |
test | только тесты | только тесты | JUnit |
provided | да | предоставляет среда | API контейнера |
mvn dependency:tree, найдите путь транзитивной
зависимости и только затем управляйте версией или исключением.
JPMS: явная граница модуля
module ru.nerosky.monitor {
requires java.net.http;
exports ru.nerosky.monitor.api;
}
requires объявляет зависимость, exports открывает пакет
другим модулям. Не каждый проект обязан немедленно переходить на JPMS, но продвинутый
разработчик должен понимать разницу между classpath и module path.
Практика инструменты
- Перенесите старый проект в стандартную Maven-структуру.
- Добавьте JUnit только в scope
testи проверьтеdependency:tree. - Соберите JAR на чистом клоне проекта одной командой.
- Создайте
module-info.javaдля приложения, использующегоjava.net.http.
Чек-лист воспроизводимой сборки
- JDK и Maven проверяются командами
java --versionиmvn --version; - в Git нет
target/, IDE-файлов и секретов; mvn clean testпроходит без заранее созданных локальных файлов;- версии прямых зависимостей зафиксированы;
- ресурсы лежат в
src/main/resources, тестовые — вsrc/test/resources.
Контракты объектов и неизменяемость
Объект может компилироваться и при этом быть опасным ключом HashMap,
отдавать наружу изменяемое состояние или считать одинаковые деньги разными.
equals/hashCodeРавенство значений и идентичность ссылок
String first = new String("java");
String second = new String("java");
System.out.println(first == second); // false
System.out.println(first.equals(second)); // true
== для объектов проверяет одну и ту же ссылку.
equals отвечает на доменный вопрос «считаются ли значения одинаковыми?».
Контракт equals
- рефлексивность:
x.equals(x)всегда true; - симметричность: если x равен y, то y равен x;
- транзитивность: x = y и y = z означает x = z;
- стабильность: результат не меняется без изменения участвующих данных;
- null:
x.equals(null)возвращает false; - равные объекты обязаны иметь одинаковый
hashCode.
hashCode, поменялось после помещения объекта
в HashMap, ключ может оказаться «не в той корзине» и перестать находиться.
Value object для денег
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
int scale = currency.getDefaultFractionDigits();
if (scale < 0) {
throw new IllegalArgumentException("Валюта без стандартной точности");
}
amount = amount.setScale(scale, RoundingMode.HALF_EVEN);
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Валюты не совпадают");
}
}
}
Record создает корректные equals, hashCode и
toString по компонентам. Операция add не меняет объект,
а возвращает новое значение.
double не подходит для точных денежных расчетов
Двоичное представление дробей дает неожиданные остатки. Используйте
BigDecimal с явно выбранными точностью и правилом округления.
Защитная копия коллекции
import java.util.List;
import java.util.Objects;
public final class Order {
private final long id;
private final List<OrderLine> lines;
public Order(long id, List<OrderLine> lines) {
if (id <= 0) {
throw new IllegalArgumentException("id");
}
Objects.requireNonNull(lines, "lines");
if (lines.isEmpty()) {
throw new IllegalArgumentException("Заказ не может быть пустым");
}
this.id = id;
this.lines = List.copyOf(lines);
}
public List<OrderLine> lines() {
return lines;
}
}
Если сохранить входной ArrayList напрямую, вызывающий код сможет изменить
заказ после конструктора. List.copyOf разрывает этот канал изменения.
Элементы списка при этом тоже должны быть неизменяемыми или копироваться отдельно.
BigDecimal.equals и compareTo
var a = new BigDecimal("10.0");
var b = new BigDecimal("10.00");
System.out.println(a.equals(b)); // false: разный scale
System.out.println(a.compareTo(b) == 0); // true: численно равны
Практика модель
- Создайте неизменяемый
EmailAddressс нормализацией и валидацией. - Создайте
TimeRange(start, end), запрещающий конец раньше начала. - Докажите тестом, что изменение исходного списка не меняет объект
Order. - Объясните, можно ли использовать
Moneyкак ключHashMap.
Решение TimeRange
import java.time.Instant;
import java.util.Objects;
public record TimeRange(Instant start, Instant end) {
public TimeRange {
Objects.requireNonNull(start, "start");
Objects.requireNonNull(end, "end");
if (end.isBefore(start)) {
throw new IllegalArgumentException("Конец раньше начала");
}
}
public boolean contains(Instant moment) {
Objects.requireNonNull(moment, "moment");
return !moment.isBefore(start) && !moment.isAfter(end);
}
}
Продвинутые generics: гибкость без потери типов
Generics — не украшение коллекций. Это способ выразить связь между входами и выходами API так, чтобы ошибку обнаружил компилятор, а не пользователь в production.
Два параметра типа
import java.util.Optional;
public interface Repository<ID, T> {
Optional<T> findById(ID id);
void save(T entity);
boolean deleteById(ID id);
}
Repository<Long, User> users = ...;
Repository<String, Session> sessions = ...;
Один контракт сохраняет связь между типом идентификатора и сущностью.
Возврат Object заставил бы клиента выполнять небезопасные приведения.
Ограниченный параметр
static <T extends Comparable<? super T>> T max(
List<? extends T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("Пустой список");
}
T max = values.getFirst();
for (T value : values) {
if (value.compareTo(max) > 0) {
max = value;
}
}
return max;
}
Сигнатура говорит: T можно сравнивать с T или его родительским типом, а входной список может содержать T или его подтип.
Почему List<Integer> не является List<Number>
List<Integer> integers = new ArrayList<>();
// List<Number> numbers = integers;
// Иначе сюда можно было бы добавить Double:
// numbers.add(3.14);
Generics в Java инвариантны. Связь задается wildcard-типами.
PECS: Producer Extends, Consumer Super
static <T> void copy(
List<? extends T> source,
List<? super T> target) {
for (T value : source) {
target.add(value);
}
}
List<Integer> source = List.of(1, 2, 3);
List<Number> target = new ArrayList<>();
copy(source, target);
? extends T
Источник производит T. Безопасно читать как T, но нельзя добавлять новое значение.
? super T
Приемник потребляет T. Безопасно добавлять T, но чтение дает только Object.
Type erasure
Большая часть информации о параметрах типа стирается при компиляции. Поэтому ограничения существуют не случайно:
// Нельзя:
// new T()
// new T[10]
// value instanceof List<String>
// static T shared;
// Можно:
if (value instanceof List<?> list) {
System.out.println(list.size());
}
Фабрика вместо new T()
import java.util.function.Supplier;
public final class BoxFactory<T> {
private final Supplier<T> creator;
public BoxFactory(Supplier<T> creator) {
this.creator = creator;
}
public T create() {
return creator.get();
}
}
BoxFactory<ArrayList<String>> factory =
new BoxFactory<>(ArrayList::new);
List<? extends User> заставляет каждого клиента разбираться
с неизвестным типом. Обычно wildcard полезнее на входных параметрах API.
Практика типизация
- Реализуйте
filter(List<? extends T>, Predicate<? super T>). - Напишите метод, добавляющий числа из
List<Integer>вList<Object>. - Объясните каждую часть сигнатуры метода
maxвыше. - Найдите raw types в старом проекте и удалите небезопасные cast.
Решение generic filter
static <T> List<T> filter(
List<? extends T> source,
Predicate<? super T> predicate) {
List<T> result = new ArrayList<>();
for (T value : source) {
if (predicate.test(value)) {
result.add(value);
}
}
return List.copyOf(result);
}
Коллекции и алгоритмическая сложность
«Работает на десяти элементах» ничего не говорит о миллионе. Выбор коллекции — это выбор гарантий: порядка, уникальности, поиска и стоимости операций.
Big O — скорость роста, а не секундомер
| Обозначение | Смысл | Пример |
|---|---|---|
O(1) | Стоимость почти не зависит от размера | Доступ ArrayList.get(index) |
O(log n) | Размер можно многократно делить | Поиск в сбалансированном дереве |
O(n) | Нужно пройти элементы | Поиск в несортированном списке |
O(n log n) | Типичная эффективная сортировка | List.sort |
O(n²) | Каждый элемент сравнивается со многими | Два вложенных прохода |
Практическая карта коллекций
| Нужно | Частый выбор | Почему |
|---|---|---|
| Порядок и доступ по индексу | ArrayList | Компактный массив, быстрый random access |
| Уникальность без сортировки | HashSet | Средний поиск O(1) |
| Ключ → значение | HashMap | Средний поиск O(1) |
| Ключи отсортированы | TreeMap | Операции O(log n), навигация по диапазонам |
| Порядок добавления | LinkedHashMap | Хеш-таблица плюс связанный порядок |
| Ключ — enum | EnumMap | Компактная и быстрая специализированная структура |
| Очередь по приоритету | PriorityQueue | Быстро извлекает минимальный/максимальный элемент |
Почему LinkedList редко ускоряет приложение
Теоретически вставка по известному узлу O(1), но сначала узел обычно нужно найти за O(n).
Узлы занимают больше памяти и хуже используют процессорный кэш. Для очереди чаще выбирают
ArrayDeque, для списка — ArrayList.
Атомарные операции над Map
Map<String, Long> frequency = new HashMap<>();
for (String word : words) {
frequency.merge(word, 1L, Long::sum);
}
Map<String, List<Order>> byCustomer = new HashMap<>();
for (Order order : orders) {
byCustomer
.computeIfAbsent(order.customerId(), ignored -> new ArrayList<>())
.add(order);
}
Comparator должен быть предсказуемым
Comparator<User> byNameThenId =
Comparator.comparing(User::name)
.thenComparingLong(User::id);
List<User> sorted = users.stream()
.sorted(byNameThenId)
.toList();
TreeSet считает элементы одинаковыми по comparator
Если comparator вернул 0 для двух разных объектов, второй не попадет в множество.
Добавляйте стабильный tie-breaker, например ID, когда объекты должны оставаться различными.
Простой ограниченный LRU-кэш
import java.util.LinkedHashMap;
import java.util.Map;
public final class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LruCache(int capacity) {
super(capacity, 0.75f, true);
if (capacity <= 0) {
throw new IllegalArgumentException("capacity");
}
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
Третий аргумент true включает порядок по обращениям. Это учебный
однопоточный кэш: для production нужны политика истечения, метрики и потокобезопасность.
Практика выбор структуры
- Подберите коллекцию для истории последних 100 команд.
- Подберите структуру для расписания задач по ближайшему времени запуска.
- Оптимизируйте поиск пересечения двух списков ID без вложенного цикла.
- Напишите тест вытеснения самого давно не использованного элемента LRU-кэша.
Решение поиска пересечения
static <T> Set<T> intersection(
Collection<T> left,
Collection<T> right) {
Set<T> index = new HashSet<>(left);
index.retainAll(right);
return Set.copyOf(index);
}
Построение индекса и проход дают ожидаемую сложность O(n + m) вместо O(n × m), если хеш-функции распределяют значения нормально.
Streams, Collectors и Optional без злоупотреблений
Продвинутый Stream API — это не самая длинная цепочка методов. Это прозрачный pipeline без скрытых изменений, с правильно выбранной терминальной операцией.
Pipeline выполняется только по запросу результата
Stream<String> pipeline = names.stream()
.filter(name -> {
System.out.println("Проверяем " + name);
return name.length() > 4;
});
// Пока ничего не напечатано.
long count = pipeline.count(); // здесь начинается обход
Stream одноразовый. После terminal operation повторно использовать его нельзя. Храните коллекцию или фабрику stream, а не stream в поле.
Группировка с последующей агрегацией
Map<OrderStatus, BigDecimal> totals = orders.stream()
.collect(Collectors.groupingBy(
Order::status,
() -> new EnumMap<>(OrderStatus.class),
Collectors.reducing(
BigDecimal.ZERO,
Order::total,
BigDecimal::add
)
));
Здесь явно выбрана EnumMap, а downstream collector суммирует значения
внутри каждой группы, не создавая промежуточные списки заказов.
flatMap: один объект содержит несколько значений
Set<String> uniqueProductIds = orders.stream()
.flatMap(order -> order.lines().stream())
.map(OrderLine::productId)
.collect(Collectors.toUnmodifiableSet());
Два результата за один проход
record Summary(long count, BigDecimal total) {}
Summary summary = orders.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.reducing(
BigDecimal.ZERO,
Order::total,
BigDecimal::add
),
Summary::new
));
Optional описывает возможное отсутствие
Optional<User> user = repository.findById(id);
String displayName = user
.filter(User::active)
.map(User::displayName)
.orElse("Гость");
| Уместно | Неуместно |
|---|---|
| Возвращаемое значение поиска | Поле entity или DTO |
| Результат max/min | Параметр каждого метода |
| Цепочка преобразований отсутствующего значения | Коллекция: пустой список уже выражает отсутствие |
| Явная альтернатива null в API | Optional.get() без проверки |
orElse и orElseGet
User first = optional.orElse(loadExpensiveFallback());
// fallback вычисляется всегда
User second = optional.orElseGet(this::loadExpensiveFallback);
// fallback вычислится только при пустом Optional
map или filter — тревожный сигнал
Не изменяйте внешнюю коллекцию, счетчик или базу данных из промежуточной операции.
Pipeline должен быть понятен как преобразование данных.
Почему parallelStream() не кнопка ускорения
- работа должна быть достаточно крупной, иначе накладные расходы выше пользы;
- операции должны быть независимыми и без общего изменяемого состояния;
- общий ForkJoinPool делится с другим кодом процесса;
- блокирующий I/O обычно не является хорошей задачей для parallel stream;
- ускорение нужно измерять на реалистичных данных.
Практика pipeline
- Сгруппируйте сотрудников по отделу и найдите максимальную зарплату в каждом.
- Получите частоту тегов из списка статей, где у статьи несколько тегов.
- Перепишите цепочку с
Optional.get()без небезопасного вызова. - Сравните читаемость решения циклом и Stream API; выберите лучшее и объясните.
Решение группировки максимальной зарплаты
Map<Department, Optional<Employee>> highestPaid =
employees.stream().collect(Collectors.groupingBy(
Employee::department,
Collectors.maxBy(
Comparator.comparing(Employee::salary)
)
));
Optional появляется потому, что collector формально умеет работать с пустой группой. Если нужен только размер зарплаты, используйте downstream mapping или собственную модель результата.
Records, sealed-типы и pattern matching
Современная Java позволяет моделировать закрытый набор состояний так, чтобы компилятор проверял полноту обработки и не допускал невозможных комбинаций.
Команда как закрытый набор вариантов
public sealed interface Command
permits AddTask, CompleteTask, DeleteTask {
}
record AddTask(String title) implements Command {
AddTask {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title");
}
}
}
record CompleteTask(long id) implements Command {
}
record DeleteTask(long id) implements Command {
}
Исчерпывающая обработка
public final class CommandHandler {
public String handle(Command command) {
return switch (command) {
case AddTask(var title) ->
"Добавить: " + title;
case CompleteTask(var id) ->
"Завершить #" + id;
case DeleteTask(var id) ->
"Удалить #" + id;
};
}
}
default не нужен: компилятор знает все разрешенные реализации.
Если позже добавить RenameTask, он покажет все switch, которые нужно обновить.
data, error и
retryAfter создайте отдельные варианты Success, Rejected и TemporaryFailure.
Модель результата операции
public sealed interface PaymentResult {
record Success(String paymentId) implements PaymentResult {}
record Rejected(String reason) implements PaymentResult {}
record TemporaryFailure(
String reason,
Duration retryAfter
) implements PaymentResult {}
}
String message = switch (result) {
case PaymentResult.Success(var id) ->
"Платеж " + id + " принят";
case PaymentResult.Rejected(var reason) ->
"Отклонено: " + reason;
case PaymentResult.TemporaryFailure(var reason, var retryAfter) ->
"Повтор через " + retryAfter + ": " + reason;
};
Когда record не подходит
- объект имеет сложный изменяемый жизненный цикл;
- нужно скрыть представление и предоставить другой публичный API;
- идентичность важнее равенства всех полей;
- требуется наследование от другого класса;
- компоненты record не являются настоящим контрактом модели.
Sealed и расширяемость
Sealed-иерархия хороша, когда владелец модели контролирует все варианты. Для plugin API, куда сторонние команды должны добавлять реализации, обычный открытый интерфейс подходит лучше.
Практика моделирование
- Смоделируйте результат валидации: Valid и Invalid со списком ошибок.
- Смоделируйте события заказа и обработайте их исчерпывающим switch.
- Найдите класс с несколькими nullable-полями и замените его закрытой иерархией.
Пример результата валидации
public sealed interface Validation<T> {
record Valid<T>(T value) implements Validation<T> {}
record Invalid<T>(List<String> errors)
implements Validation<T> {
public Invalid {
errors = List.copyOf(errors);
if (errors.isEmpty()) {
throw new IllegalArgumentException("Нужна хотя бы одна ошибка");
}
}
}
}
Контракты API, валидация и стратегия ошибок
Исключение — часть контракта. Клиент должен понимать, можно ли исправить вход, повторить операцию, показать сообщение пользователю или остановить процесс.
Таксономия ошибок
| Категория | Пример | Обычная реакция |
|---|---|---|
| Неверный вход | Пустой email | Отклонить до выполнения операции |
| Доменный отказ | Недостаточно средств | Вернуть ожидаемый результат/ошибку |
| Не найдено | Нет заказа с ID | Явно сообщить клиенту |
| Временная инфраструктура | Timeout внешнего API | Ограниченный retry с backoff |
| Дефект программы | Нарушен внутренний инвариант | Записать контекст и исправить код |
Перевод инфраструктурной ошибки
public final class FileUserRepository implements UserRepository {
@Override
public User findRequired(long id) {
try {
return readUser(id);
} catch (NoSuchFileException error) {
throw new UserNotFoundException(id, error);
} catch (IOException error) {
throw new RepositoryException(
"Не удалось прочитать пользователя " + id,
error
);
}
}
}
Верхний слой не обязан знать детали NIO. При этом исходная причина сохраняется
через cause, поэтому диагностика не теряется.
Checked или unchecked
Checked
Клиент действительно может и должен принять решение прямо в этой точке.
Unchecked
Нарушен контракт, инвариант или ошибка переводится на границе приложения.
Универсального правила нет. Важно не количество throws, а понятный
сценарий восстановления. Не оборачивайте все подряд в один безымянный RuntimeException.
Идемпотентность
public record PaymentCommand(
UUID idempotencyKey,
long orderId,
Money amount
) {
}
Повтор одной команды с тем же ключом должен вернуть прежний результат, а не создать второй платеж. Это особенно важно, когда timeout не сообщает, успел ли удаленный сервис выполнить операцию.
Проверка аргументов на границе
public record RegisterUser(String email, String displayName) {
public RegisterUser {
email = requireText(email, "email").toLowerCase(Locale.ROOT);
displayName = requireText(displayName, "displayName");
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " обязателен");
}
return value.trim();
}
}
Прерывание потока нужно сохранять
try {
return client.send(request, BodyHandlers.ofString());
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new ServiceInterruptedException(error);
}
InterruptedException — просьба остановиться
Если метод не может пробросить checked exception, восстановите флаг через
interrupt(). Иначе верхний уровень может потерять сигнал отмены.
Практика контракт
- Опишите ошибки операции «создать заказ» и реакцию клиента на каждую.
- Добавьте idempotency key в учебный платежный сервис.
- Найдите catch, который теряет cause или interrupt status, и исправьте.
- Решите, когда поиск должен вернуть Optional, а когда бросить NotFound.
Optional или NotFound: ориентир
findByIdобычно возвращает Optional: отсутствие — нормальный результат поиска;updateRequiredUserможет бросить NotFound: без сущности команда невыполнима;- не возвращайте null рядом с Optional — контракт станет двусмысленным;
- на HTTP-границе оба варианта могут преобразоваться в 404, но внутри приложения семантика различается.
SOLID и управление зависимостями
SOLID — не экзамен на пять букв и не требование создать интерфейс для каждого класса. Это набор вопросов, помогающих локализовать изменения и сделать код проверяемым.
Пять принципов в практической формулировке
| Принцип | Вопрос на ревью |
|---|---|
| SRP | Сколько независимых причин изменить этот класс? |
| OCP | Можно ли добавить вариант без правки стабильной логики? |
| LSP | Любая реализация честно соблюдает контракт базового типа? |
| ISP | Клиент зависит только от операций, которые ему нужны? |
| DIP | Бизнес-правило зависит от абстракции или от файла/HTTP/БД? |
Класс, который трудно тестировать
public final class RegistrationService {
public User register(String email) {
var repository = new PostgresUserRepository();
var mailer = new SmtpMailer();
var user = new User(UUID.randomUUID(), email, Instant.now());
repository.save(user);
mailer.sendWelcome(user);
return user;
}
}
Создание конкретных зависимостей, случайность и текущее время спрятаны внутри. Unit-тесту придется подключать PostgreSQL и SMTP, а результат меняется при каждом запуске.
Явные зависимости
public final class RegistrationService {
private final UserRepository users;
private final Mailer mailer;
private final Clock clock;
private final Supplier<UUID> idGenerator;
public RegistrationService(
UserRepository users,
Mailer mailer,
Clock clock,
Supplier<UUID> idGenerator) {
this.users = Objects.requireNonNull(users);
this.mailer = Objects.requireNonNull(mailer);
this.clock = Objects.requireNonNull(clock);
this.idGenerator = Objects.requireNonNull(idGenerator);
}
public User register(String email) {
User user = new User(
idGenerator.get(),
new EmailAddress(email),
Instant.now(clock)
);
users.save(user);
mailer.sendWelcome(user);
return user;
}
}
Production-композиция передает PostgreSQL, SMTP, системные часы и UUID. Тест передает in-memory repository, fake mailer, фиксированные часы и известный ID.
Interface Segregation
interface UserReader {
Optional<User> findByEmail(EmailAddress email);
}
interface UserWriter {
void save(User user);
}
final class RegistrationService {
private final UserReader reader;
private final UserWriter writer;
// ...
}
Разделение оправдано, если клиенты действительно имеют разные потребности. Делить интерфейс механически по одному методу без причины не нужно.
Liskov: реализация не должна удивлять
Если контракт Repository.save обещает сохранение, реализация
ReadOnlyRepository, бросающая UnsupportedOperationException, нарушает ожидание.
Лучше отдельный интерфейс чтения, чем «реализация», не способная выполнить контракт.
UserServiceInterface только потому, что существует UserService.
Практика рефакторинг
- Сделайте сервис с текущим временем детерминированным через
Clock. - Разделите большой интерфейс хранилища для read-only клиента.
- Найдите класс с тремя причинами изменения и предложите границы.
- Напишите ручную composition root — место, где создаются реальные зависимости.
Пример composition root
public static void main(String[] args) {
DataSource dataSource = createDataSource();
UserRepository users = new JdbcUserRepository(dataSource);
Mailer mailer = new SmtpMailer(loadMailSettings());
RegistrationService service = new RegistrationService(
users,
mailer,
Clock.systemUTC(),
UUID::randomUUID
);
new ConsoleApplication(service).run();
}
Внутри бизнес-сервиса больше нет знания о способе создания инфраструктуры.
Паттерны проектирования без «паттернов ради паттернов»
Паттерн — имя повторяющегося решения и его компромиссов. Он полезен, когда проблема уже существует, а не когда хочется усложнить три строки кода.
Strategy: заменяемый алгоритм
@FunctionalInterface
public interface DiscountPolicy {
Money discountFor(Order order);
}
DiscountPolicy noDiscount =
order -> new Money(BigDecimal.ZERO, order.currency());
DiscountPolicy loyaltyDiscount = order ->
order.total().multiply(new BigDecimal("0.05"));
Контекст вызывает единый контракт и не содержит растущий switch по типам скидки.
Decorator: добавляем поведение вокруг контракта
public final class CachingUserReader implements UserReader {
private final UserReader delegate;
private final Map<Long, User> cache = new HashMap<>();
public CachingUserReader(UserReader delegate) {
this.delegate = delegate;
}
@Override
public Optional<User> findById(long id) {
User cached = cache.get(id);
if (cached != null) {
return Optional.of(cached);
}
Optional<User> loaded = delegate.findById(id);
loaded.ifPresent(user -> cache.put(id, user));
return loaded;
}
}
Тот же прием добавляет метрики, retry, tracing или авторизацию.
Клиент продолжает зависеть от UserReader.
Factory: выбор и создание реализации
public final class ReportWriterFactory {
public ReportWriter create(ReportFormat format) {
return switch (format) {
case CSV -> new CsvReportWriter();
case JSON -> new JsonReportWriter();
};
}
}
Factory полезна, когда создание имеет правила, зависимости или несколько вариантов.
Для простого new User() отдельная фабрика обычно не нужна.
Adapter: перевод чужого интерфейса
public final class LegacySmsAdapter implements NotificationSender {
private final LegacySmsClient client;
public LegacySmsAdapter(LegacySmsClient client) {
this.client = client;
}
@Override
public void send(Notification notification) {
client.sendText(
notification.recipient().phone(),
notification.message()
);
}
}
Как выбрать паттерн
| Симптом | Кандидат |
|---|---|
| Один алгоритм имеет несколько вариантов | Strategy |
| Нужно добавить сквозное поведение | Decorator |
| Создание объекта разветвлено и сложно | Factory |
| Чужой API не совпадает с нашим контрактом | Adapter |
| Нужно уведомлять подписчиков о событии | Observer, но с контролем ошибок и жизненного цикла |
Практика дизайн
- Вынесите расчет стоимости доставки в Strategy.
- Добавьте к репозиторию decorator, считающий число вызовов и время.
- Адаптируйте старый интерфейс оплаты к новому
PaymentGateway. - Найдите в проекте паттерн, который можно удалить и заменить прямым кодом.
Каркас decorator с метриками
public final class MeasuredUserReader implements UserReader {
private final UserReader delegate;
private final Metrics metrics;
public MeasuredUserReader(UserReader delegate, Metrics metrics) {
this.delegate = delegate;
this.metrics = metrics;
}
@Override
public Optional<User> findById(long id) {
long started = System.nanoTime();
try {
return delegate.findById(id);
} finally {
metrics.record(
"users.find",
System.nanoTime() - started
);
}
}
}
Аннотации, Reflection API и динамическое поведение
Framework видит @Test, @Entity или @Inject
потому, что читает метаданные и строит поведение. Создадим маленький механизм сами.
Собственная аннотация
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Command {
String value();
String description() default "";
}
RUNTIMEсохраняет аннотацию для Reflection API;METHODограничивает допустимое место применения;- элементы аннотации имеют ограниченный набор типов и могут иметь default.
Аннотированные методы
public final class AdminCommands {
@Command(value = "status", description = "Состояние системы")
public void status() {
System.out.println("Система работает");
}
@Command("cleanup")
public void cleanup() {
System.out.println("Очистка завершена");
}
}
Сканируем известный объект
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
public final class CommandRegistry {
private final Map<String, Runnable> commands = new HashMap<>();
public void register(Object handler) {
for (Method method : handler.getClass().getDeclaredMethods()) {
Command annotation = method.getAnnotation(Command.class);
if (annotation == null) {
continue;
}
validate(method);
Runnable action = () -> invoke(method, handler);
Runnable previous = commands.putIfAbsent(
annotation.value(),
action
);
if (previous != null) {
throw new IllegalStateException(
"Команда уже зарегистрирована: " + annotation.value()
);
}
}
}
private static void validate(Method method) {
if (method.getParameterCount() != 0
|| method.getReturnType() != void.class) {
throw new IllegalArgumentException(
"Команда должна быть void-методом без параметров: " + method
);
}
}
private static void invoke(Method method, Object target) {
try {
method.invoke(target);
} catch (ReflectiveOperationException error) {
throw new CommandExecutionException(method.getName(), error);
}
}
}
Dynamic Proxy для интерфейса
import java.lang.reflect.Proxy;
public final class TimingProxy {
@SuppressWarnings("unchecked")
public static <T> T wrap(
Class<T> contract,
T target,
Metrics metrics) {
return (T) Proxy.newProxyInstance(
contract.getClassLoader(),
new Class<?>[]{contract},
(proxy, method, args) -> {
long started = System.nanoTime();
try {
return method.invoke(target, args);
} finally {
metrics.record(
method.getName(),
System.nanoTime() - started
);
}
}
);
}
}
JDK Proxy работает только с интерфейсами. Вызов через reflection оборачивает
исключение целевого метода в InvocationTargetException; production-код
должен аккуратно извлекать и переводить исходную причину.
Reflection или annotation processing
Reflection во время запуска
Гибко, но ошибки обнаруживаются позже и доступ контролируется JPMS.
Annotation Processor при компиляции
Может генерировать код и выдавать ошибки раньше, но требует отдельного инструментария.
setAccessible(true) по привычке
Это создает хрупкую зависимость от внутреннего устройства класса и может быть
запрещено модульной системой. Сначала используйте публичный контракт.
Практика метапрограммирование
- Добавьте аннотацию
@Retry(attempts = 3)и прочитайте ее значение. - Расширьте registry: выводите description всех команд.
- Проверьте дубликат команды и неверную сигнатуру тестами.
- Объясните, почему обычный decorator может быть лучше dynamic proxy.
Объявление @Retry
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
int attempts() default 3;
}
Retry retry = method.getAnnotation(Retry.class);
int attempts = retry == null ? 1 : retry.attempts();
if (attempts < 1) {
throw new IllegalArgumentException("attempts должен быть положительным");
}
NIO.2 и надежная работа с файлами
Запись напрямую в основной файл может оставить половину данных после сбоя. Продвинутый код явно выбирает кодировку, закрывает ресурсы и публикует результат атомарно.
Path вместо склеивания строк
Path base = Path.of("data").toAbsolutePath().normalize();
Path report = base.resolve("reports").resolve("daily.csv");
if (!report.startsWith(base)) {
throw new SecurityException("Выход за разрешенную папку");
}
Нормализация и проверка базового каталога особенно важны, если часть пути приходит от пользователя.
Ленивый обход обязательно закрывается
try (Stream<Path> paths = Files.walk(base)) {
List<Path> javaFiles = paths
.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java"))
.toList();
}
Files.lines, Files.list и Files.walk возвращают ресурс
Оборачивайте stream в try-with-resources. Обычный stream коллекции закрывать не требуется.
Атомарная публикация файла
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
public final class AtomicTextFile {
public static void write(Path target, String content)
throws IOException {
Path absolute = target.toAbsolutePath();
Path directory = absolute.getParent();
Files.createDirectories(directory);
Path temporary = Files.createTempFile(
directory,
absolute.getFileName().toString(),
".tmp"
);
boolean published = false;
try {
Files.writeString(
temporary,
content,
StandardCharsets.UTF_8
);
moveIntoPlace(temporary, absolute);
published = true;
} finally {
if (!published) {
Files.deleteIfExists(temporary);
}
}
}
private static void moveIntoPlace(Path source, Path target)
throws IOException {
try {
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
);
} catch (AtomicMoveNotSupportedException error) {
Files.move(
source,
target,
StandardCopyOption.REPLACE_EXISTING
);
}
}
}
Temporary-файл создается в том же каталоге: так выше шанс поддержки атомарного move в пределах одной файловой системы. Fallback честно признает, что гарантия зависит от платформы.
Потоковое чтение больших данных
long errors;
try (Stream<String> lines = Files.lines(log, StandardCharsets.UTF_8)) {
errors = lines
.filter(line -> line.contains(" ERROR "))
.count();
}
Формат данных — отдельный контракт
- зафиксируйте версию формата;
- определите escaping разделителей и переводов строк;
- решите, как читать старую версию;
- не смешивайте доменную модель с деталями CSV/JSON;
- проверяйте размер и структуру недоверенного файла.
Channels и ByteBuffer
Для высокопроизводительного или неблокирующего I/O изучайте FileChannel,
SocketChannel и ByteBuffer. Но не заменяйте простой
Files.writeString сложным буферным кодом без измеренной причины.
Практика надежность
- Реализуйте атомарное сохранение списка задач с UTF-8.
- Посчитайте уровни логов в файле, не загружая его целиком.
- Защитите выгрузку отчета от path traversal.
- Добавьте версию формата и миграцию одной старой строки.
Проверка безопасного имени файла
static Path safeResolve(Path root, String userName) {
Path normalizedRoot = root.toAbsolutePath().normalize();
Path candidate = normalizedRoot
.resolve(userName)
.normalize();
if (!candidate.startsWith(normalizedRoot)) {
throw new IllegalArgumentException("Недопустимый путь");
}
return candidate;
}
Дополнительно ограничьте допустимые символы имени и не позволяйте пользователю выбирать произвольное расширение, если оно влияет на обработку.
HTTP и внешние сервисы
Сеть ненадежна по определению: ответ может задержаться, соединение — оборваться, а сервис — вернуть ошибку. Продвинутый разработчик проектирует эту неопределенность, а не надеется, что «обычно работает».
HttpClientHTTP-разговор простыми словами
Клиент отправляет request: метод, адрес, заголовки и иногда тело.
Сервер возвращает response: статус, заголовки и тело. Статус
404 — это успешно доставленный HTTP-ответ, а не исключение Java.
Исключение возникает, например, когда не удалось установить соединение.
| Диапазон | Смысл | Обычная реакция клиента |
|---|---|---|
2xx | Запрос выполнен | Обработать тело |
3xx | Перенаправление | Следовать только по заданной политике |
4xx | Проблема запроса или доступа | Исправить запрос; обычно не повторять |
5xx | Проблема сервера | Иногда повторить с задержкой |
Один клиент — много запросов
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class HealthClient {
private final HttpClient client;
public HealthClient() {
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
public int status(URI uri)
throws java.io.IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(5))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<Void> response = client.send(
request,
HttpResponse.BodyHandlers.discarding()
);
return response.statusCode();
}
}
HttpClient безопасен для совместного использования. Создавайте его на уровень
приложения или компонента: так клиент сможет повторно использовать соединения. Отдельный
клиент на каждый запрос теряет эту пользу и усложняет управление ресурсами.
Connect timeout
Сколько ждать установления соединения с узлом.
Request timeout
Сколько максимум ждать завершения конкретного запроса.
Синхронно и асинхронно
var future = client.sendAsync(
request,
HttpResponse.BodyHandlers.ofString()
);
future
.thenApply(HttpResponse::statusCode)
.thenAccept(code -> System.out.println("Статус: " + code))
.exceptionally(error -> {
System.err.println("Сеть недоступна: " + error.getMessage());
return null;
});
send проще читать и отлично сочетается с virtual threads.
sendAsync полезен, когда приложение уже строит неблокирующую цепочку
CompletableFuture. Не выбирайте асинхронный API только ради слова
«производительность»: сначала определите модель выполнения.
Retry: повторять можно не всё
GET обычно идемпотентен: повтор не меняет смысл операции.
Повторный POST может создать второй заказ или второй платеж.
Для изменяющих операций нужны идемпотентные ключи и договоренность с сервером.
static Duration retryDelay(int attempt) {
long baseMillis = 200L * (1L << Math.min(attempt - 1, 5));
long jitterMillis = java.util.concurrent.ThreadLocalRandom
.current()
.nextLong(100);
return Duration.ofMillis(baseMillis + jitterMillis);
}
Экспоненциальная задержка не дает клиентам атаковать восстанавливающийся сервер частыми повторами, а небольшой jitter разводит одновременные попытки разных клиентов. Всегда задавайте предел попыток и общий бюджет времени.
InterruptedException, восстановите флаг через
Thread.currentThread().interrupt() и завершите операцию. Прерывание —
сигнал отмены, а не случайная ошибка сети.
Защитный пояс интеграции
- Rate limit ограничивает частоту обращений.
- Bulkhead не дает одной интеграции занять все ресурсы.
- Circuit breaker временно прекращает заведомо бесполезные запросы.
- Валидация не доверяет внешнему JSON, даже если сервер «наш».
- Наблюдаемость записывает длительность, статус и имя операции без секретов.
Практика интеграция
- Напишите клиент, который проверяет три URI и возвращает статус с длительностью.
- Разделите результат на успешный ответ, HTTP-ошибку и транспортную ошибку.
- Добавьте не более трех попыток только для
5xxи сетевых сбоев. - Проверьте, что секретный токен не попадает в сообщение исключения и лог.
Мини-модель результата без исключений в вызывающем коде
public sealed interface HttpResult {
record Ok(int statusCode) implements HttpResult {}
record Rejected(int statusCode) implements HttpResult {}
record Unavailable(String reason) implements HttpResult {}
}
static HttpResult classify(int statusCode) {
if (statusCode >= 200 && statusCode < 300) {
return new HttpResult.Ok(statusCode);
}
return new HttpResult.Rejected(statusCode);
}
Транспортное исключение перехватывается на границе HTTP-адаптера и превращается
в Unavailable. Доменный код больше не обязан знать классы сетевой библиотеки.
Java Memory Model: почему потоки видят мир по-разному
Потоки работают с общей памятью, но чтения и записи могут наблюдаться в разном порядке. Java Memory Model задает правила видимости. Это не магия процессора, а контракт, на котором строится вся корректная конкурентность.
Три разные проблемы
| Проблема | Вопрос | Пример решения |
|---|---|---|
| Atomicity | Может ли операция вклиниться посередине другой? | synchronized, AtomicLong |
| Visibility | Увидит ли поток новое значение? | volatile, lock, безопасная публикация |
| Ordering | В каком порядке эффекты видны другим потокам? | happens-before через средства синхронизации |
counter++ — не одна операция
final class BrokenCounter {
private long value;
void increment() {
value++; // прочитать → прибавить → записать
}
long value() {
return value;
}
}
Два потока могут прочитать одно значение и оба записать одинаковый результат.
Одно увеличение потеряется. volatile long value улучшит видимость,
но не сделает составную операцию атомарной.
import java.util.concurrent.atomic.AtomicLong;
final class AtomicCounter {
private final AtomicLong value = new AtomicLong();
void increment() {
value.incrementAndGet();
}
long value() {
return value.get();
}
}
Happens-before — гарантия видимости
Если действие A happens-before действия B, то эффекты A гарантированно видны B. Это отношение порядка, а не измерение времени по часам.
- выход из
synchronizedhappens-before следующим входом в тот же монитор; - запись в
volatileполе happens-before последующему чтению этого поля; - вызов
Thread.start()публикует ранее подготовленные данные новому потоку; - завершение потока happens-before успешному возврату из
join(); - отношение транзитивно: A → B и B → C означают A → C.
final-полями намного
проще безопасно передавать между потоками, чем защищать изменяемый граф объектов.
Когда подходит volatile
final class Worker {
private volatile boolean running = true;
void stop() {
running = false;
}
void runLoop() {
while (running) {
doOneSmallStep();
}
}
private void doOneSmallStep() {
// Короткая единица работы.
}
}
Здесь одно поле хранит независимое состояние-флаг. Но условие
if (balance >= amount) balance -= amount уже состоит из нескольких
связанных действий: ему нужен общий lock или другая атомарная модель.
Инвариант определяет границу lock
final class Inventory {
private int available;
private int reserved;
Inventory(int available) {
this.available = available;
}
synchronized boolean reserveOne() {
if (available == 0) {
return false;
}
available--;
reserved++;
return true;
}
synchronized int total() {
return available + reserved;
}
}
Инвариант «общее количество не меняется» связывает два поля, поэтому они защищены одним монитором. Два разных lock сделали бы снимок состояния несогласованным.
Concurrent collections
var counters =
new java.util.concurrent.ConcurrentHashMap<String, AtomicLong>();
counters
.computeIfAbsent("http.200", ignored -> new AtomicLong())
.incrementAndGet();
ConcurrentHashMap безопасна для конкурентных операций, но не превращает
произвольную цепочку get → вычислить → put в одну транзакцию.
Используйте ее атомарные методы и держите функцию compute… короткой:
не выполняйте внутри медленный I/O и не пытайтесь рекурсивно обновлять тот же ключ.
Deadlock: два замка, два ожидания
Если поток A держит lock X и ждет Y, а поток B держит Y и ждет X, никто не продолжит. Базовая защита — единый порядок захвата: например, всегда сначала lock с меньшим id. Еще лучше — уменьшить число одновременно удерживаемых замков.
Практика потоки
- Объясните, какие три действия скрыты в
count++. - Реализуйте потокобезопасный лимит: списать можно, только если остаток не отрицателен.
- Сделайте счетчик событий по ключам через
ConcurrentHashMap. - Нарисуйте happens-before для «главный поток заполнил список → запустил worker».
Решение задачи с лимитом
final class Limit {
private long remaining;
Limit(long remaining) {
if (remaining < 0) {
throw new IllegalArgumentException("Лимит меньше нуля");
}
this.remaining = remaining;
}
synchronized boolean tryAcquire(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Сумма должна быть положительной");
}
if (remaining < amount) {
return false;
}
remaining -= amount;
return true;
}
synchronized long remaining() {
return remaining;
}
}
Проверка и изменение выполняются под одним lock — другой поток не может вклиниться между ними. Именно весь инвариант, а не отдельное поле, задает критическую секцию.
Конкурентность и virtual threads
Конкурентность помогает ждать много независимых операций одновременно. Она не делает вычисления бесплатными. Сначала определяем вид нагрузки, затем выбираем executor, границы отмены и лимиты.
CPU-bound
Сжатие, хеширование, вычисления. Параллелизм ограничен ядрами процессора; слишком много потоков добавят переключения.
I/O-bound
HTTP, база данных, файлы. Задача большую часть времени ждет; virtual threads позволяют удобно держать много таких ожиданий.
Structured lifetime без preview API
import java.util.ArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new ArrayList<Future<Integer>>();
for (var uri : uris) {
futures.add(executor.submit(() -> client.status(uri)));
}
for (var future : futures) {
System.out.println(future.get());
}
}
На каждую задачу создается легкий virtual thread. Блокирующий код остается прямым и читаемым. Закрытие executor прекращает прием новых задач, инициирует штатное завершение и ждет выполнения уже отправленных задач.
Thread, которую JVM может временно снимать с carrier thread,
пока она ждет I/O. Семантика исключений, ThreadLocal, interruption
и синхронизации остается важной.
Virtual threads не нужно объединять в маленький pool
Их создают по одной на задачу. Если внешний ресурс выдерживает только 20 одновременных
запросов, ограничивайте ресурс через Semaphore, пул
соединений или rate limiter — не количество virtual threads.
import java.util.concurrent.Semaphore;
final class LimitedProbe {
private final Semaphore permits = new Semaphore(20);
ProbeResult check(Endpoint endpoint) throws InterruptedException {
permits.acquire();
try {
return performRequest(endpoint);
} finally {
permits.release();
}
}
private ProbeResult performRequest(Endpoint endpoint) {
// HTTP-вызов.
throw new UnsupportedOperationException("пример");
}
}
Future: результат, timeout, отмена
try {
var result = future.get(6, java.util.concurrent.TimeUnit.SECONDS);
consume(result);
} catch (java.util.concurrent.TimeoutException e) {
future.cancel(true); // просьба прервать задачу
reportTimeout();
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
return;
} catch (java.util.concurrent.ExecutionException e) {
throw new IllegalStateException("Задача завершилась с ошибкой", e.getCause());
}
cancel(true) не уничтожает поток: это кооперативный сигнал.
Задача обязана не игнорировать interruption и регулярно доходить до прерываемых
операций или проверять флаг.
CompletableFuture для графа зависимостей
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var profile = java.util.concurrent.CompletableFuture.supplyAsync(
() -> loadProfile(userId),
executor
);
var orders = java.util.concurrent.CompletableFuture.supplyAsync(
() -> loadOrders(userId),
executor
);
var page = profile.thenCombine(orders, UserPage::new).join();
}
Передавайте executor явно, особенно для блокирующей работы. Иначе async-операция
часто попадет в общий ForkJoinPool, который вы не контролируете.
Длинные цепочки обрабатывайте с единой политикой ошибок и timeout.
Что может пойти не так
- неограниченная очередь быстрее принимает работу, чем система успевает выполнять;
- один потерянный timeout удерживает задачу бесконечно;
- общий изменяемый объект создает race между тысячами легких потоков;
- тяжелые
ThreadLocal-объекты умножаются на число virtual threads; - долгий I/O внутри широкой критической секции блокирует остальных владельцев lock.
synchronized закрепляла virtual thread за carrier thread.
Но длинные критические секции по-прежнему создают обычную конкуренцию за lock,
а редкие native/foreign-вызовы могут закреплять поток. Измеряйте реальную нагрузку.
Structured Concurrency в Java 25 остается preview-возможностью, поэтому курс
намеренно не делает ее основой production-кода. Сначала уверенно освойте
стабильные ExecutorService, Future и отмену.
Практика конкурентность
- Проверьте 50 адресов через virtual-thread-per-task executor.
- Ограничьте одновременные HTTP-запросы десятью разрешениями.
- Добавьте общий deadline и корректно отмените незавершенные задачи.
- Сравните время последовательного и конкурентного запуска на искусственном I/O.
Мини-лаборатория: почему ожидание хорошо масштабируется
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.IntStream;
public class VirtualThreadLab {
public static void main(String[] args) throws Exception {
Instant started = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = IntStream.range(0, 1_000)
.mapToObj(i -> executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100));
return i;
}))
.toList();
for (Future<Integer> task : tasks) {
task.get();
}
}
System.out.println(Duration.between(started, Instant.now()));
}
}
Это демонстрация ожидания, а не полноценный benchmark. Замените sleep
вычислением и увидите, что тысяча задач не создает тысячу процессорных ядер.
Тестирование с JUnit 6
Тест — это исполняемый пример контракта. Хороший тест объясняет поведение, быстро находит регрессию и не ломается от безопасного рефакторинга.
Аранжировка из трех частей
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class PriceCalculatorTest {
@Test
void appliesTenPercentDiscountForPremiumCustomer() {
// Arrange
var calculator = new PriceCalculator();
var price = new BigDecimal("100.00");
// Act
var result = calculator.discounted(price, CustomerType.PREMIUM);
// Assert
assertEquals(new BigDecimal("90.00"), result);
}
}
Название отвечает на вопрос: какое правило проверяется и при каком условии? Комментарии Arrange–Act–Assert полезны в начале; в коротком очевидном тесте их можно убрать.
Проверяйте границы таблицей примеров
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PasswordPolicyTest {
private final PasswordPolicy policy = new PasswordPolicy(8);
@ParameterizedTest(name = "[{index}] «{0}» → {1}")
@CsvSource({
"'', false",
"1234567, false",
"12345678, true",
"strong-pass, true"
})
void validatesMinimumLength(String password, boolean expected) {
assertEquals(expected, policy.isValid(password));
}
}
Для числовой границы нужны примеры граница − 1, граница
и граница + 1. Для коллекции — пустая, один элемент, несколько элементов
и повторения. Таблица делает пропуски заметными.
Исключение — тоже часть контракта
var error = org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> new Endpoint("", java.net.URI.create("https://example.com"))
);
org.junit.jupiter.api.Assertions.assertEquals(
"Имя не должно быть пустым",
error.getMessage()
);
Проверяйте тип и полезную часть сообщения, если сообщение является частью пользовательского или интеграционного контракта. Не привязывайте тест к служебным деталям, которые разрешено менять.
Файловый тест без мусора на машине
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EndpointLoaderTest {
@TempDir
Path directory;
@Test
void ignoresCommentsAndBlankLines() throws Exception {
Path config = directory.resolve("endpoints.txt");
Files.writeString(config, """
# production
Site|https://example.com
""");
var endpoints = new EndpointLoader().load(config);
assertEquals(1, endpoints.size());
assertEquals("Site", endpoints.getFirst().name());
}
}
Fake вместо настоящей сети
final class FakeProbe implements Probe {
private final ProbeResult answer;
private int calls;
FakeProbe(ProbeResult answer) {
this.answer = answer;
}
@Override
public ProbeResult check(Endpoint endpoint) {
calls++;
return answer;
}
int calls() {
return calls;
}
}
Fake — маленькая рабочая реализация интерфейса для теста. Она часто понятнее сложной конфигурации mock-фреймворка. Mock полезен, когда важно проверить конкретное взаимодействие, но злоупотребление делает тест копией внутренней реализации.
Пирамида проверок
| Уровень | Что проверяет | Скорость и количество |
|---|---|---|
| Unit | Правило в одном компоненте, зависимости заменены | Миллисекунды; большинство тестов |
| Integration | Работу с БД, HTTP, файлами, сериализацией | Медленнее; целевые границы |
| End-to-end | Критический сценарий всей системы | Самые дорогие; немного |
Детерминизм: время и случайность должны приходить снаружи
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
final class TokenService {
private final Clock clock;
private final Duration lifetime;
TokenService(Clock clock, Duration lifetime) {
this.clock = clock;
this.lifetime = lifetime;
}
Instant expiresAt() {
return clock.instant().plus(lifetime);
}
}
@org.junit.jupiter.api.Test
void calculatesExpirationFromInjectedClock() {
var now = java.time.Instant.parse("2026-07-27T10:00:00Z");
var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
var service = new TokenService(clock, java.time.Duration.ofMinutes(15));
org.junit.jupiter.api.Assertions.assertEquals(
java.time.Instant.parse("2026-07-27T10:15:00Z"),
service.expiresAt()
);
}
Future.get(timeout), latch, очередь или тестовый scheduler.
Timeout нужен, чтобы ошибка завершалась, а не зависала.
Что тестировать в первую очередь
- денежные, доступные и юридически важные правила;
- границы ввода, пустые значения и дубликаты;
- ветви восстановления после ошибок;
- форматы, которыми обмениваются разные компоненты;
- баг, который уже случился: сначала воспроизводящий тест, затем исправление.
Практика тест-дизайн
- Напишите таблицу тестов для трех диапазонов HTTP-статусов.
- Проверьте retry через fake, считающий вызовы, без реального ожидания.
- Протестируйте атомарный writer через
@TempDir. - Найдите тест, который знает приватную реализацию, и перепишите его через контракт.
Тест количества попыток
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
class RetryingProbeTest {
@Test
void stopsImmediatelyAfterSuccess() {
var endpoint = new Endpoint(
"NeroSky",
URI.create("https://nerosky.ru")
);
var success = new ProbeResult.Success(
endpoint,
200,
Instant.EPOCH,
Duration.ZERO
);
var fake = new FakeProbe(success);
var probe = new RetryingProbe(
fake,
3,
Duration.ofMillis(100),
duration -> {},
Clock.systemUTC()
);
var result = probe.check(endpoint);
assertInstanceOf(ProbeResult.Success.class, result);
assertEquals(1, fake.calls());
}
}
Функция ожидания передана как зависимость, поэтому unit-тест не спит. В итоговом проекте увидите полноценную реализацию этой идеи.
JVM, производительность и надежность
Оптимизация начинается не с хитрого цикла, а с вопроса «что именно тормозит?». JVM собирает мусор, компилирует горячий код и умеет записывать события приложения. Наша задача — наблюдать, измерять и менять только доказанное узкое место.
Путь кода внутри JVM
- Class loader находит и загружает bytecode классов.
- Verifier проверяет структурную корректность и безопасность bytecode.
- Interpreter начинает выполнять инструкции без долгого разогрева.
- JIT compiler замечает горячие участки и компилирует их в машинный код.
- Deoptimization позволяет JVM отказаться от предположения, если оно перестало быть верным.
Поэтому первые вызовы и прогретая система могут иметь разную скорость. Бенчмарк без warmup часто измеряет компиляцию, загрузку классов или работу, которую оптимизатор вообще удалил.
Где живут данные
| Область | Что там | Типичная проблема |
|---|---|---|
| Heap | Объекты и массивы, доступные из разных потоков | Высокая аллокация, удержание объектов, длинные GC pause |
| Stack | Фреймы вызовов и локальные данные каждого потока | StackOverflowError при глубокой рекурсии |
| Metaspace | Метаданные загруженных классов | Утечка class loader или чрезмерная генерация классов |
| Native memory | Потоки, буферы, библиотеки, структуры JVM | Процесс растет, хотя heap выглядит нормальным |
Рабочий цикл расследования
- зафиксировать симптом и пользовательский эффект;
- собрать метрики, логи и профиль в условиях, похожих на реальные;
- назвать гипотезу: CPU, блокировка, I/O, аллокации, GC или внешний сервис;
- изменить одну вещь и повторить измерение;
- проверить корректность, p95/p99 latency и использование ресурсов.
Java Flight Recorder
java -XX:StartFlightRecording=filename=nerosky.jfr,duration=60s \
-jar target/nerowatch.jar
jcmd <PID> JFR.start \
name=investigation \
settings=profile \
duration=2m \
filename=investigation.jfr
Запись открывают в JDK Mission Control. Начните с CPU samples, allocation, lock contention, thread states, file/socket I/O и пауз GC. Не собирайте чувствительные данные без политики доступа и хранения.
Микробенчмарк — только через JMH
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class LookupBenchmark {
private final List<Integer> list =
java.util.stream.IntStream.range(0, 10_000).boxed().toList();
private final Set<Integer> set = new HashSet<>(list);
@Benchmark
public boolean listLookup() {
return list.contains(9_999);
}
@Benchmark
public boolean setLookup() {
return set.contains(9_999);
}
}
JMH управляет warmup, повторениями, fork-процессами и защитой от части оптимизаций.
Самодельный цикл с System.nanoTime() почти всегда отвечает не на тот вопрос.
Но даже JMH не заменяет нагрузочный тест целого пользовательского сценария.
Наблюдаемость: три сигнала и один контекст
- Logs рассказывают о конкретном событии; используйте уровни и структуру.
- Metrics показывают тенденцию: rate, errors, duration, saturation.
- Traces связывают путь запроса через несколько сервисов.
- Correlation id помогает найти относящиеся к одному запросу события.
logger.info(
"probe_completed endpoint={} status={} latency_ms={} attempt={}",
endpoint.name(),
statusCode,
latency.toMillis(),
attempt
);
Production-готовность — это пределы
На входе
- максимальный размер запроса;
- проверка формата и диапазонов;
- лимит параллельной работы;
- безопасный разбор внешних данных.
На выходе
- timeout и deadline;
- ограниченный retry;
- backpressure и circuit breaker;
- graceful shutdown.
Graceful shutdown
После сигнала остановки приложение прекращает принимать новую работу, дает текущим операциям ограниченное время завершиться, сохраняет нужное состояние и закрывает ресурсы. Shutdown hook должен быть коротким, идемпотентным и иметь собственный deadline.
Перед релизом
- зависимости проверяются на известные уязвимости и регулярно обновляются;
- секреты приходят из защищенной конфигурации, а не из репозитория;
- права процесса минимальны;
- есть health/readiness-сигналы и понятный runbook;
- резервное восстановление проверено практикой, а не только наличием файла backup.
Практика production
- Снимите минутную JFR-запись программы с CPU- и I/O-нагрузкой.
- Найдите самое горячее место и сформулируйте гипотезу до изменения кода.
- Добавьте к HTTP-проверке метрики количества, ошибок и длительности.
- Составьте release checklist из десяти проверяемых пунктов.
Мини-чеклист расследования медленной операции
- Назовите endpoint и интервал, сравните median, p95 и p99.
- Разделите время на очередь, приложение, базу/HTTP и сериализацию.
- Проверьте saturation: CPU, heap, GC, pool, очередь, соединения.
- Сопоставьте trace с логами и JFR на том же временном отрезке.
- Сделайте минимальное изменение, нагрузите повторно и сохраните результаты.
«Похоже, медленная база» — не результат. «На p99 820 из 940 мс занимает ожидание соединения из пула при 100% utilization» — проверяемое наблюдение.
NeroWatch: монитор доступности сервисов
Соберем знания курса в одно консольное приложение. NeroWatch читает список HTTP-адресов, проверяет их конкурентно, повторяет временные сбои и атомарно записывает CSV-отчет.
Сценарий
Команда эксплуатации хранит файл адресов. Раз в запуск NeroWatch проверяет каждый сервис. Проверки идут одновременно в virtual threads, но не более заданного числа. Результат появляется либо целиком, либо не заменяет предыдущий отчет.
# Имя|URL
NeroSky|https://nerosky.ru
Example|https://example.com
Java|https://dev.java
mvn clean test
mvn clean package
java -jar target/nerowatch-1.0.0.jar config/endpoints.txt reports
Проверено: 3
Доступно: 3
Ошибок: 0
Отчет: reports/nerowatch-20260727-101530.csv
Функциональные требования
- Игнорировать пустые строки и комментарии, начинающиеся с
#. - Разрешать только абсолютные URI со схемой
httpилиhttps. - Использовать один
HttpClient, connect timeout и request timeout. - Считать успешными ответы
2xx. - Повторять сетевые ошибки и
5xx, но не4xx. - Запускать одну virtual thread на endpoint и ограничить одновременный I/O.
- Сохранить UTF-8 CSV через временный файл и atomic move, если он поддерживается.
- Не требовать реальной сети в unit-тестах.
Нефункциональные требования
- Java 25 без preview-возможностей;
- воспроизводимая Maven-сборка;
- неизменяемые модели и constructor injection;
- нет бесконечного ожидания, неограниченного retry и проглоченного interruption;
- ошибка одной проверки не отменяет отчет по остальным сервисам;
- код разделен на model, port, adapter и application.
Архитектура
endpoints.txt
│
▼
EndpointLoader ──► List<Endpoint>
│
▼
MonitorService
│ │
virtual thread Semaphore
│ │
▼ ▼
RetryingProbe ──► JdkHttpProbe ──► HTTP
│
▼
List<ProbeResult>
│
▼
CsvReportWriter ──► reports/*.csv
Probe — порт приложения. JdkHttpProbe — сетевой адаптер.
RetryingProbe — decorator. Благодаря этой границе orchestration
тестируется через fake без DNS, TLS и случайной задержки сети.
Структура проекта
nerowatch/
├── pom.xml
├── config/
│ └── endpoints.txt
├── src/
│ ├── main/java/ru/nerosky/nerowatch/
│ │ ├── Main.java
│ │ ├── application/MonitorService.java
│ │ ├── adapter/CsvReportWriter.java
│ │ ├── adapter/EndpointLoader.java
│ │ ├── adapter/JdkHttpProbe.java
│ │ ├── adapter/LimitedProbe.java
│ │ ├── adapter/RetryingProbe.java
│ │ ├── model/Endpoint.java
│ │ ├── model/ProbeResult.java
│ │ └── port/Probe.java
│ └── test/java/ru/nerosky/nerowatch/
│ ├── EndpointLoaderTest.java
│ └── RetryingProbeTest.java
└── reports/ ← создается при запуске
Этапы реализации
- Модель. Endpoint и два варианта ProbeResult с проверкой инвариантов.
- Ввод. Загрузчик конфигурации и тесты ошибочных строк.
- Порт. Интерфейс Probe и fake-реализация в тестах.
- HTTP. Один запрос с двумя timeout и измерением monotonic clock.
- Надежность. Decorator retry и decorator concurrency limit.
- Оркестрация. Virtual-thread-per-task executor.
- Вывод. Корректный CSV и атомарная публикация.
- Сборка. Main, JAR, негативные сценарии и review.
Эталонная реализация: pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.nerosky.training</groupId>
<artifactId>nerowatch</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.0</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<archive>
<manifest>
<mainClass>ru.nerosky.nerowatch.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Эталонная реализация: модель и порт
package ru.nerosky.nerowatch.model;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
public record Endpoint(String name, URI uri) {
private static final Set<String> ALLOWED_SCHEMES =
Set.of("http", "https");
public Endpoint {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(uri, "uri");
name = name.strip();
if (name.isEmpty()) {
throw new IllegalArgumentException("Имя не должно быть пустым");
}
if (!uri.isAbsolute() || uri.getHost() == null) {
throw new IllegalArgumentException("Нужен абсолютный HTTP URI");
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!ALLOWED_SCHEMES.contains(scheme)) {
throw new IllegalArgumentException("Разрешены только http и https");
}
}
}
package ru.nerosky.nerowatch.model;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
public sealed interface ProbeResult
permits ProbeResult.Success, ProbeResult.Failure {
Endpoint endpoint();
Instant checkedAt();
Duration latency();
record Success(
Endpoint endpoint,
int statusCode,
Instant checkedAt,
Duration latency
) implements ProbeResult {
public Success {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(checkedAt, "checkedAt");
Objects.requireNonNull(latency, "latency");
if (statusCode < 200 || statusCode >= 300) {
throw new IllegalArgumentException("Успех должен иметь статус 2xx");
}
if (latency.isNegative()) {
throw new IllegalArgumentException("Отрицательная длительность");
}
}
}
record Failure(
Endpoint endpoint,
String reason,
boolean retryable,
Instant checkedAt,
Duration latency
) implements ProbeResult {
public Failure {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(reason, "reason");
Objects.requireNonNull(checkedAt, "checkedAt");
Objects.requireNonNull(latency, "latency");
if (reason.isBlank()) {
throw new IllegalArgumentException("Нужна причина ошибки");
}
if (latency.isNegative()) {
throw new IllegalArgumentException("Отрицательная длительность");
}
}
}
}
package ru.nerosky.nerowatch.port;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
@FunctionalInterface
public interface Probe {
ProbeResult check(Endpoint endpoint);
}
Эталонная реализация: HTTP-адаптер
package ru.nerosky.nerowatch.adapter;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
public final class JdkHttpProbe implements Probe {
private final HttpClient client;
private final Duration requestTimeout;
private final Clock clock;
public JdkHttpProbe(
HttpClient client,
Duration requestTimeout,
Clock clock
) {
this.client = Objects.requireNonNull(client, "client");
this.requestTimeout = Objects.requireNonNull(
requestTimeout,
"requestTimeout"
);
this.clock = Objects.requireNonNull(clock, "clock");
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalArgumentException("Timeout должен быть положительным");
}
}
@Override
public ProbeResult check(Endpoint endpoint) {
long startedNanos = System.nanoTime();
HttpRequest request = HttpRequest.newBuilder(endpoint.uri())
.timeout(requestTimeout)
.header("Accept", "*/*")
.GET()
.build();
try {
var response = client.send(
request,
HttpResponse.BodyHandlers.discarding()
);
Duration latency = elapsedSince(startedNanos);
int status = response.statusCode();
if (status >= 200 && status < 300) {
return new ProbeResult.Success(
endpoint,
status,
clock.instant(),
latency
);
}
return new ProbeResult.Failure(
endpoint,
"HTTP " + status,
status >= 500,
clock.instant(),
latency
);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new ProbeResult.Failure(
endpoint,
"Проверка прервана",
false,
clock.instant(),
elapsedSince(startedNanos)
);
} catch (IOException error) {
return new ProbeResult.Failure(
endpoint,
safeMessage(error),
true,
clock.instant(),
elapsedSince(startedNanos)
);
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
private static String safeMessage(IOException error) {
String type = error.getClass().getSimpleName();
return type.isBlank() ? "Ошибка ввода-вывода" : type;
}
}
System.nanoTime() подходит для длительности: он монотонный.
Clock.instant() дает календарную отметку, которую легко заменить в тесте.
Эталонная реализация: retry и ограничение параллелизма
package ru.nerosky.nerowatch.adapter;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
public final class RetryingProbe implements Probe {
@FunctionalInterface
public interface Sleeper {
void sleep(Duration duration) throws InterruptedException;
}
private final Probe delegate;
private final int maxAttempts;
private final Duration baseDelay;
private final Sleeper sleeper;
private final Clock clock;
public RetryingProbe(
Probe delegate,
int maxAttempts,
Duration baseDelay,
Sleeper sleeper,
Clock clock
) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.baseDelay = Objects.requireNonNull(baseDelay, "baseDelay");
this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
this.clock = Objects.requireNonNull(clock, "clock");
if (maxAttempts < 1) {
throw new IllegalArgumentException("Нужна хотя бы одна попытка");
}
if (baseDelay.isNegative()) {
throw new IllegalArgumentException("Задержка меньше нуля");
}
this.maxAttempts = maxAttempts;
}
@Override
public ProbeResult check(Endpoint endpoint) {
ProbeResult last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
last = delegate.check(endpoint);
if (!(last instanceof ProbeResult.Failure failure)
|| !failure.retryable()
|| Thread.currentThread().isInterrupted()) {
return last;
}
if (attempt == maxAttempts) {
break;
}
try {
sleeper.sleep(delayFor(attempt));
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new ProbeResult.Failure(
endpoint,
"Ожидание повтора прервано",
false,
clock.instant(),
Duration.ZERO
);
}
}
return Objects.requireNonNull(last);
}
private Duration delayFor(int completedAttempt) {
long multiplier = 1L << Math.min(completedAttempt - 1, 5);
return baseDelay.multipliedBy(multiplier);
}
public static void sleepThread(Duration duration)
throws InterruptedException {
Thread.sleep(duration);
}
}
package ru.nerosky.nerowatch.adapter;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
public final class LimitedProbe implements Probe {
private final Probe delegate;
private final Semaphore permits;
private final Clock clock;
public LimitedProbe(Probe delegate, int maxConcurrent, Clock clock) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.clock = Objects.requireNonNull(clock, "clock");
if (maxConcurrent < 1) {
throw new IllegalArgumentException("Лимит должен быть положительным");
}
this.permits = new Semaphore(maxConcurrent);
}
@Override
public ProbeResult check(Endpoint endpoint) {
boolean acquired = false;
try {
permits.acquire();
acquired = true;
return delegate.check(endpoint);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new ProbeResult.Failure(
endpoint,
"Ожидание разрешения прервано",
false,
clock.instant(),
Duration.ZERO
);
} finally {
if (acquired) {
permits.release();
}
}
}
}
Порядок decorators важен. В LimitedProbe(RetryingProbe(...)) одно
разрешение удерживается на весь цикл retry, включая паузу. В
RetryingProbe(LimitedProbe(...)) разрешение освобождается между
попытками. Для NeroWatch выберем второй вариант: редкий повтор не занимает слот.
Эталонная реализация: конкурентная оркестрация
package ru.nerosky.nerowatch.application;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
public final class MonitorService {
private record Pending(
Endpoint endpoint,
Future<ProbeResult> future
) {}
private final Probe probe;
private final Clock clock;
public MonitorService(Probe probe, Clock clock) {
this.probe = Objects.requireNonNull(probe, "probe");
this.clock = Objects.requireNonNull(clock, "clock");
}
public List<ProbeResult> checkAll(List<Endpoint> endpoints)
throws InterruptedException {
Objects.requireNonNull(endpoints, "endpoints");
var results = new ArrayList<ProbeResult>(endpoints.size());
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var pending = endpoints.stream()
.map(endpoint -> new Pending(
endpoint,
executor.submit(() -> probe.check(endpoint))
))
.toList();
for (var item : pending) {
try {
results.add(item.future().get());
} catch (ExecutionException error) {
results.add(new ProbeResult.Failure(
item.endpoint(),
"Внутренняя ошибка задачи",
false,
clock.instant(),
Duration.ZERO
));
} catch (InterruptedException error) {
pending.forEach(task -> task.future().cancel(true));
Thread.currentThread().interrupt();
throw error;
}
}
}
return List.copyOf(results);
}
}
Ошибка внутри одной задачи становится результатом именно этого endpoint. Но interruption всей операции отменяет оставшиеся задачи и передается выше: это другой уровень события.
Эталонная реализация: чтение конфигурации
package ru.nerosky.nerowatch.adapter;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import ru.nerosky.nerowatch.model.Endpoint;
public final class EndpointLoader {
private static final long MAX_FILE_BYTES = 1_000_000;
private static final int MAX_ENDPOINTS = 10_000;
public List<Endpoint> load(Path source) throws IOException {
if (Files.size(source) > MAX_FILE_BYTES) {
throw new IllegalArgumentException("Файл конфигурации слишком большой");
}
List<String> lines = Files.readAllLines(
source,
StandardCharsets.UTF_8
);
var endpoints = new ArrayList<Endpoint>();
for (int index = 0; index < lines.size(); index++) {
String line = lines.get(index).strip();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (endpoints.size() == MAX_ENDPOINTS) {
throw new IllegalArgumentException("Слишком много endpoint");
}
endpoints.add(parse(line, index + 1));
}
if (endpoints.isEmpty()) {
throw new IllegalArgumentException("В конфигурации нет endpoint");
}
return List.copyOf(endpoints);
}
private Endpoint parse(String line, int lineNumber) {
String[] parts = line.split("\\|", -1);
if (parts.length != 2) {
throw invalidLine(lineNumber, "ожидается Имя|URL");
}
try {
return new Endpoint(parts[0], URI.create(parts[1].strip()));
} catch (IllegalArgumentException error) {
throw invalidLine(lineNumber, error.getMessage());
}
}
private IllegalArgumentException invalidLine(
int lineNumber,
String reason
) {
return new IllegalArgumentException(
"Строка " + lineNumber + ": " + reason
);
}
}
Лимиты защищают от случайно поданного огромного файла. В тексте ошибки есть номер строки, но нет всего входного значения: URL может содержать чувствительные параметры.
Эталонная реализация: атомарный CSV-отчет
package ru.nerosky.nerowatch.adapter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import ru.nerosky.nerowatch.model.ProbeResult;
public final class CsvReportWriter {
public void write(Path target, List<ProbeResult> results)
throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
Path directory = absoluteTarget.getParent();
if (directory == null) {
throw new IllegalArgumentException("Не удалось определить каталог");
}
Files.createDirectories(directory);
Path temporary = Files.createTempFile(
directory,
"." + absoluteTarget.getFileName(),
".tmp"
);
try {
Files.writeString(
temporary,
toCsv(results),
StandardCharsets.UTF_8
);
moveIntoPlace(temporary, absoluteTarget);
} finally {
Files.deleteIfExists(temporary);
}
}
String toCsv(List<ProbeResult> results) {
StringBuilder csv = new StringBuilder(
"name,url,state,status,checked_at,latency_ms,reason\n"
);
for (var result : results) {
csv.append(toRow(result)).append('\n');
}
return csv.toString();
}
private String toRow(ProbeResult result) {
return switch (result) {
case ProbeResult.Success success -> row(
success.endpoint().name(),
success.endpoint().uri().toString(),
"UP",
Integer.toString(success.statusCode()),
success.checkedAt().toString(),
Long.toString(success.latency().toMillis()),
""
);
case ProbeResult.Failure failure -> row(
failure.endpoint().name(),
failure.endpoint().uri().toString(),
"DOWN",
"",
failure.checkedAt().toString(),
Long.toString(failure.latency().toMillis()),
failure.reason()
);
};
}
private String row(String... values) {
return java.util.Arrays.stream(values)
.map(this::quote)
.collect(java.util.stream.Collectors.joining(","));
}
private String quote(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private void moveIntoPlace(Path source, Path target)
throws IOException {
try {
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
);
} catch (AtomicMoveNotSupportedException error) {
Files.move(
source,
target,
StandardCopyOption.REPLACE_EXISTING
);
}
}
}
Каждое значение заключено в кавычки, а внутренняя кавычка удваивается по правилам CSV. Временный файл находится рядом с целевым — это важно для atomic move.
Эталонная реализация: точка сборки Main
package ru.nerosky.nerowatch;
import java.io.IOException;
import java.net.http.HttpClient;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import ru.nerosky.nerowatch.adapter.CsvReportWriter;
import ru.nerosky.nerowatch.adapter.EndpointLoader;
import ru.nerosky.nerowatch.adapter.JdkHttpProbe;
import ru.nerosky.nerowatch.adapter.LimitedProbe;
import ru.nerosky.nerowatch.adapter.RetryingProbe;
import ru.nerosky.nerowatch.application.MonitorService;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
public final class Main {
private static final DateTimeFormatter FILE_TIME =
DateTimeFormatter.ofPattern("uuuuMMdd-HHmmss")
.withZone(ZoneOffset.UTC);
private Main() {}
public static void main(String[] args) {
try {
run(args);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
System.err.println("Проверка отменена");
} catch (IOException | IllegalArgumentException error) {
System.err.println("Ошибка: " + error.getMessage());
}
}
private static void run(String[] args)
throws IOException, InterruptedException {
Path config = args.length >= 1
? Path.of(args[0])
: Path.of("config", "endpoints.txt");
Path reportDirectory = args.length >= 2
? Path.of(args[1])
: Path.of("reports");
Clock clock = Clock.systemUTC();
var endpoints = new EndpointLoader().load(config);
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
Probe http = new JdkHttpProbe(
client,
Duration.ofSeconds(5),
clock
);
Probe limited = new LimitedProbe(http, 20, clock);
Probe resilient = new RetryingProbe(
limited,
3,
Duration.ofMillis(250),
RetryingProbe::sleepThread,
clock
);
var service = new MonitorService(resilient, clock);
var results = service.checkAll(endpoints);
Path report = reportDirectory.resolve(
"nerowatch-" + FILE_TIME.format(clock.instant()) + ".csv"
);
new CsvReportWriter().write(report, results);
printSummary(results, report);
}
}
private static void printSummary(
java.util.List<ProbeResult> results,
Path report
) {
long successes = results.stream()
.filter(ProbeResult.Success.class::isInstance)
.count();
System.out.println("Проверено: " + results.size());
System.out.println("Доступно: " + successes);
System.out.println("Ошибок: " + (results.size() - successes));
System.out.println("Отчет: " + report);
}
}
System.exit(1) при ошибке запуска и договоритесь, означает ли
недоступный endpoint ошибку процесса или только строку DOWN в отчете.
Эталонная реализация: тесты без сети и ожидания
package ru.nerosky.nerowatch.adapter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EndpointLoaderTest {
@TempDir
Path directory;
@Test
void loadsDataAndIgnoresComments() throws Exception {
Path source = directory.resolve("endpoints.txt");
Files.writeString(source, """
# Production
NeroSky|https://nerosky.ru
Java|https://dev.java
""");
var result = new EndpointLoader().load(source);
assertEquals(2, result.size());
assertEquals("NeroSky", result.getFirst().name());
}
@Test
void reportsLineNumberForInvalidValue() throws Exception {
Path source = directory.resolve("endpoints.txt");
Files.writeString(source, """
Good|https://example.com
Broken line
""");
var error = assertThrows(
IllegalArgumentException.class,
() -> new EndpointLoader().load(source)
);
assertEquals(
"Строка 2: ожидается Имя|URL",
error.getMessage()
);
}
}
package ru.nerosky.nerowatch.adapter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayDeque;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
import ru.nerosky.nerowatch.model.Endpoint;
import ru.nerosky.nerowatch.model.ProbeResult;
import ru.nerosky.nerowatch.port.Probe;
class RetryingProbeTest {
private final Endpoint endpoint = new Endpoint(
"NeroSky",
URI.create("https://nerosky.ru")
);
private final Clock clock = Clock.fixed(
Instant.parse("2026-07-27T10:00:00Z"),
ZoneOffset.UTC
);
@Test
void retriesTemporaryFailureAndStopsAfterSuccess() {
var answers = new ArrayDeque<ProbeResult>();
answers.add(new ProbeResult.Failure(
endpoint,
"ConnectException",
true,
clock.instant(),
Duration.ofMillis(20)
));
answers.add(new ProbeResult.Success(
endpoint,
200,
clock.instant(),
Duration.ofMillis(30)
));
Probe fake = ignored -> answers.removeFirst();
var delays = new ArrayList<Duration>();
var probe = new RetryingProbe(
fake,
3,
Duration.ofMillis(100),
delays::add,
clock
);
ProbeResult result = probe.check(endpoint);
assertInstanceOf(ProbeResult.Success.class, result);
assertEquals(java.util.List.of(Duration.ofMillis(100)), delays);
assertEquals(0, answers.size());
}
@Test
void doesNotRetryClientError() {
var failure = new ProbeResult.Failure(
endpoint,
"HTTP 404",
false,
clock.instant(),
Duration.ofMillis(10)
);
var calls = new java.util.concurrent.atomic.AtomicInteger();
Probe fake = ignored -> {
calls.incrementAndGet();
return failure;
};
var probe = new RetryingProbe(
fake,
3,
Duration.ofMillis(100),
duration -> {},
clock
);
probe.check(endpoint);
assertEquals(1, calls.get());
}
}
Приемочные сценарии
| Сценарий | Как проверить | Ожидаемый результат |
|---|---|---|
| Успешный сервис | Локальный stub возвращает 200. |
Строка UP, status 200, latency неотрицательна. |
| Ошибка клиента | Stub возвращает 404. |
DOWN, ровно одна попытка. |
| Временный сбой | Два ответа 503, затем 200. |
Три попытки, итог UP. |
| Медленный ответ | Stub отвечает дольше request timeout. | Ограниченное число попыток, запуск завершается. |
| Поврежденная строка | Убрать разделитель |. |
Понятная ошибка с номером строки, HTTP не запускается. |
| Кавычка в имени | Имя Team "A". |
CSV содержит "Team ""A""" и открывается корректно. |
| Отмена | Прервать поток во время retry delay. | Флаг interruption восстановлен, дальнейших попыток нет. |
| Сбой записи | Записать в каталог без права записи. | Старый отчет не превращается в частичный новый. |
Review-чеклист перед защитой
- Все зависимости создаются в
Main, доменная модель не знает HTTP и файлов. - У
HttpClientи запроса заданы разные timeout. - Повтор ограничен числом попыток и применяется только к временным сбоям.
- Каждое
acquireимеет условныйreleaseвfinally. InterruptedExceptionвосстанавливает флаг или передается выше.- CSV экранирует кавычки, а временный файл удаляется после сбоя.
- Тесты выполняются без интернета и реального
sleep. mvn clean testпроходит на чистом checkout.
Дополнительные уровни
- Bronze: добавить CLI-параметры timeout, attempts и concurrency limit.
- Silver: добавить JSON-отчет через библиотеку и контрактные тесты формата.
- Gold: хранить историю в PostgreSQL с миграциями и транзакциями.
- Platinum: превратить NeroWatch в REST-сервис с метриками и graceful shutdown.
429 нужно повторять с учетом
Retry-After. Стажер должен назвать затронутый компонент, добавить тест
и внести изменение без переписывания всего приложения.
Как провести продвинутый курс со стажерами
Это не марафон чтения. Цель — научить принимать инженерные решения, доказывать корректность и безопасно изменять код после review. Оптимальный ритм — восемь недель.
План на восемь недель
| Неделя | Уроки | Артефакт | Фокус ревью |
|---|---|---|---|
| 1 | 1–2 | Многомодульный или пакетный Maven-проект | Границы, public API, воспроизводимая сборка |
| 2 | 3–5 | Типобезопасный in-memory repository | Контракты объектов, generics, сложность операций |
| 3 | 6–8 | Конвейер обработки заказов | Collectors, sealed model, ошибки как контракт |
| 4 | 9–11 | Расширяемый command processor | Dependencies, patterns, оправданность reflection |
| 5 | 12–13 | Импорт данных и HTTP-адаптер | Ресурсы, atomic write, timeout, retry |
| 6 | 14–15 | Конкурентный агрегатор | Happens-before, отмена, лимиты, virtual threads |
| 7 | 16–17 | Тестовый набор и отчет JFR | Детерминизм, профилирование, наблюдаемость |
| 8 | 18 | NeroWatch и техническая защита | Целостность решения и изменение требования |
Ритм недели
Как давать задачу
- Опишите наблюдаемое поведение. Вход, результат, ошибки, пределы.
- Не диктуйте классы заранее. Пусть стажер предложит границы и объяснит их.
- Добавьте изменение требования. Оно показывает, выдерживает ли структура развитие.
- Попросите доказательство. Тест, JFR, complexity analysis или happens-before.
- Оставьте время на исправление. Review без второй итерации почти не обучает.
Рубрика NeroWatch
За каждый критерий поставьте 0 — отсутствует, 1 — частично, 2 — уверенно. Максимум — 20 баллов.
| Критерий | Что означает 2 балла |
|---|---|
| Контракты модели | Недопустимое состояние невозможно создать, данные неизменяемы. |
| Архитектура | HTTP, orchestration, модель и файл разделены; зависимости направлены внутрь. |
| HTTP-надежность | Есть оба timeout, корректная классификация и ограниченный retry. |
| Конкурентность | Нет общего опасного состояния; есть лимит I/O и корректная отмена. |
| Файловая целостность | CSV экранируется, UTF-8 явен, частичный отчет не публикуется. |
| Тест-дизайн | Ключевые ветви проверены детерминированно без сети и sleep. |
| Наблюдаемость | Результат содержит достаточный контекст без секретов. |
| Сборка | mvn clean test и JAR работают на чистой машине с JDK 25. |
| Читаемость | Имена отражают смысл, методы компактны, комментарии объясняют причины. |
| Защита | Стажер объясняет trade-offs и самостоятельно меняет одно правило. |
Вопросы для защиты
- Почему
Probeнаходится между сервисом и HTTP-адаптером? - Почему длительность измеряется
nanoTime, а дата — черезClock? - Почему
volatileне исправитcount++? - Что изменится, если decorators retry и limit поменять местами?
- Почему virtual threads не ограничивают давление на внешний сервис?
- Какие ответы безопасно повторять и откуда это известно?
- Что произойдет, если поток прервать во время
Semaphore.acquire? - Как доказать, что CSV корректен для запятой, кавычки и перевода строки?
- Как найти причину выросшего p99, не угадывая по коду?
- Как добавить новый transport, не изменив
MonitorService?
Изменения требования для живой части
- считать
3xxотдельным состояниемREDIRECTED; - учесть
Retry-Afterдля429; - ограничить не только параллелизм, но и пять запусков в секунду;
- выдать результаты в порядке завершения, а не конфигурации;
- добавить общий deadline всей партии;
- поддержать два независимых формата отчета.
Интерпретация результата
0–9: есть пробелы в Java Core
Вернуться к контрактам объектов, exceptions, collections и тестам.
10–14: выполняет с поддержкой
Функции работают, но надежность и обоснование требуют наставника.
15–17: самостоятельный advanced trainee
Проектирует небольшой сервис и уверенно проходит code review.
18–20: готов к backend-треку
Можно переходить к SQL, Spring, интеграциям и распределенным системам.
Словарь продвинутого Java-разработчика
Термин полезен, когда помогает точнее обсуждать решение. Возвращайтесь сюда во время review, профилирования и расследования ошибок.
| Термин | Простое объяснение |
|---|---|
| Atomicity | Операция наблюдается целиком, без промежуточного состояния. |
| Backoff | Увеличивающаяся пауза между повторными попытками. |
| Backpressure | Способ не принимать работу быстрее, чем система ее обрабатывает. |
| Benchmark | Контролируемое измерение производительности конкретной операции. |
| Bulkhead | Изоляция ресурсов, чтобы сбой одной части не занял всё приложение. |
| Checked exception | Исключение, которое сигнатура заставляет обработать или передать. |
| Circuit breaker | Временная остановка вызовов к явно недоступной зависимости. |
| Collector | Правило свертки элементов Stream в итоговую структуру. |
| Contention | Соперничество потоков за один lock или ограниченный ресурс. |
| Deadlock | Цикл ожиданий, в котором участники не могут продолжить. |
| Defensive copy | Копия, не позволяющая внешнему коду менять внутреннее состояние. |
| Erasure | Механизм реализации generics, убирающий большинство параметров типов в runtime. |
| Happens-before | Гарантия порядка и видимости действий между потоками. |
| Idempotency | Повтор операции имеет тот же итоговый эффект, что один вызов. |
| Immutability | Состояние объекта не меняется после создания. |
| Invariant | Правило, которое обязано оставаться истинным для корректного объекта. |
| JFR | Встроенная в JDK запись событий JVM и приложения. |
| JIT | Компилятор JVM, превращающий горячий bytecode в машинный код во время работы. |
| Jitter | Случайная добавка к backoff, разводящая одновременные повторы клиентов. |
| Latency p99 | Время, быстрее которого завершились 99% операций. |
| Lock | Механизм исключительного доступа к критической секции. |
| PECS | Producer Extends, Consumer Super — правило выбора wildcard. |
| Pinning | Ситуация, когда virtual thread временно нельзя снять с carrier thread. |
| Port / adapter | Интерфейс нужной операции и внешняя реализация этого интерфейса. |
| Race condition | Результат зависит от неконтролируемого порядка конкурентных действий. |
| Safe publication | Передача объекта другому потоку с гарантиями видимости. |
| Sealed type | Тип с явно ограниченным списком допустимых реализаций. |
| Throughput | Количество завершенных операций за единицу времени. |
| Timeout | Максимальное ожидание отдельной операции. |
| Type bound | Ограничение допустимых параметров generic-типа. |
| Unchecked exception | Ошибка контракта или состояния, не требующая объявления в сигнатуре. |
| Virtual thread | Легкий Thread, планируемый JVM и удобный для большого числа блокирующих I/O-задач. |
| Visibility | Гарантия, что один поток увидит запись другого. |
| Warmup | Период разогрева, когда JVM загружает классы и JIT компилирует код. |
Памятка: чем защитить общее состояние
| Ситуация | Первый кандидат | Проверочный вопрос |
|---|---|---|
| Данные не меняются | Immutable object | Можно ли вернуть List.copyOf? |
| Независимый флаг | volatile | Нет ли составного read-modify-write? |
| Один счетчик | AtomicLong | Нужен ли общий инвариант с другими полями? |
| Несколько связанных полей | synchronized / lock | Весь ли инвариант внутри одной границы? |
| Map с независимыми ключами | ConcurrentHashMap | Используются ли атомарные методы Map? |
| Ограниченный внешний ресурс | Semaphore | Освобождается ли permit в finally? |
Памятка: надежный внешний вызов
□ Один переиспользуемый клиент
□ Connect timeout
□ Request timeout или общий deadline
□ Явная классификация HTTP и transport errors
□ Retry только для безопасных временных сбоев
□ Предел попыток + backoff + jitter
□ Лимит конкурентности / rate
□ Корректное interruption
□ Метрики rate, errors, duration, saturation
□ В логах нет token, cookie и персональных данных
Памятка code review
- Correctness: какие инварианты и крайние случаи?
- Contracts: понятны ли null, ошибки, порядок, mutability?
- Complexity: как растут время и память на реальном объеме?
- Concurrency: где общее состояние и какая happens-before связь?
- Reliability: что произойдет при timeout, отмене и частичном сбое?
- Security: вход ограничен, секреты защищены, права минимальны?
- Tests: проверяют контракт и остаются детерминированными?
- Operations: как заметить проблему и безопасно остановить приложение?
Куда двигаться после курса
- SQL и JDBC. Индексы, транзакции, уровни изоляции, connection pool.
- Spring Boot. DI container, configuration, REST, validation, data access.
- Интеграционные тесты. Testcontainers, contract testing и локальные stubs.
- Распределенные системы. Идемпотентность, очереди, consistency, failure modes.
- Наблюдаемость. OpenTelemetry, SLI/SLO, alerting и incident review.
- Доставка. Containers, CI/CD, dependency scanning и безопасные секреты.
Официальные материалы
- Java SE 25 API — официальный справочник классов и методов
- Java Language Specification, глава 17 — threads и memory model
- Dev.java — generics, bounds и type erasure
- Dev.java — Stream collectors
- Dev.java — annotations и reflection
- Java 25 HttpClient API
- JEP 444 — Virtual Threads и JEP 491 — Synchronize Virtual Threads without Pinning
- Dev.java — Java Flight Recorder и OpenJDK JMH
- Apache Maven — standard directory layout
- JUnit 6.1.0 User Guide
- Oracle Java SE Support Roadmap
Версия курса: 1.0, июль 2026 года. Учебная база — Java 25 LTS и JUnit 6.1.0; preview-возможности не используются.