首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
HotSpot中C++与Java方法互跳机制:JavaCalls与JNI桥接详解
📅 2026/10/15 6:33:38
✍️ 爱科研究院
👁 阅读 3,247
很多人写 Java 写了好几年背过八股文知道 JVM 有解释器、有 JIT、有 GC但很少去追问一个最直接的问题我们用来启动 Java 程序的 HotSpot 本身是一个 C 写的程序Java 方法却是跑在“另一个世界”里的字节码和机器码那么控制权到底是怎么从 C 逻辑切进 JVM 逻辑的换句话说那个“跳”的动作发生在哪里谁完成了栈帧的切换谁又做了参数规格的适配这篇文章我想把手伸到这条边界上把 HotSpot 里“C 跳 Java”“Java 跳 C”这两条路径都捋一遍。内容适合两类人一类是每天排查线上问题、看到 hs_err 里 CFrame/JavaFrame 混在一起就头皮发麻的后端工程师另一类是读 JVM 源码读得断断续续、想把这些碎片串成一条线的开发者。我会尽量用工程实践里的说法来讲一些函数名和调用链会直接给出来方便你回头追代码。1. 先把问题本身拆开两个世界要互相跳转到底卡在哪1.1 字节码世界与机器码世界的边界HotSpot 执行 Java 方法时其实有两条执行通道解释器逐条解释字节码JIT 编译器把热点方法直接编译成当前平台的机器码。但无论是解释器还是 JIT 编译出来的机器码它们和 HotSpot 自身的 C 代码都处在完全不同的运行时约定里。C 代码调用函数遵循平台的 C ABI参数在寄存器或栈上按规则铺好返回后栈恢复Java 方法则有自己的“Java 调用约定”参数可能放在 Java 栈的局部变量区也可能放在寄存器里返回值有 int/long/对象引用等不同类型并且对象引用在 GC 搬动对象后还可能会失效。所以“从 C 跳入 JVM 逻辑”这件事表面看是一个函数调用实际上至少要解决三件事调用约定转换入参和返回值要按 Java 的规则摆好。栈帧隔离与连接C 的栈和 Java 的栈到底是同一个物理栈还是分开的。状态与安全GC 要知道哪些帧是 Java 帧、哪些是 C 帧线程当前处于什么执行状态会不会在跳转途中撞上安全点。只有把这三个问题都想清楚了C 代码才能真正安全地“进入”Java 世界。1.2 三条典型的跳转路径在实际运行中C 和 JVM 逻辑之间的跳入跳出一共就几条固定路径。梳理清楚这这些路径比背一堆 JVM 参数有用得多第一条JVM 启动时C 的 main 流程经过层层初始化后最终把控制权交给 Java 的 main 方法。这是“C 主动进入”的最典型场景。第二条运行过程中 C 需要回调 Java 代码典型如反射调用、JNI 在 native 方法里再调用 Java 方法、JVM 内部的某些 Service 逻辑。这类调用统一走JavaCalls。第三条Java 代码执行到native方法时解释器或 JIT 代码跳进 JNI 桥再跳入 C 写的 native 函数。这是“Java 跳到 C”的方向底层机制也依赖一套共享的桥接代码。这三条路里面的核心枢纽其实高度一致要么是JavaCalls机制要么是 JNI entry它们最终都会汇聚到SharedRuntime和解释器/编译器生成的代码桩上。1.3 统一的桥Method、Frame、Handle在 HotSpot 里Java 方法在 C 世界中的化身是Method对象。C 代码想进入某个 Java 方法第一步一定是要拿到Method而Method对象本身是存在于 Java 堆里的所以持有它时必须用methodHandle包一层防止 GC 搬动之后变成野指针。另外就是栈帧。你可以在崩溃日志里看到两种帧CFrame和JavaFrame。它们可能处在同一条物理栈上但语义完全不同。C 帧是发起调用者的现场Java 帧则是解释器或 JIT 代码按自己的布局抠出来的执行现场。两者之间的过渡区就是那些桥接代码adapter/stub在做切换。Handle 则是让引用在跳转过程中不失效的关键。C 侧持有 Java 对象时不能直接保存裸指针必须保存在Handle里第一次进入 Java 世界之前的HandleMark会划定这批句柄的生命周期。这个设计初看很绕但理解了它你就能明白为什么跳转点大多是成对出现的进入前建 Handle/Mark返回后清 Handle/Mark。2. 从 C 主动跳向 JavaJavaCalls 机制解剖2.1 JavaValue 和 JavaCallArguments先约定好返回值与入参如果我们要在 C 代码里调用一个 Java 方法最直觉的写法是定义一个函数指针然后 call 过去。但 HotSpot 不能这么做因为 Java 参数类型系统和 C 不完全对齐。它设计了一套自解释的参数描述结构。JavaValue用于描述返回值它里面有个_type字段标明当前操作的是T_INT、T_LONG、T_OBJECT还是T_VOID数据本身放在一个 union 里。JavaCallArguments则负责装传进去的参数每一参数也是按 Java 类型存放。这样设计的好处是跳转逻辑可以完全根据类型标记来决定如何从 JavaCallArguments 里取出数据、放到哪个寄存器或者哪块局部变量槽位不用在 C 层面感知具体方法签名。实际代码里看到的调用长这样JavaValue result(T_VOID); JavaCallArguments args(handle); JavaCalls::call(result, method, args, THREAD);如果你从反射的 JNI 实现往深处追追到底层就会看到一模一样的三段式声明返回值、组装参数、发起调用。2.2 JavaCallWrapperC 世界最后的“安全区”直接调JavaCalls::call还不够HotSpot 在真正调用 Java 方法之前还要先建立一个JavaCallWrapper。这个 wrapper 才是进入 Java 世界之前的“合法状态”。JavaCallWrapper的构造函数会做几件关键事情保存当前线程的last_java_frame也就是记录进入 Java 世界前这个线程在上一个 Java 帧的位置。把当前线程状态标记为_thread_in_Java。保存之前的JavaFrameAnchor链建立新的 anchor。为什么要这么干因为 GC 扫描线程栈时需要从 anchor 出发遍历所有 Java 帧。如果没有这一层 wrapperC 代码调用一个 Java 方法后GC 就无法区分哪一段栈是 Java 的、哪一段栈是 C 的扫一半就会中断。从工程角度理解JavaCallWrapper就是 C 逻辑和 JVM 逻辑之间的“安检通道”进入前做好身份登记返回后恢复原状。任何绕过它的直接跳转本质上都是让 GC 在栈扫描时踩空。2.3 JavaCalls::call_helper 的调用链从JavaCalls::call进去后真正干活的是JavaCalls::call_helper。它的核心流程大致如下检查目标方法是否“可进入”如果方法是抽象方法或者编译还没有完成会先做解析或者等待。计算参数布局根据方法签名把 JavaCallArguments 里的值搬到 Java 栈帧应有的局部变量区。设置返回地址和异常处理入口。取到方法的entry_point调用它。这里的entry_point是关键中的关键。对一个还没有被 JIT 编译的方法entry_point是解释器入口对一个已经被 JIT 编译的方法entry_point是 C2I adapter后面详说。call_helper自己并不关心你走哪条执行通道它只负责把人安全送到entry_point。调用链大致是这样JavaCalls::call - JavaCalls::call_helper - Method::verified_entry_point - Interpreter::invoke (解释执行) - SharedRuntime::generate_c2i_adapter (编译执行入口适配)这条链之所以能成为所有 C 调用 Java 的“总闸门”是因为它把参数、GC 根、异常状态这些细节全部统一处理了。2.4 为什么几乎所有 C 调 Java 都绕不开这条路你查 HotSpot 源码时会发现JNI 的CallStaticIntMethod、反射入口、JFR 的一些回调、各类 ServiceThread 的任务最终都会收敛到JavaCalls。原因很简单这是被封装得最完整的跳转通道所有安全点检查、栈帧锚点、句柄保护都在这里做完了谁用谁放心。如果你自己写过 JVM TI 插件或者内部服务模块想在某个 C 线程里临时调用一个 Java 方法最稳妥的做法也是借助JavaCalls而不是手动伪造一个 Java 帧。因为伪造帧没有 GC 保障线上环境里分分钟被安全点搞挂。3. 出口一解释执行从 C 栈帧切到 Java 栈帧3.1 Interpreter::invoke 之前发生了什么当Method::verified_entry_point发现目标方法还没有 JIT 版本时返回的就是解释器入口。在模板解释器里这个入口的生成代码叫generate_normal_entry。它做的事情非常像编译器生成函数序言但又比普通函数序言复杂得多。在真正跳进 Java 帧之前需要先完成一次栈溢出检查。如果当前栈空间不足以创建新的 Java 帧就进入运行时例程去做StackOverflowError的处理。这个检查是解释器生成的汇编代码里最前排的几条指令因为它必须发生在任何 Java 局部变量落栈之前。然后就是参数搬运。C 代码通过JavaCallArguments传进来的参数会按 Java 方法签名逐个移动到一个“空的” Java 帧里。这部分逻辑由解释器生成的 stub 完成不同版本的 HotSpot 细节不同但整体思路一致把入参放到locals区域多余的参数槽位留空后续字节码通过iload、aload之类指令读取。3.2 模板解释器的入口interpreter_entry模板解释器是 HotSpot 的一大特色。每个字节码对应的执行逻辑都是一段预先生成的机器码模板整个解释器本质上就是一张巨大的代码调度表。讲解器入口的汇编 stub 会把 CPU 的状态切到解释器约定上设置好 Java 栈帧的结构、初始化字节码指针、把bcp指向第一条字节码然后开始 dispatch。如果你在 gdb 里把一个正在执行 Java 方法的线程停住看到的调用栈通常不是一个个 Java 方法名而是InterpretedRFrame、interpreter_frame这些被解释器重新解释过的帧。这时候用thread-print()之类命令可以看到 Java 方法栈。3.3 参数搬运与栈帧初始化Java 方法帧的布局和 C 函数栈帧完全不同。它的头部是一个frame结构包含 previous frame、locals 基址、monitor 区等方法真正用到的局部变量和操作数栈都在这个帧里。generate_normal_entry会依次完成读取 method 常量池里的max_locals和max_stack根据它们算出帧大小。预留局部变量区并把 caller 传入的参数写进去。如果构造一些栈上锁信息monitor在帧里预留锁区。设置last_java_frame让 GC 能从当前线程的 anchor 一路扫到这个新帧。我突然发现很多人在看 hs_err 文件的时候没有把last_java_frame当回事。其实 GC 报错时如果你能顺着 anchor 链往下捋方法栈和 native 栈是完全能分开看清楚的。3.4 从 Java 方法返回 C谁负责恢复现场解释器执行到 return 系列字节码时会把返回值写到一个约定好的位置栈顶或者寄存器取决于平台和调用约定然后把 Java 帧从物理栈上退掉最后跳回调用者。这个“调用者”可能是另一个解释帧也可能是 C2I adapter还可能是最初发起调用的JavaCalls::call_helper。为了让返回逻辑统一HotSpot 在每条 return 指令的 handler 里都嵌入了对应类型的返回逻辑返回值按类型写好后pop_frame再ret到entry_point开头保存的返回地址。这里最容易踩坑的是返回值是对象的情况。解释器返回对象引用时如果栈上其他对象发生 GC 搬运返回的引用可能是旧地址。HotSpot 约定返回对象时会先在 Java 帧的某个特定槽位放好一个 handle由调用者去读取。这个约定必须严格遵守否则你看到的就是各种莫名其妙的对象丢失。4. 出口二JIT 编译执行nmethod 和适配器4.1 编译后的方法不是普通函数调用当一个方法被 C1 或 C2 编译后得到的是一段独立的机器码存放在称为nmethod的代码块里。理解“编译后”是什么意思很关键这个方法已经不再用字节码解释执行了CPU 执行的就是原生的机器指令。但与此同时这个nmethod的执行入口并不能被 C 代码直接 call 过去。因为进入nmethod之前我们需要满足 Java 世界的要求栈帧布局、安全点、可重入尤其是 GC 要知道 Java 帧在哪里。所有编译后的方法入口都套着一层适配代码这层代码叫 adapter适配器。4.2 I2C 与 C2I 适配器两种执行方式之间的海关解释器执行 Java 方法时遇到一个编译好的调用目标需要跳转到nmethod这个桥接代码叫 I2CInterpreted to Compiledadapter。反过来一个编译方法调用了一个还没编译的解释执行方法桥接代码叫 C2ICompiled to Interpretedadapter。I2C adapter 要做的事几乎和 Interpreter::invoke 一致把解释器帧里的局部变量区数据按编译代码的约定搬到寄存器或者编译帧栈上。C2I adapter 则按反方向做一遍。这样设计的原因是解释器帧与编译帧的布局完全不同如果没有适配层两段机器码直接互相 call 一定会错乱。你可以把 adapter 想成桥梁同一座桥从解释器到编译代码从编译代码回解释器方向不同但桥面是共享的。HotSpot 在SharedRuntime里为每个方法生成这两组 adapter缓存起来复用。4.3 热点栈替换OSR也是一种跳转还有一种特殊跳转值得单独拿出来说OSR栈上替换。某个方法还在解释执行但已经被判定为热点编译器把它编译出来了。为了不浪费已经解释执行了半截的栈帧编译器会生成一个特殊的 OSR 入口让解释器在执行到循环回边时直接跳进编译版本。这个入口不是从方法开头执行的而是从循环体对应的代码块开始。OSR 入口也要做参数搬运和帧适配但它服务的对象是“解释器正在执行的同一个方法实例”所以栈帧之间是同一个 Java 方法的前后半程。多亏了模板解释器和编译器共同维护的帧结构OSR 才能工作。如果你在 nid 上看到线程卡在某个nmethod里但栈底还是解释器的帧不用惊讶那很可能就是 OSR 之后的混合栈。4.4 从 JavaCalls 调用编译方法时实际走的路前面提到JavaCalls::call最终拿到entry_point。如果目标方法已经 JIT 编译entry_point就是 C2I adapter 的地址。这里的“C2I”虽然名字里有个 C实际上指的是从任何 C/C 调用方进入到编译代码入口时的适配。C2I adapter 会从汇编层接收参数可能散落在寄存器、栈槽和 JavaCallArguments 里。把它们组织成编译代码期望的栈帧形态。建立 JavaFrameAnchor。跳转到nmethod真正的方法体地址。整个过程对JavaCalls::call_helper来说是透明的它看到的是一个普通的函数地址进去执行返回后返回值已经在约定位置。这也解释了为什么 C 调试器里单步追踪编译代码会显得很混乱因为真正的方法体被 adapter 包了厚厚一层断点打到nmethod的入口等价于还没进入方法本体。5. 反向跳转Java 调 native 方法如何回到 C5.1 native 方法解析从 Method 到 entry_point解决了 C 到 Java 的方向我们再看另一个方向Java 方法里加了个native关键字怎么就能自动跑到 C 函数里去Java 方法在解析时如果发现标志里有JVM_ACC_NATIVE就会走 native 方法解析逻辑。解析出来的 entry_point 不是解释器而是一个 stubnative_entry。这个 stub 会创建 Java 栈帧但和普通解释帧不同它不需要局部变量区做参数搬运因为参数要直接传给 C 函数。然后它会跳转到JNI_ENTRY宏包装的函数体。5.2 JNI_ENTRY 宏背后的三件事JNI_ENTRY是所有 JNI 函数的共同入口它做三件事把当前线程状态从_thread_in_Java改成_thread_in_native。这一步意味着从这个点开始线程不再持有 Java 锁如果 GC 想安全点同步可以等待这个线程进入 native 区域后阻塞它。保存之前JavaFrameAnchor中指向的last_java_frame让 GC 知道 native 调用下面的 Java 帧边界在哪里。建立NativeReturnWrapper或等价的保护为返回时可能发生的异常检查做准备。如果你手动实现过 JNI 层可能对这些宏没有感觉。但是当你看到 hs_err 里线程状态是_thread_in_native说明 Java 代码正在一个 JNI 函数里执行没有持有 Java 堆锁此时不能立即执行需要 Java 堆状态稳定的操作。5.3 native 回 JavaJNI 回调函数本质是 JavaCalls在 JNI native 方法里经常要反手调 Java 对象的方法jclass clazz env-FindClass(com/example/Util); jmethodID mid env-GetStaticMethodID(clazz, parse, (Ljava/lang/String;)I); jint val env-CallStaticIntMethod(clazz, mid, jstr);你看这一串动作从 C 侧发起一个调用目标却是 Java 方法。所以CallStaticIntMethod这类 JNI 回调函数底层实现就是包装了一层JavaCalls。整个流程可以概括为JNI 函数把 JNI 层的jobject/jclass/jmethodID转成 HotSpot 内部的oop/Method*然后调用JavaCalls::call。参数中的jstring对象引用在进入 Java 世界前会先转成 Handle防止 GC 搬动对象后引用失效。所以你会发现很有意思C 调 Java 和 native 回调 Java走的是同一条路区别只在于入口是否经过了JNI_ENTRY的状态切换。5.4 返回时的异常处理与状态恢复native 方法返回 JNI 层时JNI 函数的结尾宏JNI_END做的事更关键检查线程的pending exception是否为 null。如果不是 null就把异常对象包裹成jthrowable返回给 Java 层让 Java 代码抛出。把线程状态恢复为_thread_in_Java。恢复之前保存的 anchor。这个检查如果你漏掉最常见的结果是异常被吞掉或者更糟在 Java 帧已经被退掉之后还能抛出一个野异常直接把 JVM 搞崩。实战里经常有同事在 native 回调里写了很久的逻辑忘记在方法出口处理 pending exception等到线上出现各种诡异联锁问题时才想起来查这一层。所以但凡你写过 JNI 层务必记住检查异常是 native 返回换挡时的一次强行纪律。6. 安全点、线程状态与调试定位6.1 thread_state 状态机为了支撑上面的跳转流程每个 Java 线程都维护了一个名为_thread_state的状态字段。主要状态包括状态含义典型场景_thread_in_Java正在执行 Java 字节码/编译代码解释器或 JIT 代码运行中_thread_in_vm正在执行 C 的 VM 逻辑JavaCalls 调用前的解析、反射准备_thread_in_native正在执行 JNI 传入的原生函数调用了 native 方法后_thread_blocked线程阻塞在锁监视器/等待synchronized 等待或 park看崩溃日志时state_thread_in_native表示当前线程正在 native 代码里往往伴随Native framestate_thread_in_vm表示正在执行 C VM 内部逻辑state_thread_in_Java才是真的“正在跑 Java 方法”。这个状态机也是安全点机制的基础。GC 进入全局安全点时会看每个线程的状态在 Java 世界的线程会被中断到安全点在 native 状态的线程会收到一个阻塞请求等它从 native 返回时统一阻塞。JavaThread 的状态必须在每个可能被安全点中断的地方正确维护任何遗漏都会造成SafepointSynchronize卡死或 NULL 访问。6.2 安全点期间不能随便跳理解了 thread_state就能明白为什么跳转例程里总是小心翼翼地在状态切换时“暂缓”。安全点同步并不是阻塞所有线程它要求所有 Java 线程都停留在已知位置也就是安全点区域才能开始 GC。C 代码执行时不一定是安全点。所以JavaCalls::call这类跳转点必须在进入 Java 世界之前确保当前线程是安全的否则一进入解释器就是一个不可中断的区域GC 和 JIT 的挂起都无法生效。这层约束对排障也有启发如果你自己写的内部模块在某个 C 调用里做了很长时间操作不显式进入安全点线程状态一直挂在_thread_in_vm那 GC 就会一直等你最终表现为安全点停顿暴涨。很多线上 GC 长停顿的根因并不在 GC 本身而在这些跨世界跳转的“门岗”处。6.3 调试工具与断点设置真想观察“从 C 跳入 JVM 逻辑”这个过程建议在调试器里下这几个断点按先后顺序跟踪break JavaCalls::call break JavaCalls::call_helper break Interpreter::invoke break SharedRuntime::generate_c2i_adapter break nmethod::nmethod先打断JavaCalls::call再步进入 call_helper然后观察Method::entry_point的走向如果进了解释器走InvocationCounter之类的逻辑如果进了 C2I adapter你会看到一段完全不同的汇编代码。想观察 Java 调 JNI 反向跳转打断JNI_ENTRY和JNI_END可以看到线程状态在断点前后的变化进入 JNI_ENTRY 前thread-thread_state()是_thread_in_Java。进入后状态变成_thread_in_native。返回时经过 JNI_END状态恢复_thread_in_Java。实战里我一般还会顺手打一个条件断点只在某个特定方法被调用时才停。比如在JavaCalls::call上设条件method-name()-equals(main)。这样过滤掉海量内部调用只观察入口。6.4 现场排查经验栈上为什么同时有 CFrame 和 JavaFrame最后一个很实用的点。线上崩溃栈里hs_err 通常会分段显示Java frames: ... com.example.X.run ... jdk.internal.reflect.DirectMethodHandleAccessor Native frames: ... JavaCalls::call_helper ... Interpreter::invoke当你看到 Java frames 上面还有一段Native frames说明当前线程正处于“C 逻辑和 Java 逻辑之间”。最常见的现场是C 通过 JavaCalls 调用了一个 Java 方法Java 方法内部又调了 native 方法native 方法崩了。于是栈上既有 CFrame又有 JavaFrame。这时候不要尝试把两段栈当连续整体看。要先找last_java_frame和anchor确认最靠近 native 层的 Java 帧是哪一个然后逐个往上恢复 Java 方法栈。这也是调试器里最反直觉的一点C 步进的栈是“物理栈”Java 采样栈是“逻辑栈”两栈错位是常态。如果用 gdb 里thread-print()通常能看到完整的 Java 栈但和 hs_err 里两段式展示的对应关系需要慢慢对照。我的经验是优先关注state和last_java_frame确定边界后再看业务代码。实操总结一条记忆主线回顾整个跳转机制其实有一条非常清晰的记忆主线任何一次 C 逻辑进入 JVM 逻辑本质上都是先统一了参数与返回值再通过JavaCalls走到目标方法的entry_point而entry_point要么指向解释器生成的汇编入口要么指向编译代码的 C2I adapter。反过来 Java 跳 C 则要先经过 JNI 桥把状态切到 native 世界返回时再严格恢复。顺着这条线去追代码你会看到 HotSpot 里真正复杂的不是某一个函数而是为了支撑这些跳转而铺的大量桩代码、状态维护和适配层。它们的存在不是为了炫技而是为了在 GC、安全点、异常传播都不可妥协的前提下给两个世界留出安全通道。如果你手头正好在看一个调用链相关的崩溃问题我个人的习惯是先不急着看业务代码而是把thread_state、last_java_frame、pending_exception这三个字段抓出来理清当前线程卡在哪个世界再开始看方法栈。这一步理顺了后面的大多数问题都会变得非常直接。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/15 6:33:38
GEO生成式引擎优化:从SEO到内容被引用的技术实践
2026/10/15 6:33:38
Linux程序地址空间:虚拟内存与内存布局全解析
2026/10/15 6:28:38
VL805手册实战:PCIe转USB3.0桥接芯片硬件设计避坑指南
2026/10/15 8:28:51
小红书私域流量转化实战指南
2026/10/15 8:28:51
SpringBoot+Vue房屋租赁管理系统全栈开发实践
2026/10/15 8:28:51
若依框架周末参数接收异常分析与解决方案
2026/10/15 8:28:51
状态空间表达式入门:从直流电机建模到嵌入式部署
2026/10/15 8:28:51
SpringBoot渔具管理系统开发实践与架构设计
2026/10/15 8:23:50
PD-L1抗体在肿瘤免疫治疗中的原理与应用
2026/10/15 0:02:59
AI Coding时代的柔性之美:从静态软件到自演化软件,软件研发正在重新进化
2026/10/15 0:02:59
亨得利客户服务中心官方网点分布一览:2026年全国多城门店地址与最新联系电话公布
2026/10/15 0:02:59
防关联浏览器哪个好?三款产品实测差异解析
2026/10/13 12:25:09
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/14 18:35:19
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/14 14:52:59
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/15 1:07:55
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/15 5:07:59
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/15 8:10:01
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)