【问题反馈】JNI 开发:为什么 C++ 在 Debug 正常,Release 却返回 NaN?

摘要:

在 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 = NaN
    • sin(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;

八、为什么"以前这么写也没事"?

原因通常是:

  1. 旧编译器优化弱
  2. Debug 构建长期被使用
  3. 数据规模较小
  4. 没踩到"恰好会覆盖栈"的场景

但:

未定义行为从来不是"偶尔才错",而是"早晚会炸"。


九、如何系统性避免这类问题?

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 行为不一致时遇到诡异问题,
第一时间检查:是否触发了未定义行为。


相关推荐
apcipot_rain8 小时前
C++转Java日记(04)
java·开发语言·c++
云和数据.ChenGuang10 小时前
LangGraph 异步 Node 的设计与写法实践
开发语言·人工智能·python·机器学习·php
WL学习笔记11 小时前
vector模拟实现
c++
蜗牛一步步往上爬13 小时前
第十六章 C++语言在深度学习中的应用
开发语言·c++·深度学习
张张123y15 小时前
DSH 插件调试避坑:client.js 设置面板、热重载与高频报错排查表
开发语言·javascript·人工智能·开源·ecmascript·后端开发·ai agent
wo_jack17 小时前
C++ 入门笔记(十六):第16课 vector 模拟实现十问十答 —— 从三指针到迭代器失效
c++·后端·面试·stl·vector
D e re k ZH17 小时前
C++ 标准库算法全景指南
数据结构·c++·算法
冻柠檬飞冰走茶18 小时前
字母移动游戏题解:贪心求最长可保留连续子串(附 C 实现)
c语言·开发语言·数据结构·算法·动态规划
FRing0318 小时前
vue2干货总结7(自定义指令、插槽、单页应用程序、路由的使用(VueRouter))
开发语言·前端·vue.js·vue2
雪落漂泊18 小时前
C++算法 二分查找篇(上)
开发语言·c++·算法