Сервлеты в Java: инстансы, сессии и многопоточность — разбор полётов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему сервлеты до сих пор пугают, и как с этим жить?

Если вы когда-нибудь заглядывали на 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) или более тонкие механизмы блокировок.

Многопоточность в сервлетах: три главных правила

Давайте сформулируем основные принципы, которые помогут вам не сойти с ума:

  1. Не используйте поля экземпляра для хранения состояния запроса. Если вам нужно что-то временное — создавайте локальные переменные внутри метода.
  2. Если делите данные между потоками — синхронизируйтесь. Используйте synchronized, ReentrantLock или атомарные классы из java.util.concurrent.
  3. Для данных пользователя — используйте сессии. Не пытайтесь хранить логин или корзину в статической переменной — это приведёт к тому, что один пользователь увидит данные другого.
  4. Для глобальных данных — используйте ServletContext с синхронизацией. Или, ещё лучше, вынесите логику в отдельный сервис с правильным управлением состоянием.

Продвинутый пример: потокобезопасный сервис

<

Блоги

Рекомендаций по книгам