从代码到进程:源码怎样成为可运行服务
把代码推到仓库,不等于把服务交付出去;看到 BUILD SUCCESS,也不等于服务已经可接流量。中间至少隔着四次转换:源码变成字节码,字节码和资源变成制品,制品被 JVM 装载成运行时对象,应用再把一个端口变成可用的服务入口。
这篇先用一个不依赖框架的 Java HTTP 服务走通整条链。这样以后学习 Spring Boot、容器镜像和发布平台时,能分清哪些是 Java/JVM 的职责,哪些只是框架或交付工具增加的自动化。
这条交付链从源码走到监听端口
接下来回答“源码怎样变成可运行服务”:从编译、测试、打包和制品校验,一直走到 JVM 启动、类加载、线程、监听端口、进程信号与退出。最小实验只使用 JDK 自带工具,可以逐步观察 .java、.class、JAR、PID、线程和端口。
Maven 插件、JVM 内存与 GC、HTTP 框架源码以及 Docker/Kubernetes 部署各有专门链路;这里遇到镜像时,只判断它在交付链中的位置,不把镜像等同于进程。
动手前确认 JDK 工具来自同一套安装
已读“后端能力地图”,知道语言、运行时、网络、框架和交付属于不同知识域。本机安装 JDK 21 或更高版本;示例只使用 Java 21 已有的标准 API。可选安装 Maven 3.9,用于理解标准构建生命周期;最小实验本身只需 javac、jar、java、jps 和 jcmd。
先确认工具来自同一套 JDK:
java -version
javac -version
jar --version如果 java 与 javac 显示不同主版本,先修正 PATH。否则“本机能编译、目标环境不能运行”的问题会在链路最前端就被制造出来。
它在知识树里的位置
工程入口
├─ 后端能力地图
├─ 从代码到进程(本文)
│ ├─ 源码与依赖输入
│ ├─ 编译、测试与打包
│ ├─ 制品身份与可重复交付
│ ├─ JVM 启动、类加载与 main
│ ├─ 线程、端口、进程与退出
│ └─ 失败证据、回滚与治理
├─ 架构形态
└─ 环境边界后续 JVM 专题会拆解类加载和运行时数据区,网络专题会解释端口背后的 TCP 连接,Spring Boot 专题会解释框架如何创建应用上下文和嵌入式服务器;这里先建立共同坐标。
先建立一条完整运行链
每一格都有不同的成功证据。编译成功证明语法和类型在当前编译配置下成立;测试成功证明被测试的行为成立;JAR 摘要证明拿到的是哪个制品;进程存在只证明 JVM 尚未退出;端口监听只证明 socket 已绑定;一次 HTTP 成功才证明当前请求链可用。生产门禁不能用后一个模糊信号替代前一个精确信号。
最小实验:让源码真正监听端口
新建 src/example/HelloServer.java:
package example;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
public final class HelloServer {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(System.getenv().getOrDefault("APP_PORT", "8080"));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 32);
server.createContext("/health", exchange -> {
byte[] body = "ok\n".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (var output = exchange.getResponseBody()) {
output.write(body);
}
});
server.setExecutor(Executors.newFixedThreadPool(4));
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("stopping");
server.stop(2);
}, "shutdown-hook"));
server.start();
System.out.printf("READY pid=%d port=%d%n", ProcessHandle.current().pid(), port);
}
}编译到独立输出目录:
mkdir -p out
javac --release 17 --add-modules jdk.httpserver -d out src/example/HelloServer.java--release 17 同时约束可用 API 与目标 class 文件版本;-d out 避免把构建产物混进源码目录。这里选择 17 是为了给出仍处于长期支持范围、且容易在企业环境复现的最低实验基线;更高 JDK 应按团队支持矩阵单独验证。查看产物:
find out -type f
javap -classpath out -public example.HelloServer随后写入清单 MANIFEST.MF:
Manifest-Version: 1.0
Main-Class: example.HelloServer打包并检查,而不是只相信命令退出码:
jar --create --file hello-server.jar --manifest MANIFEST.MF -C out .
jar --list --file hello-server.jar
sha256sum hello-server.jarWindows PowerShell 可用 Get-FileHash hello-server.jar -Algorithm SHA256。摘要不是安全扫描,但可以回答“测试的 JAR 与随后启动的 JAR 是否为同一字节序列”。
启动服务:
APP_PORT=8080 java --add-modules jdk.httpserver -jar hello-server.jar另开终端验证三层证据:
jps -lv
jcmd <PID> VM.command_line
curl -i http://127.0.0.1:8080/health预期响应为 HTTP/1.1 200 OK 和 ok。jcmd 输出还能证明 JVM 实际收到的启动参数,避免只看启动脚本猜测运行配置。
从 java -jar 到业务线程发生了什么
java 启动器先创建 JVM,再根据 JAR 清单选择初始类。JVMS 将后续过程分成加载、链接和初始化:加载从 class 文件创建运行时表示;链接包含验证、准备以及按需解析符号引用;初始化执行类初始化方法;最后初始类的 main(String[]) 驱动应用继续执行。
在示例里,主线程读取 APP_PORT,创建 HttpServer、绑定地址、注册处理器和关闭钩子,再创建固定大小的执行器。服务启动后,进程不会因为 main 即将结束就退出,因为 HTTP 服务和执行器持有非守护线程。一次请求到达后由服务实现接收,再交给工作线程执行 handler;响应体关闭后,本次交换结束。
这里的状态分散在不同边界:配置值来自进程环境;class 元数据和对象在 JVM 中;线程由 JVM 与操作系统共同调度;监听 socket 属于进程持有的操作系统资源;请求字节在网络缓冲区和堆对象间流动。没有“服务状态都在进程里”这么简单的结论。
构建工具解决的是可重复转换
真实项目不会手写每条 javac 命令。Maven 的默认生命周期按阶段组织 validate → compile → test → package → verify → install → deploy。调用后面的阶段会依次执行前面的阶段,所以日常门禁通常应运行 mvn verify,而不是只运行 mvn package -DskipTests。
但 Maven 不会自动保证供应链安全、可重复构建或运行时可用。POM、插件版本、依赖仓库、JDK、操作系统和构建参数都是输入。团队至少要记录:
源码提交 ID、JDK 主版本、构建命令与依赖锁定策略。制品名、版本、SHA-256、SBOM/依赖扫描结果与签名或来源证明。测试报告、构建日志和发布审批对应的是同一个摘要。
运行配置不烘焙真实密码;秘密由目标环境在运行时注入。
OCI 镜像是在 JAR 之外再加一层可寻址交付封装。OCI Image Manifest 引用配置对象和按顺序应用的文件系统层;镜像仍然是静态内容,只有运行时根据配置创建进程后才开始执行。镜像摘要能固定内容身份,却不能证明进程健康。
反向实验:主动制造三种失败
失败一:运行时比编译目标旧
用较新 JDK 编译且不设置合适的 --release,再交给旧 JVM,典型现象是 UnsupportedClassVersionError。证据链应是:
javap -verbose -classpath out example.HelloServer | grep "major version"
java -version根因不是“JAR 损坏”,而是 class 文件版本超过运行时支持范围。修复是统一工具链,或显式选择经过测试的目标版本,而不是下载随机 JRE 重试。
失败二:端口已经被占用
保持第一个实例运行,再启动第二个:
APP_PORT=8080 java --add-modules jdk.httpserver -jar hello-server.jar预期看到 BindException: Address already in use,第二个 JVM 退出。Linux 可用 ss -ltnp,Windows 可用 Get-NetTCPConnection -LocalPort 8080 找到占用者。不要用“换一个随机端口”掩盖发布编排错误;先确认旧实例为何没有退出、是否仍承载流量。
失败三:进程活着但请求不可用
把执行器改成单线程,并在 handler 中临时加入长时间阻塞,再并发发送两个请求。PID 和监听端口仍存在,第二个请求却会排队甚至超时。这证明 liveness、readiness 和端到端探测不能混为一个信号。修复前先用线程 dump 和请求延迟确认瓶颈,再决定增加并发、消除阻塞或施加超时与背压。
停止不是直接杀进程
正常停止链是“停止接收新流量 → 等待在途请求到预算上限 → 关闭监听入口 → 释放线程、连接和临时资源 → 进程退出”。强制终止跳过清理,可能留下半写文件、未提交事务或重复任务窗口。
示例的 shutdown hook 只能演示钩子,不等于完整优雅停机。钩子必须有时间上限,不能依赖已经不可用的远程服务,也不能在其中启动无限等待的新任务。生产编排还要把摘流时间、应用终止预算和平台强杀时间对齐。
故障、性能与安全怎么取舍
编译期失败通常最便宜,启动期失败次之,接流量后才失败最昂贵。因此依赖缺失、配置格式、模块边界和数据库兼容性应尽量前移到 verify 或启动探针。另一方面,过度在启动期连接所有依赖,会让无关依赖抖动阻止服务启动;应按“没有它是否能正确提供核心能力”决定硬依赖和可降级依赖。
性能上不要从“CPU 高”直接跳到“加机器”。先分解启动时间、类加载、堆与直接内存、线程数、监听 backlog、请求排队和下游等待。固定线程池限制并发也提供保护;队列过大则会把过载隐藏成高延迟和高内存。
安全边界至少包括:构建依赖和插件来源可信;制品按摘要晋级而非在每个环境重新构建;运行账户最小权限;服务默认不监听所有网卡;命令行不携带容易出现在进程列表中的密码;诊断接口不对业务网络公开;日志不打印令牌和完整敏感配置。
从个人项目演进到团队交付
小项目可以从“单 JAR + 明确启动命令”开始。多人协作后先建立统一 JDK、verify 门禁、制品仓库与摘要;需要跨环境一致性时再引入 OCI 镜像;需要多实例编排时才引入健康探针、滚动发布和资源配额。每一步都应解决已经观察到的重复劳动或风险,不要把工具层数当成熟度。
团队评审可固定问五个问题:输入能否重建同一制品;测试与发布是否指向同一摘要;启动配置从哪里来;什么证据表示可以接流量;停止与回滚如何限制数据损坏窗口。回滚也应切换到已验证的旧摘要,而不是临时在服务器改代码重新打包。
构建、制品与进程边界查这些资料
Java SE 25 / JDK 25 规范总入口:JLS、JVMS、工具与 JAR 规范入口。JVMS 第 5 章:加载、链接与初始化:JVM 启动与类型运行时创建的规范口径。
Maven 构建生命周期:默认生命周期阶段及顺序。OCI Image Manifest Specification:镜像配置、层与内容寻址模型。
最后记住这组证据
源码是输入,class 是编译结果,JAR 是交付制品,镜像是可寻址封装,进程是正在执行的实例,端口是操作系统资源,健康响应才是当前路径的行为证据。把这些对象分开,才能在失败时准确回答:坏在代码、构建、制品、运行时、配置、资源,还是流量入口。
