凌晨两点多被电话叫起来,某个业务节点整进程消失了,运维只甩过来一句“机器上的 Java 进程没了,日志目录多了个 hs_err_pid 文件”。等我把那份日志翻到第二页,就看见那行熟悉得让人头皮发麻的字:Problematic frame: # C 0x0000000000000000。这不是 OutOfMemory,也不是 GC 卡死,而是 Java 虚拟机在 native 层直接撞墙退出了,连一句像样的 Java 异常都没留给你。这篇东西就是围绕这行字展开的:它到底在说什么、为什么地址是 0、哪些写法会稳定复现它、现场该怎么保、日志该按什么顺序读、参数该怎么配。写给谁看?写过 JNI 或者维护过带 native 依赖服务的人看最合适,但只要你排查过一次进程莫名消失,也能从里面的判读方法里拿到东西。
1. 读懂这行字:Problematic frame 到底指向谁
很多人第一次看到这行,第一反应是“地址 0,那就是空指针呗”,然后去翻 Java 代码找 NPE,翻三天也翻不出来。方向一开始就偏了。这行输出是虚拟机的崩溃处理器(信号处理器)在收到致命信号后,做栈回溯时打印出来的“出问题的那一帧”,它描述的层级是机器码层,不是 Java 层。你得先搞清楚它说的是哪个执行单元,再谈后面的定位。
1.1 三种 frame 前缀,决定了你该往哪个方向查
虚拟机的栈回溯会给每一帧打一个前缀字母,这个字母的信息量极大,比后面那串地址有用得多。常见的有这么几类:J表示这段是 JIT 编译出来的 Java 方法机器码;j表示解释器正在执行的字节码帧;V或v表示虚拟机自身生成的代码(比如 stub、适配器、运行时例程);C表示 native 的 C/C++ 代码,也就是你或者第三方库编译出来的那部分。
看到# C,方向就非常明确了:崩在 native 代码里。它可能是你自己写的 JNI 实现,可能是某个带 native 层的中间件,也可能是某个看起来纯 Java 但实际上偷偷加载了 .so 的库。我见过最迷惑的一次,是一个号称“纯 Java 实现”的加解密组件,实际上通过 SPI 加载了一个本地加速库,出问题的时候 Java 栈上干干净净,全靠这行# C才把嫌疑锁定过去。
反过来,如果前缀是J,那重点就该放在 JIT 编译优化、方法内联、边界检查消除这些方向;如果是V,那多半是虚拟机的运行时例程,比如 safepoint、异常分发、类加载,这种往往和版本缺陷或者内存越界有关。前缀不同,后面查的东西几乎不重叠,所以第一步永远是把前缀看清楚。
1.2 一份 hs_err_pid 日志的标准阅读顺序
崩溃日志动辄几千行,从头读是浪费时间。我习惯固定按这个顺序扫,基本三分钟内能定性:
| 顺序 | 段落 | 你要找什么 |
|---|---|---|
| 1 | 头部信号摘要 | 信号类型、pc 值、pid/tid |
| 2 | Problematic frame | 前缀字母、地址、有无符号 |
| 3 | Current thread | 线程名、状态(_thread_in_native 等) |
| 4 | siginfo | si_signo、si_code、si_addr 三元组 |
| 5 | Registers | RIP 落在哪个模块、关键寄存器是否为 0 |
| 6 | Native frames / Java frames | 崩溃点的调用链上下文 |
| 7 | Dynamic libraries | 是否加载了同名多版本、路径是否异常 |
| 8 | VM Arguments | 排查参数是否被覆盖、是否开了奇怪选项 |
头部那段通常是这个样子(我裁掉了无关行):
# A fatal error has been detected by the Java Runtime Environment: # # SIGSEGV (0xb) at pc=0x00007f3c8a1b2c40, pid=27841, tid=27899 # # JRE version: ... # Problematic frame: # C 0x0000000000000000注意这里有个非常关键的细节:头部的pc=是有一个具体地址的,0x00007f3c8a1b2c40看起来完全正常,但下面 Problematic frame 却打印了全零。这两个值不一致,本身就说明了一件事——真正执行到的位置是在某个模块里的,只是虚拟机没能把那个 pc 翻译成函数名。这个矛盾点,恰恰是后面第二节要展开的核心。
siginfo那一行同样不要跳过,它给出的是“为什么崩”而不是“崩在哪”:
siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000000si_signo: 11就是 SIGSEGV,段错误;si_code: 1 (SEGV_MAPERR)表示访问的地址没有映射到任何内存区域;si_addr是触发故障的那个地址。有的日志这里是SEGV_ACCERR(值为 2),意思是地址映射了但权限不对——这两种情况的排查方向差别很大,MAPERR更像野指针或空指针,ACCERR更像踩了只读页或者栈保护页。
2. 0x0000000000000000 的两种截然不同含义
这是整篇文章最想讲清楚的一点。同一个全零地址,背后可能是完全相反的两种情况,处理方式也完全相反。搞混了,你就会在错误的方向上耗尽一整晚。
2.1 符号没找到:库被 strip 或代码来自未知模块
第一种情况,也是最常见的:崩溃确实发生在某个正常的函数里,但虚拟机做符号解析时失败了,于是它把“解析不出来的地址”填成了 0。栈回溯的底层依赖是遍历已加载模块的符号表,把 pc 落在哪个模块的哪个符号区间里算出来。如果那个 .so 在上线前被 strip 过(发布包为了体积经常这么干),或者代码是由某个运行时动态生成的、不在任何模块的符号表里,解析就会落空。
验证方法很直接:去 Registers 段看 RIP 的值,再对照 Dynamic libraries 段里各个库的加载基址区间,看 RIP 落在谁的范围内。
Registers: RAX=0x0000000000000001, RBX=0x00007f3c00000000, RCX=0x0000000000000000, RDX=0x0000000000000020 RIP=0x00007f3c8a1b2c40, RSP=0x00007f3c7a4f8d20, RBP=0x00007f3c7a4f8d60只要RIP落在一个合理的可执行区间里,那这一帧其实是“有主”的,只是名字丢了。这种情况下去追“谁跳到了零地址”毫无意义,你要做的是把符号找回来:把对应版本的带符号库拿到手,用addr2line一类工具反查,或者干脆在预发环境用未 strip 的库复现一次。我在一个项目里就这么干过,把解析出来的函数名一对照,问题立刻清楚了——是一个第三方库内部的对象池回收后又被访问,跟业务代码一行关系都没有。
还有一种变体:pc 落在 JIT 生成的代码区里。JIT 代码没有符号表,虚拟机自己心里有数,通常会把这一帧标成J而不是C。但如果是从 JIT 代码回调进 native,再从 native 里崩掉,就可能出现“外层是 C,内层信息全无”的混合栈,这种最考验耐心。
2.2 真的跳到了零地址:空函数指针调用现场
第二种情况就凶险得多:程序真的把控制流交给了 0 地址。这时候你会看到si_addr: 0x0000000000000000且si_code: 1 (SEGV_MAPERR),RIP 也确实是 0 或者极小的值(有时候是 0x8、0x10 这种,那是因为调用约定的偏移)。这种崩溃的根本原因只有一个——某个函数指针变量的值是 NULL,然后被当成函数调用了。
为什么会有空函数指针?在 JNI 世界里,最常见的路径是动态符号查找失败却没检查返回值。dlsym找不到符号时返回 NULL,它会老老实实告诉你失败了,但如果你不判空、不打印dlerror(),后面一调用就是直接跳零。这段代码我见过太多次,几乎是模板级的错误写法:
typedef int (*process_fn)(void* ctx, int len); static process_fn g_process = NULL; JNIEXPORT void JNICALL Java_com_demo_NativeBridge_init(JNIEnv* env, jclass cls) { void* handle = dlopen("libcodec.so", RTLD_NOW | RTLD_LOCAL); if (handle == NULL) { /* 这里只打日志不返回,后面照样往下走 */ fprintf(stderr, "dlopen failed\n"); } g_process = (process_fn) dlsym(handle, "codec_process_v2"); /* 缺了判空,也缺了 dlerror() */ } JNIEXPORT jint JNICALL Java_com_demo_NativeBridge_doWork(JNIEnv* env, jclass cls, jlong ctx, jint len) { return g_process((void*) ctx, len); /* 这一行就是崩溃点 */ }改成什么样才算合格,其实就三条:dlopen失败必须提前返回并把dlerror()打出来;dlsym失败必须判空并在init阶段就抛异常,让问题在启动时暴露;函数指针在每次使用前做一次防御性判断。启动时崩比跑了两小时后崩要好一万倍,前者十分钟能查清,后者要花一整夜。
除了dlsym,另外两个高频来源是回调注册和 C++ 虚表。回调注册的经典场景是:native 层持有 Java 侧传下来的函数指针或全局引用,Java 侧对象被回收了、引用被删了,native 侧还在往上调;C++ 虚表的情况则是对象被memset清零过或者已经被析构,虚表指针变成 NULL,一调虚函数就跳零。这两种在日志上的表征几乎一模一样,得靠 Java frames 段有没有对应的调用上下文来区分。
3. 按场景倒推:五类最容易踩的真凶
光看一行日志当然定不了案,但把日志特征和代码里常见的坏味道对起来,能大幅缩小范围。下面这五类是我这些年遇到频率最高的,基本覆盖了七八成的现场。
3.1 JNI 函数指针判空缺失(最高频)
这条上面已经说了,但值得再强调一次,因为它有很强的隐蔽性。很多封装库在初始化时做了容错——查找失败就记一条 warn 日志,然后继续跑,心里想的是“这个功能用不到就不影响”。结果某天业务真的调到了这个功能,进程直接没了。日志级别是 warn,报警规则里没配,线上跑三个月才炸一次,炸的时候谁也想不起来那条 warn。
正确的处理思路是把这类错误提升为致命错误:RegisterNatives或者动态查找阶段失败,就在静态初始化块里抛UnsatisfiedLinkError,让服务起不来。启动失败是可控事故,运行时崩溃是不可控事故。我现在的习惯是,所有 JNI 桥接类的静态初始化块里必写一段自检,把关键符号挨个查一遍,查不到就直接抛。多花的那几十毫秒启动时间,换来的是不用半夜爬起来。
3.2 同名库版本错配导致函数表错位
这一类更阴,因为它不报错。典型场景是这样的:基础镜像里带了一份某加解密库的 1.x,业务镜像里又通过环境变量或者打包方式塞进了 2.x,两个版本的 .so 名字一样但 ABI 不同。动态链接器按搜索顺序加载了先找到的那一份,而你在代码里按照另一份的头文件去调,入参结构体布局对不上、函数表偏移对不上,调用能进去但不一定回得来。
判断特征要看 Dynamic libraries 段里有没有重复条目:
Dynamic libraries: 0x00007f3c88000000 /lib64/libc.so.6 0x00007f3c8a000000 /app/lib/libcodec.so 0x00007f3c8a200000 /usr/local/lib/libcodec.so同一个库名出现两条、路径不同,基本可以确定问题在这。解决方式不是改代码,是收敛加载路径:明确指定LD_LIBRARY_PATH的优先级,镜像构建阶段用ldd做一次依赖树检查,把不该出现的路径清掉。我现在的做法是在 Dockerfile 最后加一步自检,如果发现同一个 soname 出现在两个路径下就直接让构建失败,从根上堵住。
3.3 栈溢出打穿保护页
这一类的日志特征相当明显:si_code一般是SEGV_ACCERR而不是MAPERR,因为不是访问了未映射区域,而是碰到了操作系统放在栈底的那一页保护内存。日志里通常会有一句明确的提示,告诉你栈保护页被触碰了,同时 Stack 段的sp值会非常接近栈区间的下界。
Stack: [0x00007f3c7a4f9000,0x00007f3c7acf9000], sp=0x00007f3c7a4f9f80, free space=12kfree space=12k就是铁证——线程还剩十几 K 栈就到底了。Java 层的深递归一般会被虚拟机拦下来抛StackOverflowError,但 native 层的递归没有这层保护,一个解析嵌套结构(比如深层嵌套的 JSON、XML、表达式树)的递归函数,遇上恶意构造的输入就能直接把栈打穿。
算一笔账:默认线程栈大小在多数 64 位平台是 1MB,如果你起了 800 个线程,光栈就占 800MB 虚拟地址空间(这部分是惰性分配的,实际物理内存没这么多,但地址空间是真占了)。真正决定生死的是单线程的栈深度。如果 native 递归每层帧占 200 字节,1MB 的栈大约能撑 5000 层;把-Xss调到 2M 就翻倍,但这属于治标——治本还是得给递归加深度上限或者在业务入口做嵌套层数校验。
3.4 CPU 指令集与库编译目标不匹配
这一类报的往往不是 SIGSEGV 而是 SIGILL(信号值 4),但也会以# C帧的形式出现。原因很简单:库在编译时启用了较高版本的指令集优化,而运行机器的 CPU 不支持。这个在异构集群里太常见了——本地开发机是新款芯片,编译镜像的构建机也是新款,线上那批老机器的 CPU 少几条指令,一跑到热点路径就非法指令。
我踩过的最典型一次,是某个矩阵计算库在构建脚本里默认开了激进的向量化选项,本地跑得好好的,上了老机器直接崩,日志里帧地址也是解析不出来的一串零。排查方法是在两边机器上分别查 CPU 支持的指令集,把差异列出来,再对照编译选项。稳妥的做法是按最低基线编译,或者编译多份产物按运行时探测选择,别图那点性能直接上高基线。
3.5 堆外内存与 mmap 的系统级限制
严格说这一类不一定表现为崩溃,更多时候是Map failed这样的错误,但配置不当的时候也可能以信号的形式出现。技术点上主要有两个坑:一是映射区域数量上限,vm.max_map_count在很多发行版里默认是 65530,大内存机器、大量线程栈、大量文件映射叠加起来很容易撞线;二是ulimit -v这类虚拟内存限制被容器运行时设得偏小,JVM 预留堆空间时就出问题。
这里要顺便澄清一个高频误解:hs_err_pid出现不等于内存不够。它出现只说明进程收到了不该收到的信号。我看过太多次把崩溃日志误判成 OOM,然后去调堆大小,调完照崩不误。判断依据是日志开头那段信号摘要,SIGSEGV 是段错误,SIGBUS 是总线错误(常见于映射文件被外部截断),两者跟“堆满了”是两回事。
4. 一套能落地的排查流程
理论聊完,说流程。我现在的固定动作分四步,每次崩溃都按这个走,基本不会漏关键信息。
4.1 先保现场:core、日志、加载库清单
最忌讳的操作是出事之后一条restart把容器重建了。容器一重建,core 文件、临时目录、加载的库版本全没了,剩下的日志就是个孤证。所以第一件事是:确认 core dump 有没有落盘,把日志文件原样拷出来,同时把出问题的机器隔离掉,不要再重启。
core dump 这块在容器里坑特别多。宿主的core_pattern如果是管道到某个收集程序,core 文件根本不会出现在当前目录,而你ulimit -c unlimited设得再对也没用。检查一下:
cat /proc/sys/kernel/core_pattern ulimit -c如果core_pattern是管道形式,得让容器运行时那边的配置配合,或者干脆在容器内单独把core_pattern设成相对路径。另外-XX:ErrorFile也建议显式指定到一个有大空间、可写的目录,默认写到工作目录,容器里工作目录经常是只读的或者挂在很小的临时卷上,写满了会引发二次故障。
库清单也别落下,这个信息在日志的 Dynamic libraries 段里有,但现场再抓一份更保险:
pid=$(pgrep -f 'java.*yourapp' | head -1) cat /proc/$pid/maps > /tmp/maps.txt ls -l /proc/$pid/fd > /tmp/fd.txtmaps里能看到每个映射段的权限和来源,fd里能看到打开的文件描述符。这两份东西在事后分析时价值极高,尤其是判断“到底是哪一份 .so 被真的加载了”。
4.2 六段定位法:从 Current thread 到 Native frames
日志到手,按固定顺序抠六段出来,前两段定方向,中间两段定位置,后两段定嫌疑人。
第一段是信号摘要和 Problematic frame,确定崩溃类型和层级。第二段是 Current thread,看线程状态:
Current thread (0x00007f3c9000a800): JavaThread "worker-7" [_thread_in_native, id=27899, stack(0x00007f3c7a4f9000,0x00007f3c7acf9000)]_thread_in_native这个状态值很说明问题——说明线程正处在 native 代码里,跟 Java 堆操作无关,这时候去查 GC 日志是浪费时间。对应的还有_thread_in_Java、_thread_in_vm、_thread_blocked,状态不同,后续方向的优先级完全不同。
第三段是 siginfo 三元组,第四段是 Registers,这两段配合着看能确定“真的跳零”还是“符号丢失”。第五段是 Native frames,能看出崩溃点外层的调用链有多深、有没有明显的第三方库名字。第六段是 Java frames,有时候能从 Java 侧看到一个可疑的调用路径,比如某个反序列化入口、某个资源释放钩子。
我一般会用几条命令把关键段落抽出来单独看,免得在几千行里翻:
grep -n -A 3 "Problematic frame" hs_err_pid27841.log awk '/Registers:/,/Top of Stack/' hs_err_pid27841.log | head -30 awk '/Native frames:/,/Java frames:/' hs_err_pid27841.log | head -40 awk '/Dynamic libraries:/,/^$/' hs_err_pid27841.log | head -604.3 用 -Xint 和编译排除做二分
如果现场能复现,最快的定位手段是关掉 JIT 再跑一遍。加-Xint走纯解释执行,性能会掉得厉害,但能快速排除“是不是 JIT 编译出来的代码有问题”这个可能性。如果关掉 JIT 就不崩了,那基本锁定在编译优化或者内联边界上,接下来就是用编译排除精准定位到方法:
java -XX:+UnlockDiagnosticVMOptions \ -XX:CompileCommand=exclude,com/demo/codec/NativeBridge::doWork \ -jar app.jar反过来的排除法也很有用:-Xcomp强制编译所有方法,如果加上之后崩溃频率明显上升,说明问题跟编译产物强相关。另外-XX:+PrintCompilation打出来的编译日志能对上时间点,看看崩溃前正在编译哪个方法,常常能发现意外收获。
还有一个参数组合专门对付 native 边界问题:-Xcheck:jni。它会给所有 JNI 调用加上校验,包括参数合法性、引用有效性、数组边界。代价是性能掉一大截,只能在预发或者压测环境开。但只要开了它还能复现,日志里通常会给出一句非常明确的提示,告诉你哪次 JNI 调用参数不对,比事后猜快得多。
4.4 core 文件回放:栈回溯与地址反查
有 core 的话,直接用系统调试器加载,第一件事看完整栈:
gdb /path/to/java /path/to/core.27841 -ex "thread apply all bt" -ex "quit" > /tmp/bt.txt如果栈回溯出来大量??,说明符号缺失,得把带符号的库和可执行文件放到调试器能找到的路径下。缺符号的情况下只能靠地址反查:先确定 pc 落在哪个模块,再算相对偏移,然后用addr2line反查行号:
addr2line -e /app/lib/libcodec.so -f -C 0x1b2c40如果偏移也算不出来,那就退一步用反汇编看那一段指令在干什么,重点看崩溃指令的操作数是不是在访问一个为 0 的寄存器:
objdump -d --start-address=0x1b2c00 --stop-address=0x1b2c80 /app/lib/libcodec.so看到类似mov (%rax),%rbx而rax是 0 的指令,基本就确认是空指针解引用了。这一步虽然原始,但在没有符号的情况下几乎是唯一出路,我做 JNI 排查那几年,靠反汇编读指令救回来的现场不止一次。
5. 参数清单与工具对照
排查靠流程,但流程能不能走通,取决于崩溃那一刻有没有留下足够的信息。这部分全是配置层面的东西,平时不起眼,出事的时候是救命的。
5.1 建议常开的几个启动参数
下面这套是我现在所有带 native 依赖的服务都会配的,成本很低:
java -XX:ErrorFile=/var/log/app/hs_err_pid%p.log \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/app/ \ -XX:+CreateCoredumpOnCrash \ -Xss2m \ -XX:NativeMemoryTracking=summary \ -jar app.jar逐个说下选择的理由。-XX:ErrorFile显式指定路径,避免工作目录不可写导致日志丢失,%p带上 pid 防覆盖。HeapDumpOnOutOfMemoryError虽然跟崩溃不是一回事,但内存问题和崩溃经常交替出现,一起开着省事。CreateCoredumpOnCrash是默认开启的,写出来是为了提醒你它依赖系统ulimit配合,光有参数没ulimit -c unlimited一样白搭。-Xss2m是给 native 递归留余量,具体数值要看你的实际调用深度,不是越大越好——线程多的时候栈空间是乘算的。NativeMemoryTracking开销很小,但事后能告诉你堆外内存到底花在哪了。
有一个参数我要专门提醒:-XX:+ShowMessageBoxOnError千万别在生产环境开。它会在崩溃时弹一个对话框等人确认,在没有图形界面的服务器上表现为进程挂死,既不退出也不产生完整日志,反而把现场搞得更复杂。
5.2 工具与命令速查表
| 场景 | 工具或命令 | 关键点 |
|---|---|---|
| 抓当前线程栈 | jstack或kill -3 | 崩溃后进程已没了,只能靠 core |
| 看 JNI 调用合法性 | -Xcheck:jni | 性能损失大,只在预发开 |
| 看本地内存分布 | NativeMemoryTracking+jcmd | 定位堆外增长趋势 |
| 反查地址对应函数 | addr2line | 需要带符号的库 |
| 看崩溃指令 | objdump -d | 确认操作数是否为 0 |
| 看完整栈 | gdb 加载 core | 缺符号时栈会显示?? |
| 看映射段 | /proc/pid/maps | 确认实际加载的是哪个 .so |
| 看库依赖树 | ldd | 查同名多版本 |
| 看 CPU 指令支持 | /proc/cpuinfo | 对齐编译基线与运行环境 |
表格里每一项我都实际用过,其中ldd和/proc/pid/maps这两项最容易被人忽略,但恰好是排查“版本错配”类问题的最短路径。尤其maps,它反映的是运行时的真实情况,比你在构建脚本里写的依赖声明可信得多。
6. 常见问题速查与踩坑记录
6.1 症状对照速查表
| 日志特征 | 大概率原因 | 首选动作 |
|---|---|---|
# C 0x0+si_addr: 0x0+RIP为 0 | 空函数指针调用 | 查所有dlsym/回调注册的判空 |
# C 0x0+RIP落在某模块内 | 符号被 strip | 换带符号库重跑或地址反查 |
si_code: 2 (SEGV_ACCERR)+free space很小 | 栈溢出 | 查 native 递归深度 |
| 信号为 4 (SIGILL) | 指令集不兼容 | 对比构建机与运行机 CPU |
| 信号为 7 (SIGBUS) | 映射文件被截断 | 查磁盘空间与映射文件大小 |
Dynamic libraries同名两条 | 版本错配 | 收敛加载路径 |
| 崩溃前刚做过 GC | 可能是 JIT/屏障相关 | 试-Xint排除 |
| 崩溃集中在并发高峰 | 数据竞争 | 查共享状态与锁粒度 |
这张表我最常用的其实是第二行和第三行。第二行对应的场景特别容易误判,因为日志上写着零地址,直觉就去追空指针,结果查了三天发现代码里根本没有解引用为空的路径——因为压根不是解引用的问题,是符号丢了。
6.2 几条花时间换来的经验
第一条,也是最值钱的一条:别急着相信“地址是 0 就是空指针”。先花两分钟对照 RIP 和 Dynamic libraries 段,确认是符号问题还是真跳零。这个习惯帮我省掉的无效排查时间,比任何工具都多。
第二条,崩溃日志要和业务日志对齐时间轴。单看崩溃日志只能知道死在哪,结合业务日志才知道死之前在处理什么请求、传了什么参数、调了哪个分支。我现在排这类问题,第一动作是先grep崩溃时间点前后三十秒的业务日志,往往能直接看到“某个超大报文进来”“某个开关被动态打开”这种关键线索。
第三条,在 native 边界做输入校验,比事后排查便宜得多。所有进 JNI 的数组长度、指针有效性、句柄合法性,在入口处做一次校验,成本是一次比较指令,收益是避免整进程退出。我们现在的 JNI 层统一加了一个校验宏,任何参数不合法就抛异常回 Java 层,让它变成一次可捕获的业务异常,而不是一次进程死亡。
第四条,版本升级后必须做一次 native 兼容性回归。很多崩溃是新版本引入的,但表现是概率性的,只有压测或者长跑才暴露。我在项目里加了一个简单的长稳测试,每天跑一轮小时级的压测,专门盯崩溃日志目录有没有新文件生成,有就报警。这个投入不大,但把“线上炸”变成了“测试环境炸”。
第五条,遇到确实查不动的,学会用排除法缩小到模块级。逐个禁用可选 native 组件跑基线,虽然笨,但有效。有一次我们把七个可选组件一个个关掉,最后锁定到第四个,整个过程花了两个小时,比在代码里瞎找快了不知道多少。笨办法在信息不足的时候往往是最优解,别嫌它土。