Android NDK 内存悬案(二 ):HWASan解决智能指针崩溃

23 阅读5分钟

6cbf76e509d6c2cb.jpg

一、线上现象:崩溃变多,但没有业务堆栈

书接上文,Android NDK 内存悬案(一):野指针

某次版本发布后,监控后台出现异常:

  • 崩溃量明显上升

  • 主要集中在 ARM64 设备

  • 无明显业务堆栈

  • 随某个新功能模块上线而增加

典型堆栈:

#00 pc 000000000004f3c8  libc.so (abort+160)
#01 pc 0000000000079abc  libc.so
#02 pc 0000000000012345  libEGL.so
#03 pc 000000000000xxxx  vendor/libGLESv2_*.so

或者:

double free or corruption
malloc(): invalid pointer
SIGABRT

特征很明显:

  • 崩在 libc / EGL / vendor

  • 没有业务函数

  • 偶现

  • 难以本地复现

这类问题通常归类为:

内存破坏类问题(Memory Corruption)

二、为什么第一时间怀疑智能指针?

版本 diff 显示:

  • 新增一个多线程渲染模块

  • 使用大量 std::shared_ptr

  • 线程中使用 shared_from_this()

经验判断:当崩溃无业务栈,随业务复杂度线性上升,和线程模型强相关时,优先怀疑:

  • double free

  • use-after-free

  • 多控制块问题

  • 生命周期错位

但猜测必须被工具验证。

三、第一步验证:ASan

构建 AddressSanitizer 版本:

-fsanitize=address

压测后成功复现,ASan 报告:

ERROR: AddressSanitizer: attempting double-free on 0x6040000012f0
    #0 0x7f8a3c1 in free
    #1 0x7f9bcd2 in std::__shared_ptr::~__shared_ptr()
    #2 0x7f8caa1 in Player::~Player()

初步确认:

double free

但线上是 ARM64。ASan 开销大,不适合验证偶现问题。于是继续使用 HWASan。

四、关键一步:HWASan 报告拆解

开启 HWASan:

-fsanitize=hwaddress

压测后抓到如下报告(简化版):

ERROR: HWAddressSanitizer: invalid-free on address 0x0070001234a0

0x0070001234a0 is located 0 bytes inside of 64-byte region
freed by thread T1 here:
    #0 0x7c8f1a in operator delete(void*)
    #1 0x7d1234 in Player::~Player()
    #2 0x7d5678 in std::__shared_count::~__shared_count()

previously freed by thread T2 here:
    #0 0x7c8f1a in operator delete(void*)
    #1 0x7d9abc in std::__shared_ptr::~__shared_ptr()
    #2 0x7ddef0 in RenderThread::stop()

Thread T1 created by:
    #0 pthread_create
    #1 std::thread::_M_start_thread

逐行拆解。

1️⃣ invalid-free

invalid-free on address 0x0070001234a0

说明:

这块内存已经被释放过


2️⃣ freed by thread T1

freed by thread T1 here:
    Player::~Player()
    std::__shared_count::~__shared_count()

第一次释放路径:

  • shared_count 析构
  • 触发 Player 析构
  • 调用 delete

3️⃣ previously freed by thread T2

previously freed by thread T2 here:
    std::__shared_ptr::~__shared_ptr()
    RenderThread::stop()

第二次释放路径:

  • 另一个 shared_ptr 析构

  • 触发 delete

关键点:

两次释放来自两个线程

析构路径不同

这几乎可以确定:

存在两个独立的控制块

五、根因还原

排查代码后发现:

Player* p = new Player();

std::shared_ptr<Player> sp1(p);
std::shared_ptr<Player> sp2(p);  // 问题

或者 JNI 场景:

auto sp = std::make_shared<Player>();
return reinterpret_cast<jlong>(sp.get());  // Java 持裸指针

Java later:

delete playerPtr;

同时 native 内部 shared_ptr 析构。

结果:

同一块内存被释放两次

问题核心:

shared_ptr 共享的是控制块,不是裸指针。

多个 shared_ptr 不能从同一个裸指针构造。

六、第二类问题:逻辑野指针

排查中还发现另一类隐蔽问题:

std::thread([self = shared_from_this()]() {
    self->renderLoop();
}).detach();

现象:

  • Java 层认为对象已销毁

  • 线程仍持有 shared_ptr

  • 对象未析构

  • 但 Surface / EGL 已销毁

render 继续调用:

eglSwapBuffers()

最终崩在:

libEGL.so
vendor driver

这不是 UAF。

这是:

逻辑野指针

对象内存仍在。

但业务语义已经失效。

Sanitizer 抓不到这种问题。

只能通过:

  • 生命周期分析

  • 线程模型梳理

定位。

七、为什么 join 也不能完全解决?

即便改成:

m_thread.join();

如果:

  1. Java 先销毁 Surface

  2. EGLContext 被 destroy

  3. 渲染线程还在最后一次 swap

依然会崩。

join 只能保证线程结束。

不能保证:

外部资源生命周期仍然有效。

八、工程治理措施

整改包括:

1. 禁止从裸指针构造 shared_ptr

统一使用:

std::make_shared<T>()

2. JNI 不暴露裸指针

Java 不再持有 T*

统一由 native 管理生命周期。

3. 跨线程使用 weak_ptr

避免线程劫持生命周期。

4. CI 引入 Sanitizer 构建

  • Debug 默认开启 ASan
  • ARM64 灰度 HWASan
  • 多线程模块定期跑 TSan

九、排障方法论总结

当在监控后台看到:

  • 崩溃在 libc / EGL / vendor

  • 没有业务堆栈

  • 随新模块上线增加

  • 偶现难复现

优先怀疑:

内存破坏 + 生命周期错位

排查路径:

  1. 版本 diff
  2. 检查 shared_ptr 用法
  3. 查是否裸指针混用
  4. 查线程是否持有 shared_ptr
  5. 开启 ASan / HWASan
  6. 梳理资源销毁顺序

十、结语

智能指针不会让你自动安全。

它只是把:

手动 delete

变成:

引用计数归零时 delete

如果:

  • 所有权设计混乱

  • 生命周期边界不清晰

  • 线程模型不明确

它只会制造更隐蔽的崩溃。

这次事故的本质,不是 C++ 语法问题。

而是:

生命周期设计问题。