刚学Spring Boot那会儿,我第一次把项目用java -jar扔到Linux服务器上的时候,感觉特别神气,觉得"部署嘛,不过就是把包丢上去,跑起来就完事了"。直到后来在真实环境里被各种问题折腾了几回,才明白那双引号里藏着的坑远比想象中多。本地IDEA里跑得溜的代码,换到Linux上可能连启动都过不去。这篇文章就围绕"Linux环境,使用jar包部署"这件事,把从打包、上传、启动、守护到排错的全过程拆开来讲,适合第一次接触服务器部署的Java后端新人,也适合那些准备把demo级别的部署规范化成生产环境的老哥做参考。
我得先说个结论:在Linux上部署jar包,java -jar your-app.jar只是最后一行命令,决定成败的往往是这行命令之前的准备工作,以及这行命令之后的服务守护。下面按我自己的实操顺序,把整个链路从头到尾捋一遍。
1. jar包部署这件事,难点从来不在java -jar这一行
1.1 一个jar包的部署链路其实很长
上手之前,建议先把"部署"这个词拆开看。它不是一个动作,而是一串动作:
- 本地将项目打成可执行jar包(含依赖、配置文件、资源文件)
- 把jar包上传到Linux服务器
- 服务器上准备好对应版本的JDK运行环境
- 用正确的启动命令和JVM参数把应用拉起来
- 确认日志、端口、健康检查正常
- 让服务在意外宕机后能自动恢复,重启服务器后能自动拉起
- 后续版本迭代时,能平滑替换、能回退
很多人栽在前两个环节,以为mvn package跑完,jar包就"一定能跑"。实际上,jar包打完之后,先要在本地命令行里试一下能不能用java -jar启动,再上传。这一步能筛掉大量低级问题,尤其是Spring Boot这种内置Tomcat的应用,本地能跑通,服务器上大概率也就跑通了。
1.2 Linux环境差异:发行版、CPU架构、JDK版本要一起看
"Linux环境"这四个字,范围其实很大。我见过有人把CentOS 7的经验套到Ubuntu 22.04上,结果开机的进程守护方式都不一样;也见过有人在x86服务器上下载了ARM版的JDK,启动直接报Cannot execute binary file。部署之前,先确认三件事:
| 项目 | 常见选项 | 需要注意的点 |
|---|---|---|
| 发行版 | CentOS / Ubuntu / Debian / 国产系统(如统信、麒麟) | 包管理器不同(yum/apt),systemd用法基本一致,但个别细节有差异 |
| CPU架构 | x86_64 / arm64(含鲲鹏、飞腾等国产芯片) | JDK必须下载匹配架构的版本,用uname -m确认 |
| JDK | OpenJDK 8 / 11 / 17 / 21 | 编译时版本和运行时版本必须匹配(或向后兼容兼容性 |
尤其是JDK版本,这是新手踩得最密集的坑。项目用Java 11编译的,服务器上只装了Java 8,启动时给你报一个UnsupportedClassVersionError,不仔细看版本号你根本反应不过来。后面我会专门用一节来写这个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先把环境收拾利索:JDK、目录、上传方式
2.1 JDK选型:部署环境用OpenJDK就够了,但别只看java -version
先说结论:除非你们的商业授权策略有特殊要求,否则生产环境用OpenJDK完全够。下载方式有两种,看你的管理习惯:
- 用发行版自带的包管理器装:
yum install -y java-1.8.0-openjdk-devel或apt install openjdk-17-jdk。好处是安装简单、系统自动管理路径,坏处是版本可能不是最新的,而且不同发行版里JDK版本的命名方式不一样。 - 手动下载tar.gz解压:去JDK发布页下载对应架构的包,放在
/usr/local/java下,设置JAVA_HOME和PATH。好处是版本可控,多版本切换方便,坏处是需要自己维护环境变量。
这里要特别提醒一点:装JDK时尽量选带-devel或-jdk的完整包,而不是仅仅jre。很多框架在运行期会用到tools.jar或javac,纯JRE环境会偶尔冒出奇怪报错。我曾在CentOS上用java-1.8.0-openjdk-headless跑一个Spring Boot应用,功能倒是正常,直到有一次热加载机制触发,直接报classpath里找不到编译器,排查了半天才发现是JDK没装全。
2.2 目录规划:别把jar包丢在/tmp里
很多第一次部署的同学,图省事把jar包直接传到/tmp或者/root底下就跑。/tmp目录在部分系统中有自动清理机制,重启后文件可能直接消失;/root虽然不会消失,但管理混乱,日志和jar混在一起,后面排错非常难受。
我自己习惯的目录结构是这样:
code复制/opt/app/
├── myapp.jar # 当前运行版本
├── myapp.jar.bak # 上一次的版本,回滚用
└── logs/
├── app.log # 业务/框架日志
├── gc.log # JVM GC日志
└── nohup.out # 启动过程标准输出
之所以放在/opt下,是因为/opt在Linux FHS标准里专门用于存放第三方应用程序,权限隔离清晰,运维也认。上传文件我用scp或rsync,有图形界面的话用Xftp或宝塔面板也行,但命令行方式更通用:
bash复制scp target/myapp.jar user@your-server:/opt/app/
如果你对断点续传有要求,用rsync -avP --partial更靠谱,大文件传到一半断了能续传,不用重来。
2.3 启动前快速检查:端口通不通、依赖连得上吗
环境准备好之后,别急着启动。动手前先用几条命令做一轮快速体检:
- 确认JDK就位:
java -version和javac -version都看一遍,避免只有JRE没有JDK。 - 确认端口没被占:
lsof -i:8080或ss -tlnp | grep 8080,如果你要启动的应用固定用8080,而服务器上已经有一个Java进程占着了,启动就会报Address already in use。 - 确认依赖服务可用:应用要连MySQL、Redis的话,先用
telnet mysql-host 3306或nc -vz mysql-host 3306测一下网络通不通。很多启动失败根本不是应用的问题,是数据库地址写错或者防火墙没放行。
这一步花不了两分钟,但能帮你把"应用启动报错"和"环境本身有问题"这两类问题隔离开。
3. 启动命令:命令短,但每一段都值得搞清楚
3.1 为什么很少有人直接裸用java -jar
在终端里直接敲java -jar myapp.jar,应用会在前台运行,终端一关进程就没了。你ssh一断开,服务就跟着断了。所以实际部署要用后台方式启动。最朴素的方式是nohup:
bash复制nohup java -Xms256m -Xmx1024m -Xss512k \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Asia/Shanghai \
-jar /opt/app/myapp.jar \
--spring.profiles.active=prod \
> /dev/null 2>&1 &
这条命令看着长,其实每一段都有意义。nohup的意思是no hang up,让进程忽略挂断信号,即使你断开ssh它也不会被杀掉;结尾的&表示放入后台执行。> /dev/null 2>&1则是把标准输出和错误输出都做重定向。
3.2 JVM参数和Spring参数怎么区分
新手最容易犯的错,是把应用参数和JVM参数混在一起写。注意看一下上面命令里的区别:
-Xms、-Xmx、-Xss这类是JVM参数,必须紧跟在java后面;这些参数控制堆内存大小、栈大小、垃圾回收行为。-D开头的也是JVM系统属性,常用来设置编码、时区。--spring.profiles.active=prod这类是Spring Boot应用参数,放在-jar之后,传递给的是Spring容器(实际上是通过命令行参数解析),用来激活配置环境。
如果你把--spring.profiles.active=prod写在java后面、jar包前面,启动时会报错:Unrecognized option: --spring。而你如果忘了内存参数,默认情况下JVM堆大小是基于物理内存自动调整的,在服务器上可能直接占掉物理内存的1/4,多个应用跑在一起很容易把内存吃满。所以生产环境一定要显式指定-Xms和-Xmx,既保证性能,也保证可预期。
举一个我自己的典型配置,一个中等流量的Spring Boot接口服务,2核4G的机器:
bash复制-Xms512m -Xmx1024m -Xss512k -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
然后根据业务峰值再做微调。新手可以先按这个起步,监控到GC频繁或OOM时再往上加。另外建议加上GC日志参数:
bash复制-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/app/logs/
这样一旦OOM,JVM会自己把堆转储文件dump下来,后面排查内存泄漏就有第一手材料。
3.3 日志输出:>/dev/null 2>&1不是让你丢弃日志
很多教程喜欢写>/dev/null 2>&1,新手误以为日志不重要,其实这是为了不让控制台输出刷爆终端。日志应该由应用框架负责写入文件。Spring Boot默认集成Logback,只要在application.yml里配置了日志路径,比如logging.file.name=/opt/app/logs/app.log,日志就会自动写文件。
如果你没有配置框架日志,想靠nohup.out存输出,那命令要改成:
bash复制nohup java ... -jar /opt/app/myapp.jar > /opt/app/logs/nohup.out 2>&1 &
嗯,把/dev/null替换成实际文件路径。但/dev/null有一个好处是避免生成巨大的nohup.out文件占用磁盘,所以我的建议是:框架日志和nohup输出至少留一个。如果你的应用在启动阶段打印的信息没有进入Logback(比如某些第三方库直接往stdout打),那这些输出只能从nohup.out里看到。用tail -f /opt/app/logs/app.log实时看日志,这是部署完第一件要做的事。
4. 让服务自己活着:systemd守护与开机自启
4.1 纯nohup方式的两个硬伤
nohup java -jar ... &启动的应用,进程活着但没人管它。一旦进程崩溃或者机器重启,应用不会自己回来。有些同学用cron写个每分钟检查的脚本,也能用,但总觉得不够优雅。真正规范的做法是用systemd。几乎所有现代Linux发行版都内置了systemd,它本身就是用来管理服务生命周期的:崩溃自动拉起、开机自启、日志统一收集、状态可视化。
我第一次用systemd管理jar包的时候,最大的体会就一个字:稳。重启服务器,服务自动跟着起来,再也不用担心人不在旁边服务挂了无人恢复。配置方式也不复杂,关键是理解service文件里每个字段的意思。
4.2 一个可用的service文件,逐行拆解
在/etc/systemd/system/myapp.service里写:
ini复制[Unit]
Description=My Spring Boot Application
After=network.target
[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/myapp.jar --spring.profiles.active=prod
ExecStop=/bin/kill -s TERM $MAINPID
Restart=on-failure
RestartSec=5
Environment="JAVA_HOME=/usr/local/java"
Environment="SPRING_PROFILES_ACTIVE=prod"
[Install]
WantedBy=multi-user.target
几个关键点分别说:
Type=simple:表示ExecStart启动的进程就是主进程,systemd会直接跟踪它。大多数Java应用用这个就够了,不需要复杂的fork逻辑。User=appuser:指定运行用户。绝对不要用root跑应用,权限太大会让安全风险成倍增加。专门建一个低权限用户来跑服务,这是生产环境的基本卫生习惯。ExecStart:必须写完整路径。java命令在systemd的PATH里可能找得到,也可能找不到,直接写/usr/bin/java最稳妥,用which java查一下真实路径。Restart=on-failure:服务异常退出时自动拉起。系统崩溃重启后配合WantedBy=multi-user.target,服务也会自动启动。RestartSec=5:重启前的等待秒数,防止进程频繁崩溃导致无限重启打满CPU。
写好之后执行:
bash复制systemctl daemon-reload
systemctl enable myapp
systemctl start myapp
systemctl status myapp
enable是设开机自启,只会做一次。之后每次部署新版本,只需要systemctl restart myapp。看日志也有专门的方式:journalctl -u myapp -f,systemd会把应用输出自动纳入journal,排查问题比翻nohup.out顺手很多。
4.3 利用软链接实现快速切换与回滚
jar包版本的更新和回滚,有一个简单又稳的做法:把实际运行的jar包通过软链接指过去。
code复制/opt/app/
├── releases/
│ ├── myapp-1.0.0.jar
│ ├── myapp-1.0.1.jar
│ └── myapp-1.0.2.jar
└── myapp.jar -> releases/myapp-1.0.2.jar
新版本上传后,先改软链接指向新jar,再systemctl restart myapp;发现新版本有问题,一条命令改回旧版本再重启即可:
bash复制ln -sfn /opt/app/releases/myapp-1.0.1.jar /opt/app/myapp.jar
systemctl restart myapp
这个方案看着土,但在没有CI/CD流程的团队里非常实用,回滚代价几乎为零。我在中间件团队里给上百个Java服务都用这套目录套路,运维接手零学习成本。
5. 实战踩坑记录:五类最常遇见的启动失败
这一节是我最想写的。**直接给答案的教程到处都是,但能让你真正成长的,是把错误日志背后的逻辑链走完。**下面这几个坑,是我在帮别人排查部署问题时几乎每周都会遇到的。
5.1 端口占用:Address already in use的完整排查链
启动报错:
code复制Web server failed to start. Port 8080 was already in use.
这个报错很直白,但具体是谁占了端口,新手就容易慌了。排查链路是这样的:
bash复制# 查看8080端口被哪个进程占用
ss -tlnp | grep 8080
# 或者
lsof -i:8080
如果有进程占用,再判断这个进程能不能杀:
bash复制ps -ef | grep [j]ava
很常见的情况是:上一个部署遗留的Java进程没被杀掉。此时先停掉systemd服务再确认进程清理:systemctl stop myapp,然后kill掉残留进程。如果是别的应用在占用,就需要改当前应用的端口配置:--server.port=8081。不要盲目杀不知道归属的进程,尤其是服务器上可能跑着别人的应用。
5.2 JDK版本不匹配:UnsupportedClassVersionError的犯错现场
日志长这样:
code复制Exception in thread "main" java.lang.UnsupportedClassVersionError:
org/springframework/boot/loader/Launcher has been compiled by a more recent version
of the Java Runtime (class file version 55.0), this version of the Java Runtime
only recognizes class file versions up to 52.0
这条信息的"编译版本号"和"运行版本号"是核心。55.0对应Java 11,52.0对应Java 8。也就是说,你本地用Java 11编译的包,被丢到了只有Java 8的服务器上。编译时版本和运行时版本必须匹配(或者运行时版本更高)。
排查方式:
bash复制# 服务器上当前用的版本
java -version
# jar包编译时使用的版本,用javap查看
javap -verbose myapp.jar | grep "major version"
解决办法,要么升级服务器JDK,要么降低编译版本重新打包。我的建议是:项目一开始就约定好统一的Java版本,用Maven的<java.version>或Gradle的sourceCompatibility锁死,部署文档里写明版本号。不要让"能跑"建立在偶然匹配上。
5.3 中文乱码:从编译到启动一路追查
日志和接口返回里的中文变成???,这种现象在Linux服务器上非常常见。原因可能出现在编译期、启动参数、服务器locale三个层面。
- 编译期:Maven编译时源码编码没指定,Windows本地和Linux服务器的默认编码不一致,包里的class文件存了错误编码的字符串。在
pom.xml里显式设置project.build.sourceEncoding为UTF-8。 - 启动参数:JVM默认的文件编码可能跟系统locale走,Linux环境变量
LANG经常是POSIX,这时需要加-Dfile.encoding=UTF-8。 - 服务器locale:执行
locale命令看LANG设置,如果系统本身不支持中文,应用内打印没问题,但涉及写文件名、读日志就可能出问题。
排查的时候别猜,直接看日志文件编码:file /opt/app/logs/app.log。如果日志文件是UTF-8,内容是乱码,那就是应用内部编码转换问题;如果文件本身就是ASCII,那就是启动参数问题。
5.4 内存不足:Java heap space和51%的误导
日志里出现:
code复制java.lang.OutOfMemoryError: Java heap space
排查时先看服务器物理内存多大:free -h。很多人看到used只有51%,觉得物理内存够,其实不够的地方是JVM堆。堆内存由-Xmx限制,应用在运行期创建了大量对象,堆满了并且GC回收不掉,就会抛OOM。
另一个容易忽略的场景是元空间溢出:java.lang.OutOfMemoryError: Metaspace。应用加载了大量类(尤其在热部署/动态代理场景),而-XX:MaxMetaspaceSize设置过小或没设。默认情况下元空间上限是物理内存大小,应用会一直增长直到把内存耗尽。
稳妥的做法是:先按物理内存的一半设置-Xmx,留一半给堆外内存、线程栈和系统缓存;再加-XX:+HeapDumpOnOutOfMemoryError,OOM时自动生成java_pid*.hprofdump文件,后面用MAT分析是哪块对象没释放。
5.5 连不上数据库:本地地址和服务器地址不是一回事
启动时报错:
code复制Failed to configure a DataSource: 'url' attribute is not specified
或者连接超时。这类问题的普遍原因是配置文件的active profile没指定正确,或者服务器上localhost指向的库和你本地指向的不一样。本地开发用localhost:3306,部署到服务器上,Spring Boot默认还是读localhost,但服务器上根本没有MySQL。另外云厂商RDS实例只允许内网访问,你即使知道IP也连不通。
解决方案:
- 用
--spring.profiles.active=prod激活生产环境的配置文件,生产环境里把数据库地址写成云数据库的内网IP。 - 启动前用
nc -vz ${db_host} 3306连通性测试,别等应用启动失败再回头排查。 - 如果生产库不在内网,需要在云控制台的安全组或数据库白名单里把服务器的公网IP放进去,这一步很容易忘。
6. 一点后续选择:这套传统部署方式的边界在哪里
文章写到这里,核心的jar包部署链路已经完整了。最后说点工具选择上的体会。现在Docker、Kubernetes已经非常普及,有人会觉得用jar包直接部署很"古老"。我的看法是:**工具是给场景服务的。**如果你的团队规模不大、服务器就三五台、部署频率不高,那套systemd加软链接的方案远比你想象中能扛事——它不依赖额外编排层,排障链路短,任何一个熟练工程师都能直接在服务器上查。而如果你的服务数量上来了、弹性扩缩容是硬需求,容器化自然是要走的下一步。
即使将来切换到容器,这篇文章里讲的环境匹配、启动参数设计、端口与依赖排查、回滚思路,绝大多数知识依然能用。把基础打好,工具怎么换都不慌。
最后一个小建议,来自我的实际习惯:**每次部署前,把当前版本号、启动命令、期望端口、依赖地址写在一个deploy.md里,哪怕只有十行。**下次出了故障,你翻文档的速度决定了恢复时间,而恢复时间在运维场景里就是钱和用户体验。
