摘要:
在 Android NDK / JNI 开发中,经常会遇到这样一种"诡异"问题:Debug 模式下运行完全正常,而 Release 模式却出现 NaN、Infinity 甚至随机结果。
本文通过一次真实的 JNI 坐标转换案例,深入分析了该问题的根本原因------C++ 返回局部栈内存指针所导致的未定义行为(Undefined Behavior)。

【问题反馈】JNI 开发:为什么 C++ 在 Debug 正常,Release 却返回 NaN?
本文为以下问题的解决记录。由于问题较为典型,故梳理备忘。
https://github.com/eqgis/Sceneform-EQR/discussions/16

一、问题现象描述
1. 现象
-
Debug 构建
- JNI 返回的坐标数值正常

-
Release 构建
- 返回坐标中出现
NaN/Infinity - 且仅在Release出现

- 返回坐标中出现
2. 出问题的方法
cpp
JNIEXPORT void JNICALL
Java_com_eqgis_eqr_core_CoordinateUtilsNative_jni_1ToScenePosition(
JNIEnv *env, jclass clazz,
jdouble ref_x, jdouble ref_y,
jdouble target_location_x,
jdouble target_location_y,
jdouble azimuth_rad,
jdoubleArray outJNIArray)
{
double *offset = ComputeTranslation(ref_x, ref_y,
target_location_x,
target_location_y);
double deX = *offset;
double deY = *(offset + 1);
double x = deX * cos(azimuth_rad) - deY * sin(azimuth_rad);
double y = deX * sin(azimuth_rad) + deY * cos(azimuth_rad);
double outArray[] = {x, y};
env->SetDoubleArrayRegion(outJNIArray, 0, 2, outArray);
}
二、问题根因定位:一个"看起来没问题"的函数
问题最终锁定在这个函数:
cpp
double * ComputeTranslation(double x1, double y1,
double x2, double y2)
{
double res[2] = {0, 0};
...
res[0] = flagX * x;
res[1] = flagY * y;
return res;
}
乍一看逻辑完全正确,但这里隐藏了一个致命错误。
三、致命问题:返回了栈内存指针(未定义行为)
1. res 是什么?
cpp
double res[2];
res是 函数内部的局部变量- 存储在 当前函数的栈帧(stack frame)中
2. 函数返回后发生了什么?
当 ComputeTranslation 返回时:
-
函数栈帧被销毁
-
res对应的内存 立刻失效 -
返回的指针指向:
- 已被释放的栈空间
- 或即将被复用的内存区域
3. C++ 标准如何定义这种行为?
这是典型的 Undefined Behavior(未定义行为)
含义是:
-
编译器不保证任何结果
-
程序:
- 可能"看起来能跑"
- 也可能随机崩溃
- 也可能只在 Release 模式出问题
四、为什么 Debug 正常,而 Release 出 NaN?
这是很多开发者最困惑的地方。
1. Debug 模式的特点
-
编译器优化极少
-
栈内存分配保守
-
局部变量:
- 生命周期"看起来"更长
- 内存内容不容易被覆盖
返回的指针虽然非法,但数据暂时还在
2. Release 模式的特点
-
启用
-O2 / -O3等激进优化 -
栈空间:
- 快速复用
- 指令重排
- 寄存器替代变量
-
编译器甚至可能认为:
"你返回这个指针是非法的,那我随便优化"
结果就是:
*offset变成随机值- 或直接成为
NaN / Inf
五、为什么 NaN 特别容易出现?
在 Release 下,offset 可能是:
- 未初始化内存
- 被 SIMD / 浮点寄存器覆盖
- 任意 bit pattern
而 浮点数中:
-
特定 bit pattern ⇒
NaN -
一旦参与计算:
NaN + x = NaNsin(NaN) = NaN
导致后续都是NaN
六、这不是 JNI 的问题
值得特别强调的是:
- JNI 只是一个函数调用边界
- 问题在 C++ 层就已经发生
- Java 侧只是"忠实地接收了 NaN"
如果这是纯 C++ 工程:
- 现象 完全一致
七、正确修复方式:返回值语义而不是指针
修复方案:使用结构体返回
cpp
struct Vec2 {
double x;
double y;
};
Vec2 ComputeTranslation(double x1, double y1,
double x2, double y2)
{
Vec2 res{0.0, 0.0};
...
res.x = flagX * x;
res.y = flagY * y;
return res;
}
调用:
cpp
Vec2 offset = ComputeTranslation(...);
double deX = offset.x;
double deY = offset.y;
八、为什么"以前这么写也没事"?
原因通常是:
- 旧编译器优化弱
- Debug 构建长期被使用
- 数据规模较小
- 没踩到"恰好会覆盖栈"的场景
但:
未定义行为从来不是"偶尔才错",而是"早晚会炸"。
九、如何系统性避免这类问题?
1. 永远不要返回局部变量地址
cpp
return &localVar;
return localArray;
2. 优先使用值语义
cpp
struct / std::array / std::pair
3. Debug ≠ 正确
-
Debug 只能说明:
"在当前编译条件下恰好没炸"
十、总结
| 问题 | 结论 |
|---|---|
| Debug 正常 | 不代表代码正确 |
| Release 出 NaN | 典型 UB 表现 |
| 根因 | 返回栈内存指针 |
| JNI 是否有问题 | 没有 |
| 正确解法 | 返回结构体 / 值语义 |
这次问题再次验证了一点:
C++ 中,最危险的 Bug 往往不是"复杂算法",
而是"看起来理所当然的代码"。
如果在 Debug / Release 行为不一致时遇到诡异问题,
第一时间检查:是否触发了未定义行为。