1. 从一道面试题聊起:JVM里那张“与生俱来”的地图
我当初入行Java时,第一次被问到“JVM启动过程中,最先准备好的代码是什么”这个问题,整个人是懵的。你背过类加载、背过双亲委派、背过对象创建流程,但很少有人告诉你,HotSpot在Java世界还没苏醒之前,自己先手写了一批“原生代码片段”,它们就是StubRoutines——我习惯叫它“Java世界的地图”。
为什么说它是地图?因为JVM本身是用C++写的,但它要解析字节码、调用系统原生方法、处理异常、做GC时的内存复制……这些“翻译”和“跳转”动作,不能临时抱佛脚去查表,必须在启动早期就把一套固定套路生成好、固化在内存里。StubRoutines就是这套固定套路的总称,它是HotSpot在启动阶段生成的一系列机器码例程。你写的第一行Java代码还没跑起来,这些例程已经躺在内存里等着被召唤了。
这类知识属于典型的“面试不会重点考,但考到就能拉开差距”的内容。如果你正在准备JVM相关的面试,或者想看明白hs_err日志里那些XX_code_stub的地址段,再或者对“解释器怎么跳进JIT编译后的代码”这种机制有好奇心,这篇文章就是写给你的。我会从StubRoutines的生成时机、内部结构、典型类型,一路讲到实际排查问题时会遇到的表现,尽量不堆术语,讲点真正能在实践中用上的东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. StubRoutines不是什么黑科技,它是HotSpot的“预制菜”
2.1 先理解一个前提:JVM自己也是程序
很多Java开发者容易把JVM当成一个黑盒,觉得它天生就能跑Java代码。但回过神想一想,JVM本身是一个用C++写成的进程,它启动时要先做自己的运行时初始化,比如分配内存、初始化全局数据结构、解析JVM参数,然后才具备“执行Java字节码”的能力。
问题来了:执行Java字节码需要什么?至少需要一套“从机器码到字节码”或“从字节码到机器码”的桥接逻辑。HotSpot的架构中,解释器(Interpreter)负责一条一条解析字节码,JIT编译器(C1/C2)负责把热点方法编译成本地机器码。但不管是解释器还是JIT产物,它们生成的代码都不是凭空出现的——解释器启动时要把自己的模板代码准备好,JIT编译器也要依赖一批生成好的辅助例程。
这批辅助例程,就是StubRoutines。它们的作用有点像餐厅后厨提前切好的菜:客人点单(Java方法调用)的时候,主厨不需要现场去洗菜切菜,直接拿预制菜下锅就行。JVM里那些高频的、固定的、平台相关的操作,提前生成好机器码,运行时直接跳过去执行,避免了每次都在C++层做分支判断或函数调用的开销。
2.2 StubRoutines和JIT编译结果不是一回事
这里有第一个容易混淆的点,必须拎清楚。很多人听到“JVM生成的机器码”,先想到的是JIT编译后的方法代码。但StubRoutines和JIT编译产物有本质区别:
- JIT编译产物是“跟着业务走”的,某个方法被判定为热点,编译成优化后的机器码,存进CodeCache,它服务于具体的Java方法。
- StubRoutines是“跟着JVM走”的,不管你的业务代码是什么,只要JVM启动,这批例程就一定会生成,它们服务于JVM自身的运行机制。
你可以把JIT产物理解成裁缝按客人身材做的定制西装,而StubRoutines是店面里挂着的成衣——无论谁来,店里都得有现货。HotSpot源代码里,StubRoutines的生成逻辑分布在stubRoutines.cpp、stubGenerator_xx.cpp(xx是平台后缀,比如stubGenerator_x86_64.cpp)等文件里,启动时由StubRoutines::initialize2()统一驱动生成。
2.3 为什么不用C++函数,非要自己生成机器码?
这是StubRoutines最核心的设计动机,也是理解它的钥匙。理论上,JVM里的这些固定操作(比如“把参数从解释器栈搬到C++调用约定”),完全可以写一个C++函数,Java代码调JNI的时候走一遍函数调用不就行了?但现实是:
- 调用约定不同。Java方法调用使用的是JVM自己的栈帧约定(比如解释器栈),而C++函数使用平台ABI(比如System V AMD64或Windows x64)。两者之间需要一个“翻译层”,把参数、返回值、栈指针在两种约定之间来回切换。
- 性能极度敏感。这个翻译层是每次Java方法调用、每次异常抛出都可能经过的路径,如果还用普通函数一层层嵌套,开销直接爆炸。
- 有些操作根本没法用C++写。比如“生成一段代码,它把自己的地址压栈后跳转到另一个地址”,这种底层控制流的活,只能直接在机器码层面拼。
所以HotSpot在启动时就“按需定制”了一批直接能跑的机器码,并给每个例程起好名字。之后运行时一旦需要进入底层操作,JVM代码直接拿到这些例程的入口地址,一条call/jmp指令跳进去,干净利落。
注意:StubRoutines属于HotSpot内部实现,不同JDK版本的生成逻辑和例程名称会有变化,但核心设计思想——启动期预生成、平台相关、固化代码块——是一直稳定的。
3. StubRoutines在“创世”过程中扮演什么角色
3.1 JVM启动早期的时间线
想理解StubRoutines,得先把它放在JVM启动流程的时间线里看。HotSpot启动的大致顺序是:
- 解析JVM参数,初始化日志系统、内存管理相关的全局状态。
- 初始化线程系统,创建主线程。
- 初始化解释器与CodeCache。
- 调用
StubRoutines::initialize1()和StubRoutines::initialize2()生成各类Stub。 - 初始化类加载器、系统类,加载
java.lang.Object等核心类。 - 执行
main方法,Java世界正式运转。
注意第4步,它排在核心类加载之前。也就是说,StubRoutines在java.lang.Object都还没加载出来的时候,就已经在内存里待命了。这个顺序不是随意的——类加载本身就要触发异常的抛出、调用方法的跳转,而这些机制全都依赖StubRoutines。
你可以把JVM启动想象成一场舞台剧的筹备:StubRoutines是舞台搭建时的脚手架和灯光调试脚本,演员(Java核心类)还没上台,这些基础设施得先到位。
3.2 StubRoutines到底管理哪些代码?
HotSpot里,StubRoutines类本身是一个包含大量静态指针变量的“登记表”,每个指针指向一段机器码入口。常见的包括:
_call_stub_entry:解释器调用编译代码(或反过来)时用的桥接例程。_catch_exception_entry:异常处理入口。_forward_exception_entry:异常转发入口。_atomic_load/store系列:原子读写操作的平台实现。_arraycopy_*系列:各种类型数组的System.arraycopy底层实现。_safepoint_poll相关例程:安全点轮询。_verify_oop系列:对象指针验证。
这些例程覆盖了JVM运行时的地基能力:异常、对象复制、并发原语、安全点、方法调用桥接。每一样都是高频且底层的,没有它们,解释器就算能读字节码,也推不动整个Java运行时。
3.3 one_stub还是two_stub?JIT栈帧的“翻译官”
举个具体例子,call_stub是面试里最能拿出来讲的一个类型。它的存在原因是:解释器栈和C1/C2编译代码的栈帧布局不完全一致,从解释器跳进JIT代码时,需要一段例程先把寄存器、参数、返回地址按编译代码的约定布置好。
HotSpot里call_stub通常有两种形式:
- 一种是从Java态进入StubRoutine时使用,需要完整的栈帧切换和参数处理。
- 另一种是在Java态内部跳转时使用,省掉不必要的寄存器保存,逻辑更轻量。
这个设计说明什么?说明StubRoutines不是几段孤零零的代码,而是一套按使用场景做了裁剪的“多档位”例程集合。JVM启动时选哪个档位、怎么跳,完全由生成的机器码自身决定,运行时零判断、零分支预测误差,直接跳转。
4. 深入一块典型Stub:从生成到运行的完整链路
4.1 生成入口:StubGenerator的模板方法
在HotSpot源码里,StubGenerator类负责实际生成机器码,它采用了一种很朴素的“字符串拼装”思路——不过拼的是字节码指令。以x86_64平台为例,StubGenerator::generate_all()里会依次调用gen_call_stub()、gen_catch_exception()、gen_atomic_xchg()等函数,每个函数里通过__ movq(...)、__ call(...)这种宏汇编器(MacroAssembler)的调用,把机器指令逐条emit到缓冲区。
这就像写一篇没有编译器帮助的汇编代码:每条指令的字节、寻址方式、立即数大小都要明确指定。所以StubGenerator代码冗长但确定性极高,不会像JIT那样做激进的优化。
生成完以后,StubRoutines类里的静态指针变量会被赋值为这段缓冲区中对应例程的起始地址。这一步完成后,解释器、运行时、甚至其他Stub都可以通过StubRoutines::_xxx_entry()这样的静态函数拿到入口地址。
4.2 调用路径:一个异常抛出后发生了什么
举个实际场景:你的Java代码里抛了一个NullPointerException,这个异常对象是谁创建的?抛出的路径上,StubRoutines至少出现了两次。
第一次,解释器执行athrow指令,发现要抛异常。此时解释器内部会把异常对象放在指定的寄存器,然后跳转到_throw_exception_entry这个Stub。这段Stub的责任是:根据当前栈帧的状态,找到异常对应的Handler(catch块位置),如果找到了,就跳到catch块的字节码继续执行;如果没找到,就向上一帧传播。
第二次,如果异常的处理器在上一层方法里,解释器需要把栈帧回退到上一层。栈帧回退过程中,需要恢复被调方法保存的寄存器、局部变量表等,这个过程涉及的一系列“展开栈帧”的底子代码,同样有一部分落在StubRoutines里(比如_handle_exception_from_caller相关的例程在不同版本中有不同体现)。
你看到的异常堆栈能一层层向上打印出来,底层靠的不仅是Java层面的Throwable链条,还有机器码层面的栈帧穿越,StubRoutines就是那个负责在栈帧之间“开门”的角色。
4.3 运行期通过CodeCache观察StubRoutines
实际排错时,怎么知道当前JVM里有哪些StubRoutines?最常用的方式是看hs_err日志。JVM崩溃时,hs_err日志中有一段CodeCache的摘要信息,会粗略列出各个区间的分布。而更精确的视角可以用-XX:+PrintStubRoutines启动参数(配合-XX:+UnlockDiagnosticVMOptions),在JVM启动时打印所有Stub例程的地址和名称。
以服务端JVM为例,启动日志里会出现类似下面的输出:
bash复制StubRoutines::call_stub = 0x00007f8b510a0c00 [0x00007f8b510a0c00, 0x00007f8b510a0ca0) 160 bytes
StubRoutines::catch_exception = 0x00007f8b510a0ca0 [0x00007f8b510a0ca0, 0x00007f8b510a0d20) 128 bytes
这段日志告诉你:call_stub这段例程的内存范围多大、入口在哪。如果你在排查一个“为什么CodeCache碎片化严重”的问题,StubRoutines是CodeCache里“固定住的那批对象”,它们不像JIT方法会被清理,一旦生成就常驻直到JVM退出。
提示:去看
-XX:+PrintStubRoutines输出时,别被一大堆地址吓到。你只需要关注几个主入口(call_stub、catch_exception、forward_exception、arraycopy)的地址区间就够了,排查时能跟hs_err日志里的栈帧地址对上号,就谢天谢地了。
5. StubRoutines相关面试题与八股文的正确打开方式
5.1 面试官问“JVM启动过程”,你该怎么答出区分度
很多人答JVM启动流程,会从“类加载子系统启动”开始讲。但稍微有深度的面试官,会继续追问:“类加载之前呢?HotSpot自己是怎么跑起来的?”
这时候你可以把StubRoutines拿出来做加分项。比如这样组织回答:
- JVM是一个C++程序,启动先做本身的运行时初始化。
- 初始化过程中,有一个步骤专门生成平台相关的机器码例程,也就是StubRoutines,它们负责方法调用桥接、异常处理、原子操作、数组复制等底层地基。
- StubRoutines的生成早于Java核心类的加载,因为它本身就是类加载和异常机制依赖的基础设施。
- 之后才是Java世界的启动,类加载器初始化,核心类加载,主类执行。
这个回答的价值在于:它把“JVM启动”从八股文式的“加载-连接-初始化”升级到了进程级视角,体现的是你对虚拟机本身的关注。
5.2 JVM内存模型与StubRoutines的关系
JVM内存模型的经典内容——堆、栈、元空间、CodeCache——里面真正能跟StubRoutines挂钩的是CodeCache。CodeCache存的就是“机器码”,包括JIT编译产物和StubRoutines。所以调优时如果发现CodeCache不足(CodeCache is full的告警),不能只想着“是不是JIT编译方法太多”,也得考虑StubRoutines占掉的固定开销。
不过说实话,StubRoutines通常只占CodeCache很小的一部分(几百KB量级),一般不会成为CodeCache溢出的主因。它更像一栋楼里的楼梯间——固定占一块面积,但你不能因为它小就忽略它,因为所有楼层(方法)都要经过它。
5.3 热词里那些“java八股文”其实有个共同盲区
热词列表里出现了大量“java面试八股文”、“java八股文”的检索词,这说明求职市场对JVM基础知识的查考热度一直没降。但多数八股文资料里,StubRoutines连一个章节都轮不到,因为社区资料很少讲这一层,而且JDK源码变动不频繁,写出来不容易“维护”。
我的建议很直接:如果你只是为了应付面试,StubRoutines没必要深挖到每条指令的字节大小。你需要掌握的粒度是——它是什么、什么时候生成、管哪些事、和JIT产物有什么区别、从哪个入口可以观测它。这五条能自然讲清楚,已经超过绝大多数面试者了。
6. 排查实录:StubRoutines相关的坑与技巧
6.1 hs_err日志里找不到方法地址?可能撞进了Stub
我处理过一次线上JVM崩溃,hs_err日志里显示崩溃地址落在CodeCache,但Compiled method信息那一段是空的,只有一行很小的字写着“StubRoutines::_throw_NullPointerException_entry + 0x34”。当时排查的同学一头雾水,觉得我们明明没有调用任何native方法,崩溃地址怎么会落进一段“看不到源码”的代码里。
其实这就是StubRoutines在干活。Java里抛NPE,HotSpot会走到专门生成好的NPE抛出一段Stub,该Stub内部可能触发了某个平台指令级问题(比如非对齐访问、栈失衡),于是JVM直接crash。这类问题的关键不是“Stub写错了”,而是“为什么调用这个Stub之前的状态不对”——通常指向调用方与被调方栈帧不匹配,典型场景是JIT编译器bug或者字节码被篡改(比如运行时增强类)。
排查思路:
- 对比崩溃顶帧往上几帧,找第一个“正常”的Java方法或VM方法。
- 检查该栈帧是否和StubRoutines附近的地址存在固定偏移,如果偏移每次都不同,考虑是不是堆内存随机化与CodeCache布局的问题。
- 拉出启动时的
-XX:+PrintStubRoutines日志,确认崩溃地址确实落在某个已知Stub区间。
6.2 常见问题速查表
| 问题现象 | 可能原因 | 排查/处理建议 |
|---|---|---|
| hs_err日志崩溃地址显示在Stub区间 | 栈帧状态异常、JIT编译产物与Stub约定不一致 | 加-XX:-TieredCompilation或-XX:+PrintCompilation复现,缩小范围 |
启动时-XX:+PrintStubRoutines无输出 |
参数顺序或诊断参数没解锁 | 确认同时加了-XX:+UnlockDiagnosticVMOptions |
| CodeCache容量紧张,怀疑Stub占太多 | StubRoutines通常只占KB级别,主角仍是JIT编译方法 | 用-XX:ReservedCodeCacheSize调大,同时分析编译日志 |
| 不同JDK版本Stub名称不一致 | HotSpot版本演进了例程拆分 | 以当前JDK源码为准,别拿老文章死记 |
6.3 一个小技巧:怎么确认某段机器码是不是StubRoutines
如果你正在用JDK自带的hsdis插件看汇编(配合JIT Watch之类的工具),看到一段汇编代码又短又整齐、没有任何源码对应,可以先怀疑它是StubRoutines。判断方法很朴素:看它是否带有明显的固定功能命名,比如StubRoutines::开头;再看它是否在启动阶段就存在,可以通过打印StubRoutines到日志验证。
另外,调试时还可以用一个冷门参数-XX:+PrintInterpreter配合-XX:+PrintStubRoutines,两者一起输出,能直观看到解释器模板和Stub例程在内存里的排布关系。这个组合我在排查“解释器与JIT跳转异常”时用过一次,效果直白粗暴——两段代码的起始地址一对比,跳转偏差一目了然。
7. StubRoutines的扩展认知:从CodeCache到JVM调优
7.1 CodeCache的结构:Stub只是拼图一角
CodeCache整体的可观测性,直接影响到你对JVM运行状况的判断。以JDK 8u以后的分段式CodeCache为例,通常分为non-method、profiled、non-profiled三个段:
| 分段 | 用途 | 典型内容 |
|---|---|---|
| non-method | 存储非方法代码 | StubRoutines、JavaNativeInterface桥接代码、运行时Stub |
| profiled | 带profiling信息的编译方法 | C1编译产物(部分场景) |
| non-profiled | 完整优化编译方法 | C2编译产物 |
StubRoutines落在non-method段,它们不会被JIT编译器清理,生命周期与JVM一致。因此,如果你用类似jcmd Compiler.codecache的命令查看CodeCache统计,会发现non-method段的占用几乎恒定。这不代表StubRoutines是负担——相反,它意味着一套“永远不会被反优化”的底层储备,稳定性在这里就是性能的一部分。
7.2 调优时的视角:别把Stub当报表,把它当基础设施
每次做JVM调优,我都会把StubRoutines放在“基础设施视角”里看:它的内存占用稳定可预期;它的执行频率极高但单次开销极小;它和解释器、JIT、异常机制深度耦合,不适合随意修改。有些团队想通过修改StubRoutines生成逻辑来优化某些底层性能(比如自定义arraycopy),这种尝试往往得不偿失,因为你动的是地基,上面几百层楼全部受影响。
更实际的调优思路是:既然StubRoutines是确定的,那么针对它的观测就是一条“参考基线”。比如,你可以在压力测试阶段记录StubRoutines的入口地址,然后对比某些热点方法的调用栈,观察是否频繁穿越call_stub——穿越得多,说明解释器与JIT之间切换频繁,这时候优化点应该是JIT编译阈值,而不是Stub本身。
7.3 与其他虚拟机结构的边界:JNI、VMOperation、safepoint
StubRoutines常常出现在JNI临界区、VMOperation、safepoint这类底层状态切换的场景中。比如从Java线程进入safepoint时,需要一段例程检查当前线程是否应该阻塞,这个检查状态的过程就可能在StubRoutines中完成。你观察线程转储中处于at safepoint的线程时,其中一个隐藏的环节就是这些代码在回答“我该停吗”。
这也引出一个面试里可能会追问的点:StubRoutines和JavaThread、VMThread的关系。简单说,StubRoutines是被动响应的工具代码,它是被其他线程调用后执行;而VMThread是发起VMOperation的管理线程。两者不是同一层级的实体,前者是“路”,后者是“车”。
8. 我在实践中感受到的StubRoutines使用方法
8.1 不要试图修改它,但要学会读取它
StubRoutines是JVM生成的代码,不是配置文件,也不是启动参数能直接改的逻辑。网上有一些极端做法,比如想通过修改stubGenerator_x86_64.cpp并重编JVM来调整arraycopy的行为,除非你是OpenJDK本身在演进新特性,否则不建议在生产里这么做。
读它、观测它,才是日常工作用得上的能力。我养成了一个习惯:每次遇到奇怪的JVM崩溃或性能拐点,先开-XX:+PrintStubRoutines把启动时的基线数据留下来,再结合hs_err日志、jcmd输出一起看。这套“基线+现场”的组合,比凭空猜问题高效很多。
8.2 用StubRoutines当锚点,建立JVM整体认知
如果你还在学习阶段,给你一个实操建议:把StubRoutines当作学习HotSpot源码的“锚点”。从stubRoutines.hpp里的静态变量声明开始,看到stubGenerator怎么生成每一段Stub,再看解释器什么时候调它们,最后到JIT编译器如何基于它们做拼接——这一条链路走通了,你对“JVM怎么真正运行起来”的理解,会从“一本八股文”变成“一张有脉络的地图”。Java世界的地图这个比喻,到这里才算名副其实。
8.3 最后的最后:一个能写在简历里的经验
真要在简历或面试中体现这一块,最稳妥的经验描述是:“深入研究过HotSpot启动期生成StubRoutines的机制,能结合hs_err日志与CodeCache结构分析JVM底层崩溃/性能问题。”这句话不是让你去造轮子,而是表明你能看懂别人看不进去的那层细节。在Java岗位竞争烈的市场里,这种不人云亦云的底层观察,确实能帮你和“背诵型选手”区分开来。
我做JVM底层问题排查这些年,最大的体会是:越是接近机器码的东西,越不会说谎。StubRoutines就是一串写在内存里的、朴素而精确的“低级语言”——它不优化自己、不解释自己,但每一个跳转和字节,都在忠实地捍卫着Java世界的运行秩序。你不需要爱上它,但值得认识它。
