一、问题初现:升级后启动直接崩了

最近公司里有个老项目要升级Tomcat,从8.5升到9.0。本来以为只是换个服务器版本,结果刚把安装包解压,部署完应用,一点启动,控制台直接抛出一堆异常,应用根本起不来。大家围在一起看日志,发现错误信息里到处都是java.lang.NoSuchMethodErrorjava.lang.NoClassDefFoundError,一开始还以为是jar包冲突,后来仔细一看,原来根子出在Servlet API上。

我们举个例子,假设项目里有一个很普通的Servlet,代码大概长这样:

// 技术栈:Java 8 + Tomcat 8.5 升级到 Tomcat 9.0
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 简单的示例Servlet,打印一段文字
 */
public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        // 设置响应内容类型
        response.setContentType("text/plain;charset=UTF-8");
        // 往客户端写一句话
        response.getWriter().write("Hello, Tomcat!");
    }
}

这段代码在Tomcat 8.5下面跑得好好的,但是在Tomcat 9.0下面启动时就报错了。为什么会这样?就是因为Tomcat 9.0对Servlet API的版本要求变了,从上往下兼容有了裂痕。

我们再看看Maven项目里的依赖,通常会有这样一个东西:

<!-- 技术栈:Java 8 + Maven -->
<dependency>
    <!-- 这里是Servlet API,scope设置为provided,
         因为实际Servlet API由Tomcat提供,避免打包冲突 -->
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
    <scope>provided</scope>
</dependency>

这个依赖用的是javax.servlet这个命名空间。而Tomcat 9.0虽然还是支持javax.servlet,但是Servlet API版本已经升级到4.0了。如果你的应用里还依赖着老的3.1版本,并且某些API在4.0里变了,就有可能出现版本冲突。

注意,等到了Tomcat 10,变化更彻底,它把整个命名空间从javax.servlet换成了jakarta.servlet。这个改动可大了,相当于把一栋楼的每户门牌号全换了。如果你直接把老项目扔到Tomcat 10里面,那妥妥起不来,而且报错比上面例子还多。

二、核心原因:Servlet API版本变了

要搞清楚这个问题,我们得先明白Tomcat和Servlet API之间的关系。Tomcat本身是一个Servlet容器,它的职责就是实现Servlet规范。不同的Tomcat版本对应着不同版本的Servlet规范。我整理了一张简表(在文字里说不画图了),大概是这样:

  • Tomcat 7 -> Servlet 3.0 -> javax.servlet
  • Tomcat 8/8.5 -> Servlet 3.1 -> javax.servlet
  • Tomcat 9 -> Servlet 4.0 -> javax.servlet
  • Tomcat 10/11 -> Servlet 5.0/6.0 -> jakarta.servlet

你看,从Tomcat 10开始,命名空间都换了。这个变更源自Oracle把Java EE技术移交给Eclipse基金会后,所有Java EE相关的包名都要变化,因为Oracle不让别人继续用javax这个商标。所以之后所有官方标准库都改名成jakarta

顺便说一下,Servlet规范可以理解为一份合同,它规定了Web容器和你的应用之间怎么配合。比如请求怎么进来,响应怎么出去,过滤器怎么生效。合同版本变了,你应用里用的条款也要跟着变。这也是为什么升级Tomcat后,代码里那些import语句会直接决定程序的生死。

这就给应用开发带来了一个很大的坑。你的项目里如果有很多依赖了javax.servlet的第三方库,比如老版本的Spring、Struts、Shiro等,升级Tomcat到10之后,这些库可能全都没法用。

三、排查步骤详解

那面对这种启动报错,我们得一步步排查。别着急,我带你走一遍完整的思路。

3.1 查看实际加载的Servlet API版本

首先,我们要确认当前Tomcat实际提供的Servlet API到底是哪个版本。你可以去Tomcat的安装目录下的lib文件夹里看看,一般会有一个类似于servlet-api.jar的文件。在Tomcat 9里,这个jar包还是servlet-api.jar;到了Tomcat 10,变成了jakarta.servlet-api.jar。你可以用下面这个命令直接查看jar包里的类路径:

# 技术栈:Shell/Linux
# 查看Tomcat lib目录下的所有jar文件
ls -l $CATALINA_HOME/lib/*servlet*

# 解压jar包,看里面META-INF/MANIFEST.MF,里面会有规范版本信息
jar xf $CATALINA_HOME/lib/servlet-api.jar META-INF/MANIFEST.MF
cat META-INF/MANIFEST.MF

这个命令会显示一些版本信息,比如Bundle-Version等。如果找不到这个jar,那可能你的Tomcat是简化版,或者你的应用用的是嵌入式Tomcat。嵌入式Tomcat的话,你需要检查你的应用依赖的Spring Boot版本。

3.2 检查项目依赖中的Servlet API

如果应用是Maven项目,你可以用依赖树命令看看项目中到底引了哪些Servlet API。命令行如下:

# 技术栈:Shell/Maven
# 查看项目中所有与servlet相关的依赖
mvn dependency:tree -Dincludes=javax.servlet:*,jakarta.servlet:*

这个命令会把所有和servlet相关的依赖都列出来,你就能看到有没有版本冲突。比如可能你的项目里某个第三方库间接引用了servlet-api 2.5,而你的主代码用的是3.1,这就容易出问题。

看依赖树输出的时候,注意有没有出现多个不同版本的servlet-api,有的话,就得用<exclusions>排除掉一些。

3.3 常见异常类型及原因

升级Tomcat后启动报错,最常见的异常有这么几种:

  • NoClassDefFoundError:意思是有个类在编译期存在,运行期却找不到。比如代码里写了import javax.servlet.http.HttpServletRequest,但Tomcat 10里没有javax这个包了,所以你一加载这个类,就报错。
  • NoSuchMethodError:类还在,方法没了。比如Servlet 3.1有ServletRequest#getAsyncContext,但某些老版本没有,或者被移除了,就会这样。
  • ClassCastException:强转失败。比如一个对象在Tomcat 8里能被强制转换成javax.servlet.Servlet,在Tomcat 10里实际是jakarta.servlet.Servlet,类型不匹配,直接抛异常。

我们来看一个具体的报错片段(这是控制台日志,不是可运行代码):

// 技术栈:Java 控制台日志(用于描述报错信息)
// 实际报错可能长这样:
java.lang.NoClassDefFoundError: javax/servlet/ServletException
    at com.example.MyApp.init(MyApp.java:15)
    at org.apache.catalina.core.StandardContext.init(StandardContext.java:1234)
    ...

只要看到javax/servlet的包路径,就知道是命名空间切换导致的。

四、兼容性修复方案

既然问题清楚了,那么怎么修?这里分几种情况。

4.1 全量替换javax到jakarta

如果你是从Tomcat 8/9升到Tomcat 10及以上,那么你的代码中所有javax.servlet开头的import都要改成jakarta.servlet。这工作量如果手工改,那真是大海捞针。不过我们可以用一些自动工具,或者干脆用IDE的全局替换功能。但要注意,替换不是简单把字符串换一下就行,因为有些javax.servlet的依赖可能是从第三方传递过来的,你得把第三方库也升级到支持jakarta的版本。

先看一个简单的例子,把原来的Servlet改成兼容Tomcat 10:

// 技术栈:Java 11 + Tomcat 10
// 注意import语句的变化
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 兼容Tomcat 10的Servlet
 */
public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        // 设置响应类型
        response.setContentType("text/plain;charset=UTF-8");
        // 输出内容
        response.getWriter().write("Hello, Tomcat 10!");
    }
}

看到没有,只是import改了一下。但是你的项目里可能有几十个文件都这样,那就要用工具。

4.2 使用迁移工具

目前最常用的是Eclipse Transformer,它可以把javax替换成jakarta,同时还能处理一些其它元数据。另一个是OpenRewrite,它更智能,可以自动改代码,还能改配置文件。

比如用Eclipse Transformer的命令行工具,可以这样执行:

# 技术栈:Shell
# 下载Eclipse Transformer后,对项目根目录执行:
java -jar transformer.jar -r -p "**/*.java" -m javax.servlet=jakarta.servlet .

# 参数解释:
# -r 代表递归处理
# -p 指定文件匹配模式
# -m 指定要替换的包名映射

这个命令会遍历当前目录下所有.java文件,把javax.servlet替换成jakarta.servlet。当然这只是最简单的用法,实际项目还需要处理一些其它细节。

如果你用的是Maven,还可以引入OpenRewrite插件,在pom.xml里配置一下。

4.3 针对非标准库的兼容

如果你的项目里用了像Spring Framework 4.x或5.0这种比较老的版本,它们内部用的是javax.servlet,那你光改自己的代码没用,得升级Spring版本。Spring从5.3开始支持jakarta命名空间,6.0起完全切换到jakarta。所以你要把Spring升级到5.3+或6.0+,同时还有其它第三方库,比如MyBatis、Shiro等,也要找支持jakarta的版本。

这里给个建议:先列一个全项目依赖清单,然后逐个排查。

五、配置变更与迁移注意事项

除了代码,配置也有很多坑。很多人升级Tomcat后,代码改好了,结果配置不对,还是起不来。

5.1 Tomcat环境变量和JVM参数

老项目可能会有自己的setenv.shcatalina.bat,里面配置了JVM内存。升级后这些配置一般还能用,但注意Tomcat 9+默认的垃圾回收器变了,如果你之前指定了老的GC参数,可能会不兼容。比如-XX:+UseConcMarkSweepGC这种在Tomcat 9的默认JVM(JDK 11+)上可能已经被移除了。

建议先在测试环境用简单配置启动,再逐步加回原来的参数。

5.2 server.xml配置变化

Tomcat 9和10的server.xml里,Connector标签的一些属性变了。比如protocol属性,老版本可能会写HTTP/1.1,新版本支持Http11NioProtocol等。如果你之前配置了maxThreadsminSpareThreads这些,新版本有了一些默认值调整。总体影响不大,但还是要仔细比对官方文档。

这里给一个典型的Connector配置示例:

<!-- 技术栈:Tomcat server.xml配置 -->
<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           redirectPort="8443"
           maxThreads="200"
           minSpareThreads="25"
           acceptCount="100" />

这个配置在Tomcat 9下没问题,在Tomcat 10下也兼容,但protocol名称最好改成org.apache.coyote.http11.Http11NioProtocol,更明确。

5.3 context.xml配置变化

如果应用用了JNDI数据源,在context.xml里配置了数据库连接。升级后,注意数据源类的包路径也可能变化。比如DBCP连接池,老版本用org.apache.tomcat.dbcp.dbcp2.BasicDataSource,新版本用org.apache.tomcat.dbcp.dbcp2.BasicDataSource(其实一样)。但要注意Tomcat 10里,javax.sql.DataSource已经变成jakarta.sql.DataSource了,所以如果你的代码里直接用了javax.sql.DataSource,也要改。

另外,web.xml文件的头声明从javax.servlet变成了jakarta.servlet,注意版本号。

<!-- 技术栈:Tomcat 10 的 web.xml 头声明 -->
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">
</web-app>

在Tomcat 10里,web.xml的命名空间其实已经改成了jakarta.xml,但为了兼容,Tomcat 10也允许用旧的。最好改成新的,以免将来还要改。

六、最佳实践建议

这部分是重点,我们把所有经验浓缩成清单。

6.1 升级前评估清单

  • 确定从哪个Tomcat升到哪个Tomcat,比如8->9,还是8->10。搞清楚Servlet API版本跨度。
  • 列出所有依赖的第三方库,看是否支持目标Servlet版本。
  • 检查代码中有没有直接使用javax.servlet的import,统计一下数量。
  • 检查web.xml、context.xml、server.xml等配置文件的命名空间和属性。
  • 准备一个可以随时回滚的部署包,包括旧版Tomcat。

6.2 升级后验证清单

  • 先跑一个最简单的健康检查接口,确认应用能启动。
  • 把启动日志里的WARN和ERROR都过一遍。
  • 跑一遍核心业务流程,特别是有文件上传、异步请求、WebSocket这些依赖Servlet API的地方。
  • 使用JMeter做一次冒烟性能测试,确认Tomcat对新版本没有性能异常。

6.3 回滚方案

万一升级失败,要能快速回退。我的做法是保留旧版本的Tomcat目录,不删除。然后把部署包用软链接指向旧Tomcat。切换时只需要改一下端口映射即可。注意应用本身也要能兼容旧版本,所以升级前最好把应用代码和配置做成与版本无关的。

七、文章总结

这次升级Tomcat的踩坑经历,让我们深刻认识到一个道理:光换服务器版本不等于安全升级,Servlet API的兼容性才是核心。从Tomcat 8到9,变化还不算太剧烈,javax.servlet还在;但从9到10,整个命名空间都变了,这个迁移工作量堪比一次小重构。所以大家在升级之前,一定要做好评估,把代码、依赖、配置的变更点全部找出来,再动手。升级后也不要掉以轻心,按功能逐一验证。希望这篇文章能帮你少踩几个坑,顺利把Tomcat的问题解决掉。