从代码到进程:后端工程怎样成为可运行服务
./mvnw clean verify 成功结束后,工作区才会得到经过当前构建规则和测试处理的制品;java -jar 或容器入口再把这份制品交给新的进程。Java 启动器和框架读取运行参数与配置,进程申请线程、内存、文件描述符和 socket。初始化完成、端口开始监听并进入就绪状态后,这个实例才适合接收流量。
这条转换链可以压缩为:
工程输入
├─ 源码、资源、测试、生成规则
├─ 依赖声明、仓库与构建插件
└─ JDK、构建工具及其配置
│
▼
构建进程 ── 解析 → 编译 → 测试 → 打包 → 校验
│
▼
制品 ── class / 普通 JAR / 可执行 JAR / WAR / 容器镜像
│ + JVM 参数、应用参数、配置、身份、资源限制
▼
运行进程 ── JVM → 应用入口 → Spring 上下文 → Web Server
│
├─ PID、用户、线程、内存、文件描述符、标准流
└─ bind → listen → liveness → readiness → 业务请求
│
▼
终止协调 ── 摘流 ∥ SIGTERM → 有界停止 → 退出状态每个箭头都会产生新的对象和新的失败方式。Git 提交、BUILD SUCCESS、JAR 文件、PID、监听端口、健康检查和一次 HTTP 200 分别只能证明其中一段;它们不能互相替代。
贯穿示例锁定 Java 25、Maven 3.9.12 与 Spring Boot 4.1.1 作为可重复验证基线。Spring Boot 的最低与最高兼容 Java 版本会随发行版变化,实际工程应先检查当前稳定版系统要求,再确定 JDK、框架、构建插件、Agent 和驱动的共同支持区间。示例提供完整工程压缩包,解压后的 README.md 包含运行入口和文件说明。
一份工程由哪些输入组成
“代码”在日常交流里常指整个仓库,但构建系统看到的是一组有类型、有作用域、有来源的输入。以示例工程为例:
code-to-process-lab/
├── pom.xml # 项目、依赖、插件和制品声明
├── mvnw # POSIX Maven Wrapper 入口
├── mvnw.cmd # Windows Maven Wrapper 入口
├── .mvn/
│ └── wrapper/
│ ├── maven-wrapper.jar # Wrapper 引导程序
│ └── maven-wrapper.properties # Maven 版本、下载地址与摘要
├── src/
│ ├── main/
│ │ ├── java/dev/example/process/
│ │ │ ├── CodeToProcessApplication.java # 应用 main 入口
│ │ │ ├── HelloController.java # 示例 HTTP 处理器
│ │ │ └── ServiceLifecycleReporter.java # 启动与停止事件
│ │ └── resources/
│ │ └── application.properties # 随制品携带的默认配置
│ └── test/
│ └── java/dev/example/process/
│ └── HelloControllerTest.java # 测试源码
├── Dockerfile # 镜像构建与容器入口
├── compose.yaml # 本地运行输入
├── deploy/systemd/ # Linux 主机托管示例
│ ├── code-to-process.service
│ └── code-to-process.env.example
├── target/ # Maven 派生输出,不纳入源码
└── README.mdMaven 标准目录约定让构建插件默认知道去哪里找主源码、测试和资源。目录可以改,但修改意味着要在 POM 中明确告诉插件新的位置。约定的价值不在目录名整齐,而在开发机、IDE 和流水线能够使用同一套输入规则。
主源码、测试和资源进入不同路径
src/main/java 中的源文件参与主编译,生成的 .class 默认进入 target/classes。src/test/java 参与测试编译,生成物进入 target/test-classes;测试代码可以看到主代码和测试依赖,却不会被正常打进应用 JAR。
Maven 会把 src/main/resources 中的文件复制或过滤到 target/classes,随后与主 class 一起进入 JAR。Spring Boot 默认配置、日志配置、模板、静态资源、证书或迁移脚本都可能从这里进入运行时 classpath。真实生产密码一旦放进该目录,就会被复制到每份制品;环境差异与 Secret 应来自明确的外部配置源。
src/main/java/**/*.java ── compiler ──────────────┐
├─ target/classes ──┐
src/main/resources/** ── resource processing ─────┘ │
├─ 主制品
直接与传递运行依赖 ── dependency resolution ──────────────────────────┘
src/test/java/**/*.java ── test compiler ── target/test-classes ─┐
主 class + 测试依赖 ─────────────────────────────────────────────┴─ test process生成源码、OpenAPI/Protobuf 代码、注解处理器、数据库迁移或前端构建产物也可能加入这张图。构建脚本和插件声明的 input/output 才能给出完整输入集合,单看 src/main/java 或 Git 目录容易漏掉生成阶段。
pom.xml 同时描述项目、依赖与构建动作
示例 POM 中最重要的关系可以拆成下面这棵树:
project
├─ coordinates
│ └─ dev.example:code-to-process-lab:1.0.0
├─ parent
│ └─ org.springframework.boot:spring-boot-starter-parent:4.1.1
│ ├─ dependency management:一组经过协调的依赖版本
│ └─ plugin management / defaults:编译、测试、打包等默认值
├─ properties
│ └─ java.version = 25
├─ dependencies
│ ├─ spring-boot-starter-webmvc # 编译与运行
│ ├─ spring-boot-starter-actuator # 编译与运行
│ └─ spring-boot-starter-test [scope=test] # 只进入测试路径
└─ build/plugins
└─ spring-boot-maven-plugin # 构建动作,不是应用库依赖、插件、父 POM 与 starter 处在不同层次:
| POM 对象 | 直接作用 |
|---|---|
| dependency | 应用在编译、测试或运行时使用的库。 |
| build plugin | Maven 构建进程执行的程序,例如调用编译器、运行测试、创建 JAR 或重新打包。 |
| parent POM / BOM | 统一管理版本或默认构建配置;出现在 parent 中不代表成为运行依赖。 |
| starter | 一组便利依赖及其约定,不是单独的运行容器。 |
Spring Boot 的 Web MVC starter 会引入 Spring MVC、内嵌 Tomcat 等传递依赖。POM 只直接写了一个 starter,实际运行 classpath 却包含一棵依赖图;Maven 依赖机制会根据 scope、传递关系、版本管理和冲突调解得到最终选择。
依赖坐标只是解析起点
Maven 依赖通常由 groupId:artifactId:version 标识,但坐标还不足以决定最终 classpath。解析过程还要确定 POM 与二进制来自哪个 repository 或 mirror,继续展开它声明的传递依赖,调解同一组件的候选版本,并按 scope 决定它进入编译、测试、运行还是打包路径。
常见 scope 的作用可以先用下面的操作性模型理解:
| Maven scope | 主编译可见 | 测试可见 | 一般运行可见 | 对应用打包的典型影响 |
|---|---|---|---|---|
compile(默认) | 是 | 是 | 是 | Spring Boot 可执行 JAR通常会收集。 |
runtime | 否 | 是 | 是 | 运行需要但源码编译不直接使用,例如某些驱动实现。 |
provided | 是 | 是 | 由外部环境提供 | 通常不应按普通运行依赖捆入;外部 Servlet 容器 API 是典型场景。 |
test | 否 | 是 | 否 | 只参与测试,不进入正常应用运行。 |
本机 Maven 缓存只能说明某个 JAR 已经下载。它是否进入运行 classpath、运行依赖是否齐全,要查看解析后的依赖树和最终制品。
仓库配置同样属于有效输入。用户级或全局 settings.xml 中的 mirror、proxy、profile、server credentials,企业仓库的代理内容,以及本地 ~/.m2/repository 缓存,都可能让同一份 POM 得到不同结果。凭据可以决定是否有权下载,但不应写进 POM、命令行或构建日志。
Wrapper 固定构建工具,不固定整个世界
项目内 mvnw/mvnw.cmd 会读取 .mvn/wrapper/maven-wrapper.properties,下载并调用声明的 Maven 版本。Apache Maven Wrapper因此让开发机和 CI 不必各自猜测 Maven 版本。示例还记录了 Maven 3.9.12 发行包的 SHA-256,下载内容不匹配时 Wrapper 会拒绝执行。
Wrapper 仍然不能单独保证可重复构建。它不会替项目选择 JDK;外部 repository 的内容、可变版本或 SNAPSHOT 仍可能变化;未固定的插件、基础镜像标签和操作系统包也会漂移。环境变量、系统属性、时区、locale、生成时间乃至构建脚本读取的网络、Git 状态和宿主文件,都可能进入结果。
同一 Git commit 固定了仓库中受版本控制的字节,制品还受 JDK、构建工具、解析后的依赖与插件、仓库来源和构建参数影响。测试结果与产物摘要把这些输入最终落到可比较的输出上。
Maven 与 Gradle 承担相同问题的不同表达
Java 工程也常使用 Gradle。两者的 DSL 和执行模型不同,但都必须把工程声明转成可执行的依赖图和任务图:
| 工程职责 | Maven | Gradle |
|---|---|---|
| 项目与子项目 | 根/模块 pom.xml | settings.gradle(.kts) 与各项目 build.gradle(.kts) |
| 固定工具版本 | mvnw、.mvn/wrapper | gradlew、gradle/wrapper |
| 外部依赖 | <dependencies>、scope | dependencies {}、configuration |
| 版本协调 | dependency management、BOM | platform、constraints、version catalog、locking |
| 工作组织 | lifecycle phase 绑定 plugin goal | initialization、configuration、execution;task graph |
| 测试与构建 | test、package、verify | test、build |
| Spring Boot 可执行 JAR | spring-boot:repackage 绑定到构建 | bootJar task |
Gradle Wrapper同样是推荐入口;Gradle 构建生命周期先识别项目、配置模型并形成任务图,再执行被选择的任务。不能把 Maven phase 名原样套到 Gradle,也不能因为 IDE 按下同一个“Build”按钮,就认为底层行为完全相同。
模块、JAR 与服务分别承担不同划分
一个 Maven reactor 可以包含多个 module,一个 module 可以生成主 JAR、源码 JAR、测试报告等多个输出;多个 module也可以最终被一个 Spring Boot应用聚合为一个可执行 JAR。反过来,一个源码仓库也可以构建多个可独立部署的服务。
源码 module 数量 ≠ 制品数量 ≠ 部署单元数量 ≠ 进程/副本数量模块结构回答构建和代码依赖怎样组织;单体、模块化单体与微服务还要看独立部署、远程调用、数据写入权和运行责任,见单体、模块化单体与微服务。
构建怎样得到可以交付的制品
Maven 执行 clean verify 时,先读取 effective POM,解析项目、插件和依赖,再按 lifecycle phase 的顺序调用绑定的 plugin goal。Maven 构建生命周期定义了 clean、default 和 site 三套内建生命周期;指定靠后的 phase 时,前置 phase 会依次执行。
示例实际使用的主链为:
clean
└─ 删除 target 中的旧派生输出
default lifecycle
validate
→ process-resources
→ compile
→ process-test-resources
→ test-compile
→ test
→ package
→ verify
绑定的主要 goal
resources:resources
→ compiler:compile
→ resources:testResources
→ compiler:testCompile
→ surefire:test
→ jar:jar
→ spring-boot:repackagephase 是生命周期里的阶段名称,goal 是某个插件可以执行的具体动作。mvn package 会执行到 package 及此前阶段;mvn spring-boot:repackage 只直接调用一个插件 goal,而且它需要已有的普通 JAR。把二者混为一谈,常会得到“重打包找不到源 JAR”或“以为测试没有执行”的误判。
编译发生在确定的 classpath 上
javac 读取 Java 源码以及编译 classpath 上的类型,进行语法与类型检查并生成 class 文件。示例的 <java.version>25</java.version> 经 Spring Boot父 POM配置给编译插件,实际日志显示:
Compiling 3 source files with javac [debug parameters release 25] to target/classes
Compiling 1 source file with javac [debug parameters release 25] to target/test-classes--release 25 同时按照 Java 25语言规则、公开 API和 class 目标版本编译。目标 JVM版本更低时,运行会在业务代码执行前因 class 版本不兼容而失败;只改 -source 而仍调用更高版本 API也不能得到真正兼容的制品。完整选型与迁移边界见 Java 版本基线。
编译成功把当前源码与编译输入转换成了 class。测试断言、资源复制、运行依赖、Manifest 入口、目标环境配置、端口和外部连接属于后续阶段,需要各自的检查结果。
注解处理器和代码生成器还可能在编译过程中产生源文件或 class。IDE 若自动启用了处理器,而命令行构建未声明对应插件或依赖,就会出现“IDE 能运行、流水线不能编译”。正确修复是把生成规则纳入工程定义,而不是把 IDE输出目录提交进仓库。
测试约束构建结果,运行事实继续向后验证
示例测试直接实例化 HelloController,验证配置值、状态和当前进程标识。mvn verify 的真实结果为:
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0这里至少要同时确认“测试数为 1”和“零失败”。如果测试命名不符合发现规则、引擎没有加载或参数跳过了测试,BUILD SUCCESS 也可能没有覆盖预期用例。
常见的两个选项含义也不同:
| 参数 | 常见行为 | 仍未得到的证据 |
|---|---|---|
-DskipTests | 跳过测试执行,但仍编译测试源码。 | 测试是否通过。 |
-Dmaven.test.skip=true | 让部分测试相关插件同时跳过测试编译和执行。 | 测试源码能否编译、测试是否通过。 |
跳过测试不会修复失败。确实需要跳过时,发布流程应记录授权人以及由哪一层验证补位。测试通过说明所选测试在当前构建环境和输入下满足断言;监听、就绪和外部依赖继续由运行阶段验证。
用 Maven Wrapper 完成一次可复现构建
下面的命令以 Ubuntu 22.04/24.04为基线;执行身份为普通用户,已安装 JDK 25、unzip 和 curl,首次执行允许访问 Maven Central或组织批准的 mirror。Windows 可以使用同一工程,把 ./mvnw 换成 .\mvnw.cmd;后续 Linux进程命令不在 PowerShell 中照抄。
先下载示例压缩包,在下载目录执行:
set -eu
LAB_DIR="$PWD/code-to-process-lab"
unzip -q code-to-process-lab.zip
cd "$LAB_DIR"
java -version
./mvnw --version
./mvnw -B -ntp clean verify预期 java -version 的主版本为 25,Wrapper 显示 Maven 3.9.12;构建日志中应依次出现资源处理、主编译、测试编译、1 个测试、JAR 和 Spring Boot repackage,最后退出码为 0。JDK主版本不符时先回到版本基线;Wrapper下载失败时检查 DNS、代理、TLS、mirror和仓库凭据,不要把下载失败误判成源码编译错误。
如果终端出现 Jansi 调用受限 native access 的 warning,但命令仍显示 Maven 版本并返回 0,这通常是 JDK 对构建工具内部库的兼容提醒。Wrapper 摘要校验失败会有另一组错误。应使用 Maven 与 JDK 的受支持组合并关注上游修复,避免用 2>/dev/null 隐藏全部错误。
检查测试报告和生成文件:
grep -R "Tests run:" target/surefire-reports
find target -maxdepth 2 -type f -printf '%P\n' | sort预期能看到 Tests run: 1, Failures: 0, Errors: 0, Skipped: 0,以及:
code-to-process-lab.jar
code-to-process-lab.jar.original
surefire-reports/...若报告不存在,先确认测试是否被发现、是否使用了跳过参数以及 Surefire provider 是否加载;不要只检查 target 中有 JAR就宣布验证通过。
普通 JAR 与 Spring Boot 可执行 JAR 使用不同布局
JAR本质上是基于 ZIP的归档,可容纳 class、资源和 Manifest。JDK jar 工具可以创建与检查归档,但一个普通 JAR不会自动收集 Maven依赖,也不会自动得到启动入口。
示例在 package 阶段先生成普通 JAR;Spring Boot Maven 插件随后执行 repackage,用可执行 JAR 替换主产物,并把原始文件重命名为 .jar.original。这个过程由插件提供,Maven 和 JAR 格式本身并未规定。Spring Boot Maven 插件文档记录了默认行为。
检查两份 Manifest:
unzip -p target/code-to-process-lab.jar META-INF/MANIFEST.MF \
| grep -E '^(Main-Class|Start-Class|Spring-Boot-|Java-Version)'
unzip -p target/code-to-process-lab.jar.original META-INF/MANIFEST.MF \
| grep -E '^(Main-Class|Start-Class)' || true可执行 JAR应包含类似结果:
Java-Version: 25
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: dev.example.process.CodeToProcessApplication
Spring-Boot-Version: 4.1.1
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/原始普通 JAR没有这两个入口字段。这里的两个 class 也承担不同职责:
java -jar code-to-process-lab.jar
└─ Manifest Main-Class
└─ org.springframework.boot.loader.launch.JarLauncher
├─ 从 BOOT-INF/classes 加载应用 class 与资源
├─ 从 BOOT-INF/lib 加载嵌套依赖 JAR
└─ 调用 Start-Class
└─ dev.example.process.CodeToProcessApplication.main(...)Java标准 classpath不能直接按普通 JAR方式加载归档内部再嵌套的 JAR;Spring Boot loader为这种布局提供启动支持。它与另一种“把依赖 class 全部展开并合并”的 uber/shaded JAR不同。Spring Boot executable JAR规范给出了完整目录与 loader约束。
检查关键条目:
jar --list --file target/code-to-process-lab.jar \
| grep -E '^(BOOT-INF/classes/dev/example/process/|BOOT-INF/lib/spring-boot-|META-INF/MANIFEST.MF)' \
| head -n 20预期应用 class 位于 BOOT-INF/classes/dev/example/process/,依赖 JAR位于 BOOT-INF/lib/。若只有应用 class而没有运行依赖,或者 Manifest的 loader/应用入口不正确,JAR可能存在但不能按预期启动。
直接运行普通 JAR可以形成清晰的反例:
set +e
java -jar target/code-to-process-lab.jar.original
PLAIN_JAR_RC=$?
set -e
printf 'plain_jar_exit=%s\n' "$PLAIN_JAR_RC"预期出现 no main manifest attribute 一类错误并返回非零状态。该结果只说明这份普通 JAR没有 java -jar 所需入口;它仍可作为其他程序的 classpath依赖,不能据此说文件损坏。
制品身份需要内容证据与来源证据
构建完成后记录摘要与大小:
ARTIFACT='target/code-to-process-lab.jar'
ls -lh "$ARTIFACT" "$ARTIFACT.original"
sha256sum "$ARTIFACT"本次验证中,可执行 JAR 约 22 MB,普通 JAR 约 6 KB,差异主要来自嵌套的运行依赖。文件大小适合发现明显异常,SHA-256 则标识当前字节;把摘要与流水线、制品仓库、签名或批准发布记录中的可信值比较,才能确认它是否为获准制品。
一份可追溯制品至少应能回到:
artifact digest
├─ source revision
├─ build JDK / Maven / plugin versions
├─ resolved dependencies and repositories
├─ build parameters and environment boundary
├─ test and verification results
└─ builder / pipeline identity and timeSBOM描述制品包含哪些组件,签名与 provenance回答谁生成、由什么输入生成,漏洞扫描评估已知风险;它们解决的问题不同。文件名中的版本号只是命名信息,不能替代可信摘要、签名或构建来源记录。
WAR、jlink runtime image、GraalVM native image和 OCI镜像是其他常见交付形态。WAR通常把 Servlet容器责任交给外部应用服务器;runtime image把应用需要的 Java模块与运行时组合;native image提前把更多工作移到构建期;OCI镜像再把文件系统层、配置、入口和元数据封装起来。无论形态如何,制品仍是静态对象,只有运行时创建进程后才开始占用 CPU、内存和端口。

制品怎样成为正在服务的进程
制品不会自己“变成服务”。终端、systemd 或容器运行时请求操作系统创建进程,进程执行 Java launcher;launcher 创建 JVM并选择入口,Spring Boot loader再装配应用 class和依赖。应用上下文、内嵌 Web Server、监听 socket和 readiness是在此后逐步形成的。
启动者
├─ interactive shell
├─ systemd service manager
└─ container runtime
│ executable + arguments + environment + cwd + identity + limits
▼
操作系统进程
└─ java launcher
├─ 创建 JVM
├─ 处理 JVM options
└─ 按 -jar 读取 Main-Class
└─ Spring Boot JarLauncher
└─ 调用应用 Start-Class.main(args)
└─ SpringApplication
├─ 构造并刷新 ApplicationContext
├─ 创建 Bean、连接池、线程池和客户端
├─ 创建内嵌 Tomcat
├─ bind / listen
├─ 执行启动任务
└─ readiness: ACCEPTING_TRAFFIC这条时序对应当前 Spring Boot Web 示例。普通 classpath 应用没有 Boot loader;WAR 由外部 Servlet 容器创建应用上下文;响应式服务可能使用 Netty;命令行任务也可能完全不监听端口。切换运行形态后,保留对象检查方法,再替换具体启动节点。
java 命令中有三类参数
Java launcher支持按 main class、JAR、module或单源码文件启动。打包服务最常见的结构为:
java [JVM options] -jar artifact.jar [application arguments]例如:
java \
-XX:InitialRAMPercentage=20.0 \
-XX:MaxRAMPercentage=60.0 \
-Duser.timezone=Asia/Shanghai \
-jar target/code-to-process-lab.jar \
--app.message=assembled-from-command-line| 位置 | 消费者 | 示例 | 放错位置的结果 |
|---|---|---|---|
java 与 -jar 之间 | JVM / launcher | -Xmx512m、-Dname=value、-javaagent:... | 放到 JAR之后时通常会成为应用参数,JVM设置不生效。 |
-jar 后的第一个参数 | launcher | target/app.jar | 必须是实际可读 JAR,Manifest需给出入口。 |
| JAR之后 | 应用 main(String[]) | --server.port=8081 | 能否识别及优先级由应用/框架决定。 |
执行 java -jar 时,指定 JAR 是 launcher 选择用户 class 的来源,普通 -classpath 设置不会以直觉中的方式再补齐依赖。Spring Boot 可执行 JAR 先通过 Manifest 启动 Boot loader,再由它读取 BOOT-INF/lib;java -jar 本身没有通用的嵌套 JAR 解析能力。
环境变量也要区分机制与约定:
| 变量 | 读取者与作用 |
|---|---|
JDK_JAVA_OPTIONS | 由 java launcher 预先加入命令行,并在启动时提示其存在。 |
JAVA_TOOL_OPTIONS | 由 JVM 初始化路径读取,常用于注入 JVM 选项或 Agent,也会产生提示。 |
JAVA_OPTS | 只是大量启动脚本采用的变量名;java 不会自动读取,只有脚本显式展开 $JAVA_OPTS 才生效。 |
APP_PORT、APP_MESSAGE | 由 Spring 配置系统或应用代码读取,不是 JVM 内存参数。 |
因此排查“参数没有生效”时,应先查看运行进程的真实命令行和环境,再检查配置来源,不能只查看部署模板。
配置在进程启动时完成一次装配
示例随 JAR携带默认值:
server.address=${SERVER_ADDRESS:0.0.0.0}
server.port=${APP_PORT:8080}
app.message=${APP_MESSAGE:assembled-from-default-config}它表达的是“若外部没有提供值则使用默认值”。APP_MESSAGE=assembled-by-docker 不会修改 JAR字节,只会在该进程启动时改变解析结果。配置来源可能包括制品内外的 properties/YAML、环境变量、Java system property和命令行参数;Spring Boot外部化配置规定了完整的 PropertySource顺序,越靠后的高优先级来源可以覆盖前面的值。
简化到常见冲突时,可以先检查:
制品内默认配置
< 制品外配置文件
< OS environment
< Java system properties
< command-line arguments测试注解、SPRING_APPLICATION_JSON、JNDI 和 devtools 等来源也可能参与解析。诊断时应按当前 Spring Boot 版本的官方顺序逐项核对,避免凭“环境变量肯定最高”跳过其他来源。
配置会落到不同对象上。JVM 内存、时区、locale 与 Agent 是进程输入,Bean、Profile 和功能开关决定应用怎样装配。监听地址、线程池、连接池和目录会成为进程持有的资源;数据库、缓存、消息与第三方端点,则把这个进程接入具体数据和调用身份。
相同制品还会在开发、测试、预发和生产中绑定不同身份、数据与依赖;这些环境约束见开发、测试、预发与生产环境边界。
PID 只是进程身份的一个字段
操作系统创建进程时,不只分配 PID。运行实例至少包含:
process instance
├─ identity:PID、PPID、process group、UID/GID
├─ executable and arguments:java、JVM options、JAR、application args
├─ environment:APP_*、JAVA_TOOL_OPTIONS、PATH、locale...
├─ filesystem view:cwd、root、mounts、open files
├─ execution:threads、scheduling、CPU time
├─ memory:heap、Metaspace、Code Cache、thread stacks、direct/native...
├─ file descriptors:files、pipes、event handles、sockets
├─ standard streams:stdin、stdout、stderr
├─ network:bound/listening/connected sockets
├─ limits:memory、CPU、open files、process/thread count
└─ lifecycle:parent/supervisor、signals、exit statusLinux /proc/<pid> 把其中许多事实暴露为进程视图。status 包含 PID、父 PID、UID/GID、线程数与若干内存字段;这些字段的正式含义见 proc_pid_status(5)。
JVM 堆只占进程内存的一部分,堆外还可能包含 Metaspace、JIT Code Cache、线程栈、直接缓冲区、GC 结构、JNI/native 库和内存映射。容器内存使用高于 -Xmx 时,先明确指标口径并拆分这些区域,再判断是否泄漏。进一步分析见 JVM 运行时内存与对象模型 与 JVM 综合诊断。
文件描述符同样不只代表普通文件。监听 socket、已连接 socket、日志文件、管道和某些事件对象都会占用描述符;数据库连接最终也依赖 socket。Too many open files 可能让日志、网络或文件访问同时失败,不能只检查磁盘目录。
在 Linux 上验证进程、socket 与行为
下面继续使用已构建的示例。环境为 Ubuntu 22.04/24.04,执行身份为构建 JAR的同一普通用户,JDK 25提供 java 和 jcmd,端口 8080未被占用。命令只在实验目录创建 runtime.log 与 second-instance.log。
在当前 shell启动后台实验进程并保存 $!:
set -eu
ARTIFACT="$PWD/target/code-to-process-lab.jar"
test -r "$ARTIFACT"
APP_PORT=8080 \
APP_MESSAGE=assembled-on-linux \
java -XX:InitialRAMPercentage=20.0 \
-XX:MaxRAMPercentage=60.0 \
-jar "$ARTIFACT" \
>runtime.log 2>&1 &
APP_PID=$!
printf 'app_pid=%s\n' "$APP_PID"$! 是当前 shell刚创建的后台作业 PID,比根据名称 pgrep java 更精确。这个 & 只便于本地取证,不是生产服务管理方式。
等待 readiness,最多 30秒:
READY_URL='http://127.0.0.1:8080/actuator/health/readiness'
for attempt in $(seq 1 30); do
if curl -q --noproxy '*' --fail-with-body --silent --show-error "$READY_URL"; then
printf '\nready_after_attempt=%s\n' "$attempt"
break
fi
if ! kill -0 "$APP_PID" 2>/dev/null; then
printf 'process_exited_before_ready\n' >&2
tail -n 80 runtime.log >&2
exit 1
fi
sleep 1
done
curl -q --noproxy '*' --fail-with-body --silent --show-error "$READY_URL" >/dev/null \
|| { tail -n 80 runtime.log >&2; exit 1; }预期先出现若干次连接失败或直接得到 {"status":"UP"},最终第二次检查必须成功。进程提前退出时下一步是看启动日志和退出状态;进程仍在但 30秒未就绪时,继续检查监听、启动线程和外部依赖,而不是无限延长探针超时。
这些回环请求把 -q 放在 curl 的第一个选项以跳过用户级 .curlrc,--noproxy '*' 排除代理环境,--fail-with-body 让 HTTP 4xx/5xx 保留响应体并返回非零状态。这样,客户端默认配置不会悄悄改变当前进程与 socket 的验证结果。
检查进程身份、父进程、用户、命令行和工作目录:
ps -o pid,ppid,user,stat,lstart,args -p "$APP_PID"
printf 'cwd=' && readlink -f "/proc/$APP_PID/cwd"
printf 'cmdline=' && tr '\0' ' ' <"/proc/$APP_PID/cmdline" && printf '\n'
tr '\0' '\n' <"/proc/$APP_PID/environ" \
| grep -E '^(APP_PORT|APP_MESSAGE)='
grep -E '^(Name|State|Pid|PPid|Uid|Gid|Threads|VmRSS|VmSize):' \
"/proc/$APP_PID/status"
printf 'open_fd_count='
find "/proc/$APP_PID/fd" -mindepth 1 -maxdepth 1 | wc -l读取 /proc/<pid>/environ 和某些 fd需要相同用户或足够权限。这里只按白名单显示两个无敏感值的 APP_*变量;不要在共享终端、工单或文章中全量打印环境,因为 token、密码和连接串常在其中。
再从 JVM内部确认启动命令和版本:
jcmd "$APP_PID" VM.command_line
jcmd "$APP_PID" VM.version
jcmd "$APP_PID" Thread.print -l | head -n 40jcmd需要在同一主机上以与目标 JVM相同的有效用户/组执行;目标必须支持 attach。精简 JRE镜像可能根本没有 jcmd,容器中的 PID namespace也可能要求先进入容器或使用宿主可见 PID。jcmd失败不能反向证明 Java进程不存在。
确认 socket归属:
ss -ltnp 'sport = :8080'ss中的 -l 只显示监听 socket,-t 选择 TCP,-n 避免名称解析,-p显示关联进程。预期 Local Address:Port 含 0.0.0.0:8080,进程信息指向当前 Java PID。看不到进程名时可能是权限限制;看不到监听记录才说明该 network namespace里没有匹配 socket。
最后分别验证三个不同层级:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:8080/actuator/health/liveness
printf '\n'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:8080/actuator/health/readiness
printf '\n'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:8080/api/hello
printf '\n'预期依次得到:
{"status":"UP"}
{"status":"UP"}
{"message":"assembled-on-linux","state":"completed","pid":12345}实际 PID以当前进程为准。三个结果的含义不同:liveness说明应用内部没有进入必须重启的破坏状态;readiness说明实例当前愿意接收流量;业务接口还验证了路由、序列化和配置装配。它们都不证明所有业务路径和外部依赖永久健康。
端口冲突发生在第二个进程的 bind 阶段
保持第一个实例运行,用同一端口启动第二个实例:
set +e
APP_PORT=8080 java -jar "$ARTIFACT" >second-instance.log 2>&1
SECOND_RC=$?
set -e
printf 'second_exit=%s\n' "$SECOND_RC"
grep -E 'Port 8080|already in use|BindException' second-instance.log || true
curl -q --noproxy '*' --fail-with-body --silent --show-error http://127.0.0.1:8080/api/hello
printf '\n'预期第二个进程非零退出,日志指向端口已占用;最后一条请求仍由第一个实例成功处理。这个对照证明失败的是新实例获取 socket,不是已有实例自动失效。下一步应用 ss确认 owner,再决定停止旧进程、修正端口还是检查错误启动来源;不要靠不断换随机端口掩盖所有权问题。
liveness、readiness 和业务健康不能合并成一个布尔值
Spring Boot Application Availability把 liveness 与 readiness 分开:
| 信号 | 回答的问题 | 典型使用方式 |
|---|---|---|
| liveness | 实例内部是否还能自行恢复。 | 破坏时平台可考虑重启。 |
| readiness | 实例当前是否应该接收新流量。 | 启动、摘流或关键依赖不可用时拒绝流量。 |
| startup probe | 实例是否仍处在合理启动期。 | 保护慢启动实例,避免 liveness 过早触发重启。 |
| 聚合 health | 被配置的多个 indicator 当前是什么状态。 | 用于诊断;含义取决于聚合规则。 |
| 业务探测 | 代表性操作的更长路径是否成立。 | 控制成本、副作用与权限后使用。 |
不要把所有外部依赖塞进 liveness。一处共享数据库故障如果让每个实例都变成 liveness失败,平台可能同时重启全部副本,形成重启风暴却修不好数据库。外部依赖通常更适合影响 readiness、告警和降级决策;真正策略取决于服务能否在该依赖缺失时提供任何安全能力。
端口监听通常早于 readiness。Web Server 可能已经接受连接,应用却仍在执行数据预热或启动任务;进程刚出现而尚未 bind 时,连接又会被拒绝。负载均衡器应根据明确的 readiness 信号登记实例,PID 只用于确认进程存在。
终端、systemd 与 Docker 托管的是同一个进程职责
前面的后台作业依赖当前 shell:父进程、标准流、退出状态和生命周期都没有长期托管。nohup ... & 最多改变挂断信号和输出去向,不提供可靠的身份降权、依赖顺序、状态查询、重启策略、日志索引和停止预算。
常见运行外壳的责任可以并列比较:
| 运行方式 | 谁创建/监视 Java | 日志默认去向 | 停止入口 | 适用边界 |
|---|---|---|---|---|
| 终端前台/实验后台 | shell | 终端或重定向文件 | Ctrl+C / kill | 本地开发与短时诊断。 |
| systemd unit | systemd service manager | journal或 unit定义的目标 | systemctl stop | 采用 systemd的 Linux主机服务。 |
| Docker容器 | container runtime/daemon | 容器 stdout/stderr的 logging driver | docker stop | 镜像化、隔离文件系统与资源约束。 |
| Kubernetes Pod | kubelet 经 container runtime | 容器日志采集链 | Pod 终止生命周期 | 多副本编排、探针和发布。 |
三者不会改变 Java应用必须正确读取配置、初始化、绑定、就绪和停止的事实;它们改变的是身份、namespace、资源限制、日志与信号由谁管理。
systemd 服务必须以前台进程运行
下面是采用 systemd 的 Linux 主机配置示例。环境为使用 systemd 且提供 useradd、getent、install 的常见 Linux 发行版;安装操作者需要 sudo,服务则使用专用不可登录账户 codeproc。示例假定 Maven Wrapper 已在工程根目录生成 target/code-to-process-lab.jar,并且 Java 25 位于 /usr/bin/java。
先核对运行时并安装账号、目录、JAR、非敏感示例配置和 unit:
/usr/bin/java -version
test -r target/code-to-process-lab.jar
if ! getent passwd codeproc >/dev/null; then
sudo useradd --system --home-dir /nonexistent --no-create-home \
--shell /usr/sbin/nologin codeproc
fi
sudo install -d -o root -g root -m 0755 /opt/code-to-process
sudo install -d -o root -g codeproc -m 0750 /etc/code-to-process
sudo install -o root -g root -m 0644 \
target/code-to-process-lab.jar \
/opt/code-to-process/code-to-process-lab.jar
sudo install -o root -g codeproc -m 0640 \
deploy/systemd/code-to-process.env.example \
/etc/code-to-process/code-to-process.env
sudo install -o root -g root -m 0644 \
deploy/systemd/code-to-process.service \
/etc/systemd/system/code-to-process.service预期 /usr/bin/java -version 显示 Java 25,getent passwd codeproc 能找到服务账号,JAR 与 unit 由 root 持有,环境文件只允许 root 和 codeproc 组读取。真实凭据不要写入 unit、Git 或命令行;应改用组织认可的 Secret 注入机制。命令失败时先停止安装,检查发行版的账号管理工具、Java 路径、源文件和目标权限,不要在路径不明时递归放宽权限。
安装的 unit 内容如下:
[Unit]
Description=Code to Process Lab
After=network.target
[Service]
Type=exec
User=codeproc
Group=codeproc
WorkingDirectory=/opt/code-to-process
EnvironmentFile=/etc/code-to-process/code-to-process.env
ExecStart=/usr/bin/java -XX:InitialRAMPercentage=20.0 -XX:MaxRAMPercentage=60.0 -jar /opt/code-to-process/code-to-process-lab.jar
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=20s
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetExecStart 不加 &、nohup 或自行 fork,systemd 需要直接监视主进程。Type=exec 确认可执行文件已经成功 execve;HTTP readiness 由应用探针负责。需要精确服务就绪通知时,还可以设计 Type=notify/sd_notify 集成。
安装 unit 后先验证再启动:
sudo systemd-analyze verify /etc/systemd/system/code-to-process.service
sudo systemctl daemon-reload
sudo systemctl start code-to-process.service
sudo systemctl status --no-pager code-to-process.service
sudo journalctl -u code-to-process.service -n 80 --no-pager
curl -q --noproxy '*' --fail-with-body --silent --show-error http://127.0.0.1:8080/actuator/health/readiness预期 unit状态为 active (running),journal中包含 Spring Boot启动与 SERVICE_READY;readiness返回 UP。active 只说明 systemd仍认为主进程运行,不代替 HTTP检查。若 status 显示启动频繁失败,先查看 ExecMainStatus和 journal,再检查 Java绝对路径、JAR权限、工作目录、环境文件和端口,不要立即把 RestartSec调得更短。
停止并确认:
sudo systemctl stop code-to-process.service
sudo systemctl show code-to-process.service \
-p ActiveState -p SubState -p ExecMainStatus -p ExecMainCode
ss -ltnp 'sport = :8080'
sudo journalctl -u code-to-process.service -n 40 --no-pager预期服务变为 inactive/dead,端口不再监听,日志出现优雅停机过程。超过 TimeoutStopSec 仍未退出时,systemd会按 kill策略升级终止;此时应检查在途请求、线程池、非 daemon线程和资源关闭,而不是把超时无限放大。systemd指令的完整语义见 systemd.service、systemd.exec 与 systemd.kill。
容器在隔离边界内运行进程
Docker image是静态模板;docker run 根据 image配置和运行参数创建 container,并在隔离的文件系统、network namespace和 process tree中启动主进程。Docker运行容器文档把 container描述为宿主上的隔离进程。镜像、容器与进程的关系为:
Dockerfile + build context
│ docker build
▼
image
├─ filesystem layers
├─ config:USER、WORKDIR、ENTRYPOINT、ENV...
└─ metadata:EXPOSE、labels、healthcheck...
│ docker run / compose up + runtime options
▼
container
├─ writable layer and mounts
├─ namespaces and cgroup/resource limits
├─ port publishing
└─ main process (container PID 1)示例 Dockerfile 使用两个构建阶段。build 阶段基于 maven:3.9.12-eclipse-temurin-25 执行 clean verify;runtime 阶段只带 Java 运行时、用于 healthcheck 的 curl 和最终 JAR。USER 10001:10001 避免应用以 root 运行,exec-form ENTRYPOINT ["java", "-jar", "/app/application.jar"] 让 Java 直接成为容器 PID 1并接收停止信号,HEALTHCHECK 则请求容器内部 127.0.0.1:8080 的 readiness。
shell-form ENTRYPOINT java -jar ... 会额外启动 /bin/sh -c,Java不一定是 PID 1,shell也可能不转发 SIGTERM。Dockerfile reference因此推荐 exec form。需要入口脚本时,脚本最后应使用 exec java ...,并对自己承担的子进程回收与信号转发负责。
EXPOSE 8080 只记录镜像声明的监听端口,不会让应用自动 bind,也不会把端口发布到宿主。compose.yaml中的:
ports:
- "127.0.0.1:18080:8080"才请求 Docker把宿主回环地址的 18080映射到容器 8080。绑定宿主 127.0.0.1 使端口只对该宿主可达;省略宿主地址时可能暴露到所有接口。完整安全边界见 Docker端口发布。
构建并验证 Docker 实例
环境为 Docker Engine 25或更高版本,执行用户有权访问 Docker daemon;该权限通常等价于很高的宿主控制权,只应授予受信任账号。当前目录为解压后的 code-to-process-lab。先确认服务端版本并展开最终 Compose配置:
docker version --format 'client={{.Client.Version}} server={{.Server.Version}}'
docker compose config接着构建并启动:
docker compose up --build --detach
for attempt in $(seq 1 30); do
STATUS=$(docker inspect --format '{{.State.Health.Status}}' code-to-process-lab)
[ "$STATUS" = 'healthy' ] && break
[ "$(docker inspect --format '{{.State.Status}}' code-to-process-lab)" = 'running' ] \
|| { docker logs code-to-process-lab; exit 1; }
sleep 1
done
docker compose ps预期状态从 health: starting进入 healthy,端口显示 127.0.0.1:18080->8080/tcp。如果容器已经退出,直接看 docker inspect与 docker logs;如果保持 running却不健康,查看 .State.Health.Log中的探针退出码和输出。增加 start-period只能为合理初始化留时间,不能修复错误地址、错误端口或应用死锁。
检查镜像配置、容器状态与主进程:
docker image inspect local/code-to-process-lab:4.1.1 \
--format 'image_id={{.Id}} user={{.Config.User}} entrypoint={{json .Config.Entrypoint}}'
docker inspect code-to-process-lab \
--format 'status={{.State.Status}} health={{.State.Health.Status}} host_pid={{.State.Pid}} oom={{.State.OOMKilled}} image={{.Image}} ports={{json .NetworkSettings.Ports}}'
docker top code-to-process-lab -eo pid,ppid,user,stat,args
docker stats --no-stream \
--format 'name={{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} pids={{.PIDs}}' \
code-to-process-lab
docker logs --tail 40 code-to-process-lab示例复验得到的关键关系为:
container user = 10001:10001
entrypoint = ["java","-jar","/app/application.jar"]
host port = 127.0.0.1:18080 → container 8080/tcp
memory limit = 384 MiB
OOMKilled = false
application = Java 25 / Spring Boot 4.1.1 / PID 1 inside containerdocker inspect .State.Pid 和 docker top 显示 Docker Engine 所在 Linux 主机或虚拟机 namespace 中的 PID;接口响应中的 pid:1 来自容器 PID namespace。Docker Desktop 运行在 Linux VM 中时,这个 host PID 位于虚拟机,也不会出现在 Windows 进程列表中。几个编号对应同一任务在不同 namespace 中的身份。
从宿主验证探针和业务:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:18080/actuator/health/liveness
printf '\n'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:18080/actuator/health/readiness
printf '\n'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:18080/api/hello
printf '\n'预期业务结果包含外部配置和容器内 PID:
{"message":"assembled-by-docker","state":"completed","pid":1}Docker的 HEALTHCHECK只维护 starting/healthy/unhealthy 状态并记录探针输出;Docker Engine不会仅因 unhealthy 自动重启容器。重启取决于 restart policy或上层编排,流量摘除也取决于调用该状态的基础设施。不要把“写了 HEALTHCHECK”理解为已经拥有完整自愈。
SIGTERM 与优雅停机要同时覆盖摘流和在途请求
Spring Boot对内嵌 Tomcat、Jetty和 Reactor Netty默认支持优雅停机;关闭 ApplicationContext时会进入停止阶段,拒绝新请求并在超时预算内等待已有请求完成。不同服务器拒绝新请求的具体方式和持久连接行为不同,完整边界见官方优雅停机说明。
应用优雅停止同时涉及流量面和进程面。流量面让实例退出可用端点并等待路由传播,进程面把终止信号送到 Java、停止接收新请求并等待在途工作。supervisor 与编排平台可能先摘流、先发信号或并行触发;无论采用哪种次序,都要在强杀期限到达前完成两条路径,而不仅依赖一个 shutdown hook。
termination requested
├─ traffic plane:readiness / endpoint removal
│ └─ load balancer stops new assignments + propagation wait
└─ process plane:SIGTERM reaches Java
└─ stop accepting new requests + drain in-flight work
│
▼
close pools / consumers / executors → process exits
→ supervisor records exit and releases container
└─ timeout exceeded → SIGKILL / forced termination示例的 /api/work?delayMs=5000 用于观察一个 5秒在途请求。确保容器处于 healthy后,在 Linux shell执行:
RESPONSE_FILE='/tmp/code-to-process-inflight.json'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
'http://127.0.0.1:18080/api/work?delayMs=5000' \
>"$RESPONSE_FILE" &
CURL_PID=$!
sleep 1
time docker stop --timeout 20 code-to-process-lab
wait "$CURL_PID"
cat "$RESPONSE_FILE"
printf '\n'
docker inspect code-to-process-lab \
--format 'status={{.State.Status}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}} finished={{.State.FinishedAt}}'
docker logs --tail 30 code-to-process-lab
rm -f -- "$RESPONSE_FILE"这个单容器实验没有负载均衡器,因此只验证 SIGTERM 到达 Java、服务器等待在途请求以及退出状态,不验证外部端点摘除和路由传播。本次复验中,docker stop 约 4.6 秒后返回,在途请求得到:
{"message":"assembled-by-docker","state":"completed-after-5000ms","pid":1}日志顺序为:
SERVICE_STOPPING pid=1
Commencing graceful shutdown. Waiting for active requests to complete
Graceful shutdown complete容器随后为 status=exited、exit=143、OOMKilled=false。143 = 128 + 15表示主进程因 SIGTERM结束;在当前信号和日志证据下,这是正常停止结果,不应被一条“非零即应用崩溃”的规则误报。反过来,退出 137通常表示 signal 9,但可能来自人工 docker kill、停止超时或 OOM killer;必须结合 .State.OOMKilled、daemon/内核事件和停止记录才能归因。
SIGKILL无法被应用捕获,shutdown hook、finally和优雅停机都没有机会完成。只有在停止预算耗尽且明确接受未清理风险时,supervisor才应升级强杀;正常操作不要从 kill -9开始。
完成实验后删除精确命名的容器与 Compose网络:
docker compose down
docker ps -a --filter 'name=^/code-to-process-lab$'第二条预期无输出。镜像 local/code-to-process-lab:4.1.1会保留以便复测;确认没有其他实验依赖它后,才可显式执行 docker image rm local/code-to-process-lab:4.1.1。不要使用宽泛名称过滤或 docker system prune清理共享开发环境。
输出图压缩了命令行,只保留运行身份、端口、响应与停止证据。实际复现应使用上面的完整 curl 命令,继续隔离 curlrc 和代理并让 HTTP 错误返回非零退出码。

从最后一个成功阶段定位故障
从源码到服务包含两个独立生命周期。构建工具以短生命周期进程读取工程输入并生成制品;应用服务再读取制品与运行输入,成为长期持有资源的进程。构建退出码、服务进程状态和制品摘要需要分别记录,任何一个都无法代替其余两个。
构建故障?
├─ Wrapper / JDK 尚未启动成功
├─ POM / repository / dependency 尚未解析成功
├─ 主源码或测试源码尚未编译成功
├─ 测试尚未通过
└─ JAR / image 尚未形成或身份不符
运行故障?
├─ launcher 尚未找到入口或兼容 JVM
├─ 应用上下文尚未初始化完成
├─ 配置、权限或外部依赖尚未满足
├─ socket 尚未 bind / listen
├─ liveness / readiness 尚未成立
├─ 运行中资源已经耗尽或阻塞
└─ 停止信号、摘流或清理尚未完成先确定最后一个已经确认的对象,再阅读与下一阶段相关的异常。下面这张表给出每层的首个观察项及其有效范围:
| 阶段 | 首先确认 | 观察结果的有效范围 | 下一步 |
|---|---|---|---|
| 工具启动 | java -version、./mvnw --version及退出码 | 依赖能解析、源码能编译 | 核对 PATH/JAVA_HOME、Wrapper配置、下载摘要。 |
| 模型与依赖解析 | dependency tree、effective POM、仓库下载日志 | 选中的库一定兼容、没有运行冲突 | 检查 scope、版本调解、BOM、mirror与组件来源。 |
| 编译 | 主/测试 class已产生 | 测试执行或运行依赖齐全 | 看测试发现数、报告和运行 classpath。 |
| 测试 | 预期测试数且零失败 | 目标环境能启动或依赖健康 | 检查打包结果和运行实验。 |
| 制品 | Manifest、JAR条目、摘要 | 当前进程加载了该文件 | 查进程命令行、打开文件、镜像 ID。 |
| 进程 | PID、用户、cwd、真实参数 | socket已监听 | 查启动日志、ss与应用事件。 |
| 监听 | 地址、端口和 owner | 实例愿意接流量 | 查 readiness。 |
| 就绪 | readiness响应成功 | 所有业务功能正确 | 发一条安全的代表性业务请求。 |
| 运行 | 请求、指标、线程/内存/fd证据 | 下一时刻仍健康 | 结合趋势、容量与依赖状态。 |
| 停止 | readiness拒绝、日志、退出状态 | 所有远端副作用已收敛 | 检查摘流传播、在途请求、异步任务与对账。 |
Wrapper、JDK 或依赖解析失败
先确认命令解析到什么工具:
command -v java
java -version
printf 'JAVA_HOME=%s\n' "${JAVA_HOME-<unset>}"
./mvnw --version典型分支如下:
| 现象 | 所在边界与下一步 |
|---|---|
java: command not found | 系统没有可执行 JDK 路径,尚未进入 Maven 或源码阶段;先修正安装和 PATH。 |
| Java 主版本与 POM/toolchain 不一致 | 修正当前 shell、CI image 或 toolchain,不要只改 IDE 设置。 |
| Wrapper 发行包摘要失败 | 停止执行,核对官方发行地址、项目中的可信摘要和代理是否改写内容。 |
Unknown host、连接超时或 TLS 错误 | 检查 DNS、代理、证书信任和 repository 可达性。 |
401/403 | 检查 repository 身份与授权,避免把凭据回显到命令或日志。 |
| artifact/plugin not found | 确认坐标、repository、mirror 和版本存在,不能用反复 -U 替代判断。 |
Maven 下载、Docker daemon 拉取和应用运行分别读取自己的代理配置。浏览器能够访问仓库网页时,容器内 Maven 或 Docker daemon 仍可能无法连接制品端点;排查应先确认失败发生在哪个进程。
依赖解析异常时导出两类证据:
./mvnw -B -ntp dependency:tree -Dscope=runtime
./mvnw -B -ntp help:effective-pom -Doutput=target/effective-pom.xmldependency tree显示最终选择的依赖路径;effective POM展开 parent、profile和默认配置。二者仍不包含所有用户级 settings.xml秘密和 repository现场状态,分享前也要检查是否含内部地址。不要把 ~/.m2/repository整个删除作为第一步:这会扩大网络依赖、破坏其他项目缓存,并丢失原本可取证的文件。只有确认单个缓存条目损坏时,才删除精确坐标目录并重新解析。
编译失败、测试失败与“测试没有运行”
编译错误先按输入类型区分:
| 现象 | 常见边界 | 第一检查 |
|---|---|---|
cannot find symbol | 源码、生成源码或编译依赖缺失 | 报错符号、source root、compile classpath、代码生成阶段。 |
| package不存在 | 坐标/scope错误或 module没有参与 reactor | dependency tree、module列表、scope。 |
| API不允许或语法不支持 | --release/toolchain与源码目标不一致 | 编译日志中的实际 release,不只看 POM文字。 |
| duplicate class | 相同 class来自多个 source/JAR | 生成目录、shade过程、依赖冲突。 |
| 注解生成类型缺失 | annotation processor未声明或未运行 | processor path、插件日志、生成目录。 |
测试失败时保留 target/surefire-reports,先看第一个根因和失败断言;后续异常可能只是上下文关闭或共享资源污染。测试数为 0则检查类名、JUnit engine、Surefire provider和过滤参数。-Dtest=...只运行指定测试,不能用一次窄测试替代发布流程声明的完整集合。
缓存与增量结果可疑时,clean verify用于重建本项目派生输出。它删除的是当前 module的 target,不会自动清理本地依赖仓库、Docker cache或 IDE输出。若 clean build通过而增量 build失败,应检查插件 input/output和生成规则;若只有本机通过,使用与 CI等价的干净构建环境比较 JDK、settings、环境变量和外部服务。
JAR 存在但无法启动
先在不执行应用代码的情况下检查静态对象:
ARTIFACT='target/code-to-process-lab.jar'
test -r "$ARTIFACT"
sha256sum "$ARTIFACT"
unzip -p "$ARTIFACT" META-INF/MANIFEST.MF
jar --list --file "$ARTIFACT" | head -n 40
java -version常见启动前故障对应不同边界:
| 现象 | 判断与下一步 |
|---|---|
Unable to access jarfile | 当前用户、路径、cwd 或权限错误,launcher 尚未读取 JAR。 |
Invalid or corrupt jarfile | 归档字节不是有效 JAR;核对文件大小与可信摘要。 |
no main manifest attribute | 普通 JAR 没有 -jar 入口;检查打包插件或改用正确 classpath 启动。 |
Could not find or load main class | 入口名、class 位置或 classpath 不正确。 |
ClassNotFoundException | 按名称加载的 class 在有效 classpath 中不存在。 |
NoClassDefFoundError | JVM 在解析或初始化已引用类型时无法得到定义,也可能包装初始化失败;要查看完整 cause,不能只按“少一个 JAR”处理。 |
UnsupportedClassVersionError | 运行 JVM 不支持该 class 版本;比较构建 release 与实际 java -version。 |
UnsatisfiedLinkError | native library 缺失、架构/ABI 不符或加载路径错误,已超出纯 Java 依赖图。 |
同属 classpath 错误,也可能来自完全不同的缺口。盲目追加目录会掩盖构建遗漏,并制造一套与制品仓库不同的运行输入;正常启动命令应从已声明制品得到完整 classpath。
容器场景还要验证镜像与平台:
docker image inspect local/code-to-process-lab:4.1.1 \
--format 'id={{.Id}} os={{.Os}} arch={{.Architecture}} user={{.Config.User}} entrypoint={{json .Config.Entrypoint}}'tag可以移动,本地 image ID或 registry digest才指向具体内容。多架构 image index还会按平台选择具体 manifest;“同一个 tag”在 amd64与 arm64主机上可以得到不同平台镜像,但应属于同一索引发布。生产取证应记录 index digest与实际平台 manifest,而不是只截一张 tag名称。
进程很快退出:先保留退出状态与第一段启动日志
直接以前台方式复现最容易保留错误:
set +e
APP_MESSAGE=diagnostic java -jar "$ARTIFACT" >startup.log 2>&1
START_RC=$?
set -e
printf 'startup_exit=%s\n' "$START_RC"
sed -n '1,160p' startup.log如果服务正常启动,该命令会持续占用前台而不是立即返回;只用于已知“启动即退出”的故障。正常服务应由另一终端发信号停止。
Spring Boot启动失败常见于配置绑定、Bean创建、端口、证书、目录权限、数据库迁移或外部连接。日志中的最外层 Application run failed只表示上下文未完成,应沿 Caused by找到第一个具体资源与参数。完整启动阶段、自动配置和条件报告进入 Spring Boot启动。
Docker中对应证据为:
docker inspect code-to-process-lab \
--format 'status={{.State.Status}} running={{.State.Running}} restarting={{.State.Restarting}} exit={{.State.ExitCode}} error={{json .State.Error}} oom={{.State.OOMKilled}} started={{.State.StartedAt}} finished={{.State.FinishedAt}}'
docker logs --timestamps --tail 160 code-to-process-lab若 restart policy不断拉起进程,日志会混合多次启动。先查看 restart count与时间戳,必要时在受控环境临时取消自动重启再复现;不要在生产直接删除容器丢失现场。
进程存在但端口不通
按下面的边界顺序检查:
PID exists
→ expected process identity
→ expected network namespace
→ bind address and port
→ LISTEN socket owner
→ host/container port publishing
→ firewall / routing / proxy
→ HTTP readiness and business pathLinux主机先执行:
ps -fp "$APP_PID"
ss -ltnp 'sport = :8080'
curl -q --noproxy '*' --verbose --max-time 3 http://127.0.0.1:8080/actuator/health/readinessconnection refused通常说明目标地址/端口当前没有接受连接的监听者,或中间设备主动拒绝;超时则可能是路由、防火墙、队列或无响应,不能等价处理。--noproxy '*'用于确认本机直连路径,避免环境代理改变目标;它不应被用于绕过组织要求的出站代理访问公网。
Docker再分三层:
docker inspect code-to-process-lab \
--format 'net={{.HostConfig.NetworkMode}} ports={{json .NetworkSettings.Ports}}'
docker top code-to-process-lab
docker exec code-to-process-lab curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:8080/actuator/health/readiness
curl -q --noproxy '*' --fail-with-body --silent --show-error \
http://127.0.0.1:18080/actuator/health/readiness容器内部成功、宿主失败,优先检查 publish、宿主绑定地址和防火墙;容器内部也失败,检查应用监听与 readiness。应用若只绑定容器内 127.0.0.1,端口转发到容器接口仍可能无法到达它;常见容器服务会绑定 0.0.0.0,再由 publish或网络策略决定外部范围。
docker exec要求容器仍在运行且镜像内存在 curl。生产精简镜像可能没有 shell和诊断工具,不能为临时排障随意修改运行容器;可以从同一 network namespace启动经过授权的诊断容器,或使用宿主/平台观测能力。
端口已监听但实例不就绪
先查看探针本身最后几次结果:
docker inspect code-to-process-lab \
--format '{{range .State.Health.Log}}{{.Start}} exit={{.ExitCode}} {{printf "%q" .Output}}{{println}}{{end}}'再对照应用日志、启动线程和依赖:
docker logs --timestamps --tail 200 code-to-process-lab
docker stats --no-stream code-to-process-lab常见分支包括:
| 现象 | 下一步 |
|---|---|
| 探针 URL、端口、scheme 或认证错误 | 修正探针契约。 |
| 应用仍在执行启动任务 | 确认任务必要性、耗时与 startup 预算。 |
| readiness 主动拒绝流量 | 查看触发状态的内部原因或关键依赖。 |
| event loop 或线程池被阻塞 | 抓取线程与请求指标。 |
| 容器 CPU 严重受限 | 结合 throttle 指标判断启动进度,不要只加 timeout。 |
| 探针成功但业务失败 | 探针覆盖过浅,继续验证代表性业务和依赖。 |
readiness 表达实例此刻能否承接规定的流量,并不要求所有下游同步健康。非关键能力可以降级时,实例仍可保持就绪;继续接流量会产生错误或数据风险时,才应拒绝。探针契约需要与路由、重试、告警和容量共同设计,完整方法见健康、就绪与优雅启停。
运行一段时间后变慢、被杀或资源耗尽
先把容器/进程外部资源证据与 JVM内部证据放在同一时间窗口:
date -Is
ps -o pid,ppid,user,stat,%cpu,%mem,rss,vsz,nlwp,etime,args -p "$APP_PID"
grep -E '^(State|Threads|VmRSS|VmSize|VmSwap):' "/proc/$APP_PID/status"
printf 'open_fd_count=' && find "/proc/$APP_PID/fd" -mindepth 1 -maxdepth 1 | wc -l
jcmd "$APP_PID" VM.flags
jcmd "$APP_PID" GC.heap_info
jcmd "$APP_PID" Thread.print -l >"thread-$APP_PID.txt"Thread.print输出可能很大并含业务类名;保存和分享时要按事故数据规范处理。更长窗口、低开销事件和内存归因应使用 JFR、Native Memory Tracking或对应指标,不能连续高频抓取重型诊断。
Docker使用:
docker stats --no-stream code-to-process-lab
docker inspect code-to-process-lab \
--format 'memory_limit={{.HostConfig.Memory}} nano_cpus={{.HostConfig.NanoCpus}} pids_limit={{.HostConfig.PidsLimit}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'诊断时至少区分:
| 资源现象 | 不能直接得出的结论 | 需要补充的证据 |
|---|---|---|
| heap接近上限 | 一定泄漏 | GC后占用趋势、对象增长、分配速率、业务负载。 |
RSS高于 -Xmx | JVM无视堆上限 | native memory、线程栈、direct buffer、mmap和共享页口径。 |
| CPU接近容器配额 | 某个方法一定死循环 | throttle、线程 CPU、GC、锁、请求量和宿主争用。 |
| thread数持续上升 | 全是平台线程泄漏 | 线程类型、创建栈、池配置、虚拟线程与阻塞状态。 |
| fd接近上限 | 只有文件未关闭 | socket、pipe、日志、连接池和 /proc/<pid>/fd目标。 |
| exit 137 | 一定容器 OOM | OOMKilled、内核/cgroup事件、人工 kill与停止超时记录。 |
应用级 OutOfMemoryError可以在 JVM仍获得执行机会时写出异常;cgroup/内核 OOM kill可能直接发送 SIGKILL,应用没有机会打印 Java堆错误或运行 hook。没有异常日志不能排除被外部强杀。
停止不干净或进程“杀不掉”
先确认谁是 supervisor以及信号实际发给谁:
ps -o pid,ppid,user,stat,args -p "$APP_PID"
cat "/proc/$APP_PID/status" | grep -E '^(Pid|PPid|State|SigBlk|SigIgn|SigCgt):'容器检查入口和 PID 1:
docker inspect code-to-process-lab \
--format 'entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} stop_signal={{json .Config.StopSignal}} pid={{.State.Pid}}'
docker top code-to-process-lab -eo pid,ppid,user,stat,args常见原因可以按信号、应用资源、预算和监督器分层判断:
| 层次 | 常见原因 |
|---|---|
| 信号入口 | shell-form 入口没有把 SIGTERM 转发给 Java,或进程处于不可中断内核等待,信号已 pending 但不能立即退出。 |
| 应用生命周期 | readiness 已拒绝流量,但在途请求、后台任务、线程池、消费者、连接池或自建非 daemon 线程尚未结束;shutdown callback 也可能自身阻塞。 |
| 时间预算 | supervisor 配置的强杀超时短于应用优雅窗口。 |
| 监督策略 | systemd/Docker 已经停止旧 PID,却按 restart policy 创建了新 PID,看起来像“又活了”。 |
正确的时间关系应满足:
应用拒绝新流量所需时间
+ 负载均衡摘流传播
+ 最大允许在途请求时间
+ 资源关闭余量
< supervisor强杀超时停止超时需要覆盖接口 deadline、消费者确认、连接池关闭和平台 termination 流程。设置过长会阻塞发布与故障恢复,过短又会截断请求、事务外副作用或日志。
把一次运行闭环记录成可复查证据
一次完整验证至少应记录以下对象;单张“启动成功”截图无法承载它们之间的对应关系:
BUILD
├─ source revision
├─ JDK / Maven Wrapper / plugin versions
├─ resolved dependency evidence
├─ expected test count and result
└─ artifact path + size + trusted digest comparison
RUN
├─ artifact/image identity
├─ effective arguments + selected non-secret configuration revision
├─ PID / user / cwd / limits
├─ listening address + port + owner
├─ liveness / readiness
├─ representative business response
└─ logs / metrics / trace correlation
STOP
├─ readiness and traffic removal
├─ signal + sender + deadline
├─ in-flight result
├─ resource-close logs
└─ exit status + PID/socket disappearance + supervisor decision这组证据把“源码能编译”“制品可识别”“进程按预期装配”“服务愿意接流量”和“实例可以有界停止”分开。进入更深问题时,可按当前最后成功节点选择专题:
| 最后成功节点或主要问题 | 后续入口 |
|---|---|
| JDK、class版本或升级兼容 | Java 版本基线 |
| class存在但加载、链接或初始化失败 | classfile 与类加载 |
| Maven生命周期、依赖和插件需要系统实践 | Maven 实战 |
| Maven与 Gradle选型或迁移 | Maven 与 Gradle 选型迁移 |
| SBOM、签名、provenance与构建可信度 | 供应链证据选择 |
| Spring上下文与内嵌服务器启动 | Spring Boot 启动 |
| 线程、内存、GC和在线进程取证 | JVM 综合诊断 |
| 健康语义、摘流和停止预算 | 健康、就绪与优雅启停 |
| Docker镜像、网络与运行时操作 | Docker Engine |
| JAR/镜像部署、回滚与服务治理 | Spring Boot 部署运维 |
| 端口已监听,要追踪真实请求处理 | 从请求到响应 |
| 同一制品进入不同环境 | 开发、测试、预发与生产环境边界 |
权威资料与规范地址
下面的资料分别承接工具规范、构建模型、运行时语义和操作系统边界。版本化文档可用于复核示例实验;采用其他版本时,应切换到对应版本页面后再比较行为。
Java 启动、编译、JAR 与诊断
Maven 与 Gradle
Spring Boot 构建、启动、配置与生命周期
| 资料 | 地址 |
|---|---|
| Spring Boot 系统要求 | https://docs.spring.io/spring-boot/system-requirements.html |
| Spring Boot Maven 可执行归档 | https://docs.spring.io/spring-boot/maven-plugin/packaging.html |
| Spring Boot executable JAR 格式 | https://docs.spring.io/spring-boot/specification/executable-jar/index.html |
| Spring Boot 外部化配置 | https://docs.spring.io/spring-boot/reference/features/external-config.html |
| Spring Boot 应用生命周期与 availability | https://docs.spring.io/spring-boot/reference/features/spring-application.html |
| Spring Boot 优雅停机 | https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html |
Linux 进程、socket、signal 与 systemd
Docker 与 OCI
| 资料 | 地址 |
|---|---|
| Docker 运行容器 | https://docs.docker.com/engine/containers/run/ |
| Dockerfile reference | https://docs.docker.com/reference/dockerfile/ |
| Docker 端口发布 | https://docs.docker.com/engine/network/port-publishing/ |
