本系列文章旨在提供定位与解决OpenHarmony应用与子系统内存泄露的常见手段与思路,将会分成几个部分来讲解。首先我们需要掌握发现内存泄漏问题的工具与方法,以及判断是否可能存在泄漏。接着需要掌握定位泄漏问题的工具,以及抓取trace、分析trace,以确定是否有泄漏问题。如果发现问题的场景过于复杂,需要通过分解问题来简化场景。最后根据trace来找到问题代码并尝试解决。

目录:

  1. 发现问题
  2. 定位问题
  3. 分析Trace
  4. 分解问题
  5. 解决问题(Native)
  6. 解决问题(NAPI&JavaScript)
  7. 解决问题(综合)

在通过hiprofiler或chrome devtools抓取trace后,接下来重点就是找出可疑的对象,并反编译,然后分析代码确认其是否有泄露。

可疑对象

hiprofiler

Native trace需要从下往上看。最下方一般是malloc等分配内存的函数,往上一层则是operator new或调用其的函数,以此类推:

img

Native trace中,如果录制时操作多次(不建议仅操作一次),可以参考如下方法出去找出可疑对象:

  • 大部分情况下可以使用Sample Count Filter来过滤远小于操作次数的trace。比如操作了10次,可以仅展示5次及以上的trace。
  • 警惕创建次数是操作次数倍数的对象,该对象非常有可能存在泄露。操作次数建议为质数,如7、11次,这样能减小误判的几率,节省时间。
  • 操作间隔可以稍微长一点,这样方便单独选中需要的某一次操作,如下图,可以清晰的看到5次操作:

img

  • 如果创建次数是操作次数倍数,缩小时间轴选中范围。
    • 如操作了7次,对象创建次数为14次。
    • 缩小范围至6、5、4次,如果创建次数规律的变为12、10、8,那么该对象99%存在泄露。
  • 警惕创建次数远大于操作次数的对象,比如操作了7次,某对象创建了100+,那么该对象也有可能泄露。
  • 对象符合泄露的规律,但是是由libark_jsruntime.so malloc,说明该对象是JavaScript对象。那么建议在录制的最后手动触发一次JavaScript内存回收后,再对比看该对象是否已销毁。如果未销毁,建议使用chrome devtools抓取JavaScript的trace分析。

chrome devtools

JavaScript trace需要从上往下看。最上方是创建对象的函数,如果函数名与类名一致,则代表构造函数。往下一层则是调用其的函数,以此类推:

img

JavaScript trace中,可以参考如下方法出去找出可疑对象:

  • 观察时间轴中,如果蓝条多于灰条,说明创建的对象数量大于销毁的,如果两者差距较大,则需要警惕

  • 可以录制3次,第一次录制操作1次,第二次录制操作2次,第三次录制操作3次

  • 对比3次录制的trace中,有规律增长的对象,如下图,Set与JSOBJECT成规律的增长

img

  • 录制时无需手动触发gc

  • 重点关注Set、JSOBJECT、JSNativePointer三类对象,JSOBJECT代表了内存中的JS对象,JSNativePointer则是与JS对象关联的Native对象指针。

  • 如果JSNativePointer规律增长,可能需要考虑NAPI存在泄露。

代码分析

Native

判断对象是否存在泄露,无非就是查看对象的创建与销毁是否匹配。可以参考如下方法:

  • 对业务熟悉的情况下,可以直接通过分析代码判断
  • 如果对业务不熟悉,或业务复杂时,可以在对象的构造函数与析构函数中加上日志。在操作时观察创建与销毁是否能对应上。
  • 如果对象在操作时大量创建,或者只有部分对象无法销毁时,可以为对象加上一个自增的id属性,并在构造函数与析构函数的日志中打印该id。在操作时观察id是否能配对。
  • 如果对象是被智能指针管理,可以在持有其的对象的析构函数中打印智能指针的use count,以便分析是否存在智能指针的泄露。

常见的泄露原因

智能指针

智能指针是重灾区,如:

  • 智能指针的循环引用
  • 智能指针被更长生命周期的对象或容器持有
new、delete
  • new与delete不配对
  • 调用析构函数,而不调用delete
napi
  • 未使用handle scope
  • 创建async_work未删除

分析案例

元能力

场景:应用有A、B两个Ability,在A中通过startAbility跳转B并按返回按钮退回A,反复操作后内存有增长。录制该操作发现,下面的对象的创建次数呈规律性的上涨,说明很有可能存在泄露:

img

trace中对应的代码如下:

Ability *JsAbility::Create(const std::unique_ptr<Runtime> &runtime)
{
    return new JsAbility(static_cast<JsRuntime &>(*runtime));
}

通过在JsAbility的构造与析构函数增加日志,并反复跳转关闭页面发现,JsAbility的析构函数并不会执行,说明该对象存在泄露。通过查看JSAbility的继承链发现JSAbility继承自Context:

JsAbility - Ability - AbilityContext - ContextContainer - Context

而在JsAbility创建过程中的AbilityThread::Attach函数中:

void AbilityThread::Attach(std::shared_ptr<OHOSApplication> &application,
    const std::shared_ptr<AbilityLocalRecord> &abilityRecord, const std::shared_ptr<EventRunner> &mainRunner,
    const std::shared_ptr<AbilityRuntime::Context> &stageContext)
{
    ...

    // 2.new ability
    auto ability = AbilityLoader::GetInstance().GetAbilityByName(abilityName);
    if (ability == nullptr) {
        HILOG_ERROR("Attach ability failed, load ability failed.");
        return;
    }

    currentAbility_.reset(ability);
    ...
    std::shared_ptr<Context> abilityObject = currentAbility_;
    std::shared_ptr<ContextDeal> contextDeal = CreateAndInitContextDeal(application, abilityRecord, abilityObject);
    ability->AttachBaseContext(contextDeal);
    ...
}
std::shared_ptr<ContextDeal> AbilityThread::CreateAndInitContextDeal(std::shared_ptr<OHOSApplication> &application,
    const std::shared_ptr<AbilityLocalRecord> &abilityRecord, const std::shared_ptr<Context> &abilityObject)
{
    std::shared_ptr<ContextDeal> contextDeal = nullptr;
    ...
    contextDeal = std::make_shared<ContextDeal>();
    contextDeal->SetContext(abilityObject);
    ...
}

调用CreateAndInitContextDeal时,将ability对象传递给了ContextDeal的SetContext函数,接着将ContextDeal对象通过AttachBaseContext设置给了ability,即ContextContainer::AttachBaseContext

std::shared_ptr<Context> abilityContext_ = nullptr;

void ContextDeal::SetContext(const std::shared_ptr<Context> &context)
{
    HILOG_DEBUG("ContextDeal::SetContext");
    if (context == nullptr) {
        HILOG_ERROR("ContextDeal::SetContext failed, context is empty");
        return;
    }
    abilityContext_ = context;
}
std::shared_ptr<Context> baseContext_;

void ContextContainer::AttachBaseContext(const std::shared_ptr<Context> &base)
{
    if (base == nullptr) {
        HILOG_ERROR("ContextDeal::AttachBaseContext failed, base is nullptr");
        return;
    }
    baseContext_ = base;
}

这样ability对象与contextDeal对象相互持有对方的智能指针,造成内存泄露

ResourceManager

场景:在Ability跳转过程中,ResourceManager中的部分对象调用次数多达3000多次,需要重点分析代码是否存在泄露:

img

创建栈指向的代码如下:

int32_t ParseId(const char *buffer, uint32_t &offset, ResId *id)
{
    ...
    for (uint32_t i = 0; i < id->count_; ++i) {
        IdParam *ip = new (std::nothrow) IdParam();
        ...
    }

    return OK;
}

分析IdParam对象被谁持有,持有链如下:

HapResource - ResDesc - vector<ResKey> - ResId - vector<IdParam>

由于第二层栈指向HapManager::ReloadAll,且其中有操作HapResource的代码,因此重点分析该函数代码:

RState HapManager::ReloadAll()
{
    if (hapResources_.size() == 0) {
        return SUCCESS;
    }
    std::vector<HapResource *> newResources;
    for (auto iter = loadedHapPaths_.begin(); iter != loadedHapPaths_.end(); iter++) {
        ...
        std::unordered_map<std::string, HapResource *> result = HapResource::LoadOverlays(iter->first.c_str(),
            overlayPaths, resConfig_);
        if (result.size() == 0) {
            continue;
        }
        for (auto iter = result.begin(); iter != result.end(); iter++) {
            newResources.push_back(iter->second);
        } 
    }
    for (size_t i = 0; i < hapResources_.size(); ++i) {
        delete (hapResources_[i]);
    }
    hapResources_ = newResources;
    return SUCCESS;
}

该函数的主要流程是,首先加载所有资源到newResources中,然后delete旧的HapResource,最后将newResources指向了hapResources_。从ReloadAll流程上看,不论ReloadAll执行多少次,泄露的对象大部分会被回收,但是会始终保留一份在内存中。逻辑上没有泄露。

接下来为HapResource与IdParam等对象的构造与析构函数加上日志,发现在执行ReloadAll函数后,创建的对象数量与销毁的对象数量保持一致。因此说明残留在内存中的对象不属于泄露,属于正常缓存。

JavaScript

JavaScript中,没有析构函数可以加打印日志观察对象是否被销毁,因此只能从代码逻辑上分析,对象是否还被其他生命周期更长的对象引用。

常见的泄露原因

api使用错误
  • 在生命周期或其他函数中使用AppStorage.Link或AppStorage.SetAndLink,会导致SynchedPropertyObjectTwoWay对象泄露
  • 使用自定义弹窗JSCustomDialogController,未在aboutToDisappear内将其置空
JavaScript泄露

如将对象挂在到了全局变量下、错误的使用闭包等。线上资料很多就不在此赘述了。

案例

这里我们构造一个泄露场景,来演示下如何分析。

场景:从A页面跳转到B页面然后返回,如此反复

img

从Snapshot中看,分配堆栈为aboutToAppear的对象,在每次跳转是都会留存一个在内存中。aboutToAppear函数位于RealSecond.js文件中,接下来我们需要查看编译后的RealSecond.js文件,找到aboutToAppear函数中可能存在泄露的对象:

aboutToAppear() {
   globalThis.arr.push({ data: 1 });
}

编译后的页面默认位于entry\build\default\cache\default\default@CompileArkTS\jsbundle\temporary\ets\pages中。需要注意的是,如果使用模块化编译是找不到编译后的文件的,直接查看ets源文件即可。使用模块化编译后,分配堆栈右侧的文件名会与非模块化编译的不一样,其文件保留了源码文件的命名。

可以看到aboutToAppear函数中,向globalThis中的arr数组push了一个对象,该对象有一个data属性,正好与Snapshot中的对象信息吻合:

img

同时在该对象的保留器页面,查看对象被谁引用:

img

可以看待,该对象位于JSArray中,也就是GlobalObject的arr数组,与代码也吻合。因此该对象由于被保存在了global的数组中,造成页面销毁时,其不会被销毁,导致泄露。

总结

本文提供了常见的初步筛选可疑对象创建栈的方法。列举了常见的内存泄露原因,并演示了如何根据创建栈分析代码,确认是否存在内存泄漏,下一步的目标是解决内存泄露。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐