Почему сервлеты до сих пор пугают, и как с этим жить?
Если вы когда-нибудь заглядывали на Stack Overflow в поисках ответа на вопрос «Как работают сервлеты?», то наверняка натыкались на легендарный тред с тысячами просмотров. Вопрос, который там задают снова и снова: «Сколько экземпляров сервлета создаётся? Как делить данные между запросами? И почему моя переменная ведёт себя как сумасшедшая?»
Давайте честно: сервлеты — это не rocket science, но подводных камней хватает. Особенно когда дело доходит до многопоточности и общих переменных. В этой статье мы не просто перескажем документацию. Мы возьмём реальный пример из жизни, разберём его по косточкам и покажем, как не наступить на грабли. Поехали!
Как сервлет попадает в память: инстанциирование и жизненный цикл
Давайте начнём с простого: сколько экземпляров сервлета создаётся? Ответ: один. Да, вы не ослышались. Контейнер сервлетов (Tomcat, Jetty и другие) создаёт ровно один экземпляр каждого сервлета для всего приложения. И этот экземпляр живёт, пока приложение не будет выгружено.
Вот как выглядит типичный жизненный цикл:
init()— вызывается один раз при загрузке сервлета. Здесь вы инициализируете соединения с БД, конфигурации и прочее.service()— вызывается для каждого входящего запроса. Именно здесь происходит обработка.destroy()— вызывается перед выгрузкой сервлета. Чистите ресурсы.
Ключевой момент: service() (и его производные doGet, doPost) выполняется в отдельном потоке для каждого запроса. То есть один и тот же экземпляр сервлета обрабатывает сотни запросов одновременно. И вот тут начинается самое интересное.
Пример: один сервлет, много потоков
@WebServlet("/counter")
public class CounterServlet extends HttpServlet {
private int counter = 0; // shared variable!
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
counter++;
resp.getWriter().write("Counter: " + counter);
}
}
Если вы запустите этот код и отправите 10 параллельных запросов, вы не получите числа от 1 до 10 по порядку. Вместо этого вы увидите что-то вроде: 2, 5, 3, 8, 1... Или вообще counter может пропустить значения. Почему? Потому что counter++ — это не атомарная операция. Два потока могут прочитать одно и то же значение, увеличить его и записать обратно, потеряв одно приращение.
Вывод: поля экземпляра сервлета — это shared variables. Они видны всем потокам. И если вы не синхронизируете доступ к ним, получите race condition.
Сессии: как не перепутать пользователей
Теперь представьте: вы хотите хранить данные, уникальные для каждого пользователя (корзина покупок, логин, настройки). Для этого в Java Servlets существует HttpSession. Сессия создаётся для каждого нового пользователя и живёт, пока он не закроет браузер или не истечёт таймаут.
Сессия — это не разделяемая память. Каждый пользователь получает свой собственный экземпляр сессии. Но внутри сессии вы можете хранить любые объекты, и они будут доступны только этому пользователю.
Пример: работа с сессией
@WebServlet("/session-demo")
public class SessionServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
HttpSession session = req.getSession();
Integer visitCount = (Integer) session.getAttribute("visitCount");
if (visitCount == null) {
visitCount = 0;
}
visitCount++;
session.setAttribute("visitCount", visitCount);
resp.getWriter().write("Вы посетили эту страницу: " + visitCount + " раз(а)");
}
}
Здесь нет проблем с многопоточностью, потому что каждый поток обращается к своей собственной сессии. Но будьте осторожны: если вы решите хранить в сессии что-то, что изменяется из нескольких потоков (например, общий счётчик), вы всё равно получите проблемы. Сессия — это не магическая защита от race condition, это просто изолированное хранилище для данных пользователя.
Shared variables: где живут данные, которые видят все
Кроме полей экземпляра сервлета, есть ещё два места, где можно хранить общие данные:
- ServletContext — глобальное хранилище для всего приложения. Доступно всем сервлетам и всем пользователям.
- Статические поля — тоже видны всем, но живут вне контекста сервлета.
И то, и другое — shared variables. И с ними нужно работать аккуратно.
Пример: общий счётчик через ServletContext
@WebServlet("/global-counter")
public class GlobalCounterServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
ServletContext context = getServletContext();
synchronized (context) {
Integer counter = (Integer) context.getAttribute("globalCounter");
if (counter == null) {
counter = 0;
}
counter++;
context.setAttribute("globalCounter", counter);
resp.getWriter().write("Глобальный счётчик: " + counter);
}
}
}
Обратите внимание на synchronized (context). Это грубый, но рабочий способ синхронизации. В реальных проектах лучше использовать атомарные типы (например, AtomicInteger) или более тонкие механизмы блокировок.
Многопоточность в сервлетах: три главных правила
Давайте сформулируем основные принципы, которые помогут вам не сойти с ума:
- Не используйте поля экземпляра для хранения состояния запроса. Если вам нужно что-то временное — создавайте локальные переменные внутри метода.
- Если делите данные между потоками — синхронизируйтесь. Используйте
synchronized,ReentrantLockили атомарные классы изjava.util.concurrent. - Для данных пользователя — используйте сессии. Не пытайтесь хранить логин или корзину в статической переменной — это приведёт к тому, что один пользователь увидит данные другого.
- Для глобальных данных — используйте ServletContext с синхронизацией. Или, ещё лучше, вынесите логику в отдельный сервис с правильным управлением состоянием.