
Servlet 写到第四节很多人会卡在一个很奇怪的地方业务逻辑明明跑通了控制台日志也打印得干干净净浏览器里却是一片空白或者点了提交之后 URL 变了、页面数据却全丢了。这两种症状前者多半出在HttpServletResponse的响应体输出上后者基本就是重定向的锅。它是和 HttpServletRequest 成对出现的孪生参数一个负责把数据搬进来一个负责把结果送出去。前面几节我们把请求对象拆得比较细这一节把响应对象完整过一遍状态码怎么设、响应头怎么加、字符编码在哪一步决定中文会不会变成问号、getWriter 和 getOutputStream 为什么不能同时拿以及 sendRedirect 这个看起来只有一行的动作背后到底发生了什么、带走了什么、又留下了什么。如果你正在跟着一套 JavaEE 教程往下走或者已经能写出跑得起来的 Servlet 但对“为什么这么写”说不出所以然这篇应该能补上那段空白。我按实际动手的顺序来先把对象的结构和容器里的位置讲清楚再讲编码这条最容易翻车的链路然后重点拆重定向最后给几个能直接抄的场景写法和一张排查表。文中代码以 Servlet 3.1 及以上、Tomcat 8/9 为运行环境其他容器大同小异。1. 先把响应对象的位置摆正它到底管什么1.1 请求进来、响应出去一对孪生参数的分工service(HttpServletRequest req, HttpServletResponse resp)这个方法签名几乎是每个 Java Web 初学者看到的第一段“框架代码”。很多人对 req 的印象很具体——取参数、取头、取 session因为业务开发天天要用对 resp 的印象却很模糊好像除了resp.getWriter().write(hello)就没别的用处了。这其实是把响应对象想小了。一次 HTTP 交互本质上是“一行请求报文进去一行响应报文出来”。请求报文里有请求行方法、URI、协议版本、请求头、请求体响应报文里同样有三段状态行协议版本、状态码、原因短语、响应头、响应体。HttpServletRequest 负责让你读取前者的每一部分HttpServletResponse 负责让你写入后者的每一部分。你写setStatus(404)是在改状态行写setHeader(Cache-Control, no-store)是在加响应头写getWriter().write(...)是在填响应体。这三件事拼起来才是“一次完整的响应”缺一个都不算完整。把这个对应关系记住后面所有的 API 就不再是零散的记忆点而是各归其位。很多“为什么这个设置不生效”的问题本质上是你以为自己在改响应头实际上写的是请求对象的 API或者写在了响应已经提交之后。1.2 你手里的不是容器本体而是一层门面有个细节值得说清楚你在 Servlet 里拿到的resp类型声明是HttpServletResponse接口实际运行时它并不是容器的核心对象而是一层**门面Facade**包装。以 Tomcat 为例连接器里真正的响应对象是内部的一个实现类容器在外面套了一层实现了HttpServletResponse接口的包装类再交给你。为什么要多这一层原因很实际容器的内部响应对象上有一大堆方法比如直接操作底层 socket 缓冲区、强制关闭连接、修改内部状态标记。这些方法如果暴露给业务代码任何一个不小心的调用都可能把容器搞进不一致的状态排查起来极其痛苦。门面模式的思路就是“只给你该用的那部分”接口上暴露的是规范定义的标准方法容器私有的东西一律挡住。所以你会遇到这种情况调试时想把 resp 强转成某个看起来更“强大”的类编译器或者运行时直接拒绝——这不是你写错了是设计上就不允许。理解这一层包装还有一个好处当异常栈里出现 Facade 相关的类名时你不会一脸茫然。它只是容器在替你做代理异常的真实位置往往还在你自己的代码里。1.3 一个容易忽略的事实响应是有缓冲区的新手最容易踩的坑几乎都和“缓冲区”有关。Servlet 规范允许容器为响应提供缓冲区Tomcat 默认是 8KB。你调用getWriter().write(abc)这 3 个字节并不会立刻飞到浏览器而是先躺在缓冲区里等缓冲区满了、或者请求处理结束、或者你主动flushBuffer()才真正写出去。这个机制带来两个直接后果。第一性能好——减少系统调用次数这点不用多说。第二也是关键的一点响应一旦提交committed响应头和状态码就锁死了。所谓提交指响应内容已经开始发往客户端此时再调setHeader、setStatus、sendRedirect都不会有任何效果容器甚至不会给你报错就那么静默地忽略掉然后你对着浏览器抓包发呆。判断是否已提交用resp.isCommitted()。想在提交前“反悔”用resp.reset()清空缓冲区 清空头 清空状态码或resp.resetBuffer()只清缓冲区保留头。这两个方法在实际项目里的典型用途是统一异常处理时发现前面已经写了一部分内容需要整体推倒重写一个错误页。注意reset()在响应已提交后会抛IllegalStateException调用前务必先判断isCommitted()。2. 状态行、响应头、响应体响应对象的三层结构2.1 状态码不只是 200 和 404setStatus(int sc)是最直接的写法参数用HttpServletResponse接口上定义的那一堆SC_开头的常量更不容易写错。日常开发里真正高频的其实就那么几个常量值典型使用场景SC_OK200正常返回内容SC_NO_CONTENT204成功但无响应体如删除操作SC_MOVED_TEMPORARILY / SC_FOUND302重定向sendRedirect 内部用的就是它SC_NOT_MODIFIED304协商缓存命中让浏览器用本地副本SC_BAD_REQUEST400参数校验失败SC_UNAUTHORIZED401未登录SC_FORBIDDEN403已登录但无权限SC_NOT_FOUND404资源不存在SC_METHOD_NOT_ALLOWED405请求方法不支持SC_INTERNAL_SERVER_ERROR500服务端未捕获异常除了setStatus还有一个容易混淆的sendError(int sc)和sendError(int sc, String msg)。区别在于setStatus只是设置状态码响应体由你自己写sendError会清空缓冲区、设置状态码并让容器去生成一个标准的错误页面或者在 web.xml 里配置的error-page。所以你想自定义错误内容时别用sendError想交给统一错误页处理时才用它。2.2 响应头setHeader 与 addHeader 的区别比想象中大响应头的写入有两组方法名字只差一个字母行为完全不同setHeader(name, value)同名头覆盖最终只保留一个值。addHeader(name, value)同名头追加最终会有多个值。大部分头只允许一个值比如Content-Type用 setSet-Cookie这类允许多值的头必须用 add否则后一个 Cookie 会把前一个顶掉表现为“只种上了最后一个 Cookie”这个 bug 极其隐蔽。此外还有两个类型便捷方法setIntHeader和setDateHeader后者接收毫秒时间戳由容器负责格式化成 HTTP 日期格式省得你自己拼字符串拼错格式。几个必须掌握的响应头Content-Type内容类型 字符集通常用setContentType(text/html;charsetUTF-8)一步设置。Content-Disposition控制浏览器是内联展示还是下载值形如attachment;filenamexxx。Cache-Control/Pragma/Expires缓存控制三件套见 5.4 节。Location重定向目标地址虽然一般由sendRedirect自动写但手动实现 301 永久重定向时需要自己设。2.3 两种输出流二选一getWriter 与 getOutputStream响应体输出只有两条路getWriter()返回字符流PrintWritergetOutputStream()返回字节流ServletOutputStream。规则很简单也很硬同一个响应里只能用一个用了其中一个再拿另一个会抛IllegalStateException。怎么选按你要输出的内容类型决定。输出 HTML、纯文本、JSON、XML 这类文本内容用 writer它帮你处理字符到字节的编码转换输出图片、PDF、文件下载、二进制协议数据用 outputStream字节原样写出不经过编码转换这一层。这里有个很多人没想明白的点writer 的编码转换用的是哪个字符集答案是响应对象上设置的那个字符集默认是 ISO-8859-1。这也是中文乱码的根源下一节专门展开。实操心得选 writer 还是 outputStream最好在方法开头就定下来别在中间分支里来回换。我见过一个下载接口在异常分支里调了getWriter()打印错误信息结果正常分支的getOutputStream()直接崩掉日志里只有一句 IllegalStateException排查了半天才发现是异常处理路径污染了状态。3. 中文乱码的完整链路编码在哪一步被定死3.1 乱码产生的三个环节中文乱码是 Java Web 的经典老问题但只要把链路拆成三段判断起来其实很快第一段Java 字符串到字节。你的String在内存里是 UTF-16写出去必须转成字节。这一步用的字符集由响应对象的 character encoding 决定默认 ISO-8859-1。如果你没设置中文写进去直接变成?或一串问号方块。第二段响应头里的 charset 声明。浏览器收到Content-Type: text/html而没带charset时会自己猜猜测策略各浏览器不完全一样结果就是“我明明设了编码还是乱码”。第三段浏览器解码。它按响应头声明的字符集把字节还原成字符。声明和实际字节不一致就是满屏乱码。所以正确的做法只有一条让这三段用同一个字符集。具体到代码就是在拿 writer 之前用setContentType(text/html;charsetUTF-8)一次性把“内容类型”和“字符集声明”都设好同时这句调用会顺带把响应对象的字符编码也设成 UTF-8三段自动对齐。3.2 setContentType 与 setCharacterEncoding 的调用顺序setCharacterEncoding(String)只改编码不改内容类型响应头里不会出现 charset 声明除非你之后又设了 Content-Type。而setContentType(text/html;charsetUTF-8)会解析出 charset 部分并调用前者。这带来一个顺序陷阱// 错误写法先设编码再设带 charset 的 Content-Type看似没问题 resp.setCharacterEncoding(GBK); resp.setContentType(text/html;charsetUTF-8); // 最终是 UTF-8前面的 GBK 被覆盖而反过来// 另一个容易踩的坑先设了不带 charset 的 Content-Type resp.setContentType(text/html); resp.setCharacterEncoding(UTF-8); // 编码生效了但响应头里没有 charset浏览器仍可能猜错结论很清楚用一句带 charset 的 setContentType 搞定不要拆成两句。拆开写除了给自己留隐患没有任何好处。还有一个更隐蔽的坑这些都必须在getWriter()之前调用。writer 一旦创建它的编码就固定了之后再调setCharacterEncoding不会有任何效果容器也不会警告你。所以养成习惯把编码设置放在方法最前面紧跟参数校验之后。3.3 一组实测对照表下面这张表是我本地用 Tomcat 9 实测的结果输出内容统一是字符串“中文测试”看浏览器最终显示写法响应头 Content-Type浏览器显示什么都不设直接 getWriter().writetext/html无 charset乱码setCharacterEncoding(UTF-8) 后写text/html无 charset多数情况正常部分浏览器乱码setContentType(text/html;charsetUTF-8)text/html;charsetUTF-8正常setContentType 写在 getWriter() 之后text/html;charsetUTF-8乱码编码已固定为 ISO-8859-1setHeader(Content-Type, text/html;charsetUTF-8)同值正常但不推荐不会同步内部编码状态最后一行值得多说一句用setHeader手动拼 Content-Type 确实能让浏览器正常显示但响应对象内部的 character encoding 并没有跟着变如果你之后还用 writer 写内容字节层面仍是默认编码只是恰好某些场景下没暴露问题。这种“能跑但不对”的写法是后面出问题的种子。4. sendRedirect 拆解一行代码背后发生了什么4.1 报文层面302 与 Locationresp.sendRedirect(/app/home)这行代码做的事展开来看是三步清空当前响应缓冲区内部会调 resetBuffer 一类的逻辑之前写的内容全部丢弃把状态码设为 302并在响应头里写入Location: /app/home结束本次响应的输出。浏览器收到 302 加 Location 之后会发起一次全新的请求去访问 Location 指向的地址。注意“全新”这两个字新的请求、新的连接可能复用 TCP 但那是传输层的事、新的 request 对象、新的线程。地址栏会变成新地址这是重定向最直观的外部特征。正因为是两次独立请求第一次请求里塞进 request 域的任何数据第二次请求统统看不见。这一点和转发forward形成鲜明对比也是初学者最容易困惑的地方——“我明明 setAttribute 了跳过去怎么就取不到”4.2 重定向与转发的本质区别这两个概念经常被放在一起比较我用一张表说清楚对比项重定向 sendRedirect转发 forward请求次数2 次浏览器发起第二次1 次服务器内部转交谁在跳转浏览器服务器地址栏变化不变request 域数据丢失保留能否跳外部站点可以不可以目标资源限制无任意 URL只能是当前应用内部资源性能多一次网络往返无额外往返状态码302200内部转发选型上的经验原则跳转后如果不需要保留请求数据、或者要跳到应用外部、或者希望用户刷新时不再重复提交表单用重定向需要在一次请求内把数据交给下一个页面渲染用转发。典型的重定向场景是“登录成功后跳到首页”典型转发场景是“Controller 处理完把结果交给 JSP 渲染”。顺带提一句 PRG 模式Post-Redirect-Get表单提交用 POST处理成功后重定向到结果页。这样用户刷新结果页时发起的是 GET不会重复提交订单或者重复扣款。这个模式在生产环境里几乎是标配值得养成习惯。4.3 路径写法的坑绝对路径、相对路径与 contextPathsendRedirect的参数写法是最容易出错的地方。规则大致如下以/开头相对于当前应用的上下文根。注意不是服务器根。你的应用部署在/shop下写sendRedirect(/home)最终 Location 是/shop/home。不以/开头相对于当前请求的 URI。比如当前请求是/shop/user/login写sendRedirect(result)最终会解析成/shop/user/result。这个相对规则很容易算错层级我不推荐在业务代码里用。以http://或https://开头完整 URL跳外部站点用。基于这个规则我建议统一写法永远用req.getContextPath() /具体路径拼出应用内路径。String target req.getContextPath() /user/home; resp.sendRedirect(target);这样无论应用部署在根路径还是子路径下代码都不用改。反过来如果你图省事写死sendRedirect(/shop/home)哪天部署路径变了整站跳转全部 404而且这种错误在开发环境往往发现不了——因为开发时应用常常就部署在根路径。注意sendRedirect之后不要再写return之外的东西。它不会像forward那样自动终止方法执行代码会继续往下跑。虽然响应已提交后续写入会被忽略但如果后面还有数据库操作或者别的副作用代码那就不只是浪费而是可能出错。养成习惯sendRedirect后面紧跟return;。4.4 重定向会带走什么、又留下什么数据传递是重定向场景下绕不开的问题。既然 request 域失效可选方案有三个方案一session。把提示信息塞进 session目标页面取出来之后立刻removeAttribute。这个“取完就删”的动作必须做否则下次进页面还会看到上次的旧提示。缺点是依赖会话用户禁用 Cookie 时会失效虽然现在很少见。方案二URL 参数。拼接查询字符串目标页面从req.getParameter取。缺点是中文要URLEncoder.encode(value, UTF-8)编码而且参数暴露在地址栏敏感信息不能这么传。方案三闪存式的临时容器。一些框架提供了 FlashMap 之类的机制本质还是 session 加一次性的标记自己实现也不难。三种方案里我做后台管理系统时最常用第一种简单直接做对外的分享链接类功能时才用第二种因为要保证链接可复制可分享。5. 四个高频场景的落地写法5.1 登录成功跳转并携带提示信息登录成功后跳首页并显示一句“欢迎回来”是重定向最典型的用法。下面是完整写法protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); // 省略校验逻辑 boolean loginOk checkLogin(username, password); if (loginOk) { req.getSession().setAttribute(loginUser, username); // 提示信息放 session目标页面取完即删 req.getSession().setAttribute(flashMsg, 登录成功欢迎回来); resp.sendRedirect(req.getContextPath() /user/home); return; } req.setAttribute(errMsg, 用户名或密码不正确); req.getRequestDispatcher(/login.jsp).forward(req, resp); }首页那边取值的写法Object msg session.getAttribute(flashMsg); if (msg ! null) { session.removeAttribute(flashMsg); // 关键取完立刻删 request.setAttribute(tip, msg); }对比一下登录失败用的是 forward因为要把错误信息和用户填过的表单数据带回去重新渲染登录成功用的是 redirect因为不需要带回数据而且能避免用户刷新时重新提交登录表单。同一次交互里两种跳转混用这才是真实项目的常态。5.2 文件下载文件名不乱码的写法文件下载必须用getOutputStream()核心是三件事设置内容类型、设置 Content-Disposition、把字节写出去。中文文件名要额外做 URL 编码否则浏览器拿到的文件名会变成乱码或直接截断。protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String fileName 季度报表.pdf; byte[] data loadFileBytes(fileName); // 省略具体读取逻辑 String encoded URLEncoder.encode(fileName, UTF-8).replace(, %20); resp.setContentType(application/octet-stream); resp.setHeader(Content-Disposition, attachment;filename encoded); resp.setContentLength(data.length); try (ServletOutputStream out resp.getOutputStream()) { out.write(data); out.flush(); } }这里的replace(, %20)是个细节URLEncoder是给表单参数设计的会把空格编成而 HTTP 头里的文件名要求空格是%20不替换的话文件名里的空格会变成加号。这个坑我踩过一次用户反馈“下载的文件名多了个加号”查了半天才定位到这一行。另一个建议是设置Content-Length。带上长度浏览器就能显示下载进度条不带的话只能显示一个转圈体验差不少。写流的时候用 try-with-resources保证异常情况下流被正确关闭。5.3 返回 JSON 给前端现在大量 Servlet 接口其实是在给前端页面或者移动端返回 JSON。写法很简单但编码和内容类型必须设对否则前端JSON.parse直接报错。protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); resp.setHeader(Cache-Control, no-store); String json {\code\:0,\msg\:\ok\,\data\:{\total\:128}}; PrintWriter writer resp.getWriter(); writer.write(json); writer.flush(); }两个要点。第一application/json;charsetUTF-8里的 charset 是有意义的虽然 JSON 规范推荐 UTF-8 且不少前端库能自适应但显式声明最稳。第二写入后调一下flush()尤其在接口调试阶段不 flush 时内容可能还在缓冲区里你用工具抓包看到的是空响应体白白怀疑人生。真实项目里不会手拼 JSON 字符串通常交给序列化库处理。但即便用库编码设置这一步仍然要自己做序列化库只负责把对象变成字符串不负责响应头。5.4 禁止缓存的响应头组合后台管理系统的接口尤其是返回敏感数据的接口被浏览器缓存住是很危险的用户退出登录后点后退还能看到上一个账号的数据。标准的三件套写法resp.setHeader(Cache-Control, no-cache, no-store, must-revalidate); resp.setHeader(Pragma, no-cache); // 兼容老版本 HTTP/1.0 代理 resp.setDateHeader(Expires, 0); // 设为过去时间立即过期Cache-Control是 HTTP/1.1 的标准Pragma是为了兼容只认 1.0 的老中间层Expires: 0是给更老的客户端兜底。三条一起上基本能覆盖所有情况。反过来如果某个静态资源接口希望浏览器缓存就用setDateHeader(Expires, System.currentTimeMillis() 3600_000)配一个将来的时间再加一条Cache-Control: max-age3600。注意max-age的优先级高于Expires两个都写时以 max-age 为准这是 HTTP 规范的约定。6. 踩坑实录与排查速查表6.1 流重复获取IllegalStateException最经典的异常没有之一java.lang.IllegalStateException: getWriter() has already been called for this response反过来也有 getOutputStream 版本。成因就一个——同一个响应里两种流都拿了。真正麻烦的不是直接写两行的情况而是被封装掩盖的场景基类里统一写了个“处理成功”的方法调了 writer子类业务又调了 outputStream或者过滤器里记日志时顺手resp.getWriter().println(...)把后面的接口全废掉。过滤器那个坑尤其隐蔽因为过滤器代码往往是很久以前写的平时不报错只在特定接口上出问题。排查顺序我一般是这三步先在异常栈里找自己项目的包名看第一个属于自己代码的调用点在哪然后全局搜getWriter(和getOutputStream(看有没有在公共代码里被调用最后确认过滤器链里有没有打印响应的逻辑。实操心得在过滤器、拦截器这类公共组件里永远不要碰响应流。要看响应内容用包装类HttpServletResponseWrapper在内存里拦截或者干脆只记日志不打响应。6.2 提交之后设置无效症状是代码里明明写了setHeader、setStatus抓包看响应头里就是没有。原因九成是响应已经提交了。触发提交的常见动作有三个缓冲区写满、显式调了flushBuffer()或flush()、调了sendRedirect或sendError。排查方法很直接在设置之前插一句System.out.println(resp.isCommitted())打印 true 就说明前面已经提交了。再往前找是哪一步提交的。JSP 页面里这个情况特别多因为 JSP 本身有缓冲区页面顶部一大段 HTML 输出完之后缓冲区可能就满了后面的setHeader自然失效。所以需要设响应头的逻辑一定要放在页面最前面或者干脆挪到 Servlet 里做。6.3 重定向死循环与 404 路径错位重定向死循环的表现是浏览器报“重定向次数过多”。典型成因是登录过滤器写错了判断逻辑未登录就重定向到登录页但登录页本身也被过滤器拦住了于是无限循环。解决办法是给过滤器的拦截路径加白名单登录页、静态资源、登录接口本身都要放行。路径错位表现为重定向之后 404。多半是 contextPath 没拼对或者误用了相对路径。判断方法很简单看响应头里的Location值到底是什么。如果 Location 是/home但你期望/shop/home那就是相对上下文根解析的问题如果 Location 是/shop/user/result而你期望/shop/result那就是相对路径算错了层级。6.4 响应问题排查速查表现象最可能的原因排查动作页面空白无内容用了 outputStream 又写字符或流未 flush检查流类型补 flush中文变问号未设 charsetwriter 用默认 ISO-8859-1在 getWriter 前设 Content-Type响应头设置不生效响应已提交打印 isCommitted 定位提交点IllegalStateException两种流混用全局搜 getWriter / getOutputStream重定向后 request 取不到值两次请求域失效改用 session 或 URL 参数重定向 404路径未拼 contextPath抓包看 Location 实际值重定向次数过多过滤器白名单缺失检查拦截路径配置下载文件名乱码未做 URL 编码用 URLEncoder 并替换加号退出登录后仍能看到数据未设禁止缓存头补 Cache-Control 三件套最后分享一个我一直在用的小习惯把常用的响应操作抽成一个工具类比如writeJson(resp, obj)、download(resp, fileName, bytes)、redirect(req, resp, path)。工具方法内部统一处理编码、contextPath 拼接、流关闭这些重复劳动业务代码里就只剩一行调用。这么做过一段时间之后编码类的问题基本就从项目里绝迹了——因为没有任何一个地方需要单独去想 charset 该写在哪。