【HarmonyOS开发小实践】HarmonyOS GC 调优:触发策略、日志解读与常见问题排查
HarmonyOS GC 调优:触发策略、日志解读与常见问题排查
GC 调优不是改几个参数就完事的活儿。多数时候 GC 出问题是因为业务代码让 GC 没法好好干活——短命对象被长期引用、缓存无界增长、大对象频繁分配。调优的第一步是看懂 GC 日志,从日志里读出 GC 健康度,再回头查代码。这篇把日志字段、调试接口、常见问题和调优思路过一遍。
Heap 参数配置
HPP GC 的大部分参数由系统按设备内存档位自动设定,开发者能直接改的不多。但知道这些参数的含义,对理解 GC 行为有帮助。
堆大小档位
系统按总堆大小分三档设定各 Space 参数:
| 档位 | 总堆大小 | SemiSpaceSize | NonmovableSpaceSize |
|---|---|---|---|
| 小 | 64-128MB | 2-4MB | 2MB |
| 中 | 128-256MB | 2-8MB | 6MB |
| 大(手机默认) | >256MB | 2-16MB | 64MB |
手机默认走大档,主线程 HeapSize 448MB,Worker 线程 768MB。小内存设备(手表)会自动缩。
关键参数
几个影响 GC 行为的参数:
| 参数 | 默认值 | 作用 |
|---|---|---|
| gcThreadNum | 7 | GC 线程数,可通过 gc-thread-num 设 |
| longPauseTime | 40ms | 超长 GC 阈值,超过打完整日志 |
| semiSpaceSize | 2-16MB | 年轻代大小,决定 Young GC 频率 |
| oldSpaceOvershootSize | 4-8MB | OldSpace 允许过冲 |
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 密集出现,几秒一次甚至更密。
排查:
- 看是哪种 GC 频繁。Young GC 频繁但单次快,多数情况正常(分配速率高);Old GC 频繁才是问题。
- 看
Heap alive rate。Young GC 后存活率高,说明对象晋升快,查业务代码里有没有把短命对象存到长期容器。 - 看触发原因。
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),日志里打完整信息。
排查:
- 看哪个阶段慢。
Mark慢说明对象图大或引用深;Sweep慢说明 Region 多;ProcessMarkStack慢说明标记栈大。 - 看 GC 类型。Full GC 慢是正常的;Young GC 慢不正常。
- 看是否在前台。前台 Old GC 5-10ms 正常;前台 Full GC 异常。
典型原因:对象引用链太深,标记阶段遍历久。比如树形结构深度上千层,或者循环引用密集的对象图。这种结构 GC 标记时栈深、慢。
OOM
症状:Out of memory 报错,应用崩溃。
排查:
- 看崩溃前最后一次 GC 日志的
AllSpaces used和committed。如果都接近上限,是真的内存不够。 - 看是哪个 Space 涨爆。OldSpace 涨爆查长期持有对象;HugeObjectSpace 涨爆查大对象分配;SharedHeap 涨爆查 Sendable 对象。
- 用 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。
排查:
- 用
hidebug.getProcessMemInfo定期打 RSS/PSS,看趋势。 - 怀疑泄漏点释放后调
ArkTools.hintGC(),看堆降不降。不降说明有引用还持着。 - 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 侧破坏了堆数据。两种常见原因:
- 非法多线程操作:Native 侧跨线程操作 ArkTS 对象,破坏了 GC 并发标记的假设。用方舟运行时检测工具排查。
- 内存访问错误: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。
分析:
- Old GC 频繁说明老年代膨胀快。看 GC 日志
OldSpace used滑动时持续上涨。 - 怀疑列表数据被长期持有。查代码发现列表组件把所有 item 的渲染数据缓存在一个全局 Map 里,滑动时不断往里塞新数据,老数据不删。
- 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 退出回收,得显式置空。
更多推荐


所有评论(0)