Avatar ·

Как работают сервлеты: создание, сессии, общие переменные и многопоточность

Всем привет! Я только начал изучать Java EE и сервлеты, но запутался в некоторых фундаментальных вещах. Можете объяснить на пальцах? У меня есть простой сервлет, который считает количество посещений страницы. Я ожидал, что каждый пользователь будет видеть свой счётчик, но на деле всё работает странно. Вот мой код: ```java public class CounterServlet extends HttpServlet { private int counter = 0; protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { counter++; response.getWriter().println("Вы посетили эту страницу " + counter + " раз(а)"); } } ``` Я открыл его в двух разных браузерах (Chrome и Firefox). В первом браузере я обновил страницу 3 раза — счётчик показал 3. Потом во втором браузере обновил один раз — и счётчик стал 4! То есть он не разделяет посещения для разных пользователей, а суммирует их. Почему так? И ещё вопросы: 1. Как именно создаётся экземпляр сервлета? Он один на всех или для каждого запроса новый? 2. Если я хочу хранить данные для каждого пользователя отдельно (например, корзину покупок), как это правильно сделать? 3. Что будет, если два пользователя одновременно вызовут этот сервлет? Не возникнет ли проблем с переменной counter? Объясните, пожалуйста, как работают сервлеты в многопоточной среде, и как правильно управлять состоянием. Заранее спасибо! <<>>

Как работают сервлеты: полный разбор

Отличные вопросы! Давайте разберём всё по порядку. Ваша проблема — классическая ошибка начинающих, связанная с непониманием жизненного цикла сервлетов и областей видимости переменных.

1. Жизненный цикл сервлета (Instantiation)

Сервлет создаётся один раз при первом обращении к нему (или при старте сервера, если настроена загрузка). Контейнер сервлетов (Tomcat, Jetty и т.д.) вызывает метод init() один раз, а затем для каждого запроса — метод service(), который делегирует работу doGet() или doPost().

Важно: Экземпляр сервлета один на всех пользователей. Он живёт в памяти, пока сервер работает, и обслуживает все входящие запросы.

2. Общие переменные и многопоточность

Поскольку сервлет один, все его поля (как ваш counter) являются общими для всех потоков. Когда два пользователя одновременно вызывают doGet(), они работают с одной и той же переменной counter. Это приводит к:

  • Гонке данных (race condition): операция counter++ не атомарна (чтение → инкремент → запись). Два потока могут прочитать одно и то же значение и оба записать +1, хотя должно быть +2.
  • Неопределённому поведению: вы видите, что счётчик суммируется для всех пользователей.

Как исправить ваш пример? Если вам нужен глобальный счётчик, используйте атомарные типы:

import java.util.concurrent.atomic.AtomicInteger;

public class CounterServlet extends HttpServlet {
    private AtomicInteger counter = new AtomicInteger(0);

    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        int current = counter.incrementAndGet();
        response.getWriter().println("Всего посещений: " + current);
    }
}

Но чаще всего вам не нужен общий счётчик. Для подсчёта действий конкретного пользователя используйте сессии.

3. Сессии (Sessions) — данные для каждого пользователя

Чтобы хранить данные отдельно для каждого пользователя (корзина, логин, настройки), используйте HttpSession. Сессия создаётся автоматически при первом обращении к request.getSession() и идентифицируется по cookie (JSESSIONID) или перезаписи URL.

Пример счётчика для каждого пользователя:

public class UserCounterServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        HttpSession session = request.getSession();
        Integer counter = (Integer) session.getAttribute("counter");
        if (counter == null) {
            counter = 0;
        }
        counter++;
        session.setAttribute("counter", counter);
        response.getWriter().println("Вы посетили эту страницу " + counter + " раз(а)");
    }
}

Теперь каждый браузер (пользователь) будет видеть свой личный счётчик, потому что данные хранятся в сессии, а не в поле сервлета.

4. Правильная архитектура: что где хранить

В сервлетах есть три уровня хранения данных:

  • Поля сервлета — общие для всех потоков. Используйте только для read-only данных (конфигурация, Connection Pool).
  • Атрибуты запроса (request.setAttribute()) — живут только в рамках одного HTTP-запроса. Идеально для передачи данных между сервлетом и JSP.
  • Атрибуты сессии (session.setAttribute()) — живут, пока пользователь не закроет браузер или не истечёт таймаут. Для данных, которые должны сохраняться между запросами одного пользователя.
  • ServletContext (getServletContext().setAttribute()) — глобальные данные для всего приложения (всех пользователей). Используйте с осторожностью, синхронизируя доступ.

5. Безопасность многопоточности в сервлетах

По умолчанию сервлеты не потокобезопасны. Контейнер создаёт один экземпляр и пул потоков для обработки запросов. Правила:

  • Не используйте поля экземпляра для изменяемых данных.
  • Если нужно общее состояние — синхронизируйте или используйте synchronized, AtomicInteger, ConcurrentHashMap.
  • Локальные переменные в методах (doGet) — потокобезопасны (каждый поток имеет свой стек).
  • Для сложной логики рассмотрите @SingleThreadModel (устарел) или используйте фреймворки (Spring MVC), которые управляют состоянием грамотно.

Итог: Ваш первоначальный код суммировал посещения всех пользователей, потому что counter — поле экземпляра сервлета, которое разделяется между потоками. Для индивидуальных данных используйте сессии, для общих — атомарные типы с пониманием последствий.

<<>>

Альтернативный подход: использование ServletContextListener и Dependency Injection

Дополню ответ коллеги. Если вам нужно управлять состоянием на уровне приложения (например, счётчик всех посещений сайта), лучше вынести логику за пределы сервлета — это улучшит тестируемость и разделение ответственности.

Пример с использованием ServletContextListener для инициализации общего счётчика:

@WebListener
public class AppListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        sce.getServletContext().setAttribute("totalCounter", new AtomicInteger(0));
    }
}

@WebServlet("/count")
public class CounterServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        AtomicInteger total = (AtomicInteger) getServletContext()
                .getAttribute("totalCounter");
        int current = total.incrementAndGet();
        response.getWriter().println("Всего: " + current);
    }
}

Также в современных Java EE приложениях (Jakarta EE) принято использовать CDI (Contexts and Dependency Injection) для управления состоянием. Например, бины с областью видимости @SessionScoped или @ApplicationScoped:

import javax.inject.Singleton;
import java.util.concurrent.atomic.AtomicInteger;

@Singleton
public class VisitCounter {
    private AtomicInteger count = new AtomicInteger(0);
    public int increment() { return count.incrementAndGet(); }
}

Затем внедряете этот бин в сервлет через @Inject. Это даёт более чистую архитектуру и упрощает переход на Spring Boot в будущем.

Совет: для учебных целей подход с сессиями и атрибутами — норм, но в продакшене старайтесь минимизировать логику в сервлетах, вынося её в сервисы и бины.