Java 启动性能优化:从 Nested Jar 切换到 Exploded 部署

为什么调整打包形式 Nested Jar 会把应用自身的类和依赖 Jar 一起封装到一个可执行 Jar 中。它的优点是交付物集中、分发方便,但运行时需要通过额外的加载逻辑访问内嵌依赖。 Exploded 形式则会提前展开应用和依赖,Java 进程启动时可以直接从文件系统读取对应的类和资源,减少访问嵌套压缩包带来的开销。 Exploded 之后如何保证 Classpath 顺序 把 Jar 展开后,一种启动方式是通过 -cp 指定应用类和依赖,然后直接运行应用的 main 方法: java -cp "BOOT-INF/classes:BOOT-INF/lib/*" com.example.MyApplication 这种方式可能带来进一步的启动性能提升,但 BOOT-INF/lib/* 的展开顺序并不适合作为稳定的 classpath 顺序保证。一旦依赖中存在同名类或同名资源,顺序变化就可能导致运行行为与原来的可执行 Jar 不一致。 因此,这次调整仍然通过 Spring Boot 的 JarLauncher 启动: java org.springframework.boot.loader.launch.JarLauncher 构建产物中的 classpath.idx 记录了依赖顺序,JarLauncher 会读取该文件并据此构造 classpath。这样既能获得解包后的启动性能收益,也能保持 classpath 顺序可预测,降低打包形式变化带来的兼容性风险。 Spring Boot 官方文档也有相关说明:Unpacking the Executable JAR。类似的启动性能差异也有其他用户反馈:spring-projects/spring-boot#40125。 测试结果 在同一台测试服务器上,分别使用 Nested Jar 和 Exploded 形式对三个包各测试 3 次,结果如下: 测试环境 项目 配置 CPU 架构 x86_64 CPU 型号 Intel Xeon Platinum 8173M @ 2.00 GHz CPU 拓扑 112 个逻辑 CPU,2 路,每路 56 核,每核 1 线程 虚拟化 QEMU / KVM,全虚拟化 NUMA 2 个节点,CPU 0-55 / 56-111 Spring Boot 3.5.14 Java 17 性能数据 包 Nested 3 次(ms) Nested 平均(ms) Exploded 3 次(ms) Exploded 平均(ms) 改善 DESIGNER 8344 / 7886 / 8386 8205.3 5256 / 5273 / 5264 5264.3 2941.0 ms,约 35.84% SERVER_LOCAL 9443 / 9439 / 9540 9474.0 5787 / 5703 / 5276 5588.7 3885.3 ms,约 41.01% RUNTIME 14493 / 14566 / 14571 14543.3 7271 / 7730 / 7268 7423.0 7120.3 ms,约 48.96% 从测试结果看,三个包的启动耗时都有明显下降: ...

August 4, 2026

线程池饥饿排查:从 ThreadPool 指标追到 SQLite_BUSY

应用变慢时,第一反应很容易是“线程不够”或“CPU 不够”。但在高并发 Web 应用中,更常见的情况是线程池线程都被阻塞了:线程数量不断增加,工作项排队等待,新的请求迟迟得不到执行。 这就是线程池饥饿(Thread Pool Starvation)。本文用一次 SQLite 相关的现场问题说明如何观察它,以及为什么简单增加线程数往往会让问题更糟。 什么是线程池饥饿 当线程池没有可用线程处理新的工作项时,就发生了线程池饥饿。应用通常表现为响应变慢、请求超时,但 CPU 利用率未必很高。 常见诱因包括: 在异步方法上调用 .Result 或 .Wait(); 使用 Thread.Sleep; 长时间运行的定时任务或后台任务占用线程池线程; 同步阻塞 I/O; 多个线程竞争同一把锁; 数据库调用长时间等待锁释放。 尤其要注意,业务代码不需要“完全同步”才会产生饥饿。只要异步链路中夹杂了阻塞调用,线程池就可能在压力下逐渐耗尽可用线程。 用 dotnet-counters 观察现场 先对目标进程做压测,同时运行: dotnet-counters monitor -p 12256 重点观察 [System.Runtime] 下的几个指标: ThreadPool Queue Length 75 ThreadPool Thread Count 38 ThreadPool Completed Work Item Count 128 / sec 如果 ThreadPool Queue Length 持续增长,说明工作项已经开始排队;如果 ThreadPool Thread Count 长时间明显高于 CPU 核心数,同时队列仍然无法消化,这是线程池饥饿的强烈信号。现场案例中线程数长期达到 CPU 核心数约 3 倍,仍有大量工作项等待。 这个判断应该结合趋势和请求延迟一起看,不能把“线程数超过核心数”单独当成故障证明。线程池在遇到阻塞时主动注入线程本身就是一种正常的补救机制,问题在于注入的线程也被同样的阻塞点卡住了。 用 dotnet-stack 看谁在阻塞 线上通常无法附加 debugger,这时可以使用 dotnet-stack 获取托管线程的调用栈。现场看到的一类关键栈如下: ...

July 22, 2026