搞后端开发绕不开 JDK 和 Tomcat 这套组合。JDK 是 Java 程序的运行基础,Tomcat 是绝大多数 Java Web 项目落地的容器,两者只要有一个没配对,项目代码就算全对也跑不起来。前阵子帮同事排查一个老项目的部署问题,代码编译通过、JDK 版本看着也正常,可 Tomcat 一启动就报错,最后发现只是环境变量指向了另一个版本的 JDK。这类事写出来很小,卡住人的时候却非常耗时间,所以我决定把环境准备、版本选择、部署姿势和调优参数整理成一份能照着操作的笔记,适合刚转后端的同学,也适合被环境问题反复折腾的老手。
1. 先拆清楚:一条请求经过 JDK 和 Tomcat 时发生了什么
1.1 容器层级与一次请求的完整路径
很多人把 Tomcat 当成一个黑盒:war 包丢进去就能跑,端口改一下就行。实际上一条请求从浏览器到后端代码,中间要穿过好几层,每一层配置错了都会让排查变得很痛苦。
Tomcat 最外层的网络入口是 Connector,默认监听 8080。浏览器请求进来后,Connector 把 TCP 连接上的字节解析成 HTTP 请求对象,再交给它后面的 Engine。Engine 是一个 Servlet 引擎,它的职责是按域名和路径把请求分配到合适的 Host 和 Context,最终找到能处理这个路径的 Servlet,再把响应一层层返回给浏览器。
Context 可以理解成一个 webapp 的运行实例。一个 Tomcat 里能同时跑很多个 webapp,Tomcat 通过webapps目录下的文件名来确定应用上下文路径。比如 war 包叫demo.war,默认上下文路径就是/demo。如果你访问的是/app,但 war 包叫demo.war,那多半会撞上 404。这个对应关系不搞清楚,后面部署阶段会浪费大量时间。
JDK 在这里的作用是提供 JVM 和类库。Tomcat 本身就是一个 Java 程序,它启动的时候需要找到java命令和JAVA_HOME;所有 webapp 的 class 文件、第三方 jar 包也由同一个 JVM 加载。容器把请求交给 Servlet 时,执行的其实就是一段由 JVM 解释和编译的字节码。换句话说,JDK 是运行时环境,Tomcat 是容器框架,两者互相依赖,缺一个都跑不起来。
1.2 为什么 JAVA_HOME 差一个版本就会崩
Tomcat 的启动脚本会优先通过JAVA_HOME找java,找不到时才会退回去用 PATH 里的java。很多环境问题都出在这个“退路”上:你以为 Tomcat 用的是你刚装的 JDK 17,实际上系统 PATH 里还留着老版本的 JDK 8,脚本碰巧抓到了旧的,于是一堆类加载异常就出来了。
把 JDK 想成发动机,Tomcat 想成整车,webapp 是货物。发动机型号不对,车能点火但不一定跑得动,甚至跑起来之后某个功能突然熄火。所以判断一个环境问题,第一步永远不是改代码,而是先确认当前 JVM 到底是哪一个。
我自己的排查习惯是执行三类命令:
java -version看默认 JAVA 版本echo $JAVA_HOME看环境变量指向which java看实际被调用的二进制路径
这三条命令的输出不一致时,环境变量优先级、PATH 顺序、软链接指向基本有一个出了问题,先把它们对齐,再去看项目代码。
2. 版本对照表不是玄学:我这样选 JDK 和 Tomcat 的组合
2.1 一张表看懂版本关系
JDK 和 Tomcat 的版本关系是有明确约束的,不是越新越好。Tomcat 自身有对应的 Servlet 规范版本,而 Servlet 规范又决定代码里的包名怎么写。常用的对应关系我整理成了下面这张表:
| Tomcat 版本 | 最低 Java 版本 | Servlet 接口包名 | 典型使用场景 |
|---|---|---|---|
| Tomcat 8.5 | JDK 7+ | javax.servlet | 特别老的遗留项目 |
| Tomcat 9 | JDK 8+ | javax.servlet | 现有存量项目,覆盖面最广 |
| Tomcat 10.0 | JDK 11+ | jakarta.servlet | 新规范迁移的早期版本 |
| Tomcat 10.1 | JDK 11+ | jakarta.servlet | 当前比较推荐的新版本 |
你可能会问,JDK 版本那么多,为什么我只在表里写 8、11、17?因为这几个是长期支持版本,生产环境选长期支持版比追新版本稳得多。JDK 8 虽然出来很多年了,但大量老项目还在用,配 Tomcat 9 是非常成熟的组合;新项目我一般直接用 JDK 17 + Tomcat 10.1,遇到的新坑少,社区讨论也多,真出问题容易搜到答案。
2.2 javax 和 jakarta 是分水岭
Tomcat 9 和 Tomcat 10 之间最大的变化不是端口、不是线程池,而是接口包名。Tomcat 9 及以前,Servlet 规范里的接口都叫javax.servlet.*;从 Tomcat 10 开始,整个命名空间变成了jakarta.servlet.*。
这个变化对老项目影响巨大。如果你把一个在 Tomcat 9 上正常运行的 war 包原封不动丢到 Tomcat 10 里,启动阶段大概率会看到类似java.lang.NoClassDefFoundError: javax/servlet/ServletException这样的报错。原因是代码里的 import 仍然是javax.servlet,但新版本的 Tomcat 类库里已经没有这个包了。
所以我的选型原则很简单:
- 手里是历史存量项目,不要为了用新版本而强行升 Tomcat 10,直接留在 Tomcat 9,代码不用动。
- 展开新项目、新模块时,从一开始就按
jakarta.servlet来写依赖和 import,这样以后升级容器更顺。 - 判断当前 Tomcat 属于哪个版本体系,不要只看
ls目录或下载时的文件名,直接执行bin/version.sh,它会一次性把 Tomcat 版本和 Java 版本都打印出来,比猜靠谱得多。
查看版本还有个容易忽略的点:java -version显示的是 PATH 里的 Java,不代表 Tomcat 启动脚本用的 Java。如果JAVA_HOME和 PATH 指向不同,一切以bin/version.sh的输出为准。
3. Linux 服务器实测:JDK 的 tar 安装、软链接与环境变量配置
3.1 tar 包安装的完整步骤
我自己的项目里,生产服务器通常不用系统包管理器装 JDK,因为不同发行版仓库里的版本差异太大,且系统升级时可能被自动替换掉。更可控的方式是下载固定的二进制 tar 包,放到/usr/local/src,解压后建一个软链接。这样以后切换版本只改软链接,不用动应用代码。
大致命令如下:
mkdir -p /usr/local/java /usr/local/src cd /usr/local/src tar -zxvf jdk-17_linux-x64_bin.tar.gz -C /usr/local/java/ ln -snf /usr/local/java/jdk-17.0.12 /usr/local/java/jdk文件名里的版本号按你实际下载的包名改。解压完成后,不要直接拿完整路径到处配,而是配置一个名为jdk的软链接。这样以后升级 JDK 时,我只需要解压新包再把软链接重新指过去,JAVA_HOME和相关脚本完全不用动。
然后是环境变量。我习惯在/etc/profile.d/下放一个单独的脚本,而不是直接改/etc/profile:
cat >> /etc/profile.d/java.sh <<'EOF' export JAVA_HOME=/usr/local/java/jdk export PATH=$JAVA_HOME/bin:$PATH EOF source /etc/profile.d/java.sh用/etc/profile.d/的好处是配置独立、升级可追溯,也避免把/etc/profile改得一团乱。执行完source后,用下面的命令验证:
java -version echo $JAVA_HOME which java三条命令的输出指向同一个 JDK 时,环境才算配好了。
3.2 常见翻车点:JRE_HOME 与 PATH 抢占
JDK 8 及以前的 tar 包里通常会带一个jre子目录,所以很多老教程会让你额外设置JRE_HOME=$JAVA_HOME/jre。但 JDK 11 之后的版本不再单独提供 JRE 目录,如果还把JRE_HOME配上,Tomcat 启动脚本会去找一个不存在的目录,直接导致启动失败。
我现在的习惯是只设JAVA_HOME,不设JRE_HOME。除非你的 Tomcat 版本明确要求,否则别画蛇添足。
另一个翻车点是 PATH 抢占。有些系统自带了一个旧版 Java,安装路径会在 PATH 前面。比如你装了 JDK 17,但which java指向/usr/bin/java,那说明/usr/bin在 PATH 里排在$JAVA_HOME/bin前面。解决办法是把$JAVA_HOME/bin放在 PATH 最前面,或者删掉旧版本的软链接。检查的时候别只看java -version,一定要看which java的实际结果。
4. Tomcat 的目录结构和部署 webapp 的三种姿势,别只会丢 war 包
4.1 每个目录到底干什么用
Tomcat 解压后的目录结构看着简单,但每个目录都有自己的含义。我见过不少把lib目录当放项目 jar 包的地方随手丢文件的,轻则类冲突,重则整个容器起不来。下面这张表是我平时记的:
| 目录 | 作用 |
|---|---|
bin | 启动、关闭脚本,如startup.sh、shutdown.sh |
conf | 全局配置,重点是server.xml、context.xml、catalina.properties |
lib | Tomcat 全局共享的 jar 包,放这里会被所有 webapp 加载 |
logs | 运行日志,catalina.out是核心 |
webapps | 默认的应用部署目录,也是 appBase |
work | JSP 编译后的 class 缓存目录 |
temp | 临时文件目录 |
lib尤其要小心。它给所有 webapp 共享类库,如果项目 A 需要某个 jar 的旧版本,项目 B 需要新版本,放在lib里就会互相打架。正确的做法是把项目自己的依赖打进 war 包的WEB-INF/lib,lib里只放容器级别的公共库。
4.2 三种部署方式对比
部署 webapp 不是只有“把 war 丢进 webapps”这一种方式。根据发布场景不同,我通常在三者之间切换:
- 直接丢 war 包。把
demo.war复制到webapps目录,Tomcat 默认开启了unpackWARs和autoDeploy,会自动解压成webapps/demo目录。这种方式最简单,适合开发环境快速验证。 - 外部目录部署。如果编译产物不在服务器本地,而是由发布脚本推送到
/data/www/demo,可以用软链接让 Tomcat 找到它:ln -s /data/www/demo webapps/demo。这样更换版本时只需要切换软链接,回滚非常快。 - Context XML 部署。在
conf/Catalina/localhost/下新建一个demo.xml,内容示例:
<Context docBase="/data/www/demo" reloadable="false"/>文件名叫demo.xml,上下文路径就是/demo。docBase指向应用的实际目录,应用目录里必须包含WEB-INF/web.xml或对应配置。用这种方式部署不会污染webapps,尤其适合多环境共用同一套 Tomcat 的发布场景。
4.3 上下文路径最容易踩的坑
很多人的 404 问题不是代码问题,而是上下文路径搞错了。war 包名demo.war,路径就是/demo,无论你访问/app还是/bbs都会 404。想部署成根路径,也就是访问http://ip:8080/就能打开,需要把 war 包改名为ROOT.war,或者把 Context XML 的路径配置成根路径。
还有个细节:如果同一个应用既出现在webapps里,又被conf/Catalina/localhost/demo.xml引用,Tomcat 会报 Context 重复定义的错误。所以部署到外部目录前,先确认webapps下没有同名目录,这个冲突非常隐蔽,日志里不仔细看还以为是端口问题。
5. 别动默认值就上生产:Connector 与 JVM 参数的一次调优记录
5.1 HTTP Connector 里的几个关键参数
conf/server.xml里的 Connector 是整个 Tomcat 的流量入口,默认值能跑,但上线后体量稍大就会暴露出问题。我常用的一个配置模板如下:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="400" minSpareThreads="50" acceptCount="100" maxConnections="10000" />这里有几个参数值得逐一说一下:
maxThreads:处理请求的工作线程数上限。默认 200 对小站点够用,但并发稍高就会出现请求堆积。不是越大越好,每个线程都要吃一部分 JVM 栈内存,400 是我在 4G 内存机器上常用的保守值。minSpareThreads:启动时预先创建的空闲线程数。设成 50 可以让流量高峰到来前有一部分线程在等待,避免请求来了现建线程的抖动。acceptCount:等待队列的长度。当工作线程全忙时,新请求会先放到队列里。太小会让短时间内的大量连接被直接拒绝,太大又会让请求等太久。maxConnections:容器能同时保持的 TCP 连接数。这个值通常比maxThreads大,因为 keep-alive 长连接不一定都在处理请求,大部分时间就是挂在那里闲置。
connectionTimeout默认 20000 毫秒,是等待请求头的时间,不是整个请求的处理时间。不要为了“快”把它压得太小,否则网络慢一点的客户端会频繁断开。
5.2 setenv.sh 里的 JVM 参数
改 JVM 参数不要直接动catalina.sh,那个文件是官方脚本,升级 Tomcat 时会被覆盖。正确做法是新建bin/setenv.sh,Tomcat 启动时会自动加载它。内容示例:
export JAVA_HOME=/usr/local/java/jdk export CATALINA_HOME=/usr/local/tomcat export CATALINA_OPTS="-Xms1024m -Xmx2048m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" export JAVA_OPTS="-Dfile.encoding=UTF-8"说明几个我重点关过的问题:
-Xms和-Xmx是堆内存的初始值和上限。生产环境我通常把两者设为相同,这样 JVM 不会频繁地申请、回缩内存,GC 行为更稳定。-XX:MaxMetaspaceSize是元空间上限,很多没有持久化连接池配置的应用,会在反复重部署时把元空间吃满,设置上限比放任不管稳。-XX:+UseG1GC在 JDK 9 之后本来就是默认,我写出来更多是表意,方便后来的人知道这里明确用了 G1。-Djava.security.egd=file:/dev/./urandom是处理 Java 启动时随机数阻塞的问题,某些环境下不配这条,Tomcat 启动过程可能会卡很久。
内存上限怎么定?我的经验是总内存 4G 的服务器,-Xmx设在 2G 比较安全,因为除了堆内存,还有线程栈、元空间、直接内存和操作系统本身的占用。一个 Tomcat 上如果还要跑多个 webapp,更要克制,给系统留出至少 1G 余量,否则高峰期 JVM 一 OOM 直接拖垮整台机器。
5.3 调优之后怎么验证
参数配完不能只靠“感觉变快了”,要有基本验证手段。启动后我会看 JVM 进程实际参数是否符合预期:
jps -l jcmd <pid> VM.flagsjcmd VM.flags会输出 JVM 实际的启动参数,和setenv.sh对比一下,就能确认配置有没有被正确加载。另一个常用手段是观察 GC 日志,比如用jstat -gcutil <pid> 1000隔 1 秒打印一次堆内存和 GC 耗时,如果老年代持续增长不回落,就该考虑是不是堆设置不合理或者有内存泄漏了。
6. 三个让我熬夜的问题:端口占用、404 和 javax/jakarta 包名冲突的完整排查链路
6.1 端口占用:先看日志再判断
有一类问题很典型:Tomcat 启动脚本执行完,控制台没有输出错误,但进程立刻消失,或者就是起不来。这时不要反复重启,先看logs/catalina.out的尾部。如果看到这样一段:
java.net.BindException: Address already in use说明端口被占用了。完整排查链路是:
tail -n 100 logs/catalina.out ss -lptn 'sport = :8080'ss会输出正在监听 8080 的进程号和进程名。接下来分两种情况处理:
- 如果是另一个 Tomcat 实例,可能是旧进程没杀干净。用
shutdown.sh优雅停止,不行再用kill <pid>,最后才用kill -9。 - 如果是别的业务程序占用了端口,那就不要硬抢,改
server.xml里的port换一个空闲端口,但注意redirectPort也要配套调整。
还有一个藏着很深的点:Tomcat 的关闭端口8005也被占用时,shutdown.sh可能连不上管理端口,启动脚本会卡住或失败。所以排查端口占用时,不要只看 8080,8005这个关闭口也要扫一眼。
6.2 404:请求路径和上下文根目录对不上
Tomcat 能启动、8080 访问首页也正常,但访问具体应用报 404,这个问题的排查链路比较固定。
第一步,先看webapps目录里有什么:
ls -l webapps如果只有ROOT目录,没有demo目录,那就先确认demo.war是否真的被复制进来了,以及unpackWARs是否开启。默认 Tomcat 会自动解压,但如果你手动改过conf/server.xml里的unpackWARs="false",war 包就不会展开,访问路径可能没有对应的物理目录。
第二步,确认上下文路径。war 包名是demo.war,应用路径就是/demo。我见过很多人部署了app.war,却一直访问/project或者/test,当然 404。想用其他路径,要么改名,要么用 Context XML 配置。
第三步,如果用的是外部目录部署,检查docBase是不是指向了应用根目录。一个很容易犯的错是docBase指向了WEB-INF的父目录的父目录,导致 Tomcat 找不到WEB-INF/web.xml。可以用这条命令验证:
curl -I http://127.0.0.1:8080/demo/看返回的 HTTP 状态码是 200 还是 404,然后逐层往上报错日志。Tomcat 的部署异常通常会记在logs/localhost.*.log里,别只看catalina.out。
6.3 javax.servlet 找不到:新老容器接口迁移
第三个让我印象深刻的坑是版本迁移。某一次我把一个老项目从 Tomcat 9 的环境直接搬到 Tomcat 10,启动时大量报错:
Caused by: java.lang.NoClassDefFoundError: javax/servlet/ServletException我第一反应是缺 jar 包,于是往 Tomcat 的lib目录里放了一堆javax.servlet-api相关依赖,结果更乱了。后来看日志才发现,Tomcat 10 里根本没有javax.servlet这个包名,代码里import javax.servlet.*根本找不到对应的类。
这种问题的本质是 Servlet 规范的包名迁移。老项目用的javax.servlet是 Java EE 时代的命名空间,Tomcat 10 之后的jakarta.servlet是 Jakarta EE 时代的命名空间,两者不能混用。
排查链路如下:
- 看报错堆栈里是
NoClassDefFoundError还是ClassNotFoundException,不管哪种,先确认涉及的包名是javax.servlet还是jakarta.servlet。 - 执行
bin/version.sh确认当前 Tomcat 的实际版本。Tomcat 9 用javax,Tomcat 10 用jakarta。 - 如果是老项目,最省事的方案是切回 Tomcat 9,而不是去改几百个源文件的 import。
- 只有新项目或明确要做迁移时,才执行全局替换:把
javax.servlet批量替换成jakarta.servlet,再重新编译打包。
这个坑的教训是:升级容器的优先级永远低于“项目能稳定运行”。没有强诉求,别把生产环境的 Tomcat 从一个版本体系跨到另一个版本体系。
7. 部署完成后的日常维护:日志、备份和几个不起眼的小习惯
7.1 日志怎么读,以及怎么防止日志膨胀
Tomcat 的日志分散在logs目录下,不同文件记录的侧重点不同。我平时最关注的三个是:
catalina.out:Tomcat 的标准输出,启动过程、线程问题、进程级错误都会记在这里。localhost.*.log:webapp 部署和上下文生命周期相关日志,应用启动失败时优先看它。manager、host-manager相关日志:管理端和控制台相关操作记录。
线上环境最容易踩的坑是catalina.out无限膨胀。这个文件如果应用里有人写了System.out.println,流量一大就会涨到几个 GB,到时候磁盘空间报警、日志检索也变慢。我的习惯是让应用使用真正的日志框架,把业务日志和容器日志分开,catalina.out只留给容器级别的输出。服务器上再配合系统的日志切割工具,定期归档旧日志,不要等到磁盘写满再处理。
7.2 升级发布时该备份什么东西
很多发布事故是在替换 war 包时发生的,替换完之后配置被覆盖,应用就挂了。我给自己定过一条规矩:升级前至少备份两个东西,一是业务 webapp,二是conf目录。
tar -czf backup-webapps-$(date +%F).tar.gz webapps demo.war tar -czf backup-conf-$(date +%F).tar.gz conf备份不是形式,是为了回滚时能精确恢复到你改动之前的现场。尤其conf/server.xml这种文件,如果你曾经手工改过端口、线程池、日志阀门,一旦被新版本覆盖,光凭记忆是很难全部找回来的。
发布顺序上,我推荐先shutdown.sh优雅停机,再替换文件,然后startup.sh启动。除非遇到应用无法停止的僵死状态,否则不要直接用kill -9,因为强制杀进程会跳过 Tomcat 的资源清理,留下临时文件和半写入状态,下次启动可能会莫名其妙报错。
7.3 健康检查、GC 观察和几个小技巧
进程在不在不代表应用活着。我见过很多次 Tomcat 进程还在,但应用线程已经全部卡死的情况。所以线上健康检查不要只ps -ef | grep tomcat,最好加一个请求级的探活:
curl -f --max-time 5 http://127.0.0.1:8080/health业务方提供一个独立的健康检查接口,能真实反映应用是否能处理请求。探活失败的阈值到了就自动报警,比人肉盯着日志强很多。
另外一个不起眼但很实用的小技巧是,遇到改了 JSP 或静态资源后效果不生效,先不要怀疑缓存服务器,看一下 Tomcat 的work/Catalina目录。JSP 编译后的 class 文件缓存在这里,如果没触发重新编译,删掉对应的缓存目录再重启即可,比整个 Tomcat 重装快得多。
部署在公网环境时,webapps下默认自带的 docs、examples、host-manager 这些示例应用最好删掉或限制访问,它们不仅没有业务价值,还会暴露很多可被探测的信息。开发环境可以留着方便联调,生产环境能少开一个入口就少开一个入口。
可能有人觉得 JDK 和 Tomcat 都是再基础不过的东西,但越基础越容易在环境切换、版本升级时翻车。我自己踩过几次坑之后养成了一个习惯:在项目目录里放一份环境说明文档,固定 JDK 版本、Tomcat 版本、JAVA_HOME路径和server.xml的改动点。这样换机器、换人接手,十分钟就能把环境对齐,后面排查问题会轻松很多。这篇笔记里的命令和参数都来自实际项目,希望能帮你少走我走过的弯路。