JV-04 会话与状态管理
04 · 会话与状态管理(Session & State)
📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quizshengxia.dev/java?module=04
4.1 HTTP 无状态:服务生不记得你上回点过什么
每次请求都是一次"初次见面"
餐厅有个怪规矩:服务生只在"这一桌、这一次"服务。你点完菜吃完走人,第二天再来,新服务生完全不记得你昨天点什么、口味偏辣偏淡。这就是 HTTP 的无状态(stateless):服务器处理完一次请求就"失忆",下一次请求跟第一次一样,什么都不记得。
// 伪代码:请求带 name 就输出,不带就是 null——服务器不记得上次
String name = req.getParameter("name");
为什么 HTTP 天生无状态:HTTP 协议诞生于上个世纪,最初的用途是传输静态网页文件——打开一个页面、关掉、再打开,本来就不需要"记住"谁访问过。设计成无状态有两大好处:一是服务器不用为每个用户单独存一份状态,省内存;二是任何一台服务器都能处理任何一次请求,方便以后把网站横向扩展成集群。后面你会看到 Session 恰恰要"记住"用户,等于牺牲了这两个好处,所以我们只把必要的信息放进 Session。
无状态带来的麻烦:一旦网站需要"记住"用户,麻烦就来了。比如你往购物车加了一件商品,跳到结算页想再看一眼购物车,结果购物车空了——因为两个请求彼此独立,第二个请求根本不记得第一个请求加了什么。登录、购物车、浏览记录、语言偏好……这些都需要状态。于是就有了本讲的主角:Cookie 和 Session,它们就是 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。
⚠️ 常见错误
- 以为 Cookie 存服务器:Cookie 存在浏览器本地,服务器只是"发起方"。很多新手把登录状态直接写进 Cookie,等于把家门钥匙交给别人保管。为什么会错:混淆了"谁创建"和"谁保存"——创建是服务器,保存却在客户端。怎么避免:敏感数据(密码、身份证号)一律不进 Cookie。
- 忘设
setMaxAge:不设置过期时间,Cookie 默认是"会话级",浏览器一关就消失。为什么:未设置时过期时间缺省为会话级。怎么避免:想"记住我 7 天"必须显式setMaxAge(7*24*60*60),否则用户每次打开浏览器都要重新登录。 setMaxAge传了天数:单位是秒!setMaxAge(7)是 7 秒不是 7 天。为什么:API 按秒设计。怎么避免:天数统一换算成秒再传,写成7 * 24 * 60 * 60这样可读的乘式,防止自己算错。- 读 Cookie 忘了判空:浏览器第一次访问时没有任何 Cookie,
req.getCookies()返回null,直接for遍历抛NullPointerException。为什么:API 用null表示"一个都没有"。怎么避免:遍历前if (cookies != null)先行判空。 - 把密码放 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 重写续命。
⚠️ 常见错误
- 取出来不强转:
getAttribute返回Object,直接赋给String编译报错。为什么:Session 能存任意类型,API 只能返回最通用的Object。怎么避免:赋值时一律(String)强转;如果存的是List,要(List<String>)强转。 getSession()会无条件新建:只要当前没有 Session,它一定新建一个再返回。为什么:无参版本的设计意图是"确保拿到一个可用的会话"。怎么避免:只想查询状态的只读场景用getSession(false),没有就返回null,否则每个请求都白白创建一个空 Session 占内存。- 用 Session 存海量数据:Session 在服务器内存,每存一个大对象都占内存,用户一多内存直线上升甚至 OOM。为什么:Session 默认放在 JVM 堆内存。怎么避免:Session 只放"身份标识、小对象",大结果集放数据库或缓存。
- 以为关浏览器 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。能写代码越自由,维护越痛苦。
⚠️ 常见错误
<%=写成<%@:<%@是页面指令(import、page 配置),<%=才是输出表达式。为什么:两者语法前缀不同,容器按不同规则解析。怎么避免:看到<%后面跟@就想"这是配置不是输出";要输出一律<%=。- 输出用户内容不转义:用户输入
<script>直接输出会被浏览器当脚本执行,产生 XSS 漏洞。为什么:浏览器把字符串当 HTML 解析了。怎么避免:用 EL/JSTL(默认转义),或手动把<、>、&等转义。 - EL 取不到值:
${user.name}找不到时输出空白不报错。为什么:EL 按 page → request → session → application 顺序逐层找,全找不到就返回空。怎么排查:先确认值存在哪一层、键名拼没拼错,再确认有没有getXxx方法。 - 模板引擎写复杂逻辑:Thymeleaf/FreeMarker 只能写表达式和标签。为什么:设计上刻意限制,保证模板纯净、易维护。怎么避免:复杂计算在 Java 侧算好放进 Model,模板只做展示。
- 忘写
<%@ 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 | 服务器填模板 |
⭐ 本讲考点清单
- HTTP 无状态:请求彼此独立,服务器默认不记得
- Cookie 服务器创建、存浏览器、每次请求自动带回
setMaxAge单位秒、setPath控作用域、setHttpOnly防 XSSreq.getCookies()可能为 null,遍历前先判空- Session 数据存服务器,浏览器只拿
JSESSIONID getSession()会新建,getSession(false)不新建getAttribute返回 Object 要强转;invalidate()销毁会话- 禁用 Cookie 时 Session 失效,用 URL 重写解决
- 登录成功存 Session +
changeSessionId()防会话固定 - JSP 本质是 Servlet:
<%= %>输出、<% %>代码、EL${}取值 - JSP 九大内置对象:request、response、out、session 等
- 模板引擎数据与模板分离,不能写 Java