Android NDK 内存悬案(一):那个被 delete 掉的指针,是怎么变成“幽灵”的?

4 阅读5分钟

在 Java 世界里,我们习惯了 GC 的温柔保护。对象没人引用了?GC 帮你清理。我们最多担心的是 Memory Leak

但当走进NDK(C/C++)的世界,一切都变了。这里没有自动回收。这里只有——内存所有权

而真正可怕的,不是内存泄漏。

而是:

Use-After-Free —— 被释放的对象再次被访问。

它制造的,是野指针(Dangling Pointer)。它带来的,是 SIGSEGV。它留下的,是调试地狱。

一、delete并不会“删除指针”

很多人有一个误解:

我已经 delete p 了,那这个指针就没了。

错。delete 做的事情只有一件:

释放堆上的那块内存。

它不会:

  • 修改指针变量的值

  • 把指针变成 nullptr

  • 把地址清零

来看一段代码:

std::string* p = new std::string("Hello");
delete p;

执行完 delete 后:

  • 堆内存被标记为 FREE

  • 但 p 里依然保存着原地址(例如 0x100)

这就是灾难的起点。

二、野指针的四个阶段(结合图示)

2bdda23d012a3d59.jpg

下面这张图很好地描述了野指针的完整生命周期。

Phase 1:正常分配

std::string* p = new std::string("Hello");
  • 栈上:p
  • 堆上:0x100 存储 “Hello”
  • 状态:安全

Phase 2:释放,但未清空(野指针诞生)

delete p;
  • 堆内存被标记 FREE

  • 指针 p 仍然是 0x100

此时:

p 已经成为 Dangling Pointer

它指向一块“不再属于它”的内存。

危险等级:极高。


Phase 3:内存被重新分配

float* new_data = new float(3.14f);

系统可能复用 0x100 这块地址。

此时:

  • 0x100 现在存的是 3.14

  • 但 p 仍然指向 0x100

屋子换了主人。

钥匙还在你手里。


Phase 4:非法访问(崩溃或数据污染)

*p = "Crash";

或者:

p->size();

结果:

  • 修改了别人的数据

  • 内存破坏

  • SIGSEGV

  • 或者更可怕:静默数据损坏

最危险的不是崩溃。是——

程序还能运行,但逻辑已经错了。

三、为什么野指针比空指针更难排查?

空指针访问:

p = nullptr;
p->size(); // 立刻崩溃

崩得干脆。

野指针:

  • 可能不会立刻崩溃

  • 可能几分钟后崩溃

  • 可能在完全无关的模块崩溃

  • 可能根本不崩溃

它具备三个特性:

随机性

取决于内存是否被复用。

延迟性

错误发生点和崩溃点通常不一致。

静默性

可能只是悄悄破坏数据。

在音视频或 GPU 场景下,这种问题尤其致命。

四、NDK 中最常见的案发现场

场景一:JNI 异步线程 + detach

class MyPlayer {
public:
    void startAsync() {
        std::thread([this]() {
            sleep(2);
            this->updateUI(); // 💥
        }).detach();
    }

    void updateUI() { }
    ~MyPlayer() { }
};

问题:

  • Java 层退出

  • native 对象析构

  • 线程还在

  • 两秒后访问已释放对象

这类问题在:

  • 播放器

  • 解码器

  • 推流 SDK

  • 传感器监听

中极其常见。

场景二:## 渲染线程未停止就 delete(最隐蔽的崩溃)

class MyRenderer {
public:
    void renderLoop() {
        while (m_running) {
            drawFrame(m_gpu_handle);  // 持续使用 GPU 资源
        }
    }

    ~MyRenderer() {
        releaseGpuResource(m_gpu_handle); // 💥 危险
    }

private:
    int m_gpu_handle;
    std::atomic<bool> m_running;
};

问题在于:

析构函数执行时,renderLoop 线程可能仍在运行。

时间线通常是这样的:

  1. Java 层 Surface 销毁

  2. JNI 调用 delete renderer

  3. 析构函数释放 GPU 资源

  4. renderLoop 下一帧仍然执行 drawFrame()

此时:

  • 纹理已经被 glDelete

  • EGL Context 已销毁

  • Surface 已销毁

渲染线程却仍然在调用:

eglSwapBuffers
glDrawArrays
glBindTexture

结果不是立即崩溃在你的代码里。

而是崩在:

/vendor/lib64/egl/libGLESv2_xxx.so
libEGL.so

堆栈信息看起来像驱动问题:

Fatal signal 11 (SIGSEGV)
#00 libGLESv2_adreno.so
#01 libEGL_adreno.so

很多人第一反应是:

是 GPU 兼容问题?是厂商驱动 Bug?

但真正的根因是:

你提前释放了 GPU 资源,而渲染线程仍在使用它。

这种问题非常典型,尤其出现在:

  • 音视频播放器

  • OpenGL 渲染

  • Cocos 二次开发

  • 自研渲染引擎

五、防御策略(真正实用的)

delete 后立即置空

delete p;
p = nullptr;

优点:

  • 防止 Double Free

  • 再次访问会立即崩溃(比野指针安全)

但注意:

只能保护当前指针变量

不能保护其他副本

例如:

auto q = p;
delete p;
p = nullptr;
q->size(); // 仍然危险

严禁 detach 裸线程

推荐:

  • 使用 join

  • 使用原子退出标志

  • 使用线程池

  • 使用 shared_ptr 控制生命周期

detach 是野指针温床。


开启 AddressSanitizer(强烈推荐)

在 debug 构建中启用 ASan:

它能检测:

  • Use-After-Free

  • Double Free

  • Buffer Overflow

  • Stack Overflow

而且会告诉你:

  • 哪行代码分配

  • 哪行代码释放

  • 哪行代码访问

这对 NDK 开发是救命工具。


六、本质问题:内存所有权失控

野指针的根因不是 delete。

而是:

没有清晰的对象生命周期设计。

在复杂工程中,仅靠:

delete + nullptr

已经远远不够。

你需要:

  • 所有权模型
  • 生命周期对齐
  • RAII 思维
  • 智能指针策略

七、结尾

手动管理内存,本质上是一种“权力”。

但权力越大,责任越重。

在流媒体、OpenGL、音视频、JNI 混合开发中:

野指针不是“可能出现”,而是“迟早出现”。

下一篇,我们会进入更危险的领域:


《Android NDK 稳定性实战手册(二):智能指针,是保命符还是催命符?》

  • shared_ptr 的计数陷阱

  • 循环引用

  • 异步回调中的所有权反转

  • 逻辑野指针的诞生