OpenHarmony内存泄露指南 - 分析Trace
在通过hiprofiler或chrome devtools抓取trace后,接下来重点就是找出可疑的对象,并反编译,然后分析代码确认其是否有泄露。 可疑对象 hiprofiler Native trace需要从下往上看。最下方一般是malloc等分配内存的函数,往上一层则是operator new或调用其的函数,以此类推
本系列文章旨在提供定位与解决OpenHarmony应用与子系统内存泄露的常见手段与思路,将会分成几个部分来讲解。首先我们需要掌握发现内存泄漏问题的工具与方法,以及判断是否可能存在泄漏。接着需要掌握定位泄漏问题的工具,以及抓取trace、分析trace,以确定是否有泄漏问题。如果发现问题的场景过于复杂,需要通过分解问题来简化场景。最后根据trace来找到问题代码并尝试解决。
目录:
在通过hiprofiler或chrome devtools抓取trace后,接下来重点就是找出可疑的对象,并反编译,然后分析代码确认其是否有泄露。
可疑对象
hiprofiler
Native trace需要从下往上看。最下方一般是malloc等分配内存的函数,往上一层则是operator new或调用其的函数,以此类推:
Native trace中,如果录制时操作多次(不建议仅操作一次),可以参考如下方法出去找出可疑对象:
- 大部分情况下可以使用Sample Count Filter来过滤远小于操作次数的trace。比如操作了10次,可以仅展示5次及以上的trace。
- 警惕创建次数是操作次数倍数的对象,该对象非常有可能存在泄露。操作次数建议为质数,如7、11次,这样能减小误判的几率,节省时间。
- 操作间隔可以稍微长一点,这样方便单独选中需要的某一次操作,如下图,可以清晰的看到5次操作:
- 如果创建次数是操作次数倍数,缩小时间轴选中范围。
- 如操作了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需要从上往下看。最上方是创建对象的函数,如果函数名与类名一致,则代表构造函数。往下一层则是调用其的函数,以此类推:
JavaScript trace中,可以参考如下方法出去找出可疑对象:
观察时间轴中,如果蓝条多于灰条,说明创建的对象数量大于销毁的,如果两者差距较大,则需要警惕
可以录制3次,第一次录制操作1次,第二次录制操作2次,第三次录制操作3次
对比3次录制的trace中,有规律增长的对象,如下图,Set与JSOBJECT成规律的增长
录制时无需手动触发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,反复操作后内存有增长。录制该操作发现,下面的对象的创建次数呈规律性的上涨,说明很有可能存在泄露:
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多次,需要重点分析代码是否存在泄露:
创建栈指向的代码如下:
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页面然后返回,如此反复
从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中的对象信息吻合:
同时在该对象的保留器页面,查看对象被谁引用:
可以看待,该对象位于JSArray中,也就是GlobalObject的arr数组,与代码也吻合。因此该对象由于被保存在了global的数组中,造成页面销毁时,其不会被销毁,导致泄露。
总结
本文提供了常见的初步筛选可疑对象创建栈的方法。列举了常见的内存泄露原因,并演示了如何根据创建栈分析代码,确认是否存在内存泄漏,下一步的目标是解决内存泄露。
更多推荐
所有评论(0)