HarmonyOS GC 调优:触发策略、日志解读与常见问题排查

GC 调优不是改几个参数就完事的活儿。多数时候 GC 出问题是因为业务代码让 GC 没法好好干活——短命对象被长期引用、缓存无界增长、大对象频繁分配。调优的第一步是看懂 GC 日志,从日志里读出 GC 健康度,再回头查代码。这篇把日志字段、调试接口、常见问题和调优思路过一遍。

Heap 参数配置

HPP GC 的大部分参数由系统按设备内存档位自动设定,开发者能直接改的不多。但知道这些参数的含义,对理解 GC 行为有帮助。

堆大小档位

系统按总堆大小分三档设定各 Space 参数:

档位总堆大小SemiSpaceSizeNonmovableSpaceSize
小64-128MB2-4MB2MB
中128-256MB2-8MB6MB
大(手机默认)>256MB2-16MB64MB

手机默认走大档,主线程 HeapSize 448MB,Worker 线程 768MB。小内存设备(手表)会自动缩。

关键参数

几个影响 GC 行为的参数:

参数默认值作用
gcThreadNum7GC 线程数,可通过 gc-thread-num 设
longPauseTime40ms超长 GC 阈值,超过打完整日志
semiSpaceSize2-16MB年轻代大小,决定 Young GC 频率
oldSpaceOvershootSize4-8MBOldSpace 允许过冲

gcThreadNum 设成负值时,线程池大小初始化为 CPU 核心数的一半。CPU 核多的设备可以适当调大,并发标记更快;单核或双核设备调大没用,反而线程切换开销大。

longPauseTime 默认 40ms,单次 GC 超过这个值会打完整日志。排查卡顿时把这个值调小(比如 10ms),能抓到更多可疑 GC。通过 gc-long-paused-time 配置。

查询运行时参数

实际运行时的堆内存信息用 @ohos.hidebug 和 @ohos.util.getAllVMHeapMemoryInfo 查:

import { hidebug } from '@kit.PerformanceAnalysisKit';
import { util } from '@kit.ArkTS';

// 查进程内存
const info = hidebug.getProcessMemInfo();
console.log(`RSS: ${info.rss}, PSS: ${info.pss}`);

// 查 VM 堆内存(含 SharedHeap)
const heapInfo = util.getAllVMHeapMemoryInfo();
console.log(`Heap used: ${heapInfo.used}, committed: ${heapInfo.committed}`);

调优时先把这些值打出来,看实际占用和配置差多少。

Smart GC

Smart GC 是个智能 GC 抑制机制,不是新 GC 类型,而是在敏感场景临时提高 GC 触发阈值,减少 GC 触发频率,避免掉帧。

支持的场景

  • 应用冷启动(默认开启)
  • 滑动
  • 点击页面跳转
  • 超长帧

这些场景下用户对卡顿敏感,GC 暂停哪怕几毫秒也能感知到。Smart GC 把 Young GC 和 Old GC 的触发阈值临时调高,让 GC 推迟到场景结束后再跑。

注意事项

  • 只对主线程生效:Worker 和 TaskPool 线程的 GC 不受 Smart GC 抑制。所以重活儿放 Worker 里跑,GC 不影响主线程体验。
  • 不是无限推迟:如果敏感场景持续太久,对象分配还是会达到调高后的阈值,照样触发 GC。而且因为攒了更多对象,这次 GC 时间更长。
  • 系统侧管控:三方应用没有接口直接开关 Smart GC,由系统根据场景自动判断。

日志

SmartGC: set high sensitive status    // 进入敏感场景
SmartGC: app cold start just finished // 冷启动结束

看到这两条日志,说明 Smart GC 在工作。如果应用卡顿但日志里没有 Smart GC 标记,可能场景没被系统识别为敏感,得从别的方向优化。

GC 日志解读

开启全量日志

默认只打耗时超过 40ms 的 GC 日志。排查问题时要看到所有 GC,得手动开:

# 开启 GC 全量日志
hdc shell param set persist.ark.properties 0x905d
# 重启生效
hdc shell reboot

# 关闭(恢复默认)
hdc shell param set persist.ark.properties 0x105c
hdc shell reboot

改完得重启才生效,因为参数在启动时读一次。排查完记得关掉,全量日志量大,长期开会刷屏影响性能。

日志结构

一次完整 GC 日志长这样(以 Full GC 为例):

C03F00/ArkCompiler: [gc]  [ CompressGC ] 26.1164 (35) -> 7.10049 (10.5) MB, 160.626(+0)ms, Switch to background
C03F00/ArkCompiler: [gc] IsInBackground: 1; SensitiveStatus: 0; OnStartupEvent: 0; BundleName: com.example.demo;
C03F00/ArkCompiler: [gc] /***************** GC Duration statistic: ****************/
C03F00/ArkCompiler: [gc] TotalGC:                 160.626 ms
C03F00/ArkCompiler: Initialize:              0.179   ms
C03F00/ArkCompiler: Mark:                    159.204 ms
C03F00/ArkCompiler: MarkRoots:               6.925   ms
C03F00/ArkCompiler: ProcessMarkStack:        158.99  ms
C03F00/ArkCompiler: Sweep:                   0.957   ms
C03F00/ArkCompiler: Finish:                  0.277   ms
C03F00/ArkCompiler: [gc] /****************** GC Memory statistic: *****************/
C03F00/ArkCompiler: [gc] AllSpaces        used:  7270.9KB     committed:   10752KB
C03F00/ArkCompiler: ActiveSemiSpace  used:       0KB     committed:     256KB
C03F00/ArkCompiler: OldSpace         used:  4966.9KB     committed:    5888KB
C03F00/ArkCompiler: HugeObjectSpace  used:    2304KB     committed:    2304KB
...
C03F00/ArkCompiler: [gc] Heap alive rate:         0.202871
C03F00/ArkCompiler: [gc] /***************** GC summary statistic: *****************/
C03F00/ArkCompiler: [gc] CompressGC occurs count  6
C03F00/ArkCompiler: CompressGC max pause:    2672.33 ms
C03F00/ArkCompiler: CompressGC min pause:    160.626 ms
C03F00/ArkCompiler: CompressGC average pause:1076.06 ms

关键字段

第一行:GC 类型和耗时

[ CompressGC ] 26.1164 (35) -> 7.10049 (10.5) MB, 160.626(+0)ms, Switch to background
  • [ CompressGC ]:GC 类型,可能是 [ HPP YoungGC ]、[ HPP OldGC ]、[ CompressGC ]、[ SharedGC ]
  • 26.1164 (35) -> 7.10049 (10.5) MB:GC 前 used(committed) → GC 后 used(committed)。这次 GC 把堆从 26MB 回收到 7MB,效果显著
  • 160.626(+0)ms:总耗时 160ms,括号里是并发标记耗时(+0 表示没有并发标记阶段)
  • Switch to background:触发原因,切后台触发

第二行:运行状态

IsInBackground: 1; SensitiveStatus: 0; OnStartupEvent: 0; BundleName: com.example.demo;
  • IsInBackground:1 是后台,0 是前台
  • SensitiveStatus:1 是敏感场景(Smart GC 抑制中),0 是非敏感,2 是退出敏感
  • OnStartupEvent:1 是冷启动中

Duration statistic:各阶段耗时

TotalGC:                 160.626 ms
Initialize:              0.179   ms
Mark:                    159.204 ms
MarkRoots:               6.925   ms
ProcessMarkStack:        158.99  ms
Sweep:                   0.957   ms
Finish:                  0.277   ms

这次 GC 慢在 Mark 阶段(159ms),其中 ProcessMarkStack 占了 158ms——对象图大,标记栈处理慢。Sweep 只用了 0.9ms,清扫很快。看到 Mark 占大头,说明对象多或引用关系复杂;看到 Sweep 占大头,说明 Region 多。

Memory statistic:各 Space 占用

AllSpaces        used:  7270.9KB     committed:   10752KB
ActiveSemiSpace  used:       0KB     committed:     256KB
OldSpace         used:  4966.9KB     committed:    5888KB
HugeObjectSpace  used:    2304KB     committed:    2304KB
  • used:对象实际占用
  • committed:分配给 Space 的总内存(含碎片)

committed - used 是 Space 内部碎片。HugeObjectSpace 的 used 和 committed 总是相等(每个对象独占 Region)。

Heap alive rate:存活率

Heap alive rate:         0.202871

GC 后存活对象占 GC 前总对象的比例。这次是 20%,说明 80% 被回收了,很健康。Young GC 后存活率应该是个位数 %;如果 Young GC 后存活率 50%+,说明年轻代对象都在晋升,分代假设失效。

summary statistic:历史统计

CompressGC occurs count  6
CompressGC max pause:    2672.33 ms
CompressGC min pause:    160.626 ms
CompressGC average pause:1076.06 ms

这个虚拟机实例上 CompressGC 跑了 6 次,最长 2.6 秒,平均 1 秒。Full GC 这么慢是正常的(全量压缩),但如果在前台跑就问题大了。

从日志判断 GC 健康度

几个判断维度:

指标健康异常
Young GC 频率高频但单次 <5ms单次 >20ms 或存活率 >30%
Old GC 频率几分钟一次几秒一次
Full GC只在切后台前台触发
Heap alive rate(Young)<10%>30% 分代失效
TotalGC 耗时Young <5ms, Old <15ms远超阈值

GC 调试接口

ArkTools.hintGC()

手动提示系统做 Full GC。VM 会判断当前是否适合——后台且预期存活率低才触发,敏感场景不触发。

declare class ArkTools {
  static hintGC(): void;
}

// 用法
ArkTools.hintGC();

注意:这个接口仅供调试,不是正式 SDK 接口,别在正式版本里用。生产环境里手动触发 GC 几乎总是错的——系统的触发策略已经够好,手动触发反而干扰。

适合用 hintGC 的场景:测试内存泄漏时,释放一批对象后手动触发 GC,看堆有没有降下来。如果没降,说明有引用还持着,泄漏了。

// 泄漏测试套路
function testLeak() {
  const before = getHeapUsed();
  // 创建一批对象
  for (let i = 0; i < 10000; i++) {
    globalLeak.push(new BigObject());
  }
  // 释放引用
  globalLeak.length = 0;
  // 手动 GC
  ArkTools.hintGC();
  const after = getHeapUsed();
  if (after - before > threshold) {
    console.error('疑似泄漏');
  }
}

常见问题排查

GC 频繁

症状:日志里 Young GC 或 Old GC 密集出现,几秒一次甚至更密。

排查:

  1. 看是哪种 GC 频繁。Young GC 频繁但单次快,多数情况正常(分配速率高);Old GC 频繁才是问题。
  2. 看 Heap alive rate。Young GC 后存活率高,说明对象晋升快,查业务代码里有没有把短命对象存到长期容器。
  3. 看触发原因。ALLOCATION_LIMIT 是分配触发,正常;IDLE 是空闲触发,正常;频繁 ALLOCATION_LIMIT 说明分配速率高,得优化分配。

典型原因:

// 反例:在循环里创建闭包,每个闭包捕获大对象
for (let i = 0; i < 100000; i++) {
  const big = new BigObject();
  callbacks.push(() => big.doSomething());  // big 被闭包持有,进老年代
}

闭包捕获的 big 一直被 callbacks 数组持有,全晋升到老年代。改成不捕获或者用完即弃:

for (let i = 0; i < 100000; i++) {
  processOne();  // big 在函数内创建,函数返回即不可达
}
function processOne() {
  const big = new BigObject();
  big.doSomething();
}

GC 耗时长

症状:单次 GC 耗时超过 longPauseTime(40ms),日志里打完整信息。

排查:

  1. 看哪个阶段慢。Mark 慢说明对象图大或引用深;Sweep 慢说明 Region 多;ProcessMarkStack 慢说明标记栈大。
  2. 看 GC 类型。Full GC 慢是正常的;Young GC 慢不正常。
  3. 看是否在前台。前台 Old GC 5-10ms 正常;前台 Full GC 异常。

典型原因:对象引用链太深,标记阶段遍历久。比如树形结构深度上千层,或者循环引用密集的对象图。这种结构 GC 标记时栈深、慢。

OOM

症状:Out of memory 报错,应用崩溃。

排查:

  1. 看崩溃前最后一次 GC 日志的 AllSpaces used 和 committed。如果都接近上限,是真的内存不够。
  2. 看是哪个 Space 涨爆。OldSpace 涨爆查长期持有对象;HugeObjectSpace 涨爆查大对象分配;SharedHeap 涨爆查 Sendable 对象。
  3. 用 AllocationTracker 或 DumpHeapSnapshot 抓堆快照,看是什么对象占大头。

典型原因:

// 缓存无界增长
const cache = new Map<string, BigData>();
function handle(key: string) {
  if (!cache.has(key)) {
    cache.set(key, loadBigData(key));  // 只进不出
  }
  return cache.get(key);
}

cache 永远只增不删,最终撑爆 OldSpace。加 LRU 淘汰或定期清理:

function handle(key: string) {
  if (cache.size > 1000) {
    // 淘汰最老的
    const oldest = cache.keys().next().value;
    cache.delete(oldest);
  }
  if (!cache.has(key)) {
    cache.set(key, loadBigData(key));
  }
  return cache.get(key);
}

内存泄漏

症状:堆 used 持续上涨,GC 后不降,长期运行最终 OOM。

排查:

  1. 用 hidebug.getProcessMemInfo 定期打 RSS/PSS,看趋势。
  2. 怀疑泄漏点释放后调 ArkTools.hintGC(),看堆降不降。不降说明有引用还持着。
  3. DumpHeapSnapshot 抓堆快照,用工具分析支配树,找占大头的对象和引用链。

典型原因:

// 全局监听器没解绑
class MyComponent {
  aboutToAppear() {
    globalEventBus.on('event', this.handler);  // 注册
  }
  // aboutToDisappear 里忘了 off
}

组件销毁了但 handler 还被 globalEventBus 持有,组件对象(及其所有字段)泄漏。解绑:

class MyComponent {
  aboutToAppear() {
    globalEventBus.on('event', this.handler);
  }
  aboutToDisappear() {
    globalEventBus.off('event', this.handler);  // 必须解绑
  }
}

GC 稳定性问题(crash)

症状:在 OS_GC_Thread 线程 crash,堆栈里有 CompressGCMarker、ProcessMarkStack 等字样。

排查:GC 线程 crash 通常是 Native 侧破坏了堆数据。两种常见原因:

  1. 非法多线程操作:Native 侧跨线程操作 ArkTS 对象,破坏了 GC 并发标记的假设。用方舟运行时检测工具排查。
  2. 内存访问错误:Native 侧踩内存(buffer 溢出、use-after-free),破坏了堆上对象的元数据。用 HWASan 检测排查。

crash 堆栈示例:

Reason:Signal:SIGSEGV(SEGV_MAPERR)@0xffff000000000048
Fault thread info:
Tid:6490, Name:OS_GC_Thread
#00 pc ... libark_jsruntime.so(panda::ecmascript::JSHClass::SizeFromJSHClass(...))
#01 pc ... libark_jsruntime.so(panda::ecmascript::CompressGCMarker::EvacuateObject(...))

0xffff000000000048 这种异常地址说明 GC 标记时读到了被破坏的对象头。回查 Native 代码里有没有把 napi_value 跨线程持有、有没有 buffer 溢出。

调优实践案例

场景:列表页滑动卡顿,抓日志发现滑动时 Old GC 频繁触发,单次 15-20ms。

分析:

  1. Old GC 频繁说明老年代膨胀快。看 GC 日志 OldSpace used 滑动时持续上涨。
  2. 怀疑列表数据被长期持有。查代码发现列表组件把所有 item 的渲染数据缓存在一个全局 Map 里,滑动时不断往里塞新数据,老数据不删。
  3. Smart GC 日志没出现,说明滑动场景没被识别为敏感(可能用了自定义滑动组件)。

优化:

// 改前:无界缓存
const renderCache = new Map<string, RenderData>();
function renderItem(id: string) {
  if (!renderCache.has(id)) {
    renderCache.set(id, computeRenderData(id));
  }
  return renderCache.get(id);
}

// 改后:LRU 限制大小
const renderCache = new Map<string, RenderData>();
const CACHE_LIMIT = 100;
function renderItem(id: string) {
  if (renderCache.size >= CACHE_LIMIT && !renderCache.has(id)) {
    const oldest = renderCache.keys().next().value;
    renderCache.delete(oldest);
  }
  if (!renderCache.has(id)) {
    renderCache.set(id, computeRenderData(id));
  }
  return renderCache.get(id);
}

加 LRU 后老年代稳定,Old GC 从几秒一次降到几十秒一次,滑动卡顿消失。

实践中要注意的哦

  • 别看到 GC 就想关掉:GC 是内存管理的必要部分,关 GC 只会更快 OOM。调优的目标是让 GC 别在敏感时刻触发,不是消灭 GC。
  • 全量日志别长期开:persist.ark.properties 0x905d 开全量日志后量很大,影响性能。排查完恢复默认 0x105c。
  • hintGC 别在生产用:手动触发 GC 干扰系统策略,多数时候适得其反。只在泄漏测试时用。
  • Worker 的 GC 独立:主线程 GC 慢不等于 Worker GC 慢。重活儿放 Worker,主线程 GC 压力自然小。

小小经验

  • 排查 GC 问题先开全量日志,抓一段时间的 GC 行为,看是哪种 GC、哪个阶段、什么触发原因。盲猜代码效率低。
  • Heap alive rate 是最该关注的指标。Young GC 后存活率高(>30%)几乎一定是分代失效,查为什么短命对象被长期引用。
  • OOM 不一定是泄漏,也可能是峰值。某瞬间分配大量临时对象,即使最终都会释放,峰值超过堆上限就 OOM。这种得优化分配模式(分批、复用),不是查泄漏。
  • GC 日志里 committed 远大于 used 说明碎片多。OldSpace 碎片多会触发 Full GC 整理,看到 Full GC 频繁查这个。
  • SharedHeap 涨爆查 Sendable 对象。跨 Worker 传大对象用 Sendable,这些对象在 SharedHeap,不会随 Worker 退出回收,得显式置空。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐