JDK与Tomcat环境配置实战:版本选择、部署与调优避坑指南
搞后端开发绕不开 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 里能同时跑很多个 webappTomcat 通过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.5JDK 7javax.servlet特别老的遗留项目Tomcat 9JDK 8javax.servlet现有存量项目覆盖面最广Tomcat 10.0JDK 11jakarta.servlet新规范迁移的早期版本Tomcat 10.1JDK 11jakarta.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/profilecat /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.shconf全局配置重点是server.xml、context.xml、catalina.propertieslibTomcat 全局共享的 jar 包放这里会被所有 webapp 加载logs运行日志catalina.out是核心webapps默认的应用部署目录也是 appBaseworkJSP 编译后的 class 缓存目录temp临时文件目录lib尤其要小心。它给所有 webapp 共享类库如果项目 A 需要某个 jar 的旧版本项目 B 需要新版本放在lib里就会互相打架。正确的做法是把项目自己的依赖打进 war 包的WEB-INF/liblib里只放容器级别的公共库。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 reloadablefalse/文件名叫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 port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxThreads400 minSpareThreads50 acceptCount100 maxConnections10000 /这里有几个参数值得逐一说一下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.shTomcat 启动时会自动加载它。内容示例export JAVA_HOME/usr/local/java/jdk export CATALINA_HOME/usr/local/tomcat export CATALINA_OPTS-Xms1024m -Xmx2048m -XX:MaxMetaspaceSize256m -XX:UseG1GC -Djava.security.egdfile:/dev/./urandom export JAVA_OPTS-Dfile.encodingUTF-8说明几个我重点关过的问题-Xms和-Xmx是堆内存的初始值和上限。生产环境我通常把两者设为相同这样 JVM 不会频繁地申请、回缩内存GC 行为更稳定。-XX:MaxMetaspaceSize是元空间上限很多没有持久化连接池配置的应用会在反复重部署时把元空间吃满设置上限比放任不管稳。-XX:UseG1GC在 JDK 9 之后本来就是默认我写出来更多是表意方便后来的人知道这里明确用了 G1。-Djava.security.egdfile:/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 :8080ss会输出正在监听 8080 的进程号和进程名。接下来分两种情况处理如果是另一个 Tomcat 实例可能是旧进程没杀干净。用shutdown.sh优雅停止不行再用kill pid最后才用kill -9。如果是别的业务程序占用了端口那就不要硬抢改server.xml里的port换一个空闲端口但注意redirectPort也要配套调整。还有一个藏着很深的点Tomcat 的关闭端口8005也被占用时shutdown.sh可能连不上管理端口启动脚本会卡住或失败。所以排查端口占用时不要只看 80808005这个关闭口也要扫一眼。6.2 404请求路径和上下文根目录对不上Tomcat 能启动、8080 访问首页也正常但访问具体应用报 404这个问题的排查链路比较固定。第一步先看webapps目录里有什么ls -l webapps如果只有ROOT目录没有demo目录那就先确认demo.war是否真的被复制进来了以及unpackWARs是否开启。默认 Tomcat 会自动解压但如果你手动改过conf/server.xml里的unpackWARsfalsewar 包就不会展开访问路径可能没有对应的物理目录。第二步确认上下文路径。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 用javaxTomcat 10 用jakarta。如果是老项目最省事的方案是切回 Tomcat 9而不是去改几百个源文件的 import。只有新项目或明确要做迁移时才执行全局替换把javax.servlet批量替换成jakarta.servlet再重新编译打包。这个坑的教训是升级容器的优先级永远低于“项目能稳定运行”。没有强诉求别把生产环境的 Tomcat 从一个版本体系跨到另一个版本体系。7. 部署完成后的日常维护日志、备份和几个不起眼的小习惯7.1 日志怎么读以及怎么防止日志膨胀Tomcat 的日志分散在logs目录下不同文件记录的侧重点不同。我平时最关注的三个是catalina.outTomcat 的标准输出启动过程、线程问题、进程级错误都会记在这里。localhost.*.logwebapp 部署和上下文生命周期相关日志应用启动失败时优先看它。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的改动点。这样换机器、换人接手十分钟就能把环境对齐后面排查问题会轻松很多。这篇笔记里的命令和参数都来自实际项目希望能帮你少走我走过的弯路。