☰
IDEA中配置Servlet并理解生命周期:从部署到运行的关键指南
2026/10/14 12:12:28 网站建设 项目流程

在IDEA里写Servlet,很多人卡住的地方其实根本不是"不会写Java",而是"Idea里怎么弄都出不来一个能运行的页面"。明明代码照着书上敲了,一访问就是404,或者干脆启动的时候就报出一堆红字,看半天也看不出问题在哪。我自己带过不少新人,也专门排查过这类问题,发现绝大多数卡点就集中在两个地方:一是IDEA的部署项目结构没配置明白,二是对Servlet生命周期搞不清楚,导致改了代码不生效、莫名其妙只初始化一次、线程安全出问题都找不到解释。

这篇文章我想用一条线把两件事串起来讲。前半段手把手在IDEA里从零创建一个能跑的Servlet,工程层面怎么搭、映射怎么写、Tomcat怎么配,都按实际操作的顺序来;后半段把Servlet生命周期掰开揉碎,用日志和实测结果证明谁在什么时候被调用,再用生命周期理论去解释那几个最常见的"诡异现象"。适合刚学Java Web、或者学了Servlet但始终觉得隔了一层膜的同学。

1. 在IDEA里搭Servlet环境时,卡住最多的地方其实是依赖和部署

很多教程上来就让你new -> Servlet,好像这一步理所应当。但真到你的IDEA里,右键新建的时候根本找不到Servlet选项,或者写好的Servlet类始终提示找不到javax.servlet包,这时候大多数人就懵了。这背后其实有一个很基本但总被忽略的事实:IDEA本身并不自带Servlet标准,Servlet也不是Java SE的一部分,它是一个运行在Web容器里的规范。

1.1 为什么默认没有Servlet相关的包

Java的普通工程和Java Web工程的差别就在于后者多了一堆Servlet、JSP相关的标准API。这些API由Servlet规范定义,具体实现则由Web容器(比如Tomcat)提供。你的代码在编译期需要引用javax.servlet和javax.servlet.http这些包,但这个包不在JDK里,也不在IDEA默认的工程模板里,所以必须先引入。

解决办法有两条路:一条是用Maven,在pom.xml里引入Servlet API的依赖,这是我现在最推荐的做法,因为后续加JSP、加JSON库、打War包都会方便很多。另一条是老式做法,在IDEA里手动创建Web应用并让Tomcat把Servlet的jar包"借"给编译期使用。后者并不是不行,但对新手来说很容易把classpath搞乱。

如果你用Maven,pom.xml里加入这段就够:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>

这里有个容易忽略的小细节:scope是provided而不是默认的compile。因为Tomcat本身已经带了Servlet API的实现,打包部署的时候不需要再把这份jar包塞进你的应用里,否则反而可能和容器自带的类发生冲突。provided的意思就是"编译时需要,但运行时不打包"。这个细节我在项目的Docker部署阶段踩过坑,当时就是把Servlet API打进去了,结果Tomcat启动直接抛出类重复定义的错误。

1.2 用Maven还是直接手工配置,我的建议

如果你是让学生跟着上课,或者自己在学传统Java Web,我建议统一用Maven结构来建工程。不是因为它有多新潮,而是因为它能让"依赖"这个问题变得可复现。别人拿着你的代码一导入,Maven会自动把依赖拉齐,不至于因为本机Tomcat目录不同、jar包路径不同导致环境不一致。

IDEA里建Maven工程的时候,可以勾选Create from archetype里的maven-archetype-webapp模板,这样生成的目录自带src/main/webapp和web.xml,已经是一个完整的Web工程骨架了。如果没勾选,纯手工建Maven工程然后再把src/main/webapp目录补上也可以。这个目录名字不能写错,Tomcat在部署War包或Exploded目录时,就是要从这个目录里找WEB-INF、静态资源和JSP页面。

这里提前打一个预防针:老版本的IDE建Web工程时经常把web.xml放在WEB-INF目录下,这个没问题,是有历史原因的。但如果你用Servlet注解(后面会细说),web.xml可以退居二线,甚至保留一个空壳就行,Tomcat依然能扫描到Servlet类。

1.3 Tomcat的运行配置并不是"点一下Run"就行

新建完工程、写完依赖,接下来一步是把应用运行到Tomcat里。不少新手在这里直接用IDEA的普通Java运行方式,结果报错说没有主类,这是必然的,因为Servlet不是一个带main方法的程序,它是由Tomcat加载并调用的。正确做法是创建Tomcat运行配置。

具体操作是这样:打开Run -> Edit Configurations,点左上角加号,找到Tomcat Server -> Local,然后在Server选项卡里指定本机Tomcat的安装目录。接着切到Deployment选项卡,点加号,选择Artifact...,把项目生成的Web Artifact加进去。Artifact有两种形式:带"war exploded"后缀的是展开目录形式,IDEA默认会用这个,调试时改代码能更快反应;不带exploded的是完整的War包。日常学习用exploded就够了。

部署好之后,Application context一栏建议设置成一个能代表项目的名字,比如/demo。这个值会直接影响你之后访问的URL。如果这里设置成/demo,而你的Servlet映射是/hello,那么完整访问地址就是http://localhost:8080/demo/hello,少了demo就会404。这个"上下文路径"的概念,几乎是新手时期遇到404的最常见原因。

2. 手写一个能响应的Servlet:从类到URL映射的完整链路

环境搭好之后,我们直接写一个最简Servlet。注意我这里故意不用IDEA右键New Servlet的方式,虽然那个方式很方便,但自动生成的模板会夹杂一堆默认代码,反而干扰理解。自己手写更容易看清楚一个Servlet真正需要的东西。

2.1 实现接口还是继承HttpServlet

Servlet规范里定义了一个Servlet接口,接口里有init、getServletConfig、service、getServletInfo、destroy这五个方法。理论上你可以直接实现这个接口来写一个Servlet,但这样所有事情都得自己处理,包括解析HTTP请求、区分GET和POST,工作量大而且容易出错。

实际开发中几乎没人这么做,大家都会继承HttpServlet。HttpServlet已经替你把请求解析和分发的逻辑做完了,它默认的service方法会根据请求方法自动调用doGet、doPost、doPut等方法。你要做的只是按业务覆盖对应的处理方法。

2.2 一个Hello级别的Servlet示例

在src/main/java下新建一个包,比如com.example.demo,然后创建HelloServlet.java:

package com.example.demo; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet(name = "HelloServlet", urlPatterns = "/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("<h1>Hello Servlet</h1>"); } }

这段代码就这么短。注意两个关键点:第一,类上面的@WebServlet注解负责把/hello这个URL路径和这个类绑定起来;第二,重写的doGet方法处理客户端发来的GET请求。写好之后启动Tomcat,浏览器访问http://localhost:8080/demo/hello,页面就会显示Hello Servlet。

如果你不想用注解,也可以在web.xml里手动配置映射:

<servlet> <servlet-name>HelloServlet</servlet-name> <servlet-class>com.example.demo.HelloServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>HelloServlet</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping>

两种方式效果一样,但我个人更推荐注解。理由很简单:Servlet类本身的含义就是"处理某个URL的请求",把映射关系直接放在类上方,查看代码时一目了然,少了来回切换web.xml的麻烦。

2.3 注解映射失效的排查点

用注解有一个隐藏前提:Tomcat必须开启Servlet的注解扫描。如果你的项目里存在web.xml,要注意根元素里的metadata-complete属性。如果它被设置成true,就表示声明web.xml已经完整描述了应用的所有配置,容器不会再扫描注解。这个属性在早期自动生成的模板里不会出现,默认值为false,所以一般不会踩到坑。但如果你从某个老项目里拷贝了web.xml,而里面恰好写了metadata-complete="true",那你写的@WebServlet就会全部失效,访问路径直接404,还很容易让人怀疑是Tomcat问题。

我在迁移一个老项目时就遇到过一次,项目结构里web.xml是从旧代码带过来的,上面写死了metadata-complete="true",新加的Servlet一个都访问不了,最后排查了半天才找到。解决方案有几种,要么把属性改成false,要么干脆把注解删掉改成web.xml配置。我最后是清理掉web.xml中不需要的旧配置,把属性设为false,让注解正常生效。

2.4 部署结构变形记:为什么Tomcat找不到你新建的Servlet

还有一个很搞心态的问题:类写好了,注解也生效了,浏览器还是404。翻日志又没看到明显的报错,只在浏览器看到一个Tomcat默认的404页面。这种情况八成是Artifact没把编译后的类打包进去。

IDEA里Artifact有自己的"输出结构",它和磁盘上的源码目录不是自动同步的。新建的Java源码确实会被编译成class文件,但能不能进入最终的Exploded输出目录,取决于Artifact的Available Elements里有没有把classes目录和lib目录放进去。检查方式是打开File -> Project Structure -> Artifacts,看Output Layout里是否包含WEB-INF/classes和WEB-INF/lib。如果缺失,右键对应的目录,把自己需要的Module Output和Maven库加进去。

这个问题在第一次创建Artifact时很容易被忽略,因为模板生成的Artifact一般会自动包含这些要素,但如果你手动重建过Artifact,或者导入的项目Artifact配置丢失,就会出现"代码编译了但没部署出去"的诡异局面。我的排查习惯是先看target/classes目录(Maven工程会有一个target/classes),确认class生成了,再去Artifact的Output Layout看这个目录是否进入输出结构,两步一比就能定位问题。

3. 生命周期实际演示:init、service、destroy到底由谁驱动

接下来进入这篇文章的核心:Servlet生命周期。理解这个,比编译出几百行Servlet代码都有用。因为Spring MVC的入口DispatcherServlet本质上也是一个Servlet,你理解了Servlet生命周期,后面看Spring MVC启动流程会感觉像开了透视一样。

3.1 一张表看清生命周期的五个阶段

Servlet生命周期由Web容器管理,通俗地说,这个类什么时候被加载、被实例化、被初始化、被调用、被销毁,都不是程序员写new决定的,而是Tomcat根据内部策略决定的。

阶段触发时机相关方法
加载类容器启动,或第一次请求该Servlet时ClassLoader加载class字节码
实例化加载完成后无参构造器被调用,实例在内存中创建
初始化实例化完成之后init()方法执行
处理请求每次客户端请求到达时service()方法执行,再分发给doGet或doPost
销毁容器正常关闭,或应用被移除时destroy()方法执行,释放资源

前两个阶段在日志里很难直接观察,但init()执行非常明显。默认情况下,Tomcat并不在启动时就加载所有Servlet,而是等第一个请求到达时才触发类加载、实例化、初始化这套动作。这个特点可以直接用一段实验代码验证。

3.2 用日志记录生命周期事件

我写一个专门用来观察生命周期的Servlet,名字取为LifecycleServlet。它不做什么正经业务,就是在各阶段打印信息,让我们看得清清楚楚:

package com.example.demo; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.concurrent.atomic.AtomicInteger; @WebServlet(name = "LifecycleServlet", urlPatterns = "/life", loadOnStartup = 1) public class LifecycleServlet extends HttpServlet { private AtomicInteger requestCount = new AtomicInteger(0); @Override public void init() throws ServletException { System.out.println("init 执行,Servlet 实例已经创建: " + this); super.init(); } @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int count = requestCount.incrementAndGet(); System.out.println("service 执行,当前第 " + count + " 次请求,线程: " + Thread.currentThread().getName()); super.service(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("这是第 " + requestCount.get() + " 次请求"); } @Override public void destroy() { System.out.println("destroy 执行,Servlet 正在销毁"); super.destroy(); } }

这里有两个细节先说明。第一,@WebServlet里的loadOnStartup = 1表示让Tomcat启动时就把这个Servlet初始化,而不是等第一次请求才初始化。后面我会演示它的作用。第二,我没用普通的int来计数,用的是AtomicInteger,原因是Servlet是单实例多线程模型,多个请求可能同时到达,int的自增操作在并发下会有竞争问题,这里用一个线程安全的计数器更严谨。

启动Tomcat后,观察IDEA控制台:

init 执行,Servlet 实例已经创建: com.example.demo.LifecycleServlet@1f2f3g4h

注意这行输出是在Tomcat启动阶段就出现的,因为我们设了loadOnStartup。如果去掉这个属性,你会看到启动日志里没有init,直到浏览器第一次访问/life,init才冒出来。这就是懒加载和预先加载的差别。

接下来连续刷新/life三次,控制台会出现三条类似的日志:

service 执行,当前第 1 次请求,线程: http-nio-8080-exec-6 service 执行,当前第 2 次请求,线程: http-nio-8080-exec-8 service 执行,当前第 3 次请求,线程: http-nio-8080-exec-3

注意看三条日志里的线程名都不一样。每次请求Tomcat会从线程池里取一个空闲线程来执行service方法,但Servlet实例始终是同一个。requestCount作为一个实例字段,在多次请求之间保留了自己的值,所以计数才会累加。这就直观地证明了:Servlet是单实例多线程模型,这个类的所有实例字段都是被所有请求共享的。

3.3loadOnStartup到底什么时候用

刚才已经看到了,loadOnStartup的值不是布尔值,而是一个整数。它的作用是对多个Servlet进行初始化优先级排序,数字越小优先级越高。如果你给好几个Servlet都设置了loadOnStartup,Tomcat会按数值从0开始依次初始化,值相同的就按顺序来。

实际项目里,这个属性主要用在两类场景:一类是启动时就要完成的框架初始化,比如Spring MVC的DispatcherServlet,你希望它被尽早加载,这样后续请求到达时路由功能已经就绪;另一类是初始化资源比较重的Servlet,比如某个Servlet在init里建立了数据库连接池或加载了缓存数据,让它启动时就加载,可以及早发现问题,而不是等用户第一次访问时才突然卡顿。

反过来说,如果一个Servlet初始化成本很低、也不是核心入口,那就完全没必要加loadOnStartup,让它懒加载反而能缩短Tomcat启动时间。我给你们的建议是:不要图方便给所有Servlet都加这个属性,Java Web的很多性能问题就是这种"也没想清楚,就是顺手"的习惯积累出来的。

3.4 为什么强调service的重写位置

在我的观察Servlet里,重写的是service而不是doGet。这里要讲清楚两个方法的关系。HttpServlet内置的service方法就像一道分发的闸口:它读取请求的HTTP方法名,如果是GET就调doGet,是POST就调doPost,是PUT就调doPut,以此类推。因此,如果你直接重写service而不调用super.service(req, resp),那么请求就会被你"半路拦截",永远不会进入doGet或doPost,全部请求都由你自己的service处理。

我的代码里调用了super.service(req, resp),意味着只是把service当作一个观察点,先记录日志,然后交给父类的分发逻辑,让它再去调doGet。这种写法在AOP思想里相当于"切面日志",只是在某个必经之路上加了一行输出,并没有改变原有的处理流程。

这个理解很重要。将来你去看Spring MVC的源码,会发现HttpServlet.service的分发路径就是Web请求进入框架的第一扇门。很多框架过滤器、监听器都是构建在这个机制之上的。

4. 三个让人皱眉的问题,用生命周期来解释刚刚好

我以前帮人排查问题的时候,经常发现同一个现象:Servlet代码翻来覆去看都找不到bug,最后发现其实不是代码逻辑错,而是对生命周期的某个特性理解不到位。这里拿三个高频问题出来,用已经建立的生命周期模型逐个分析,读完你还能顺便学会排查思路,而不是只会照着答案改。

4.1 为什么是404:映射、上下文路径和部署三连问

444没有统一的面孔。你看到的404页面如果带有一行The origin server did not find a current representation for the target resource,说明请求地址确实没有匹配到可以处理的资源。遇到这种情况,第一反应不是去改代码,而是对照三个地方:

第一,你正在访问的URL有没有带上Application context。启动配置里写的是/demo,浏览器访问却是localhost:8080/hello,那必然404。先确认自己能完整回答"Tomcat对外提供这个应用的基础路径是什么"。

第二,@WebServlet(urlPatterns = "/hello")里的路径是否真的配对了。这里有个坑是路径漏写斜杠,比如写了"hello",访问/hello也找不到,因为映射要求以/开头,Tomcat收到请求后匹配的是/hello,而映射表里存的是hello,长度都不一致,怎么可能匹配上。

第三,Artifact的Output Layout是否把新写的类输出到WEB-INF/classes下。这个检查我前面讲过具体操作。如果映射没问题、路径没问题,那基本就是这个原因了。用一个笨办法验证:去Tomcat的webapps下找到一个由IDE生成的exploded目录,直接看WEB-INF/classes/com/example/demo/里有没有对应class文件。没找到,就说明代码没真正"部署"。

4.2 为什么改了代码不生效:生命周期的另一个视角

很多新人改一行doGet里面的输出文字,然后刷新浏览器,发现页面还是旧内容。第一反应是浏览器缓存,但清掉缓存还是没用。其实问题不在浏览器,而在Tomcat压根还在跑着旧代码。

IDEA里点运行的时候,Tomcat进程启动后会一直驻留内存。你重新编译Java源码,只是把新的class文件写到了target/classes里,但如果Tomcat没有重新加载应用,它内存里拥有的还是旧对象。Java类加载器装载一个类之后,不会因为你磁盘上的class文件更新了就自动换掉内存里的版本,除非显式触发重启或重新部署。

所以,磁面上"改了就能生效"有三种层次:改JSP或静态资源,IDEA的Update resources就能生效;改Java业务代码,Update classes也许能生效,但常常因为方法签名改变、依赖关系变化而出现奇怪问题;改web.xml、改注解、改依赖,必须Redeploy或者干脆重启Tomcat。我的建议是:改Java代码就直接点那个甲壳虫样式的Restart Server按钮,虽然慢一点,但最稳妥。热部署省时间的代价是有时会撞上类加载器导致的问题,排查成本更高,学习阶段不值得。

4.3 为什么某些初始化只执行一次:单实例的意义

在学生项目中经常会出现这种代码:

private int count = 0; @Override protected void doGet(...) throws IOException { count++; resp.getWriter().write("count = " + count); }

三次刷新得到1、2、3,看起来好像"变量保存下来了"。但如果换一个普通Java类,每次调用new出来的对象,它的字段都是新的起点。你能得到累加,恰恰是因为Servlet只有一个实例在服务所有请求,换句话说,这个count在整个Tomcat运行期间都是同一个对象的字段。

这里要紧接着指出隐患:因为是单实例多线程,这个共享的count在真正并发场景下会有线程安全问题。上面的示例一旦被两个请求同时命中,count++这个"读-改-写"三步操作就可能发生数据错乱。所以我在观察示例里专门用了AtomicInteger。

这是很多面试里爱问的"Servlet线程安全吗"的底层来由。直接给结论:Servlet本身不保证线程安全,service方法里的局部变量是安全的,但实例字段和类变量都是危险的。严谨的写法是:能不用实例字段就不存状态;必须共享的资源用线程安全的数据结构或加锁保护。如果你发现自己在一个Servlet里写了大量可变的实例字段,那基本就是设计出了问题,该拆Service了。

5. 跑通和踩坑之后,我现在实际写Servlet会注意这些

前面把创建和生命周期都过了一遍,最后这部分分享一些我现在写Servlet的习惯。它不是教科书式的规范,纯粹是我在实际项目中踩过坑、复盘过、最后沉淀下来的做法。

5.1 编码问题永远是第一优先级

中文乱码是Servlet新手绕不过去的一关。我现在的做法是:Servlet方法的最前面先统一设置请求和响应的编码,而不是等出问题再查。

resp.setContentType("text/html;charset=UTF-8"); req.setCharacterEncoding("UTF-8");

第一行告诉浏览器,响应的内容按UTF-8来解析。第二行让Tomcat在解析请求体参数时使用UTF-8。这里有个细节需要注意:请求编码这行,只对POST请求体里的参数有效。如果你用GET方式把中文参数拼在URL上,Tomcat默认按ISO-8859-1解析URL中的参数,那就要对参数单独做一次转码。最省心的方案是:把Tomcat连接器的URIEncoding属性设置为UTF-8,在server.xml的Connector节点上加上URIEncoding="UTF-8"。如果是指导教学环境,我建议直接改连接器配置,免得学生卡在GET乱码上。

5.2 doGet和doPost统一处理

我见过很多新手写Servlet,只处理GET不管POST,结果表单一提交就变成异常。更稳妥的做法是不要在这两个方法里直接写业务,而是让它们都去调同一个方法。

@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handle(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handle(req, resp); } private void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 业务代码写在这里 }

这样做的好处是:不管你用GET访问还是POST提交,最后都会落到同一段逻辑上,避免出现"GET能访问、POST就404"的割裂状态。而且之后如果要加请求日志、参数校验,直接在handle方法头部加一行就够了,不需要复制两份。在简单的增删改查项目中,这个模式特别实用。

5.3 Servlet里别堆业务,拆Service才是正事

我见过一些老旧的Servlet类,一个doPost里几百行,SQL、JSON组装、日志输出全揉在一起。这样的代码在前期功能少的时候没问题,一旦需求复杂起来,改一个逻辑可能要翻半天,还要冒着把请求参数处理弄坏的风险。

我的习惯是Servlet只承担"接受请求、解析参数、调用Service、返回响应"这四件事。业务计算、数据库操作、规则判断都放到独立的Service类里。如果一个Service的方法需要某个参数,就把参数从Servlet里传过去。这样做之后,单元的职责清晰了,后续想加单元测试也容易多了。虽然这篇文章的主题是Servlet本身,但"Servlet只是Web层的门面"这个观念越早建立越好,后面学Spring MVC时会无缝衔接。

5.4 生命周期和Spring MVC的一点联想

有一次我给一个朋友讲Spring MVC初始化流程,他从头听到尾只觉得一头雾水。后来我说你别把它想得太玄,Spring MVC的核心入口DispatcherServlet本质上就是一个Servlet,它同样遵循三个阶段:配置的时候让容器在启动阶段加载它,init方法里初始化组件到Spring容器,请求到达时service分发到具体处理方法。

这句话说出来之后,他瞬间就把Servlet阶段学的东西和框架连起来了。框架只是在这个基础骨架上填了大量便利功能,比如参数自动绑定、数据校验、拦截器链,但它的"命"还是那五段生命周期。这也是我认为Servlet是Java Web学习路上不能跳过的一块基石的原因。你可能会觉得用原生的Servlet写页面又累又繁琐,但正因为它繁琐,你才会真正理解后面框架替你省了什么力气。

最后,我还想分享一个实用技巧:平时写Servlet观察日志时,养成输出线程名和当前实例哈希值的习惯。比如System.out.println("instance=" + this + ", thread=" + Thread.currentThread().getName())。这样你就能在控制台直接看到"永远只有一个实例,却来自不同线程"这个事实。理解了这个,很多并发和生命周期相关的问题,你就不会再瞎猜了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询