04 · 会话与状态管理(Session & State)

📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quiz shengxia.dev/java?module=04


4.1 HTTP 无状态:服务生不记得你上回点过什么

每次请求都是一次"初次见面"

餐厅有个怪规矩:服务生只在"这一桌、这一次"服务。你点完菜吃完走人,第二天再来,新服务生完全不记得你昨天点什么、口味偏辣偏淡。这就是 HTTP 的无状态(stateless):服务器处理完一次请求就"失忆",下一次请求跟第一次一样,什么都不记得

// 伪代码:请求带 name 就输出,不带就是 null——服务器不记得上次
String name = req.getParameter("name");

为什么 HTTP 天生无状态:HTTP 协议诞生于上个世纪,最初的用途是传输静态网页文件——打开一个页面、关掉、再打开,本来就不需要"记住"谁访问过。设计成无状态有两大好处:一是服务器不用为每个用户单独存一份状态,省内存;二是任何一台服务器都能处理任何一次请求,方便以后把网站横向扩展成集群。后面你会看到 Session 恰恰要"记住"用户,等于牺牲了这两个好处,所以我们只把必要的信息放进 Session。

无状态带来的麻烦:一旦网站需要"记住"用户,麻烦就来了。比如你往购物车加了一件商品,跳到结算页想再看一眼购物车,结果购物车空了——因为两个请求彼此独立,第二个请求根本不记得第一个请求加了什么。登录、购物车、浏览记录、语言偏好……这些都需要状态。于是就有了本讲的主角:CookieSession,它们就是 HTTP 的"记忆补丁"。

无状态与集群的关系:无状态让"任何一台服务器都能处理任何请求"成为可能——用户 A 的请求这次打到服务器 1、下次打到服务器 2,结果都一样,所以可以随意加机器分摊压力。这也是大型网站总喜欢"尽量保持无状态"的原因:把状态往外挪(存数据库、存缓存),服务器就变成无状态的"计算工",好扩缩容、好故障替换。Session 恰恰是反例,它把状态留在了某台机器的内存里,后面讲 Session 集群问题时你会看到它带来的麻烦。

💡 记忆口诀:无状态 = 服务生失忆。想被记住,要么自己带纸条(Cookie),要么让他后台记一笔给你个编号(Session)。Cookie 是"你带纸条",Session 是"他记本子"。

看新闻这类静态内容无状态没问题;一旦涉及登录、购物车,就需要"记住点什么"。接下来先看第一种"记住"的方式:Cookie。


4.2 Cookie:超市储物柜的手环 ⭐

把"纸条"交给浏览器保管

超市存包,管理员给你一个手环(印着柜号),逛完亮出手环照号开柜——不需要记住你是谁,信息都在你手里的手环上。Cookie 就是这样:服务器创建,通过响应头发给浏览器,浏览器存本地,之后每次访问都自动原样带回。Cookie 是键值对(key=value),大小约 4KB。

// 创建并发送:键=值,可设置过期、作用域、安全性
Cookie cookie = new Cookie("username", "guorui");
cookie.setMaxAge(60 * 60 * 24 * 7);  // 存活 7 天(单位秒),-1 表示关浏览器就没了
cookie.setPath("/");                 // 全站生效
cookie.setHttpOnly(true);            // 禁止 JS 读取,防 XSS
resp.addCookie(cookie);              // 通过 Set-Cookie 响应头发给浏览器

// 读取:可能为 null,先判空再遍历
for (Cookie c : req.getCookies())     // 判空逻辑省略
    if ("username".equals(c.getName()))
        out.write("欢迎回来," + c.getValue());

Cookie 的一次完整旅程(流程题常考):服务器创建 Cookie → 通过 Set-Cookie 响应头发给浏览器 → 浏览器解析后存在本地(硬盘或内存)→ 之后每次请求浏览器自动在 Cookie 请求头里带上它 → 服务器从 request.getCookies() 读出,识别"这是老朋友"。整个过程中服务器不需要记住任何东西,记忆全在浏览器端这张"小纸条"上。

关键属性(四件套):setMaxAge(秒) 控存活时长——正数是秒数、-1 是会话级(关浏览器就没了)、0 是立刻删除;setPath("/") 控作用范围——默认只在当前路径下生效,/ 表示全站都带;setHttpOnly(true) 禁止 JS 读取——防止 XSS 脚本偷走 Cookie;setSecure(true) 只允许 HTTPS 传输——防止明文被抓包。

大小与数量限制:Cookie 单条大约 4KB,一个域名下浏览器一般只允许几十个 Cookie,所以别把大对象塞 Cookie。SameSite 属性(较新浏览器支持)控制 Cookie 在跨站请求时带不带——SameSite=Lax 时第三方网站发起的请求不会带这个 Cookie,能有效防 CSRF 攻击,可以理解为给 Cookie 又加了一道"跨站禁带"的保险,面试和设计题里常被问到。

再给一个"记住上次访问时间"的例子,体会 Cookie 的典型用途——不敏感、要长期的数据:

// 服务器先读出上次访问时间,再写一个新的
Cookie[] cookies = req.getCookies();
if (cookies != null)
    for (Cookie c : cookies)
        if ("lastVisit".equals(c.getName()))
            out.write("上次访问:" + new Date(Long.parseLong(c.getValue())));
Cookie c = new Cookie("lastVisit", String.valueOf(System.currentTimeMillis()));
c.setMaxAge(60 * 60 * 24 * 30);   // 存 30 天
resp.addCookie(c);

💡 记忆口诀:Cookie 三要素 = 谁发的(服务器)+ 存在哪(浏览器)+ 每次带(自动随请求返回)。属性记四件套:过期、路径、HttpOnly、Secure。

⚠️ 常见错误

  1. 以为 Cookie 存服务器:Cookie 存在浏览器本地,服务器只是"发起方"。很多新手把登录状态直接写进 Cookie,等于把家门钥匙交给别人保管。为什么会错:混淆了"谁创建"和"谁保存"——创建是服务器,保存却在客户端。怎么避免:敏感数据(密码、身份证号)一律不进 Cookie。
  2. 忘设 setMaxAge:不设置过期时间,Cookie 默认是"会话级",浏览器一关就消失。为什么:未设置时过期时间缺省为会话级。怎么避免:想"记住我 7 天"必须显式 setMaxAge(7*24*60*60),否则用户每次打开浏览器都要重新登录。
  3. setMaxAge 传了天数:单位是秒!setMaxAge(7) 是 7 秒不是 7 天。为什么:API 按秒设计。怎么避免:天数统一换算成秒再传,写成 7 * 24 * 60 * 60 这样可读的乘式,防止自己算错。
  4. 读 Cookie 忘了判空:浏览器第一次访问时没有任何 Cookie,req.getCookies() 返回 null,直接 for 遍历抛 NullPointerException为什么:API 用 null 表示"一个都没有"。怎么避免:遍历前 if (cookies != null) 先行判空。
  5. 把密码放 Cookie:明文放用户电脑等于裸奔,任何人打开浏览器设置都能看到。怎么避免:Cookie 只放"用户名、id、偏好"这类非敏感信息;真正要安全的用下一节的 Session。

4.3 Session:健身房会员卡 ⭐

卡上只印编号,资料都存在健身房

健身房前台把你的身高体重、联系方式全记在健身房电脑里,给你一张会员卡,卡上只印会员编号。每次来刷一下,前台查编号调出资料。Session 就是这套系统:数据存在服务器端内存,服务器只把一个会话编号(Session ID)交给浏览器,浏览器默认用名为 JSESSIONID 的 Cookie 存着,每次请求带着它。

HttpSession session = req.getSession();                  // 有就取,没有就新建
session.setAttribute("username", "guorui");              // 存
String name = (String) session.getAttribute("username"); // 取,返回 Object 要强转
session.invalidate();                                    // 销毁整个会话(退出登录)

Session 的工作流程:第一次访问时服务器没有 Session,getSession() 会新建一个,生成一个几乎不重复的 Session ID(一串随机字符串),通过 Set-Cookie: JSESSIONID=xxx 交给浏览器。浏览器存下这个 ID,之后每次请求带上。服务器凭 ID 在自己内存里找到对应的一组键值对——那就是这个用户的"档案"。数据在服务器,浏览器手里只有一把"钥匙"。

Session 与 Cookie 的配合关系:Session 的数据在服务器,但"编号"靠 Cookie 传递。一旦浏览器禁用 Cookie,JSESSIONID 带不过去,服务器每次都会以为来了新用户——Session 就失效了。解决办法是 URL 重写:把 Session ID 拼在 URL 后面(如 /home;jsessionid=xxx),调用 response.encodeURL("home") 会自动处理,代价是 URL 变丑、容易泄露。这是常考的知识点:Cookie 是 Session 的默认载体,但不是唯一载体

Session 的集群问题:Session 存在某台服务器的内存里,如果网站有两台服务器,用户第一次登录打到服务器 1,第二次请求被负载均衡分到服务器 2,服务器 2 没有这份 Session,用户就被"打回原形"要求重新登录。解决方案有几种:粘性会话(让同一用户固定打到同一台机器,简单但机器挂了会丢)、Session 共享(把 Session 存到 Redis 这类共享存储,集群都读它,主流方案)、或者干脆用无状态方案(JWT 令牌,服务器不存,客户端自己拿着)。考试常问的就是:Session 在单机好使,上集群就要另想办法。

超时时间的配置:默认约 30 分钟无活动销毁,防止用户不关浏览器却永远占着服务器内存。配置有两种方式:一是 session.setMaxInactiveInterval(900),单位是,900 秒 = 15 分钟;二是在 web.xml 里写 <session-config><session-timeout>30</session-timeout></session-config>,单位是分钟。两个单位不一样,混用就踩坑。

再来看购物车这个真实场景——Session 里存一个 List:

@WebServlet("/addCart")
public class CartServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        HttpSession session = req.getSession();
        // 第一次取出来是 null,要先判空再初始化
        List<String> cart = (List<String>) session.getAttribute("cart");
        if (cart == null) {
            cart = new ArrayList<>();
            session.setAttribute("cart", cart);
        }
        cart.add(req.getParameter("item"));   // 把商品名加进购物车
        out.write("购物车现有 " + cart.size() + " 件商品");
    }
}

这里有个经典细节:getAttribute 第一次返回 null,不判空就直接 cart.add(...) 会空指针。先判空、再初始化、再使用是 Session 取对象的固定套路。

💡 记忆口诀:Session = 数据在服务器 + 浏览器只拿编号,编号借 Cookie 传递,叫 JSESSIONID。禁用 Cookie 就"失联",靠 URL 重写续命。

⚠️ 常见错误

  1. 取出来不强转getAttribute 返回 Object,直接赋给 String 编译报错。为什么:Session 能存任意类型,API 只能返回最通用的 Object怎么避免:赋值时一律 (String) 强转;如果存的是 List,要 (List<String>) 强转。
  2. getSession() 会无条件新建:只要当前没有 Session,它一定新建一个再返回。为什么:无参版本的设计意图是"确保拿到一个可用的会话"。怎么避免:只想查询状态的只读场景用 getSession(false),没有就返回 null,否则每个请求都白白创建一个空 Session 占内存。
  3. 用 Session 存海量数据:Session 在服务器内存,每存一个大对象都占内存,用户一多内存直线上升甚至 OOM。为什么:Session 默认放在 JVM 堆内存。怎么避免:Session 只放"身份标识、小对象",大结果集放数据库或缓存。
  4. 以为关浏览器 Session 就没了:Session 的生命周期在服务器端,靠"超时时间"销毁。为什么:关浏览器只是把 JSESSIONID 这张"钥匙"丢了,服务器上的数据还活着。怎么避免:想立刻清掉登录痕迹,登出时调用 session.invalidate()

4.4 登录状态保持:完整例子

Session 与 Cookie 怎么选

对比项 Cookie Session
存储位置 浏览器本地 服务器内存
数据量 约 4KB,放小数据 无硬限制,受内存约束
安全性 低(明文、可被改) 高(数据不落地客户端)
生命周期 setMaxAge 控制,可长期 约 30 分钟无活动销毁
生效范围 按 path / domain 匹配 限本服务器、本会话
典型用途 记住偏好、记住登录名 登录状态、购物车、身份

选择口诀:不敏感要长期 → Cookie;敏感要安全 → Session,登录状态基本都用 Session。

完整场景:登录、放行、登出。登录成功把身份写进 Session,后续每个请求检查 Session 判断"是否已登录",登出时销毁:

// 1. 登录:验证通过,身份写进 Session
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
        String name = req.getParameter("username");
        String pwd  = req.getParameter("password");
        if ("admin".equals(name) && "123456".equals(pwd)) {
            req.getSession().setAttribute("username", name);   // 身份入库
            resp.sendRedirect("home");                         // 跳转首页
        } else {
            resp.getWriter().write("用户名或密码错误");          // 校验失败
        }
    }
}

// 2. 首页/受保护资源:每次请求先查 Session
@WebServlet("/home")
public class HomeServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        HttpSession s = req.getSession(false);    // 不新建,只查询
        if (s == null || s.getAttribute("username") == null)
            resp.sendRedirect("login.html");      // 没登录 → 踢回登录页
        else
            out.write("你好," + s.getAttribute("username"));
    }
}

// 3. 登出:销毁会话,清掉所有登录痕迹
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        HttpSession s = req.getSession(false);
        if (s != null) s.invalidate();            // 会话作废
        resp.sendRedirect("login.html");
    }
}

这三个 Servlet 拼起来就是一套最小的登录系统:login 写入身份、home 检查身份、logout 清掉身份。注意 home 用的是 getSession(false)——它只是"查询",不该为了检查就新建一个会话。

⚠️ 会话固定攻击:登录前服务器已经发了一个 JSESSIONID,登录成功后如果还用同一个 ID,攻击者可以提前把自己的 ID 发给受害者,等受害者登录完成,攻击者用同一个 ID 就能冒充登录状态。防御:登录成功后调用 req.changeSessionId() 换一个新的 Session ID,让旧 ID 作废。

补充:密码校验别偷懒:上面例子为了演示用了硬编码密码,真实项目里密码要加密存储(如 BCrypt 加盐哈希),登录时比对哈希而不是明文;还要限制登录失败次数,防止暴力猜密码。这些是接口安全的基本功,凡涉及登录的代码都应该想到,是设计题里能体现思考深度的点。

💡 记忆口诀:登录成功 = 身份写进 Session + 换掉旧 ID;每次请求 = 先查 Session 再放行;登出 = invalidate() 一锅端。


4.5 JSP 与模板引擎:把"写死的页面"变活

JSP 本质就是 Servlet

老师发了一张答题卡:表格选项印好,横线空白让你填。HTML 页面同理——静态部分写死,动态部分(用户名、商品列表)留空,服务器运行时填空。JSP(Java Server Pages)就是这种"填空卡":表面是 HTML,混着 Java 代码,被容器(Tomcat)编译成 Servlet 再执行输出 HTML。一句话:JSP 本质是 Servlet

<%@ page contentType="text/html;charset=UTF-8" %>   <%-- 页面指令 --%>
<html>
<body>
<%-- 表达式 <%= %> 输出结果;脚本片段 <% %> 写 Java 代码 --%>
<p>今天:<%= new java.util.Date() %></p>
<% for (int i = 1; i <= 3; i++) out.println("第 " + i + " 行"); %>
<%-- EL ${ } 自动从 request/session 取值;JSTL <c:if> 做判断 --%>
<p>你好,${name}</p>
<c:if test="${not empty user}">欢迎,${user.name}</c:if>
</body>
</html>

JSP 的执行流程:第一次被访问时,容器(Tomcat)把 .jsp 翻译.java(一份 Servlet 源码),编译.class,再实例化、执行,把输出拼成 HTML 回给浏览器。所以第一次访问 JSP 通常比较慢(要翻译编译),之后就快了。这也解释了为什么 JSP 里能直接写 Java——因为翻译出来的本来就是 Servlet,你在 JSP 里写的东西最终都进了 service() 方法。

JSP 的九大内置对象:不用声明就能直接用,最常用的有:out(输出流)、request(HttpServletRequest)、response(HttpServletResponse)、session(HttpSession)、application(全站共享)、exception(异常对象)等。它们都由容器创建好,翻译时自动塞进代码里,out.println(...) 在 JSP 里直接能用,就是因为容器已经帮你声明了 out

JSP 与纯 Servlet 对比:JSP 写 HTML 方便(直接写标签),Servlet 写逻辑方便(天然 Java)。经典 MVC 里,**JSP 当视图(V)**负责展示,Servlet 当控制器(C)负责接收请求、调逻辑,模型(M)放业务数据。页面复杂用 JSP,逻辑复杂用 Servlet,各司其职、互不越界。

模板引擎(Thymeleaf / FreeMarker):JSP 依赖 Servlet 容器、只能在服务端用。模板引擎是更现代的"填空卡":数据和模板分离,模板里写占位符(Thymeleaf 的 th:text、FreeMarker 的 ${user.name}),由引擎填充后输出。与 JSP 的异同:是都服务端渲染,都有静态+动态;是 JSP 能写整段 Java,模板引擎只能写表达式和标签,强迫"模板保持纯粹",更易维护,还能脱离 Tomcat 单独用(比如生成邮件模板)。

怎么选:老项目(课设、传统 JavaWeb)常碰 JSP;新项目、Spring Boot 生态默认推荐 Thymeleaf。判断题常出两条:JSP 本质是 Servlet、能写 Java;模板引擎数据与视图分离、不能写 Java。抓住这两条,选择题基本不会错。另外注意 JSP 页面顶部一般要写 <%@ page contentType="text/html;charset=UTF-8" %>,它属于 JSP 指令(directive),负责设置页面属性,是写页面时最先要写的"声明行"。

💡 记忆口诀:JSP = 能写 Java 的 HTML;模板引擎 = 只能填空的 HTML。能写代码越自由,维护越痛苦。

⚠️ 常见错误

  1. <%= 写成 <%@<%@ 是页面指令(import、page 配置),<%= 才是输出表达式。为什么:两者语法前缀不同,容器按不同规则解析。怎么避免:看到 <% 后面跟 @ 就想"这是配置不是输出";要输出一律 <%=
  2. 输出用户内容不转义:用户输入 <script> 直接输出会被浏览器当脚本执行,产生 XSS 漏洞。为什么:浏览器把字符串当 HTML 解析了。怎么避免:用 EL/JSTL(默认转义),或手动把 <>& 等转义。
  3. EL 取不到值${user.name} 找不到时输出空白不报错。为什么:EL 按 page → request → session → application 顺序逐层找,全找不到就返回空。怎么排查:先确认值存在哪一层、键名拼没拼错,再确认有没有 getXxx 方法。
  4. 模板引擎写复杂逻辑:Thymeleaf/FreeMarker 只能写表达式和标签。为什么:设计上刻意限制,保证模板纯净、易维护。怎么避免:复杂计算在 Java 侧算好放进 Model,模板只做展示。
  5. 忘写 <%@ page contentType="text/html;charset=UTF-8" %>:中文输出乱码。为什么:容器默认按 ISO 编码输出,中文就变 ???怎么避免:每个 JSP 顶部声明 UTF-8,同时保证文件本身保存为 UTF-8。

📌 双语术语表(本讲)

中文 English 记忆点
无状态 stateless 服务生失忆
会话 session 服务器记住你
Cookie cookie 浏览器端小纸条
会话标识 session ID 会员卡编号
会话固定攻击 session fixation 登录后换新 ID
属性 attribute Session 里的键值对
超时 timeout 默认约 30 分钟
过期时间 max age setMaxAge 单位秒
URL 重写 URL rewriting 禁用 Cookie 时续命
内置对象 implicit object request / out / session
JSP 表达式 JSP expression <%= %>
EL 表达式 EL expression ${user.name}
JSTL 标签 JSTL tags <c:if>
模板引擎 template engine Thymeleaf / FreeMarker
服务端渲染 server-side rendering 服务器填模板

⭐ 本讲考点清单

  1. HTTP 无状态:请求彼此独立,服务器默认不记得
  2. Cookie 服务器创建、存浏览器、每次请求自动带回
  3. setMaxAge 单位秒、setPath 控作用域、setHttpOnly 防 XSS
  4. req.getCookies() 可能为 null,遍历前先判空
  5. Session 数据存服务器,浏览器只拿 JSESSIONID
  6. getSession() 会新建,getSession(false) 不新建
  7. getAttribute 返回 Object 要强转;invalidate() 销毁会话
  8. 禁用 Cookie 时 Session 失效,用 URL 重写解决
  9. 登录成功存 Session + changeSessionId() 防会话固定
  10. JSP 本质是 Servlet:<%= %> 输出、<% %> 代码、EL ${} 取值
  11. JSP 九大内置对象:request、response、out、session 等
  12. 模板引擎数据与模板分离,不能写 Java