Как инстанцируются сервлеты: многопоточный анализ в Java
Для кого эта статья:
- Java-разработчики, работающие с веб-приложениями и сервлетами
- Специалисты, которые стремятся углубить свои знания в области многопоточности и управления сессиями в Java EE
Архитекторы и студенты, интересующиеся построением высоконагруженных и производительных серверных решений
Когда веб-приложение на Java обрабатывает миллионы запросов в час, понимание низкоуровневых механизмов сервлетов становится критически важным. За кажущейся простотой сервлетного API скрывается сложный механизм, определяющий производительность, масштабируемость и безопасность вашего приложения. Если вы когда-либо задавались вопросом, почему некоторые Java-приложения деградируют под нагрузкой или утекают память при работе с сессиями — корень проблемы часто кроется в недостаточном понимании инстанцирования сервлетов и их многопоточной природы. 🔍
Хотите глубоко разобраться в механизмах работы Java-сервлетов и построить надежные высоконагруженные веб-приложения? Курс Java-разработки от Skypro раскрывает не только базовые концепции, но и продвинутые техники работы с сервлетами, сессиями и многопоточностью. Наши студенты создают Enterprise-уровня приложения уже через 3 месяца обучения! Учитесь у практикующих архитекторов, работающих с крупнейшими Java-системами в России.
Архитектура Java сервлетов: принципы инстанцирования
Основной принцип, который отличает Java-сервлеты от других технологий обработки веб-запросов — это модель инстанцирования "один экземпляр — множество потоков". Контейнер сервлетов (например, Tomcat, Jetty или Undertow) создаёт только один экземпляр класса сервлета для обработки всех входящих запросов. Это радикально отличается от CGI-скриптов, где новый процесс создаётся для каждого запроса, и от функциональных моделей типа AWS Lambda.
Когда контейнер загружает веб-приложение, он анализирует дескриптор развёртывания (web.xml или аннотации), определяет конфигурацию сервлетов и создаёт экземпляры в соответствии с параметром load-on-startup:
| Значение load-on-startup | Поведение контейнера | Применение |
|---|---|---|
| Отсутствует или отрицательное | Ленивое инстанцирование при первом запросе | Редко используемые сервлеты, экономия ресурсов |
| 0 или положительное целое | Инстанцирование при запуске контейнера | Критически важные сервлеты, предварительная инициализация |
| Положительное целое (0, 1, 2...) | Определяет порядок инициализации (по возрастанию) | Сервлеты с зависимостями между собой |
Создание единственного экземпляра сервлета вместо множественных имеет ряд архитектурных преимуществ:
- Экономия памяти — нет необходимости создавать новый объект для каждого запроса
- Быстрая обработка запросов — нет накладных расходов на создание/уничтожение объектов
- Возможность кэширования данных в полях сервлета для многократного использования
- Упрощение управления ресурсами (пул соединений с БД, кеш и т.д.)
Однако такой подход требует особой осторожности при работе с состоянием. Любая переменная экземпляра (поле класса) становится разделяемым ресурсом между всеми потоками, обрабатывающими запросы.
Алексей Петров, lead backend-разработчик
Однажды нашей команде поступила критическая ошибка в production-окружении: финансовые транзакции пользователей периодически пересекались, что приводило к списанию неверных сумм. Проблема проявлялась только под высокой нагрузкой и нерегулярно.
При анализе кода мы обнаружили сервлет с полем класса BigDecimal amount, которое использовалось для временного хранения суммы транзакции. Один разработчик, недавно перешедший с PHP, предположил, что сервлет создаётся для каждого запроса, как это происходит в PHP-скриптах.
Фактически, при конкурентных запросах разные потоки перезаписывали это поле, что приводило к race condition. Решение было простым — перенести переменную в локальную область видимости метода doPost(). Это был отличный урок для всей команды о том, что архитектура сервлетов принципиально отличается от многих других веб-технологий.
Правильное инстанцирование сервлета также включает понимание области видимости его атрибутов:
- ServletContext — общие данные для всего приложения, доступные всем сервлетам
- HttpSession — данные, специфичные для сессии пользователя
- HttpServletRequest — данные в рамках текущего запроса
- Переменные сервлета — разделяемые между всеми потоками, требуют синхронизации

Жизненный цикл сервлета: от создания до уничтожения
Жизненный цикл сервлета чётко определён спецификацией Java Servlet API и включает три основных этапа: инициализацию, обслуживание и уничтожение. Эти этапы представлены соответствующими методами, которые контейнер вызывает в определённые моменты.
| Фаза | Метод | Когда вызывается | Потокобезопасность |
|---|---|---|---|
| Инициализация | init(ServletConfig) | Однократно при создании экземпляра | Гарантирована (только один поток) |
| Обслуживание | service(ServletRequest, ServletResponse) | Для каждого HTTP-запроса | Не гарантирована (множество потоков) |
| Уничтожение | destroy() | Однократно при выгрузке сервлета | Гарантирована (только один поток) |
Этап инициализации (метод init) — идеальное место для выполнения тяжёлых операций, таких как:
- Подключение к базе данных и инициализация пула соединений
- Загрузка конфигурационных параметров из ServletConfig
- Инициализация кэшей и других ресурсоёмких структур данных
- Установка таймеров и фоновых задач
Метод init вызывается только однажды за весь жизненный цикл сервлета и выполняется в единственном потоке, что гарантирует безопасную инициализацию разделяемых ресурсов без необходимости синхронизации. Если метод init выбрасывает ServletException или любое другое исключение, сервлет не будет загружен и не сможет обрабатывать запросы.
Этап обслуживания начинается с вызова метода service(), который обычно делегирует обработку соответствующим методам doGet(), doPost(), doPut() и т.д., в зависимости от HTTP-метода запроса. Именно на этом этапе проявляется многопоточная природа сервлетов — метод service может быть вызван одновременно во множестве потоков.
Этап уничтожения (метод destroy) вызывается контейнером при выгрузке сервлета, что может произойти при:
- Остановке или перезапуске веб-сервера
- Обновлении веб-приложения (hot deploy)
- Явном вызове метода unload для сервлета через API контейнера
В методе destroy следует освобождать ресурсы, захваченные в init:
@Override
public void destroy() {
try {
if (scheduledExecutor != null) {
scheduledExecutor.shutdown();
scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS);
}
if (dataSource != null) {
if (dataSource instanceof AutoCloseable) {
((AutoCloseable) dataSource).close();
}
}
} catch (Exception e) {
log("Error during servlet destruction", e);
}
super.destroy();
}
Контейнер гарантирует, что все текущие вызовы service() завершатся до вызова destroy(), и новые запросы не будут направляться к сервлету после начала процесса уничтожения. Однако, если в сервлете запущены отдельные потоки (например, через ExecutorService), необходимо корректно остановить их работу, иначе возможна утечка ресурсов.
HTTP сессии в сервлетах: механизмы управления данными
HTTP-сессии представляют механизм, позволяющий идентифицировать и отслеживать пользователей между HTTP-запросами, преодолевая stateless-природу протокола HTTP. Сервлеты предоставляют мощный API для работы с сессиями через интерфейс HttpSession.
Марина Соколова, архитектор веб-приложений
В проекте электронной коммерции, над которым я работала, клиент столкнулся с серьезной проблемой: корзины пользователей непредсказуемо терялись, особенно в пиковые часы. При этом логи показывали странное поведение — ID сессии пользователей неожиданно менялись в середине покупки.
Анализ показал, что проблема была в неправильном управлении сессиями. Разработчики хранили большие объекты в сессии (включая предварительно отрендеренные HTML-фрагменты страниц), что приводило к сериализации/десериализации данных при кластеризации. В условиях высокой нагрузки часть сессий не успевала реплицироваться между узлами кластера и терялась при балансировке.
Мы реструктурировали приложение, переместив тяжелые данные из сессии в распределенный кеш Redis, а в сессии стали хранить только минимально необходимые идентификаторы. Кроме того, внедрили sticky-sessions на балансировщике для снижения частоты репликаций. Это не только решило проблему, но и улучшило производительность всего приложения примерно на 30%.
Создание или получение существующей сессии в сервлете осуществляется следующим образом:
// Получение существующей сессии (null, если сессия не существует)
HttpSession session = request.getSession(false);
// Получение существующей сессии или создание новой, если не существует
HttpSession session = request.getSession(true);
// Эквивалентно request.getSession(true)
HttpSession session = request.getSession();
Каждая сессия имеет уникальный идентификатор (Session ID), который передаётся между клиентом и сервером через:
- Cookies — основной механизм, используемый по умолчанию (cookie JSESSIONID)
- URL Rewriting — резервный механизм, если cookies отключены (добавление ;jsessionid=VALUE к URL)
- SSL Session Tracking — использование SSL-сессии для идентификации (редко используется)
Хранение и извлечение данных в сессии осуществляется через атрибуты — пары ключ-значение:
// Сохранение данных в сессии
session.setAttribute("username", user.getUsername());
session.setAttribute("userPreferences", preferences);
// Извлечение данных из сессии
String username = (String) session.getAttribute("username");
UserPreferences prefs = (UserPreferences) session.getAttribute("userPreferences");
При работе с HTTP-сессиями критично понимать механизмы управления их жизненным циклом:
- Создание — происходит при первом вызове request.getSession() или request.getSession(true)
- Активное использование — сессия доступна для чтения/записи
- Таймаут — если сессия не использовалась в течение заданного времени (по умолчанию 30 минут)
- Явное завершение — при вызове session.invalidate() (например, при выходе пользователя)
Таймаут сессии можно настроить несколькими способами:
- В дескрипторе развертывания web.xml
- Программно через session.setMaxInactiveInterval(seconds)
- На уровне контейнера (например, в server.xml для Tomcat)
<!-- В web.xml -->
<session-config>
<session-timeout>30</session-timeout> <!-- в минутах -->
</session-config>
// Программно (в секундах)
session.setMaxInactiveInterval(1800); // 30 минут
При работе с сессиями в кластеризованной среде необходимо учитывать дополнительные аспекты:
- Все объекты, хранящиеся в сессии, должны быть сериализуемы (implements Serializable)
- Размер сессии влияет на производительность репликации
- Sticky-sessions могут улучшить производительность, но снижают отказоустойчивость
- Распределённые хранилища сессий (Redis, Hazelcast, Infinispan) позволяют избежать проблем репликации
Утечки памяти — распространённая проблема при работе с сессиями. Они возникают, когда объекты добавляются в сессию, но не удаляются даже после того, как становятся ненужными. Для предотвращения утечек стоит:
- Минимизировать данные, хранимые в сессии
- Удалять атрибуты, когда они больше не нужны (session.removeAttribute)
- Использовать HttpSessionListener для очистки ресурсов
- Мониторить размер сессий и количество активных сессий в production
Многопоточность в Java EE: вызов service() метода
Многопоточность — фундаментальная особенность архитектуры Java-сервлетов, которая позволяет эффективно обрабатывать множество одновременных запросов. Контейнер сервлетов поддерживает пул потоков для обработки входящих HTTP-запросов, и каждый запрос обрабатывается в отдельном потоке из этого пула. 🧵
Когда поступает HTTP-запрос, контейнер:
- Выбирает свободный поток из пула (или создаёт новый, если лимит не достигнут)
- Вызывает метод service() у соответствующего экземпляра сервлета в этом потоке
- После завершения обработки возвращает поток в пул для повторного использования
Метод service() — сердце сервлетной обработки. В классе HttpServlet он анализирует HTTP-метод запроса и делегирует обработку соответствующему методу:
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
doGet(req, resp);
} else if (method.equals(METHOD_POST)) {
doPost(req, resp);
} else if (method.equals(METHOD_PUT)) {
doPut(req, resp);
}
// ... другие HTTP-методы
}
Параллельная обработка запросов имеет ряд архитектурных следствий:
- Высокая пропускная способность — один сервлет может обрабатывать множество запросов одновременно
- Эффективное использование ресурсов сервера — потоки используются только когда есть запросы
- Сложности с разделяемыми ресурсами — необходимость синхронизации доступа
- Риск взаимных блокировок (deadlocks) при неправильной синхронизации
Настройка пула потоков критична для производительности. Большинство контейнеров сервлетов позволяют настраивать параметры пула:
| Параметр | Значение | Рекомендации |
|---|---|---|
| Минимальный размер пула | Количество потоков, поддерживаемых "горячими" в пуле | 10-20% от максимального размера |
| Максимальный размер пула | Верхний предел количества потоков | CPUCORES * (1 + %WAITTIME) |
| Таймаут ожидания в очереди | Сколько запрос может ждать в очереди | Зависит от SLA приложения |
| Время жизни потока | Как долго избыточный поток будет ждать работы | 30-60 секунд для динамической нагрузки |
При неправильной настройке пула потоков возможны два противоположных сценария деградации:
- Thread starvation — нехватка потоков для обработки запросов, приводящая к долгому ожиданию в очереди
- Thread flood — слишком много потоков, вызывающих избыточное переключение контекста и исчерпание памяти
Оптимальный размер пула потоков зависит от характера нагрузки:
- CPU-bound операции (вычисления, обработка данных) — число потоков примерно равно числу ядер CPU
- I/O-bound операции (запросы к БД, внешним системам) — число потоков может значительно превышать число ядер
Современные контейнеры сервлетов (например, Tomcat 9+, Undertow) поддерживают асинхронную обработку запросов, позволяющую освобождать поток обработки, пока ожидается результат длительной операции:
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
final AsyncContext asyncContext = req.startAsync();
asyncContext.setTimeout(30000);
asyncContext.start(() -> {
try {
// Длительная операция в отдельном потоке
Thread.sleep(5000);
HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();
response.getWriter().println("Async operation completed");
asyncContext.complete();
} catch (Exception e) {
log("Error in async processing", e);
}
});
}
}
Асинхронная обработка значительно повышает масштабируемость приложения, особенно при работе с медленными внешними системами или long-polling запросами.
Потокобезопасность в сервлетах: стратегии синхронизации
Потокобезопасность (thread safety) в сервлетах — критически важный аспект, непосредственно влияющий на корректность и стабильность работы приложения. Поскольку один экземпляр сервлета обрабатывает множество запросов в разных потоках одновременно, все разделяемые ресурсы становятся потенциальной точкой состояния гонки (race condition).
Существует несколько стратегий обеспечения потокобезопасности в сервлетах, каждая со своими преимуществами и недостатками:
- Предпочтение локальным переменным — самый простой и эффективный подход
- Синхронизация доступа к разделяемым ресурсам
- Использование потокобезопасных коллекций и объектов из java.util.concurrent
- Неизменяемые объекты (immutable objects) для разделяемых данных
- ThreadLocal для данных, специфичных для потока
Рассмотрим типичные ошибки, ведущие к нарушению потокобезопасности в сервлетах:
// НЕПРАВИЛЬНО: разделяемая переменная экземпляра
public class UnsafeCounterServlet extends HttpServlet {
private int counter = 0; // Разделяемое состояние!
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
counter++; // Race condition!
resp.getWriter().println("Counter: " + counter);
}
}
// ПРАВИЛЬНО: локальная переменная или синхронизация
public class SafeCounterServlet extends HttpServlet {
private final AtomicInteger counter = new AtomicInteger(0); // Потокобезопасный счётчик
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
int value = counter.incrementAndGet(); // Атомарная операция
resp.getWriter().println("Counter: " + value);
}
}
Для более сложных сценариев может потребоваться явная синхронизация. Однако следует помнить, что чрезмерная синхронизация может привести к снижению производительности или даже блокировкам:
public class SynchronizedServlet extends HttpServlet {
private final Map<String, UserData> userCache = new HashMap<>();
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String userId = req.getParameter("userId");
UserData userData;
// Синхронизация только критической секции
synchronized(userCache) {
userData = userCache.get(userId);
if (userData == null) {
userData = loadUserData(userId); // Затратная операция
userCache.put(userId, userData);
}
}
// Остальной код не синхронизирован
processUserData(userData, resp);
}
}
Более эффективный подход для такого сценария — использование потокобезопасных коллекций:
public class ConcurrentServlet extends HttpServlet {
private final ConcurrentHashMap<String, UserData> userCache = new ConcurrentHashMap<>();
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String userId = req.getParameter("userId");
// computeIfAbsent атомарно выполняет проверку и добавление
UserData userData = userCache.computeIfAbsent(userId, this::loadUserData);
processUserData(userData, resp);
}
private UserData loadUserData(String userId) {
// Загрузка данных пользователя
return new UserData(userId);
}
}
В особых случаях, когда требуется хранить состояние, специфичное для потока обработки (например, контекст безопасности или транзакции), подходит ThreadLocal:
public class ThreadLocalServlet extends HttpServlet {
private final ThreadLocal<UserContext> userContextHolder = new ThreadLocal<>();
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
try {
// Инициализация контекста для текущего потока
String userId = req.getParameter("userId");
UserContext context = new UserContext(userId);
userContextHolder.set(context);
// Любой вызванный метод может получить контекст через ThreadLocal
processRequest(req, resp);
} finally {
// Обязательная очистка ThreadLocal для предотвращения утечек
userContextHolder.remove();
}
}
}
Отдельно стоит упомянуть о SingleThreadModel — устаревшем интерфейсе, который когда-то предлагался как решение проблем потокобезопасности:
- Сервлет, реализующий этот интерфейс, обрабатывает запросы последовательно или через пул экземпляров
- Не рекомендуется к использованию и признан устаревшим (deprecated)
- Не решает проблем с разделяемыми ресурсами вне сервлета (БД, кеш и т.д.)
В большинстве случаев рекомендуется проектировать сервлеты как stateless-компоненты, хранящие состояние только в:
- Базе данных или другом внешнем хранилище
- HTTP-сессии пользователя
- Кеше приложения с правильно реализованной синхронизацией
При невозможности избежать разделяемого состояния, следует тщательно анализировать возможные сценарии конкурентного доступа и выбирать подходящие структуры данных и механизмы синхронизации, минимизируя "окно уязвимости" и снижая вероятность состояния гонки.
Понимание нюансов инстанцирования сервлетов, управления сессиями и многопоточности — то, что отличает обычного Java-разработчика от настоящего специалиста по серверным приложениям. Эти знания позволяют не просто создавать работающие веб-приложения, но проектировать масштабируемую архитектуру, способную выдерживать высокие нагрузки без деградации производительности и утечек памяти. Помните, что сервлеты — это фундамент любой Java EE технологии, и многие фреймворки, включая Spring MVC, JSF и JAX-RS, в конечном итоге работают поверх сервлетного контейнера, наследуя его модель выполнения и все связанные с ней особенности.