本节摘要:九大隐式对象是容器在翻译页面时自动注入的九个现成变量,分别对应请求、响应、会话、全局上下文等核心接口;四大作用域则规定了数据从"当前页面"到"全站共享"的四级可见范围与存续时长。上一节看清了请求链路的骨架,本节回答链路上数据放哪、活多久、谁看得见——这三个问题的答案,直接决定购物车这类跨请求数据的正确性。
上节末尾留了一根引信:服务方法是多线程并发执行的。要理解并发之下什么会出事,得先盘点方法体内到底有些什么。你在 1.1 节见过翻译产物的示意代码——方法开头有一行 JspWriter out 的准备语句。容器翻译每个页面时,都会在服务方法开头生成一大段类似的准备代码:把九个现成变量依次就位。这就是"隐式"的全部秘密:不是没有声明,是容器替你声明了。
九个对象各司其职,用一张表盘清家底。读表时重点看最后一列——它决定了你能不能放心往里面放数据。
| 隐式对象 | 对应的角色 | 管什么 | 线程维度 |
|---|---|---|---|
| request | 请求的化身 | 参数、请求头、本次请求携带的一切 | 每请求一份,天然安全 |
| response | 响应的信使 | 状态码、响应头、输出流控制 | 每请求一份,天然安全 |
| out | 输出流 | 把内容写回浏览器,带缓冲 | 每请求一份,天然安全 |
| session | 会话档案 | 同一用户跨请求共享的数据 | 按用户隔离,同用户并发仍共享 |
| application | 全站公告栏 | 所有用户共享的全局数据 | 全站一份,处处共享 |
| pageContext | 页面总管 | 统一存取四大作用域的中枢 | 每请求一份,天然安全 |
| page | 页面自身 | 当前类实例的自引用 | 全站一份 |
| config | 初始化配置 | 页面对应类的初始化参数 | 只读,安全 |
| exception | 异常现场 | 仅在错误页里可用的异常对象 | 每请求一份,天然安全 |
看出规律了吗:九个对象里真正需要你操心的只有 session 和 application 两个——它们的数据跨请求存活,于是必然面临"上一个请求写、下一个请求读"的共享问题。其余七个随请求生灭,各线程人手一份,想出事都没机会。老站故障里千奇百怪的"偶发串数据",排查范围可以先用这条规律砍掉四分之三。
隐式对象负责"存",作用域负责"存多久、给谁看"。四级作用域是严格的包含关系,画成同心层最好理解。

四级边界对应四条工程纪律。购物车数据放 session:它是"同一用户跨请求"的典型,放窄了(request)一跳转就丢,放宽了(application)全站顾客共享一辆车——真有老代码这么写过,那就是事故本身。控制器传给视图的数据放 request:第 4 章 MVC 落地时的标准动作,一次请求用完即弃,不占会话内存。全局配置放 application:全站只读,一份就够。临时渲染变量放 page:出了页面就没有意义。
还有一个隐蔽规则值得单独点名:EL 表达式取值时按 page、request、session、application 的顺序就近查找。同名属性放在不同作用域,窄的会遮住宽的。青梧书肆就出过一次事故:某页面在 session 里放了个旧版本的购物车,又在 request 里放了新版本,页面上 ${cart} 永远取到新的,另一处用脚本片段从 session 里取的辅助逻辑却读到旧的——两处数据对不上,排查了一下午。名字冲突时,作用域就是嫌疑顺序。
坑都在 session 与 application 的写操作上。第一个坑,非原子的读改写:
<% Integer hits = (Integer) application.getAttribute("visitCount"); if (hits == null) hits = 0; application.setAttribute("visitCount", hits + 1); %>
单线程看毫无破绽。并发下,两个线程可能同时读到同一个旧值、各自加一、先后写回——两次访问只涨了一次。修复思路有二:一是把读改写整体加锁;二是换线程安全的原子类,读改写一步完成。后者更优雅:
<% java.util.concurrent.atomic.AtomicInteger hits = (java.util.concurrent.atomic.AtomicInteger) application.getAttribute("visitCount"); if (hits == null) { hits = new java.util.concurrent.atomic.AtomicInteger(0); application.setAttribute("visitCount", hits); } hits.incrementAndGet(); %>
第二个坑更阴险,藏在页面声明区里:
<%! private Cart userCart = new Cart(); // 危险:这是类的成员变量 %> <% userCart.add(book); // 所有用户共享同一辆"购物车" %>
声明区里的变量翻译后是类的成员变量,而类是单例——于是这个名为 userCart 的"购物车"成了全站顾客的公共车。这就是 2.1 节那条引信的爆点。纪律只有一条:请求级数据一律放在脚本片段里,让它们成为服务方法的局部变量,随请求生灭。
背景:把两坑合并成一次真实事故看。青梧书肆上线初期,运维群反馈"顾客 A 的购物车里偶尔出现顾客 B 加的书",频率低到无法复现。
操作:按本节规律缩小包围圈——串的数据是购物车,跨请求存在,重点查 session 的读写路径;逐页排查后发现详情页的加入动作用了声明区变量持有购物车对象。
结果:把声明区变量改写为脚本片段内的局部变量,从 session 取放,事故消失。
解读:低频偶发是共享状态缺陷的典型面貌——冲突窗口越小,越难复现,但数学上必然发生。这类问题不能靠"再观察观察"结案,只能靠识别共享模式根除。
变式:如果你接手的站点不能立刻改代码,临时止血可以把该页面的并发度降下来(容器里限制单页面并发线程),但这只是稀释冲突概率,不解决问题。根治永远是消灭"单例里的可变请求级状态"。
⚠️ 常见坑:把本该放 request 的数据图省事放进 session,短期看功能正常,长期看是内存缓慢上涨与用户间数据干扰两颗雷——老站内存泄漏的头号来源。
数据放哪、活多久已经清楚了,还剩旅程的最后一帧:页面之间的跳转。转发与重定向这两条路线的差别,是参数丢失、重复提交这类工单的常客——下一节用一次真实排障把它们讲透。