Servlet 这门技术放在十年前是 Java Web 开发者的必修课放在今天看很多刚接触 Spring Boot 的同学可能只在依赖清单里见过它甚至有人觉得它已经过时了。但实际上去掉 Spring 的外壳真正在底层处理 HTTP 请求、管理会话、控制生命周期的还是这一套 Servlet 规范。我做了这么多年 Java 后端踩过不少框架的坑最后排查问题还是得回到 Servlet 容器这一层来定位。这篇文章我不会跟你讲那些空泛的概念而是用一次完整的项目实践把 Servlet 从原理到落地整个串一遍。内容包括 Servlet 在 Web 服务器中的定位、它的生命周期和线程模型、请求响应的处理链路以及我自己写原生 Servlet 项目时遇到的各种问题和处理方案。不管你是在学 SSM 之前想打牢基础还是用 Spring Boot 写了好几年接口但从没深究过底层机制这篇文章都值得你花点时间看完因为框架会换但 Servlet 这套底层逻辑不会变。1. Servlet 到底在解决什么问题1.1 没有 Servlet 的年代Web 开发有多痛苦先聊一点历史不是为了怀旧而是为了让你真正理解 Servlet 出现的意义。在 Servlet 规范出现之前如果想让一台服务器对外提供动态内容通常的做法是用 CGICommon Gateway Interface。CGI 的思路很简单服务器收到一个请求就 fork 一个进程去执行对应的脚本脚本处理完返回 HTML然后进程退出。听起来没什么问题对吧但你想一下如果同一秒进来 100 个请求服务器就要创建 100 个进程来处理。进程的创建和销毁需要操作系统分配内存、加载上下文这个过程是很重的。而且每个进程之间是隔离的你没法在一个进程里同时处理多个请求。这种做法扛不住任何一点流量更别说保持状态了。Servlet 把这个问题彻底换了个思路容器启动的时候创建一组常驻对象Servlet每个请求到达时由容器内部的线程池分配一个线程去调用 Servlet 的业务方法。线程比进程轻量得多复用一个 Servlet 实例处理所有请求避免了频繁创建销毁的开销。这就是为什么 Java 能成为企业级 Web 开发主流语言之一Servlet 功不可没。1.2 Servlet 的核心定位HTTP 协议与 Java 代码之间的翻译官Servlet 本质上是 Java 世界里对 HTTP 请求-响应模型的一种标准化抽象。它定义了一组接口和类让你不用关心 Socket 连接、不用手动解析 HTTP 报文、不用拼接响应头只需要实现一个继承HttpServlet的类重写doGet或doPost方法就能处理来自浏览器的请求。你看一个最简单的 Servlet 代码WebServlet(/hello) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(text/html;charsetUTF-8); resp.getWriter().write(h1Hello Servlet/h1); } }就这么几行一个能响应 HTTP GET 请求的 Web 程序就跑起来了。Tomcat 帮你完成了底下所有脏活累活监听 8080 端口、接受 TCP 连接、解析 HTTP 请求行和请求头、把参数封装成HttpServletRequest对象、调用你的doGet方法、再把你的输出包装成合法的 HTTP 响应返回给客户端。所以你可以把 Servlet 理解为 HTTP 协议和 Java 代码之间的翻译官。它不关心你的业务逻辑是什么只负责建立一条标准的通信管道。你的代码负责业务容器负责协议两边通过 Servlet 规范解耦。1.3 为什么现在的主流框架仍然离不开 Servlet现在很多人写接口直接用 Spring Boot添加一个spring-boot-starter-web依赖写个RestController一个接口就完成了。但你要知道Spring Boot 内嵌的 Tomcat 底层就是 Servlet 容器DispatcherServlet本身就是一个 Servlet。Spring MVC 做的事情是把你写的RequestMapping方法映射成对 Servlet 的调用但它无法脱离 Servlet 规范独立存在。我的建议是在学习任何 Java Web 框架之前先花时间把 Servlet 搞透彻。因为框架会给你很多便捷的封装但一旦出问题HTTP 状态码异常、请求参数丢失、过滤器不生效、会话失效最终你排查问题还是要回到 Servlet 容器这个层面。把地基打牢上层建筑才不会歪。2. Servlet 核心机制拆解生命周期、请求与响应、会话管理2.1 生命周期Servlet 从出生到消亡的完整过程Servlet 的生命周期管理是理解它一切行为的基础。整个生命周期由容器负责开发者通常只需要关注三个方法init()、service()、destroy()。初始化阶段容器加载 Servlet 类通过反射创建实例然后调用init()方法。这个方法的调用时机有两种一种是容器启动时立即初始化需要在web.xml里配置load-on-startup为正整数另一种是第一次请求到达时才初始化也就是懒加载。init()只调用一次所以适合做资源初始化的工作比如加载配置文件、建立数据库连接池。我见过有人把耗时操作放在构造方法里这是不对的因为 Servlet 实例化时容器还没完成依赖注入此时做初始化容易出问题。请求处理阶段每次请求到达时容器分配一个线程调用service()方法。HttpServlet的service()方法内部会根据 HTTP 方法类型分发到doGet、doPost、doPut等不同方法。注意Servlet 实例是单例的所有请求共享同一个实例这也是后面要讲线程安全问题的根源。销毁阶段容器关闭或应用重新部署时调用destroy()释放资源。把这个过程画成一个流程就是加载 Servlet 类 - 实例化 - init() - service() 循环处理请求 - destroy() - 垃圾回收理解生命周期对排查问题特别有帮助。比如你遇到过修改代码后没生效的情况多半是容器还在用旧的 Servlet 实例需要重新部署才能触发销毁和重新初始化。2.2 请求与响应一次 HTTP 请求在 Servlet 中的完整旅程当浏览器发起一个请求比如访问http://localhost:8080/api/users?page1Tomcat 收到后做了这些事从 TCP 连接中读取原始字节流按照 HTTP 协议解析出请求方法、请求 URI、协议版本、请求头、请求体。然后容器创建HttpServletRequest和HttpServletResponse两个对象前者封装了请求的所有信息后者是容器为你准备好的响应通道。HttpServletRequest里有几个常用的方法我实际开发中用到频率最高的是getParameter()、getAttribute()、getSession()。这里要特别注意getParameter和getAttribute的区别很多新手会混淆。getParameter获取的是客户端提交的数据也就是 URL 参数或表单提交的数据getAttribute获取的是服务端在请求转发时通过setAttribute存入的数据。简单记一个来自客户端一个来自服务端内部。响应对象比较关键的几个操作是设置状态码setStatus()、设置响应头setHeader()、设置内容类型setContentType()、获取输出流getWriter()或getOutputStream()。这里有个易错点字节流输出和字符流输出只能二选一同时调用会抛异常。字符流适合输出文本字节流适合输出文件下载等二进制内容。2.3 会话跟踪HTTP 的无状态困境与 Session 方案HTTP 协议本身是无状态的每一次请求都是独立的服务器无法区分两个请求是否来自同一个浏览器。但业务上你肯定需要知道用户是否已经登录、购物车里放了什么这就引入了会话跟踪机制。Servlet 规范提供了HttpSession接口来解决这个问题。基本原理是容器在为请求创建 Session 时生成一个唯一的 ID通过Set-Cookie响应头把这个 ID 写入浏览器 Cookie之后每次请求浏览器自动携带这个 Cookie容器通过 Cookie 值找到对应的 Session 对象。HttpSession session req.getSession(); session.setAttribute(userId, 10001); session.setMaxInactiveInterval(1800);getSession()方法在没有 Session 时会自动创建一个新的如果你只是想获取已有的 Session应该用req.getSession(false)避免在不需要创建时白白生成一个。Session 的失效时机也很重要。默认情况下容器会维护一个 Session 超时时间超过这个时间没有请求Session 就被销毁。Tomcat 默认是 30 分钟你可以在web.xml中配置session-config session-timeout30/session-timeout /session-config这里的 30 是分钟和setMaxInactiveInterval的单位不同后者是以秒为单位。这个细节我踩过坑以为设的是分钟结果实际是秒导致 Session 一两分钟就过期了。3. 从零写一个原生 Servlet 项目用户管理系统实战3.1 项目规划与技术选型思路为了把上面这些概念串起来我用原生 Servlet 写一个简单的用户管理系统。功能包括用户列表展示、新增用户、用户详情查看、登录认证、访问控制。刻意不使用任何框架只用 Servlet JSP JDBC这样你能看到 Web 应用最原始的样子。项目结构是这样的src/main/java ├── com.example.servlet │ ├── dao │ │ └── UserDao.java │ ├── entity │ │ └── User.java │ ├── filter │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ ├── servlet │ │ ├── LoginServlet.java │ │ ├── LogoutServlet.java │ │ ├── UserListServlet.java │ │ └── UserDetailServlet.java │ └── util │ └── DBUtil.java src/main/resources └── db.properties src/main/webapp ├── WEB-INF │ └── web.xml ├── index.jsp ├── login.jsp ├── user_list.jsp └── user_detail.jsp3.2 数据库访问层的设计要点我用 MySQL 作为存储建一张简单的用户表CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, email varchar(100) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;数据库连接我做了个简单的工具类把连接参数放在db.properties配置文件里用静态代码块加载驱动。生产上肯定要用连接池但这里为了演示原生 JDBC 流程先直接用DriverManager。public class DBUtil { private static String url; private static String username; private static String password; static { try { InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties); Properties props new Properties(); props.load(in); Class.forName(props.getProperty(driver)); url props.getProperty(url); username props.getProperty(username); password props.getProperty(password); } catch (Exception e) { throw new ExceptionInInitializerError(数据库初始化失败); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) {} } if (ps ! null) { try { ps.close(); } catch (SQLException e) {} } if (conn ! null) { try { conn.close(); } catch (SQLException e) {} } } }写代码的时候一定要记得关闭资源而且要按 ResultSet、Statement、Connection 的顺序逆序关闭。Java 7 之后可以用 try-with-resources 语法但我保留这个传统写法是为了让你看清楚资源的释放逻辑。3.3 Servlet 层的请求分发与参数处理用户列表功能需要查询数据库把用户数据放进request.setAttribute(userList, list)然后转发到 JSP 页面渲染。WebServlet(/user/list) public class UserListServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); ListUser userList userDao.findAll(); req.setAttribute(userList, userList); req.getRequestDispatcher(/WEB-INF/user_list.jsp).forward(req, resp); } }转发和重定向的区别是重点。req.getRequestDispatcher(url).forward(req, resp)是服务器内部跳转地址栏不变request 对象可以共享resp.sendRedirect(url)是浏览器重新发起一次请求地址栏变化request 对象不共享。登录成功后应该使用重定向防止表单重复提交查询列表后展示数据应该使用转发。新增用户我用doPost处理接收参数后做校验再调用 DAO 层保存。这里要重点演示参数获取因为实际项目中表单提交的数据类型繁多需要逐个getParameter并转型WebServlet(/user/add) public class UserAddServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); String email req.getParameter(email); // 简单校验更多校验逻辑这里省略 if (username null || username.trim().isEmpty()) { req.setAttribute(error, 用户名不能为空); req.getRequestDispatcher(/user/add.jsp).forward(req, resp); return; } User user new User(); user.setUsername(username); user.setPassword(password); user.setEmail(email); boolean result userDao.insert(user); if (result) { // 添加成功重定向到列表页防止刷新重复提交 resp.sendRedirect(req.getContextPath() /user/list); } else { req.setAttribute(error, 添加失败); req.getRequestDispatcher(/user/add.jsp).forward(req, resp); } } }3.4 Filter 实现统一编码与登录拦截如果说 Servlet 是处理业务请求的入口那 Filter 就是在入口之前的检查关卡。一个经典的场景是统一设置请求和响应的编码避免每个 Servlet 都重复写编码设置。我现在项目里用了三层服务架构每个 Controller 不用关心编码问题全靠 Filter 在入口统一处理。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }登录拦截 Filter 更为关键。我需要检查每个请求的 Session 中是否存在登录标识如果没有就重定向到登录页。要注意放行登录相关的路径否则会陷入死循环访问登录页被拦截重定向到登录页又被拦截。WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); String contextPath req.getContextPath(); if (uri.startsWith(contextPath /login) || uri.startsWith(contextPath /static)) { chain.doFilter(req, resp); return; } HttpSession session req.getSession(false); if (session null || session.getAttribute(loginUser) null) { resp.sendRedirect(contextPath /login.jsp); return; } chain.doFilter(req, resp); } }目录结构里我把 JSP 页面放在/WEB-INF下这是 Web 应用的受保护目录浏览器直接访问不到只能通过 Servlet 转发访问。这层物理隔离比 Filter 拦截更安全属于双保险。3.5 web.xml 的配置思路与注解的取舍现代 Servlet 3.0 规范支持注解配置WebServlet、WebFilter可以免去web.xml中的配置。但我仍然会保留一个web.xml因为它承担了部分注解做不到的事比如 Session 超时时间、欢迎文件列表、错误页面映射。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-nameServlet Demo/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list session-config session-timeout30/session-timeout /session-config error-page error-code404/error-code location/error404.jsp/location /error-page error-page exception-typejava.lang.Exception/exception-type location/error.jsp/location /error-page /web-app注解和 XML 的取舍我的原则是能用注解的尽量用注解减少配置文件的体量提高开发效率像 Session 超时、全局错误页这类全局配置放web.xml更清晰查找也方便。3.6 JSP 页面渲染与 EL 表达式JSP 本质上是 Servlet 的一种变体容器会把 JSP 文件编译成一个HttpJspPage实现类的 Java 源码再编译成 Class。所以 JSP 不是运行时的模板引擎它本身就是 Servlet。我在 JSP 里使用 EL 表达式来获取 request 或 session 中的数据避免在页面里写 Java 脚本片段。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title用户列表/title /head body h2用户列表/h2 table border1 tr thID/th th用户名/th th邮箱/th th创建时间/th th操作/th /tr c:forEach items${userList} varuser tr td${user.id}/td td${user.username}/td td${user.email}/td td${user.createdAt}/td tda href${pageContext.request.contextPath}/user/detail?id${user.id}查看/a/td /tr /c:forEach /table a href${pageContext.request.contextPath}/user/add.jsp新增用户/a /body /html这里我想特别提醒一点${pageContext.request.contextPath}这个表达式的意义。如果不加这个前缀你写死/user/list路径在应用部署到根路径时可以访问但一旦部署为某个子应用比如http://localhost:8080/demo/user/list所有写死的路径就全 404 了。养成使用 contextPath 的习惯可以避免这个典型的部署路径问题。4. Servlet 高级特性与性能优化实践4.1 异步 Servlet解决长轮询与耗时任务的阻塞问题Servlet 3.0 引入了异步处理能力这个特性的价值在传统同步模型下被很多人低估了。传统的同步 Servlet请求处理线程被业务逻辑阻塞Tomcat 默认线程池是 200如果 200 个请求都在等待慢业务返回第 201 个请求就只能排队。异步 Servlet 允许线程直接返回把耗时任务挂起等业务完成后通过AsyncContext再推送响应。WebServlet(value /async/hello, asyncSupported true) public class AsyncServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { AsyncContext asyncContext req.startAsync(); asyncContext.start(() - { try { Thread.sleep(3000); asyncContext.getResponse().getWriter().write(Async done); } catch (Exception e) { e.printStackTrace(); } finally { asyncContext.complete(); } }); } }注意asyncSupported true这个属性必须设置否则调用startAsync()会抛出IllegalStateException。我在接入 WebSocket 推送消息时就用了这个方案效果比同步阻塞好得多。4.2 Servlet 线程安全问题与最佳实践前面提到 Servlet 是单例多线程的这意味着实例变量是所有请求共享的存在严重的线程安全问题。看下面这个反面教材public class DangerServlet extends HttpServlet { private UserService userService new UserService(); // 没问题 private int count 0; // 严重问题多线程并发修改 Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { count; } }count在并发场景下不是原子操作会出现重复计数或计数丢失。正确做法是能不用实例变量的就不需要用确需保留的用AtomicInteger、synchronized或ThreadLocal。实际开发中Servlet 里只放无状态服务依赖业务数据一律通过方法参数传递。推荐的做法是用ThreadLocal解决部分上下文传递问题。比如通过 Filter 解析出当前登录用户存在 ThreadLocal 里后续所有业务代码都能直接取到public class UserContext { private static final ThreadLocalUser HOLDER new ThreadLocal(); public static void set(User user) { HOLDER.set(user); } public static User get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }4.3 文件上传下载的细节处理文件上传是 Servlet 里绕不开的需求。Servlet 3.0 之后MultipartConfig注解配合getPart()方法处理上传极其方便彻底告别了以前第三方组件解析的繁琐流程。WebServlet(/upload) MultipartConfig(maxFileSize 5 * 1024 * 1024, maxRequestSize 10 * 1024 * 1024) public class UploadServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part filePart req.getPart(file); String submittedFileName filePart.getSubmittedFileName(); // 解决上传文件名的路径问题 String fileName Paths.get(submittedFileName).getFileName().toString(); String savePath /data/upload/ fileName; filePart.write(savePath); resp.getWriter().write(上传成功 fileName); } }一个很隐蔽的坑是getSubmittedFileName()返回的文件名可能包含客户端伪造的路径信息比如../../etc/passwd。所以一定要用Paths.get(fileName).getFileName().toString()提取纯文件名部分。这是我在安全测试中遇到的真实攻击场景很多上传漏洞都是因为没做这个处理。4.4 性能优化从连接池到响应压缩从 JDBC 直连换成连接池是性能提升最明显的一步。我最初用直连方式做压测250 个并发请求时数据库连接频繁报错换了连接池之后稳定支持到 2000 QPS 左右。Apache Commons DBCP 和 HikariCP 我都用过后者性能更好推荐新手直接用 HikariCP。另外一个容易忽视的优化是学些响应内容压缩。Tomcat 的压缩配置很简单在server.xml的Connector节点加几个属性Connector port8080 protocolHTTP/1.1 compressionon compressionMinSize1024 noCompressionUserAgentsgozilla, traviata compressibleMimeTypetext/html,text/xml,text/plain,text/javascript,application/json/设置之后超过 1KB 的文本响应会自动 gzip 压缩。这在带宽受限的网络环境下效果很出色页面体积直接减掉 60% 以上。5. 常见问题与排查技巧实录5.1 404 与 500 错误的本质区别报 404 时第一反应不是去改代码而是先确认资源到底在哪里。404 的原因通常是三类URL 写错、Servlet 未映射、部署路径不对。排查流程我建议从浏览器网络请求开始看请求的完整 URL、请求的上下文路径、Servlet 的WebServlet注解路径是否匹配。报 500 就相对好排查因为 Tomcat 会在错误页面中打印异常堆栈。拿到堆栈先看最顶部的几行通常定位到具体是哪个类哪一行。常见原因包括 null 指针、数据库连接失败、参数类型转换异常。看异常堆栈有个技巧往下翻到Caused by部分这里才是导致异常的根因。很多新手只看第一行导致明明数据库连不上却一直在检查代码。5.2 中文乱码问题的一揽子解决方案中文乱码是老生常谈但几乎每个项目都会遇到。乱码的本质是编码和解码不一致。你要记住一条黄金法则从客户端到浏览器每一环节的字符编码必须统一为 UTF-8。具体有三个关键环节一是请求参数解码。Post 请求通过req.setCharacterEncoding(UTF-8)解决Get 请求的参数在 URL 中这个设置不生效需要改 Tomcat 的 URI 编码在server.xml的 Connector 节点加URIEncodingUTF-8。二是响应编码。设置resp.setContentType(text/html;charsetUTF-8)让浏览器用 UTF-8 解码响应体。三是数据库连接编码。JDBC URL 必须加上useUnicodetruecharacterEncodingUTF-8否则数据存进库再读出来就变了。这三个环节只要有一个出错乱码就会出现。排查时用一个简单的原则在每一环节打印数据看看到底从哪一步开始变的。我之前遇到过一次诡异乱码数据在代码里显示正常存入 MySQL 再查出来就乱码了排查半天发现是表本身的字符集是 latin1改成 utf8mb4 后就正常了。5.3 部署与热更新的注意点部署 Servlet 项目时我很早就发现一个要点开发期用 IDEA 或 Eclipse 的插件方式部署改代码后自动重新加载生产环境则建议打包成 WAR 包部署到独立的 Tomcat。两种模式的差异在于开发期插件会用 Exploded 模式把编译后的文件直接部署到容器中改 JSP 能立即生效但改 Java 类仍需重新编译并触发容器重载。Tomcat 默认支持热部署监控webapps目录下的 WAR 包变化发现新的或更新的包会自动部署。但有个坑自动部署过程中如果流量太大可能出现短暂中断。线上环境我建议关闭自动部署改成手动维护deployOnStartup属性用脚本控制部署流程。还有一种常见问题是在更新代码时被 IDE 提示 SEVERE: The web application appears to be started by a different thread这通常是因为容器在异步线程中执行代码重新加载时主线程判定错误属于容器自身的误报重启容器就好。不必过度紧张但如果你在启动代码中直接开启后台线程容器关闭时就要注意线程是否跟着结束否则会出现类加载器泄漏。5.4 Session 丢失问题排查记录Session 丢失在用户登录场景中非常致命。用户登录成功跳转页面后 Session 数据没了表现为用户被强行退回登录页。我排查这个问题的经验是先确认两点第一浏览器是否禁用了 Cookie第二Cookie 的 Path 是否覆盖了当前访问的路径。Tomcat 默认的会话 Cookie 名为JSESSIONIDPath 默认是/也就是整个应用有效。但如果你的应用部署在/app1上下文下访问http://localhost:8080/app1/user/list而 Cookie 的 Path 被改成了/other那浏览器就不会携带这个 CookieSession 自然就找不到了。用浏览器开发者工具看请求头里的 Cookie如果没有JSESSIONID基本可以断定是 Path 不匹配。另一个容易忽略的场景是跨域名共享 Session。如果用户从a.com跳到b.com两个域名之间 Cookie 不互通Session 自然也不共享。解决思路有两个domaincom的方式只在二级域名有效或者用单点登录系统。这个是架构层面的事但你要知道问题根源在 Cookie 的跨域限制。5.5 类加载冲突与依赖版本问题Tomcat 的类加载器体系有自己的层级lib目录下的 jar 由 Common 类加载器加载WEB-INF/lib下的 jar 由 Web 应用自己的类加载器加载。这造成一个经典问题如果多个 Web 应用部署在同一个 Tomcat 中某些公共类要放到lib目录而不是每个应用的WEB-INF/lib都放一份。另外如果lib目录和WEB-INF/lib存在同名不同版本的 jar会报NoSuchMethodError或ClassNotFoundException。有一年我在维护旧项目时项目里引了老版本的mysql-connector-javaTomcat 的lib里放了一个新版本驱动类加载优先级不同导致项目跑起来部分接口正常、部分报错。后来统一了版本并把公共 jar 移到lib目录问题才彻底解决。排查这类问题可以用一个技巧在代码中打印类的加载路径。Class.forName(com.mysql.jdbc.Driver).getClassLoader()或者启动时加-verbose:class参数JVM 会把每次加载类的来源打印出来。5.6 常用排查命令与技能速查写一些常用的排查命令这些在实际线上排障时非常有用# 查看端口占用确认 Tomcat 是否在运行 netstat -anp | grep 8080 # 查看 Tomcat 实时日志 tail -f /opt/tomcat/logs/catalina.out # 查看指定的应用日志 tail -f /opt/tomcat/logs/localhost.2024-01-15.log # 查看线程栈排查死锁或线程阻塞 jstack pid thread_dump.txt # 查看堆内存使用情况 jmap -heap pid当遇到请求卡住的情况直接jstack拿线程栈搜索http-nio-8080-exec-开头的线程看它们阻塞在哪个方法上问题基本就能定位。这个方法帮我排查过数据库连接池耗尽、Redis 超时、第三方接口无响应等多个线上故障。6. 亲手做一遍这个项目后我对 Servlet 的几点思考6.1 Servlet 的价值不只在技术上更在思维模式整个项目做下来最大的体会是Servlet 强迫你理解 HTTP 的每一个细节。你用 Spring Boot 写接口时参数绑定、JSON 转换、异常处理都是框架帮你搞定的你不需要关心请求是怎么进来的、响应是怎么出去的。但当你用 Servlet 手写这些流程时你会深刻理解 HTTP 协议的无状态性、请求与响应的生命周期、数据是如何在网络中流转的。这种理解在排查问题时非常有用。有一次我排查一个线上接口偶尔超时的问题Spring Boot 应用里怎么看都像是接口业务代码的问题后来一层层往下追发现问题出在 Tomcat 的maxThreads配置上线程池被长连接耗尽。如果没有 Servlet 容器的知识储备根本不会往这个方向排查。6.2 关于学习路径的建议我接触过不少同学的简历写着熟悉 Java Web但实际上只写过 Spring Boot 的 CRUD。问到底层原理基本都是面试背过实际没用过的状态。我的建议是无论你用什么框架开发都应该完整地写过一次原生 Servlet 项目感受一次没有框架的世界。只有当你亲手处理过表单参数、手动设置过响应编码、自己写过 Filter 做登录校验你才能真正理解 Spring MVC 的设计价值也才能在遇到问题时准确判断到底是框架的问题还是容器的问题。我见过有人花了三天排查一个 Spring 配置问题最后发现是 Tomcat 连接数满了这类场景你要是了解 Servlet 容器起初就会注意到这一类可能。6.3 最后分享一个学习中的小技巧如果你想快速验证自己对 Servlet 的理解有一个很有效的自测方式用原生 Servlet 实现一个 REST 接口包含路径参数解析、请求体 JSON 解析、响应 JSON 输出这三个能力不许用任何框架。当你发现你居然需要自己写 JSON 序列化工具、自己解析 URL 路径参数时你才会真正明白那些框架替你做了多少工作。这个过程对初学者来说可能有点痛苦但这是值得的。咬咬牙写完整你会对 Java Web 开发的整体脉络有通透的理解。后面再接触 Spring 全家桶就能做到知其然也知其所以然。