Lionel · 李豪个人兴趣、文章、摄影、视频和音乐
Article · 文章

如何理解runtime

Lionel
2026/08/04 · 11 min read

从 JVM 的反优化、C++ 的异常机制到容器和媒体 SDK,runtime 的价值在于持续在场,观察、调度,并在错误发生时把局面收住。

先抛一个很容易招来反驳的说法。同一个虚函数调用,JVM 有时会比 C++ 快。

这不是说 Java 整体比 C++ 快,更不是性能语言之争的结论。只看一个调用点时,JVM 可能敢做 C++ 在常见编译边界下不方便做的事。先根据现场观察作出假设,假设失效后再撤回。

这份敢不敢,恰好把 runtime 这件事照亮了。

我们每天都在说“运行时”。运行时错误、Go runtime、容器 runtime,还有项目里那个名叫 Runtime 的类。可要是追问一句“runtime 到底是什么”,回答往往停在“程序运行的时候”。这话没错,但它没帮我们做任何设计判断。

我更愿意把它当成一把尺子。很多架构争论,最后都落在同一个问题上。一个决定应该在什么时候做,又是谁留在现场承担它的后果?

一个词,至少有三层意思

中文都叫“运行时”,实际常把三件事混在一起。

第一层是时间。run time 指程序已经启动、正在执行的阶段,它和 compile time 相对。“运行时才知道”“runtime error”说的通常是它。

第二层是支撑程序执行的那部分系统。runtime system 或 runtime library。它们是你没直接写进业务逻辑、却陪着程序一起工作的代码。说 Go runtime、Node.js 的运行环境或 JVM 时,通常是在说这一层。

第三层是应用里的对象。项目中叫 Runtime 的类,往往负责把配置、资源、调度和生命周期拢到一起。它未必配得上这个名字,后面再谈。

前两层之间其实是一个很朴素的因果关系。有些信息只有程序跑起来才会出现,所以得有东西留在场内,替程序处理这些信息。

拿一个小例子说。C++ 的 int c = a + b; 经过优化后可能就是一条加法指令。Java 里的 Integer c = a + b;,从语言语义上要经过拆箱、空值检查、计算与装箱;未被优化的路径还可能涉及对象分配或缓存。JIT 可以把其中不少步骤消掉,不能把 Java 源码直接等同于固定数量的机器指令,但这些语义确实需要 JVM 负责。

所谓 runtime,不是神秘的“额外开销”。它就是那些在程序已经开始运行以后,仍有人要完成的工作。

C++ 不是没有 runtime,只是把它藏得更薄

C++ 容易给人一种“我站在 runtime 对面”的感觉。这个直觉得改一改。

从进程的角度看,main 往往不是第一条执行的指令。启动代码会准备栈和进程参数,完成 C 运行库需要的初始化,调用全局构造函数,然后才走到 main。main 返回后,它还要处理析构和退出流程。不同平台的实现不一样,但这段启动与收尾逻辑一直都在。

再看 throw。它也不是披着语法糖外衣的 goto。在常见的 Itanium C++ ABI 实现里,抛异常会进入异常运行库,先搜索哪一帧能接住异常,再在栈展开过程中执行需要清理的析构逻辑。dynamic_cast 同样依赖运行时类型信息去完成继承关系判断。

所以嵌入式项目里常见的选项。

-fno-exceptions -fno-rtti

它表示这两部分运行时支持不再随程序带上,并不涉及其他运行时工作。

C++ 会把许多 runtime 成本拆得很细,也把开关交到你手里。你可以知道自己为什么付费,也可以决定哪些能力不需要。

虚函数是另一种极简的延期。典型实现会在对象里取虚表指针,从表中取目标槽位,再做一次间接跳转。实际指令数会受平台、优化和内存状态影响,但它的运行期决策非常小。C++ 往往希望把不确定性压到最晚、最窄的一步,并把成本边界尽量留在编译期可见的地方。

JVM 的赌注来自可以撤回

这就回到开头的说法。

在普通的分离编译和动态链接边界下,C++ 编译器通常无法确信某个虚调用永远只有一个目标。别的编译单元、共享库或未来加载的代码都可能带来新的派生类。final、LTO 和 whole-program 优化能扩大它的视野,但前提是你真的掌握了程序的边界。

JVM 面对的也不是确定世界。差别在于,它能在程序运行时观察调用点实际遇到的接收者类型。若一个调用点长期只见到一种类型,JIT 可以带着类型检查做乐观去虚化和内联,让常量传播、逃逸分析继续往下走。

当这个假设不再成立,例如新加载的类让原来的依赖关系失效,已经编译的代码可以被丢弃或反优化,程序回到更保守的执行路径,再基于新的信息重新编译。

所以事情不只是“猜对了就快,猜错了就慢”。更关键的是,猜错以后有没有办法把这笔账收回来。

我给 runtime 的一个工作定义也由此而来。它是留在现场的人。

它在场,才能看见真实的负载和类型分布;看见了,才能选择乐观还是保守;选择错了,还能调整策略。编译器生成目标文件后就离场了,它必须在离场前保证结果对所有合法情况都成立,因此天然更克制。

先看确定性和适应性

这样看语言,性能排行榜就没那么有意思了。它们是在不同时间点结算成本。

C++ 把很大一部分控制权留给开发者。没有 JIT 预热,也没有垃圾回收器替你决定何时回收。作为交换,所有权、生命周期、异常安全和资源释放的责任也更多地落在代码作者身上。它给的是较稳定的成本模型,不是“永远更快”的承诺。

Java 把一部分决定交给运行期。它能知道哪些分支热、哪些调用点近似单态、哪些对象没有逃出方法,也因此可能在峰值阶段做出源代码层面看不到的优化。代价是启动和预热、内存占用,以及延迟受垃圾回收等运行期机制影响。压测时只拿前几秒的数据下结论,常常是在测另一件事。

Rust 则把不少检查和特化提前到编译期。所有权检查、泛型单态化都要在编译时完成,运行期通常不需要 GC。所谓零成本抽象说的是不额外引入普遍的运行期负担,不代表程序没有运行期成本。账并没有消失,只是更多地提前体现在编译时间、二进制体积和代码复杂度里。

我现在会用一个简单标准判断。信息在哪一刻才完整,决定就该尽量留到那一刻。类型、布局、明确的非虚调用,适合在编译期定下;真实负载、热路径和实际部署的插件,只有运行起来才知道。延期并不等于偷懒,有时只是等信息到齐。

尺度变大,模式没有变

这个“有人留在现场”的模式,会在不同尺度反复出现。

  • C 运行库的启动代码负责把控制权交给 main。
  • JVM 执行并管理字节码、编译代码和内存。
  • runc 消费 OCI bundle 与 config.json,建立 Linux 隔离资源后启动容器进程。
  • 自定义 Lambda runtime 的 bootstrap 会循环拉取下一个事件,调用 handler,再把结果或错误回传。

它们大小相差很远,但有一个共同点。主循环不在你的业务代码手上。它决定什么时候把控制权交给你,也决定如何收拾你返回的结果。

这给了我一个实用但不绝对的命名测试。你的代码持续调用它,它更像 Client、Service、Manager 或 Registry;它掌握循环与生命周期,在合适的时机调用你的代码,才更接近 Runtime。

控制反转不是 runtime 的唯一条件,但它是一个很好的警报器。很多项目里的 Runtime 只是一只收纳盒。字段不断增加,方法大多是 getter,所有模块都持有它,写一个单测却得先搭起整套依赖。这种对象更接近 service locator,只是换了一个听起来更体面的名字。

一个应用层的 Runtime,至少应该把几件事说清楚。

  • 把静态配置变成活资源。例如连接池、线程池、客户端和指标注册表。配置可以序列化,持有 fd、socket、后台线程的东西不行。
  • 管住生命周期与关停顺序。先停止接收流量,等待在途请求,再关闭连接池,这些顺序不是细节。
  • 提供清晰的依赖根,让测试可以替换外部资源,而不是到处依赖单例和静态初始化。
  • 承接确实只能在运行期出现的扩展点,例如插件注册和 hook 分发。

一个我参与过的例子

我参与过一次视频编辑 SDK 的重构。它一开始是一个很大的引擎对象,同时做时间线编辑、播放、导出和单素材处理。后来我们发现,它已经承担了几种完全不同的运行现场。

重构后,播放、导出和单素材处理按各自的压力特征拆开。播放看重交互延迟,导出看重吞吐和结果稳定,单素材处理看重批处理并发。媒体管线、资源管理和诊断则作为横切能力,不绑定到某一条业务链路上。

设计文档里没有花篇幅给 Runtime 写词典式定义,但它留下了一套很有用的检查问题。一个能力归属哪个运行现场,操作哪条时间线,申请哪些资源,怎样取消,怎样清理,出了问题如何诊断。

这些问题回答不清楚,就不该急着给它贴上 runtime 的标签。先把边界画出来,比先把名字叫漂亮更重要。

这次重构里,还有一个细节让我反复想起 JIT 的反优化。底层是一张原生媒体图,播放器 consumer 正在读取它时,直接改图的拓扑并不安全。插一条轨道、移动片段或触发整图重建,轻一点可能看到旧帧和黑屏,重一点就是并发访问带来的崩溃。

我们先给操作标记它改变的内容、影响范围、执行前需要的播放器隔离级别,以及执行后要不要刷新时间线信息。调度层再把这些标记翻译成资源作用域和排队策略,调用方不用自己猜风险。读取可以直接执行,涉及轨道拓扑的操作则独占整条时间线。

还有一条刻意保守的兜底。无法识别的操作,按“整图重建并销毁播放器”处理。

这条规则看上去有点笨,但它承认了一件事实。原生媒体图被错误地并发修改后,没有解释器可退,也没有一个轻巧的“撤销键”。既然无法可靠回滚,就别把未知操作当成安全操作。

JVM 的乐观优化和这里的保守默认,方向相反,判断标准却一样。你有多强的撤销能力,决定了你能押多大的注。

这也解释了为什么我们最终把“要不要暂停播放器”的判断从应用层收回 SDK。调用方不该自行推测一个操作是否危险,SDK 才掌握底层图和播放状态的完整信息。调用方订阅状态,SDK 负责分类、执行和清理。

相应地,操作结束或失败后保持暂停,只有新的用户播放意图才能恢复。这听起来不够聪明,却比 SDK 替用户猜“你大概还想继续播”更诚实。状态的所有权归谁,恢复行为就该由谁来界定。

最后

runtime 不是一个要背下来的术语,更像一个持续要问的问题。程序已经跑起来以后,谁还在现场?

这个答案会影响你能优化什么、该保证什么,也影响发生意外时有没有回头路。没有一个放之四海而皆准的“最好 runtime”。有的系统用更少的托管换来更清晰的成本和更多责任,有的系统用持续观察换来更强的适应能力。你自己造运行时边界时,尤其要先确认它是否真的能观察、调度、清理并承担后果。

下一次准备做一个乐观优化时,我会先问一句。

如果我猜错了,我还能退回来吗?

#文章
Lionel Written by
我叫李豪,来自云南昭通,现居杭州 喜欢摄影、足球 、Vibe Coding 和老婆一起养了两只猫
Scinentistcoldplay