前面写过 Node.js、Python 和 Go 项目在 VPS 上的部署,Java 是最需要单独讲的一类 —— 因为它的资源模型和那三种都不一样。
Node、Python、Go 的程序,内存基本就是"你用了多少就是多少"。JVM 不是:它启动时就要按机器规格划走一大块堆,而且堆之外还有 Metaspace、线程栈、JIT 编译缓存这些隐性开销。在 1G 内存的机器上,Spring Boot 的默认配置跑不起来是常态,而不是意外。
这篇按 Ubuntu 24.04 / 26.04 走一遍 Spring Boot 4 的部署流程,重点放在内存怎么算、参数怎么给上。
Spring Boot 在 VPS 上翻车,九成是内存,不是 CPU。
| 场景 | 建议配置 | 说明 |
|---|---|---|
| 单体 Spring Boot,无其他服务 | 2 vCPU / 2 GB | 底线,1G 会很难受 |
| Spring Boot + 数据库同机 | 4 vCPU / 4 GB | 别把 MySQL 和 JVM 挤一起 |
| 多个 Spring Boot 服务 | 4 vCPU / 8 GB+ | 每个 JVM 都有固定开销 |
| 带 Elasticsearch / 大数据组件 | 8 GB+ | 那是另一套资源模型 |
为什么 1G 很难受,算一笔账:
- JVM 堆本身至少要 256-512 MB 才有意义
- 堆之外还要留 200-400 MB:Metaspace(类元数据,Spring 项目加载的类很多)、线程栈、JIT 代码缓存、直接内存
- 操作系统自己和 SSH 要 100-200 MB
- Nginx 再加几十 MB
加起来会发现 1G 基本没有余量,稍微一个并发高峰就被 OOM Killer 干掉。2G 是能舒服跑起来的起点。
具体按参数选配置可以对着 VPS 配置怎么选 过一遍。内存实在紧张,先看 低内存怎么用 ZRAM 和 Swap 优化 续命,但换机器才是正解。
Java 的 LTS 节奏是两年一版:17(2021)、21(2023)、25(2025 年 9 月)。截至 2026 年 9 月,JDK 25 是当前 LTS,支持到 2032 年 10 月。
sudo apt update
sudo apt install -y openjdk-25-jdk
java -version
好消息是不用去 Adoptium 下载 tar 包:Ubuntu 22.04、24.04、26.04 的官方源里都有 openjdk-25-jdk(当前是 25.0.4)。跟着系统更新走,比自己维护 JDK 包省事。
版本选择上有个坑要避开:截至 2026 年 9 月,Spring Boot 最新的 4.1.1 要求 Java 17 到 26 之间。JDK 27 已经发布了(2026 年 9 月),但不在 Spring Boot 4.1 的支持范围内 —— 别看到"最新版"就往上装。
所以:
- 生产环境用 JDK 25 LTS,稳,且完全在支持范围里
- 想尝鲜 JDK 26 可以,但没必要 —— 它是非 LTS,支持期只有 6 个月
- JDK 27 先别用,等 Spring Boot 跟上
版本状态可以在 Spring Boot 官方 system requirements 核对,那页会明确写出支持的 Java 区间。
Maven 或 Gradle 不需要在服务器上装,编译在本地或 CI 做(见下面"部署脚本")。
和 Go 一样,服务器上不需要工具链。但 Java 有个额外理由:构建过程比 Go 重得多,mvn package 在小机器上可能跑十分钟,还容易 OOM。
本地打包:
mvn clean package -DskipTests
# 产物
ls target/*.jar
-DskipTests 是故意的还是偷懒? 在本地开发循环里跳过没问题,但 CI 上必须跑测试。别把"本地跳测试"的习惯带成"发布流程也跳"。
Spring Boot 打包出来的是可执行 jar(fat jar / uber jar),注意它和普通 jar 的区别:
# 能直接跑(含内嵌 Tomcat 和全部依赖)
java -jar target/myapp-0.0.1.jar
# 检查有没有 Main-Class 和 Start-Class
unzip -p target/myapp-0.0.1.jar META-INF/MANIFEST.MF
看到 Start-Class 那一行,说明这是 Spring Boot 的可执行 jar。没有它的话说明 spring-boot-maven-plugin 没配上,跑起来会报"没有主清单属性"。
GraalVM 原生镜像是另一条路 —— 启动毫秒级、内存占用降到几十 MB,对小内存 VPS 很有吸引力。但要求 GraalVM 25 以上、编译时间长、反射和动态代理配置麻烦。小项目想省内存可以考虑,业务项目先别碰。
这是全文最需要认真看的一节。
JVM 有自己的一套内存推算逻辑。实测默认值:
MaxRAMPercentage = 25.0 # 最多用 25% 的内存做堆
InitialRAMPercentage = 1.5625 # 初始堆
MinRAMPercentage = 50.0
UseContainerSupport = true # 默认开启
看起来"25% 很保守",问题是这个百分比乘的是什么内存。在容器里它乘 cgroup 限制,在裸机上乘物理内存,两者不一致时算出来的堆大小可能完全不是你想要的 —— 而且堆只是 JVM 占用的一部分,按 25% 算完还得再加 Metaspace、线程栈、JIT 缓存那几百兆。
结论:在 VPS 上永远显式设堆大小,不要依赖百分比推算。
给一组可用的起点(2G 内存机器):
java \
-Xms512m -Xmx768m \
-XX:MaxMetaspaceSize=192m \
-XX:MaxDirectMemorySize=128m \
-Xss512k \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/srv/app/shared/dumps \
-jar app.jar
逐个说清楚:
-Xms 和 -Xmx 设成接近的值(这里是 512m / 768m)。差距大,JVM 会反复扩缩堆,带来不必要的 GC 和停顿。生产环境常见做法是直接设成相等。
-Xmx 要比机器内存小得多。 2G 机器给 768m 是合理的 —— 剩下 1.2G 要装 Metaspace、线程栈、JIT 代码缓存、Nginx、系统本身。很多人把 -Xmx 设成物理内存的一半以上,结果堆还没满,整机先 OOM 了。
-XX:MaxMetaspaceSize 一定要设。 Metaspace 默认是无上限的(只受物理内存限制)。Spring 项目加载的类多,动态代理又频繁生成类,Metaspace 有可能一路涨上去。设个上限,让它超了报错而不是拖垮整机。
-Xss512k 是每个线程的栈大小,默认通常是 1MB。Spring Boot 应用线程多(Tomcat 的工作线程 + 各种线程池),512k × 200 个线程就是 100MB 的差别。小内存机器上值得调,代价是递归太深的代码可能 StackOverflow。
-XX:+ExitOnOutOfMemoryError 是关键。不加这个,OOM 之后 JVM 可能进入半死不活的状态 —— 进程还在,但什么都不响应,systemd 的 Restart=always 也就不会触发。加上它,OOM 直接退出,systemd 立刻拉起来。
-XX:+HeapDumpOnOutOfMemoryError 让 OOM 时自动 dump 堆快照,事后能用 MAT 或 VisualVM 分析。注意 dump 文件很大(跟堆一样大),路径要放在有空间的分区,并且记得清理,否则磁盘会被吃满。
JVM 会按机器规格自动选。想确认当前用的是哪个:
java -Xlog:gc -jar app.jar
# 启动日志第一行会打印 Using G1 / Using Serial
- G1:多核、内存充足时的默认选择
- SerialGC:单核或小内存机器上更省开销,堆小的时候停顿也短。1-2 核的机器值得考虑显式指定
-XX:+UseSerialGC
别凭感觉选,用 -Xlog:gc 看实际停顿数据再决定。
/etc/systemd/system/myapp.service:
[Unit]
Description=Spring Boot App
After=network.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
ExecStart=/usr/bin/java \
-Xms512m -Xmx768m \
-XX:MaxMetaspaceSize=192m \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/srv/app/shared/dumps \
-Duser.timezone=Asia/Shanghai \
-Dspring.profiles.active=prod \
-jar /srv/app/current/app.jar
SuccessExitStatus=143
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
MemoryMax=1500M
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/app/shared
[Install]
WantedBy=multi-user.target
几个容易漏的:
SuccessExitStatus=143 —— 143 是 128+15,也就是被 SIGTERM 结束的退出码。不写这一行,正常停服务会被 systemd 记成"失败",systemctl status 一直显示 failed,看着像出事了。
MemoryMax=1500M 要大于 -Xmx 加堆外开销。 这里 768m 堆 + 约 500m 堆外 + 余量。设成跟 -Xmx 一样大,JVM 会因为堆外内存被 cgroup 杀掉。
-Duser.timezone=Asia/Shanghai —— 精简镜像没有 tzdata,JVM 默认取 UTC。不显式指定的话,日志时间戳和业务时间都会差 8 小时,排查问题时非常误导。
WorkingDirectory —— 用了相对路径读配置(config/application.yml 这种)就会依赖它。更稳的做法是把配置都放进 shared/.env 或者用绝对路径。
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
起不来先看 journalctl -u myapp -n 50 --no-pager,排查思路见 systemd 服务启动失败怎么办。
systemctl restart 会发 SIGTERM。默认情况下 Spring Boot 收到就立刻停,正在处理的请求全部中断。
Spring Boot 自带优雅关闭,加两行配置就行(application.yml):
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
加上之后,收到 SIGTERM 会先停止接受新请求,等在途请求处理完(最多 20 秒)再退出。
超时时间要小于 systemd 的 TimeoutStopSec(上面配的 30 秒),否则你还在优雅关闭,systemd 已经等不及发 SIGKILL 了 —— 那就白配了。
这两行是这个配置项存在以来性价比最高的两行,务必加上。
Spring Boot 默认监听 8080,Nginx 对外提供 80/443。
upstream springapp {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 20m;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
location / {
proxy_pass http://springapp;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
sudo nginx -t && sudo systemctl reload nginx
Spring Boot 这边记得配 server.forward-headers-strategy,否则应用的 request.getScheme() 永远是 http,生成的重定向链接会是 http 的:
server:
forward-headers-strategy: framework
不配这个,配合 Spring Security 的 HTTPS 强制跳转,会出现无限重定向 —— 和 HTTPS 跳转循环 是同一个成因。
HTTPS 用 certbot 加上去,或者用 Caddy 两行代替整段配置。
默认端口改成 80 或 443 是个常见的错误做法 —— 低于 1024 的端口需要特权,你会被迫用 root 跑 Java 进程。老老实实让 Nginx 在前面。
如果想用 Docker,Spring Boot 有个专门优化:分层 jar。它把 jar 拆成"依赖层"和"应用代码层",依赖不变时构建缓存能复用,重新打包只要几秒而不是重新下载全部依赖。
FROM eclipse-temurin:25-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM eclipse-temurin:25-jre
WORKDIR /app
RUN useradd -r -u 1001 appuser
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
USER appuser
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-jar", "app.jar"]
几个要点:
- 基础镜像用
jre而不是jdk—— 生产环境不需要编译器,能省 200MB 左右。eclipse-temurin是常见选择。 - 容器里可以用
-XX:MaxRAMPercentage=70—— 因为 cgroup 限制明确,百分比是准确的。但同时要设--memory限制,不然百分比乘的是宿主机内存。 USER appuser不能省 —— 用 root 跑 Java 进程,一个反序列化漏洞就是整台机器。- 别用
latest标签,固定具体版本。
资源限制怎么配见 VPS Docker Compose 生产环境怎么配,磁盘吃紧看 Docker 占满磁盘怎么办。
Spring Boot Actuator 提供现成的健康端点,配合 systemd 或 Docker 的健康检查很有用:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: never
show-details: never 别改。 打开之后健康端点会暴露数据库连接串、磁盘路径、组件版本这些信息,等于给攻击者做侦察。
用的话记得把 /actuator/ 限制在本地访问,别让公网能打到。
启动就 OOM,或者跑一会儿被内核杀掉
按顺序调:先确认 -Xmx 加堆外开销没有超过机器内存;再设 -XX:MaxMetaspaceSize;然后加 -XX:+ExitOnOutOfMemoryError 保证能被 systemd 拉起来。看 dmesg | grep -i oom 确认是不是内核杀的。
启动很慢(30 秒以上)
Spring Boot 冷启动本身就不快,类加载 + 依赖注入扫描都要时间。可以看启动日志里的耗时分布,通常是某个外部连接(数据库、Redis、第三方 API)在超时重试。
改配置要重新打包吗
不用。Spring Boot 的配置优先级里,环境变量和命令行参数高于 jar 里的 application.yml。所以常见做法是打包时只放默认值,实际配置通过 EnvironmentFile 注入:
SPRING_DATASOURCE_URL=jdbc:postgresql://127.0.0.1:5432/appdb
SPRING_PROFILES_ACTIVE=prod
Spring Boot 会自动把 SPRING_DATASOURCE_URL 映射成 spring.datasource.url。这样改配置只要改 env 文件 + 重启,不用重新构建。
内存一直涨,GC 回收不掉
先抓堆快照(jcmd <pid> GC.heap_dump 或者靠前面配的 OOM 自动 dump),用 MAT 分析。常见的几类:缓存没有上限、集合只增不减、连接没关、ThreadLocal 没清理。加 Swap 治不了泄漏,只是把崩溃时间推后。
日志文件把磁盘写满
Spring Boot 默认输出到控制台,被 systemd 收进 journal。要限制 journal 占用:
sudo journalctl --vacuum-size=500M
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
502 Bad Gateway
Nginx 连不上后端。先 systemctl status myapp,再看是不是监听在 localhost 而不是 127.0.0.1(IPv6/IPv4 解析差异)。完整排查见 502 / 504 Bad Gateway 怎么办。
端口被占用
sudo ss -lntp 'sport = :8080'。常见于上次的进程没退干净,见 VPS 端口被占用怎么办。
日志整体排查思路见 VPS 日志怎么看。
#!/usr/bin/env bash
set -euo pipefail
HOST=deploy@your-vps
RELEASE=$(date +%Y%m%d-%H%M%S)
TARGET=/srv/app/releases/$RELEASE
# 1. 本地打包
mvn clean package -DskipTests
JAR=$(ls target/*.jar | grep -v original | head -1)
# 2. 传输
ssh "$HOST" "mkdir -p $TARGET"
scp "$JAR" "$HOST:$TARGET/app.jar"
# 3. 切换并重启
ssh "$HOST" "ln -sfn $TARGET /srv/app/current && sudo systemctl restart myapp"
# 4. 清理旧版本,保留最近 5 个
ssh "$HOST" "ls -1dt /srv/app/releases/* | tail -n +6 | xargs -r rm -rf"
那个 grep -v original 别漏 —— spring-boot-maven-plugin 会同时产出 app.jar 和 app.jar.original,后者是没有任何依赖的原始 jar,传错了启动就报错找不到主类。
set -euo pipefail 必须加:打包失败但脚本继续跑,会把一个空目录或者旧 jar 切成 current,线上直接 502。
回滚就是把软链接指回上一个目录再重启,几秒钟的事。
Spring Boot 项目通常还要配 PostgreSQL、Redis。这两个都有专门的部署文章:数据库见 VPS 搭建 PostgreSQL 18,缓存见 VPS 搭建 Redis 8。
关键一点:数据库只监听 127.0.0.1。 同机部署不需要开公网,必须远程访问时走 SSH 隧道,做法见 VPS 数据库不要直接暴露公网。
数据库和 JVM 同机时,连接池要配小一点 —— HikariCP 默认最大 10 个连接,每个连接在数据库侧也占内存,几个服务加起来很容易把 max_connections 打满。
Java 服务对内存最敏感,CPU 反而次之(除非做大量计算)。按这个优先级选:
- 预算优先、跑单体应用,Racknerd 的年付机器够用,注意选 2G 以上的配置
- 需要随时升配、按小时计费,Vultr 更灵活,升配不用迁移
- 要大内存跑多个 JVM,Contabo 单价低,但磁盘 IO 一般
- 国内访问要求高,DMIT 的 CN2 GIA 线路稳,代价是贵
系统版本上没有特殊要求,Ubuntu 和 Debian 都行 —— 两者都直接提供 openjdk-25-jdk。区别和选择见 VPS 装什么系统。
- VPS 部署 Node.js 项目上线 — 同一台机器还要跑前端或 BFF
- VPS 部署 Go 项目 — 对照看静态二进制部署有多省事
- VPS 配置怎么选 — 按 JVM 内存需求定配置
- VPS Docker Compose 生产环境怎么配 — 容器路线的资源限制与回滚
- VPS 备份恢复演练怎么做 — jar 能回滚,数据库不行
