当年我刚接触JSP时,最困惑的一件事就是:我明明连import都没写,为什么在页面里能用request、session、application这些对象?后来翻Tomcat自动生成的Java源码才明白,所谓的JSP九大内置对象,不过是JSP翻译成Servlet时,在_jspService方法里预先帮你声明好的一堆局部变量。这篇文章就围绕九大内置对象的基本使用展开,把每个对象是什么类型、什么场景下用、怎么用、为什么这么设计,一次性讲清楚。适合还停留在“照抄网上示例”阶段、没搞懂背后原理的初学者,也适合要维护老项目、需要快速捡起JSP知识的开发者。
1. JSP翻译成Servlet,内置对象其实是九个“局部变量”
1.1 从_jspService方法看内置对象的出生过程
很多人都知道“JSP底层是Servlet”,但没亲眼看过那个转换过程。实际上,当Tomcat第一次访问一个test.jsp时,会把它翻译成test_jsp.java,再编译成test_jsp.class。在Tomcat 9的work/Catalina/localhost/应用名/org/apache/jsp目录下,你可以直接看到这些生成的Java文件。
打开其中一个Java文件,核心方法长这样:
public void _jspService(final HttpServletRequest request, final HttpServletResponse response) throws java.io.IOException, ServletException { ... PageContext pageContext = null; HttpSession session = null; ServletContext application = null; ServletConfig config = null; JspWriter out = null; Object page = this; ... pageContext = _jspxFactory.getPageContext(this, request, response, null, true, 8192, true); application = pageContext.getServletContext(); config = pageContext.getServletConfig(); session = pageContext.getSession(); out = pageContext.getOut(); ... out.write("<html>\r\n"); }这一小段代码说明了很多东西。request和response是_jspService方法的形参,由容器传入。pageContext是九大对象里第一个被创建的对象,其余对象基本都是通过pageContext.getXxx()获得的。换句话说,内置对象不是“魔法变量”,它们有明确的创建顺序和依赖关系:容器创建request和response,接着创建pageContext,再通过pageContext拿到session、application、config、out。
理解这一点后,很多问题就能想通了。比如为什么在JSP的<%! %>声明块里不能用request?因为声明块里的内容会被编译成Servlet类的成员方法,而request只是_jspService方法的局部变量,作用域根本不在成员方法内。
1.2 九大内置对象的类型与用途速查
| 内置对象 | 真实类型 | 用途 |
|---|---|---|
| request | javax.servlet.http.HttpServletRequest | 读取客户端请求参数、请求头、属性,转发请求 |
| response | javax.servlet.http.HttpServletResponse | 设置响应头、响应编码,重定向,输出响应内容 |
| pageContext | javax.servlet.jsp.PageContext | 页面上下文,可访问其它八个对象,管理page作用域 |
| session | javax.servlet.http.HttpSession | 保存当前用户的会话状态 |
| application | javax.servlet.ServletContext | 整个Web应用全局共享的数据 |
| out | javax.servlet.jsp.JspWriter | 向客户端输出内容,自带缓冲 |
| config | javax.servlet.ServletConfig | 读取当前Servlet/JSP的初始化参数 |
| page | java.lang.Object(实际是this) | 当前JSP对应的Servlet实例,基本不用 |
| exception | java.lang.Throwable | 仅在isErrorPage="true"的错误页中可用 |
这张表建议存下来。后面讲到的每个对象的坑,基本都能在“类型和生命周期”上找到根源。
1.3 为什么JSP能直接用,Servlet里却不行
Servlet里要写request.getParameter(),必须先有HttpServletRequest对象,而它是service()或doGet()/doPost()方法的参数。JSP里之所以能直接写,是因为容器把_jspService()方法里每个局部变量都固定命名为request、response、session……你写的JSP脚本片段会被原样放到这个方法体内,当然可以直接引用这些名字。
这个设计对初学者很友好,但也带来一个负面效果:容易让人误以为这些对象是“全局有效的”。实际上它们只在一个_jspService方法调用周期内有效,每次请求都会重新创建一套全新的request、response、out等变量。session和application虽然是同一个会话/同一个应用内复用,但那是因为它们的底层对象本身生命周期长,和局部变量这个名字无关。
2. request、response、out,三个天天在一起的对象怎么配合
2.1 request:客户端请求的唯一入口
request是最常用的内置对象,它封装了浏览器发来的所有信息。读取表单参数是它的核心职责:
<% String username = request.getParameter("username"); String[] hobbies = request.getParameterValues("hobby"); Map<String, String[]> allParams = request.getParameterMap(); %>getParameter()用于单值参数,getParameterValues()用于多选框这类同名多值参数,getParameterMap()可以一次拿到所有参数。这里必须区分两个概念:参数(parameter)和属性(attribute)。参数来自客户端,只能读不能写;属性是服务端代码放进去的,通过setAttribute()写、getAttribute()读。很多初学者把getParameter和getAttribute混用,然后在转发时拿不到值,其实是因为选错了方法。
request还有一个高频操作是请求转发:
<% request.setAttribute("user", userInfo); request.getRequestDispatcher("/userInfo.jsp").forward(request, response); %>转发之后,目标页面能用request.getAttribute("user")拿到数据,因为这次forward是同一个请求在服务端内部的跳转,request对象没有换。
2.2 response:回给浏览器的所有内容都要经过它
response负责构建服务端返回给客户端的东西。最基本的两个操作是设置响应类型和重定向:
<% response.setContentType("text/html;charset=UTF-8"); response.sendRedirect("login.jsp"); %>setContentType()必须在真正开始输出内容之前调用。JSP页面里通常通过<%@ page contentType="text/html;charset=UTF-8" %>指令完成这一步,容器会自动调用response.setContentType()。这里的charset=UTF-8同时影响响应体的字符编码和浏览器解码方式,写漏了最容易出现中文乱码。
sendRedirect()就是浏览器重定向,服务端会返回一个302状态码和Location响应头,浏览器再重新发起一次全新请求。这也是它和forward最本质的区别:**一个是一次请求,一次响应;另一个是两次请求,两次响应。**后面第6章会专门讲这个坑。
2.3 out:自带缓冲的输出流,JSP页面默认的写出口
out的类型是JspWriter,它和response.getWriter()返回的PrintWriter不是同一个东西。JspWriter默认有一个8KB的缓冲区,页面里写的内容会先堆积在缓冲区,指令执行完了或者缓冲区满了才真正刷到response的输出流里。这样设计是为了减少网络IO次数,也能在页面中途发生错误时尽量不输出半截页面。
out的基本用法:
<% out.print("<p>Hello</p>"); out.println("下一行"); %>表达式<%= 表达式 %>其实也是通过out输出的。模板文本(JSP中不是Java代码的普通HTML)则由容器生成的out.write(...)来输出。也就是说,JSP页面所有写向浏览器的内容,最终都汇聚到out这条通道上。
2.4 千万别把out和response.getWriter()混在一起写
我见过不少维护烂项目的同事,在同一个JSP文件里一会儿用out.print(),一会儿用response.getWriter().write(),结果输出的内容顺序完全不可控。原因就是out有缓冲,而response.getWriter()可能直接把内容写到更底层的响应流里,两者刷新时机不同,先后顺序就会错位。
经验法则很简单:**JSP页面里一律用out,Servlet里用response.getWriter(),不要混。**如果非要用response.getWriter()输出,也要等out的内容主动flush()之后再操作,但真没必要这么折腾。
3. 状态保存怎么选:pageContext、request、session、application四个作用域
3.1 四个作用域范围对比
JSP里保存数据牵扯到四个对象:pageContext、request、session、application。它们对应四个从小到大、从短到长的作用域:
| 作用域 | 对应对象 | 存活范围 | 典型场景 |
|---|---|---|---|
| page | pageContext | 当前页面处理完成就失效 | 页面内临时变量 |
| request | request | 当前请求,包含forward转发 | 表单提交结果 |
| session | session | 同一个浏览器会话,默认30分钟 | 登录状态、购物车 |
| application | application | 整个应用运行期间 | 全局配置、访问次数 |
看到这个表,你应该能理解为什么request设置的值在sendRedirect之后拿不到了:新的请求产生,旧请求已经销毁,它的属性自然跟着没了。
3.2 pageContext:被低估的“对象工具箱”
pageContext是九大对象中最特殊的一个,因为通过它几乎能拿到其它所有内置对象:pageContext.getRequest()、pageContext.getResponse()、pageContext.getSession()、pageContext.getServletContext()、pageContext.getServletConfig()、pageContext.getOut()。所以在自定义标签、JSP片段这类不方便直接声明所有内置对象的地方,pageContext就是唯一的入口。
它还可以按指定作用域读写数据:
<% pageContext.setAttribute("message", "我在page作用域"); pageContext.setAttribute("message", "我在request作用域", PageContext.REQUEST_SCOPE); pageContext.setAttribute("message", "我在session作用域", PageContext.SESSION_SCOPE); pageContext.setAttribute("message", "我在application作用域", PageContext.APPLICATION_SCOPE); // 按顺序查找:page → request → session → application String msg = (String) pageContext.findAttribute("message"); %>这里有个非常关键的机制:pageContext.findAttribute()会按照page、request、session、application的顺序逐级查找,找到第一个就返回。JSP的EL表达式${message}底层用的就是这套查找逻辑。如果不同作用域里存在同名属性,EL取到的可能是你没想到的那一个。想精确取值,就应该用${requestScope.message}、${sessionScope.message}这样的写法。
3.3 session:用户的临时状态,不能滥用
session保存的是同一个浏览器会话内、跨多个请求都能拿到的数据。常见做法是用户登录成功后写入:
<% session.setAttribute("loginUser", username); session.setMaxInactiveInterval(1800); // 30分钟,单位秒 %>之后其它页面只要从session里取loginUser,就知道当前用户是谁。登出时调用session.invalidate()让会话失效,同时清空该会话相关数据。
session底层依赖Cookie里的JSESSIONID;如果客户端禁用了Cookie,可以通过response.encodeURL()做URL重写,但这会增加实现复杂度。实际应用中,浏览器标识一致就认为是同一个会话,所以session天然具备“用户隔离”能力。这里要提醒一点:不要把大对象、大量List数据塞进session,否则每个在线用户都会在服务器内存里占一份,用户一多,内存很容易被打爆。
3.4 application:全局共享,必须处理并发
application对应的ServletContext全局只有一份,所有用户共享。最常见的演示案例是统计网站访问次数:
<% Integer count = (Integer) application.getAttribute("visitCount"); if (count == null) { count = 0; } count++; application.setAttribute("visitCount", count); %> <p>第 <%= count %> 次访问</p>但这份代码在多线程并发下有明显的线程安全问题。两个用户同时读到count=10,各自加1,最后可能只变成11。真正的生产代码要么用synchronized包住读写,要么直接使用Java的AtomicInteger配合application.setAttribute。如果只是教学演示,这个写法没关系;放到线上就要小心。
3.5 判断标准:看这个数据需要活多久
我的选择逻辑很固定:
- 只在当前JSP页面内用一次,用page作用域或普通局部变量;
- 需要从Servlet转发到JSP展示,用request作用域;
- 登录用户、购物车这类跟着用户走的数据,放session;
- 全局配置项、全局计数器,放application。
原则是能用小作用域就不用大作用域。数据能放request,绝不放session;能放session,绝不放application。作用域越大,内存压力越大,数据错乱的可能性也越大。
4. 存在感很低的config、page、exception三个对象
4.1 config:读取JSP初始化参数
config是ServletConfig类型,在JSP里最主要的用途就是读取初始化参数。少数老项目会把某些业务配置放到web.xml里,给某个JSP单独设置参数:
<servlet> <servlet-name>configDemo</servlet-name> <jsp-file>/configDemo.jsp</jsp-file> <init-param> <param-name>publisher</param-name> <param-value>张三</param-value> </init-param> </servlet> <servlet-mapping> <servlet-name>configDemo</servlet-name> <url-pattern>/configDemo.jsp</url-pattern> </servlet-mapping>然后在JSP里读:
<% String publisher = config.getInitParameter("publisher"); out.println("发布者:" + publisher); %>这套机制相当于把页面内容和运行参数解耦。不过现在的项目里,参数更多放在数据库或配置中心,这个对象的使用频率已经很低了。但看到老代码不要慌,要认得它。
4.2 page:九大对象里最容易被人无视的一个
page指向当前JSP翻译出来的Servlet实例,本质就是this。在Tomcat生成的源码里有一句Object page = this;。由于JSP里很少需要直接操作Servlet实例本身,所以page在正常开发中用不上。唯一有点存在感的场景,是在模板JSP中通过反射获取当前Servlet信息,比如page.getClass().getName(),或者判断当前Servlet是否实现了某个接口。
我见过一些旧项目里用<%= page.toString() %>调试,能拿到类似org.apache.jsp.index_jsp@xxxx的输出。这算是一个隐藏调试技巧,但不要指望它能做太多事情。
4.3 exception:只在错误页里活着的对象
exception和其它八个对象最大的区别在于:**它不是每个页面都能用,只有<%@ page isErrorPage="true" %>的页面才能用。**想在普通页面用exception,编译阶段就会报“exception cannot be resolved”。
使用方式分为两步。先在一个普通页面声明错误页:
<%@ page errorPage="error.jsp" %> <% int result = 10 / 0; %>当页面抛出未捕获异常后,容器会跳到error.jsp。在错误页里用exception拿到异常信息:
<%@ page isErrorPage="true" %> <html> <head><title>错误页</title></head> <body> <h3>系统出了点问题</h3> <p>错误信息:<%= exception.getMessage() %></p> </body> </html>也可以去掉errorPage属性,改成在web.xml里配置全局错误页:
<error-page> <exception-type>java.lang.Throwable</exception-type> <location>/error.jsp</location> </error-page>这会作用于整个应用。需要特别注意的是,生产环境的错误页绝对不要直接打印exception.printStackTrace()或者把完整堆栈输出给用户。这些信息很容易暴露代码结构、数据库类型等敏感细节。正确做法是把完整堆栈记录到日志,页面上只展示一句友好提示。
5. 用一个个人信息展示页面,把九大内置对象串起来实践
5.1 场景说明
这一章我们做一个最简单的“个人信息展示”页面。用户先填一个表单,提交姓名、城市、爱好,第二个JSP页面负责展示;同时用session记录当前用户,用application记录总访问次数。这样能把request、response、out、session、application、pageContext全都串起来跑一遍。这个场景也可以直接改造成学生信息管理系统里“学生信息展示”的雏形。
5.2 IDEA里创建JSP文件的注意点
在IntelliJ IDEA中新建infoInput.jsp时,默认生成的模板第一行是:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>这里要注意,IDEA里JSP文件本身的编码设置。右下角File Encoding如果是GBK,而contentType里声明的是UTF-8,必然乱码。我刚入职的时候在这个问题上栽过跟头,建议在IDEA的Settings里把Editor > File Encodings的Default encoding统一改成UTF-8,创建JSP前先确认右下角编码。
5.3 表单页:收集个人信息
infoInput.jsp的代码:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head><title>个人信息提交</title></head> <body> <h3>请填写个人信息</h3> <form action="userInfo.jsp" method="post"> <p>姓名:<input type="text" name="username"/></p> <p>城市: <select name="city"> <option value="北京">北京</option> <option value="上海">上海</option> <option value="广州">广州</option> </select> </p> <p>爱好: <label><input type="checkbox" name="hobbies" value="Java"/>Java</label> <label><input type="checkbox" name="hobbies" value="Reading"/>Reading</label> <label><input type="checkbox" name="hobbies" value="Music"/>Music</label> </p> <input type="submit" value="提交"/> </form> </body> </html>这里用method="post"而不是get,有两个原因:一是避免中文参数直接暴露在URL里;二是表单内容可能很长,GET方式有URL长度限制,POST没有。
5.4 展示页:配合request、session、application使用
userInfo.jsp的代码:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <% request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String city = request.getParameter("city"); String[] hobbies = request.getParameterValues("hobbies"); if (username == null || username.trim().isEmpty()) { response.sendRedirect("infoInput.jsp"); return; } request.setAttribute("city", city); session.setAttribute("currentUser", username); Integer visitCount = (Integer) application.getAttribute("visitCount"); if (visitCount == null) { visitCount = 0; } visitCount++; application.setAttribute("visitCount", visitCount); %> <html> <head><title><%= username %>的个人信息</title></head> <body> <h3><%= username %>,你好,欢迎回来</h3> <p>当前访问次数:<%= visitCount %></p> <p>所在城市:<%= request.getAttribute("city") %></p> <p>兴趣爱好:</p> <ul> <% if (hobbies != null) { for (String hobby : hobbies) { out.println("<li>" + hobby + "</li>"); } } else { out.println("<li>无</li>"); } %> </ul> <p>会话ID:<%= session.getId() %></p> </body> </html>代码里有几个细节值得解释。第一,request.setCharacterEncoding("UTF-8")必须出现在任何getParameter()之前,如果先读了参数再设置编码,POST请求体已经按ISO-8859-1解析完,再设置就晚了。第二,request.getParameterValues("hobbies")返回的是String数组,没有选择任何爱好时返回null,所以要做空判断。第三,response.sendRedirect("infoInput.jsp"); return;这两行缺一不可,return保证后面的页面代码不再执行,否则即使跳转状态已经发出,容器仍可能继续渲染空页面。
5.5 登录状态的简单控制
在其它页面里,可以这样判断用户是否已经提交过信息:
<% Object loginUser = session.getAttribute("currentUser"); if (loginUser == null) { response.sendRedirect("infoInput.jsp"); return; } %>这就是学生信息管理系统中常见的登录校验雏形,但注意真实项目不要在每个JSP里复制这段代码,应该用Filter统一处理。JSP内置对象让你能快速写通功能,但工程化时要控制散落各处的重复脚本。
5.6 去Tomcat的work目录看编译后的Java类
这部分对应很多人搜过的“查看JSP编译后的java类”。在应用的运行目录下找到:
apache-tomcat-9.0.xx/work/Catalina/localhost/你的应用名/org/apache/jsp/userInfo_005finput_jsp.java文件名里的_005f是下划线转义。打开这个文件,就能看到_jspService方法里完整的对象初始化逻辑,以及你写的HTML和脚本变成的Java代码。我建议每个学JSP的人都去翻一次这个目录,看完之后,对内置对象、脚本片段、表达式的关系会有一种豁然开朗的感觉。
6. 内置对象使用时最常见的几个坑
6.1 转发和重定向:request作用域失效的根源
这是新手最容易踩的坑。在Servlet或JSP里写了:
<% request.setAttribute("msg", "转发测试"); request.getRequestDispatcher("result.jsp").forward(request, response); %>result.jsp里用request.getAttribute("msg")能拿到值,因为全程只有一次请求。如果改成:
<% session.setAttribute("msg", "重定向测试"); response.sendRedirect("result.jsp"); %>result.jsp里用request.getAttribute("msg")就一定拿到null,因为第二次请求是浏览器重新发起的,和第一次请求没有任何关系。
正因为这一点,我习惯把两者的区别记成一句话:**forward是服务端把活干完再回应给浏览器,浏览器全程只发了一次请求;sendRedirect是服务端告诉浏览器“你去找另一个地址”,浏览器重新发一次请求。**项目里如果用了Spring MVC的return "forward:xxx"和return "redirect:xxx",底层也是同一套逻辑。
6.2 乱码:setCharacterEncoding的时间窗口问题
中文乱码算是JSP时代最常见的坑了,而且表现五花八门。
第一种是POST表单提交乱码,解决方式就是在接收页面里第一个做:
<% request.setCharacterEncoding("UTF-8"); %>必须放在所有getParameter调用之前。第二种是响应输出乱码,解决方式是用page指令声明:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>这句话会让容器调用response.setContentType("text/html;charset=UTF-8")。第三种是JSP文件本身编码不对,文件保存成GBK却在page指令里写UTF-8,或者反过来,这样页面上所有中文都是乱码。这个坑IDE里最容易遇到,检查顺序应该是:文件编码、pageEncoding、contentType、浏览器请求头。
GET请求的乱码相对特殊。Tomcat 8及以上默认使用UTF-8解析URI中的参数,所以一般不会乱;老版本Tomcat需要在server.xml的Connector上配置URIEncoding="UTF-8"。搜“jsp乱码”能看到大量相关文章,其实就是这几种情况。
6.3 session和application的数据串台
session是按用户隔离的,application是全局共享的,这个很多人知道,但代码里不一定分得清。最典型的错误是:
<% session.setAttribute("userInfo", user); %>如果在跨用户场景下不小心用了application.setAttribute("userInfo", user),那么用户A提交的数据会被用户B覆盖,B刷新后甚至看到A的信息。这类“串数据”问题排查起来特别隐蔽,因为它不是必现的,只有并发访问时才会暴露。
另一个容易被忽略的问题是session里的数据不会自动消失,会一直占用内存直到会话过期。如果把一个十几MB的临时查询结果随手setAttribute进session,每个在线用户都占一份,服务器内存很快告急。我的建议是:能被request带的就别放session,能重建的临时数据就别缓存。
6.4 EL表达式的查找顺序会骗人
EL表达式${username}的查找顺序和pageContext.findAttribute()完全一致:page → request → session → application。假设你在request里放了username="张三",又在session里放了username="李四",EL表达式会先命中request,输出“张三”。这个行为本身是规范的,但很容易让初学者困惑:为什么我session里明明改了值,页面上展示的还是旧值?
解决方式是避免同名属性散落多个作用域,或者在EL表达式里显式指定作用域:${requestScope.username}、${sessionScope.username}。搜索引擎里那些“EL表达式取不到值”的问题,至少有一半和这个查找顺序有关。
6.5 在<%! %>声明块里使用内置对象导致编译失败
我已经不止一次看到新人在<%!块里写:
<%! private String getClientIp() { return request.getRemoteAddr(); // 编译报错:request cannot be resolved } %>原因在前面说了:<%! %>里的代码会成为Servlet类的成员方法,request只是_jspService方法的局部变量,成员方法里根本看不到它。正确做法是把request作为参数传进这个方法:
<%! private String getClientIp(HttpServletRequest request) { return request.getRemoteAddr(); } %>这个小细节能帮你避免一次莫名其妙的编译错误。
6.6 用全局错误页时,exception不显示
有些项目配置了web.xml的全局错误页,但发现页面里<%= exception %>一直编译报错。这是因为全局错误页也要满足两个条件:isErrorPage="true"和错误页本身能被容器识别为错误处理页面。如果你在JSP里没有加:
<%@ page isErrorPage="true" %>exception对象就不会被注入。加上之后,再配合web.xml的<error-page>配置,才能正常使用。另外,如果错误页本身也抛异常,会形成错误处理循环,Tomcat最终会展示它自己的默认错误页。
7. 关于JSP内置对象的个人经验和建议
最后分享一些我个人在项目里积累的体会。
维护老项目时,内置对象是理解页面的钥匙。看到一个老JSP页面,先扫一遍它用了哪些内置对象,基本就能猜出页面逻辑:用了session多半涉及登录态,用了application多半有全局统计,用了request.getAttribute多半前面有Servlet转发。这种“通过内置对象反推架构”的能力,对接手老系统特别有用。
写新代码时,我的建议是不要让JSP承担太多Java逻辑。现在的前后端开发模式里,JSP更多是充当模板,控制逻辑放在Servlet或Spring MVC的Controller中。页面里尽量少写<% %>脚本,多用EL表达式和JSTL,让request、session、application只承担传递数据的作用,而不是写一堆循环和判断。这样既好维护,也容易迁移到更现代的模板技术。
学习阶段,一定要去Tomcat的work目录看一次生成的Java文件。很多教程讲了半天“JSP的本质是Servlet”,都不如你亲眼看到_jspService方法里那九个变量是怎么声明、怎么赋值的。看完那一页源码,你对request、response、out、pageContext这些对象的理解会完全不同。
还有个记忆技巧:九个内置对象可以分成三组来记——request、response是HTTP请求响应的两端;out是页面输出通道;pageContext、session、application、config、page是一组和Servlet上下文、会话、配置、实例相关的对象;exception单独记,它只在错误页出现。分组之后,考试也好、翻代码也好,都不容易漏。