Skip to content

内存与 GC:对象布局、并发拷贝回收与低内存链 ​

本页导读:本页回答四个问题:一个 Java 对象在 ART 堆里到底占多少字节;GC 如何做到「边搬对象边跑业务」;native 内存在 malloc/Scudo 层面归谁管;以及从 onTrimMemory 到 LMKD 的整条低内存链。前置:ART 篇、进程模型。

一、对象内存布局:从 8 字节头说起 ​

ART 对象头(32/64 位下都以紧凑为目标)主要装两样东西:

  • lock word(monitor/哈希/GC 状态位打包) 与 压缩引用(compressed class pointer)——64 位设备上引用默认按 32 位压缩(boot image 基址 + 4GB 假设),这是 ART 省内存的第一刀;
  • 数组多一个 length 字段;实例字段按类型分组 + 对齐重排(字段序优化,long 归 long 区),父类字段在前。

估算口径(口诀版,具体随版本微调):

组成大小
对象头8 字节
引用字段4 字节(压缩)
原始字段按类型对齐
整对象8 字节对齐补齐

工程推论:Long/Integer 装箱一个值=对象头+字段+对齐,常是裸值的 4 倍成本;集合里 HashMap<Long,Boolean> 这类结构是隐形放大器,AndroidX 的 LongSparseArray/SparseIntArray 家族存在的理由就写在这个表里。

kotlin
// 一个「不起眼」的热点容器
val cache = HashMap<Long, Long>()        // 每个 entry ≈ Node(对象头+3引用) + 两个装箱
// 等价的省法:
val cache2 = androidx.collection.LongLongSparseMap()  // 两个 long 数组,零对象

二、GC 演进与并发拷贝(CC) ​

Dalvik 时代 STW 复制式 GC 一停几百 ms 是卡顿祖传来源。ART 的主线是 Concurrent Copying(Android 5 引入,此后持续强化):

text
CC 的心跳(概念版):
1. 标记阶段:与 mutator 并发,读屏障(read barrier)盯住「改引用」
2. 转场(flip):极短 STW,所有线程在安全点换「旧→新」转发表
3. 复制/清扫:对象搬家(去碎片)与业务并发,屏障兜住访问旧地址的手
4. 回收空间归还分配器

配套机制按版本累积:Region 分配栈(TLAB 化,Android 8/9 时代)、后台 GC(BackgroundSweeper,空闲时清 Zygote 堆)、GC record & replay(记录 Zygote 预热期分配,子进程重放或直接吃 app image,Android 10/11 时代)——「多进程省内存」的底层叙事一半在 GC 侧。

堆空间版图(决定对象「住哪、怎么被搬」):

Space装什么GC 待遇
image spaceboot image 预置对象不搬(只读共享)
zygote spaceZygote 预加载对象只读共享,回收极保守
main space常规分配CC 搬移主战场
large object space大数组/大对象基本不搬移,直接回收
app image space启动对象快照mmap 恢复,配合 record-replay

触发点(logcat 里 GC 原因的官方口径,字段名以版本为准): Alloc(分配失败)|GC limiter exceeded(分配超限)|Background(空闲清扫)|Explicit(System.gc 被限频后的礼貌版)|Sweepsystem/TrimMemory 联动|Concurrent(堆水位达标自动)。

三、分配路径:快慢两条道 ​

  • 快路径:编译器把分配内联进方法体——从线程本地分配栈(TLAB/region alloc stack)指针碰撞即得;无锁、纳秒级。
  • 慢路径:栈不够→申请新 region;失败→触发 GC→仍失败→OutOfMemoryError(Java 层的 OOM 绝大多数是堆上限问题,不是物理内存没了)。
  • 大对象直入 LOS;数组超限、byte[] 解码大图是 OOM 三兄弟之二(第三个是 Bitmap 未采样)。
  • 显式 System.gc() 被平台刻意弱化(限频 + 厂商可无视)——需要「主动归还」请用 VMRuntime/trim 回调的正确通道,而不是玄学调 GC。
java
// frameworks/base/core/java/dalvik/system/VMRuntime.java(能力面节选大意)
public final class VMRuntime {
    public void requestConcurrentGC() { ... }   // 框架内部用的「礼貌请求」通道
    public void notifyStartupCompleted() { ... } // 启动完成信号 → GC 策略切换
    public native void nativeGc(int gen, boolean force); // 仅供框架
}

内存账本怎么看:adb shell dumpsys meminfo com.example(PSS/RSS 分段:Java Heap/Native Heap/Code/Graphics);Android Studio Memory Profiler 看分配火焰与 GC 事件对齐;Debug.getMemoryInfo 拿同一份账。

bash
adb shell dumpsys meminfo com.example | head -25
  # 重点行:Java Heap / App Summary: PSS Total / Native Heap /
  #            Graphics(含 GL/Skia)/ Code(dex/oat/so 映射)

四、Native 内存:Scudo 时代 ​

  • Java 堆由 ART 管;native 堆归分配器:Android 11 起默认 Scudo(硬化版 malloc:每个 chunk 加校验、自动清零,主打防堆溢出利用,代价是略慢与略高水位),之前是 jemalloc。
  • 观察工具链:malloc debug(wrap.sh 挂到进程查泄漏/越界)、heapprofd(Perfetto 数据源,native 采样分配栈,定位「native 只涨不降」神器)、dumpsys meminfo 的 Native Heap 行。
  • Bitmap 像素自 Android 8 起在 native 堆(Graphics/native 段可见)——「Java 堆没涨但 PSS 爆」的经典真相。
  • 硬标签 MTE(Android 12+,Pixel 6+ 起)在硬件层给指针打 tag 拦 use-after-free——native 稳定性从「事后查」走向「当场炸」,灰度开启时崩溃率会「变差」,那是真 bug 现形。

malloc debug 挂进目标进程(userdebug/root 常用):

bash
adb shell setprop wrap.com.example \
  "-d libwrapshim.so -e MALLOC_DEBUG=1 -e malloc.returns_null=false"
  # 具体参数族以 bionic/docs/malloc_debug 为准

五、泄漏:模式、检测与 LeakCanary 原理 ​

高频泄漏模式(每个都对应一条强引用链):

模式引用链修复
静态集合持有 Contextstatic Map → Activity用 applicationContext / 弱引用
非静态内部类 Handler/Listenerlambda → 外部 Activity → MessageQueue 中消息静态内部类 + 弱引用 + 生命周期清理
未反注册监听系统服务/EventBus 表 → 页面onDestroy 成对解注册
线程/协程泄漏线程持有闭包,线程池不清生命周期作用域(viewModelScope 等)
WebView/地图类重资源单例缓存 View显式 destroy + 不用单例持 View

LeakCanary 原理一句话:「Activity 销毁后强引用还在 = 泄漏」的自动化审判。

kotlin
// 其核心机制(简化):
val watcher = KeyedWeakReference(activity, uuid, description, queue)
// 1. onDestroy 后触发几次 GC(主动跑几轮给足机会)
// 2. 观察 ReferenceQueue:被回收 → 无罪释放
// 3. 超时未被回收 → dump hprof → 解析对象图
// 4. 从 GC Root 找「最短保留路径」→ 输出你看得懂的引用链

被泄漏方的标准防御模板(Handler 泄漏一代目经典):

kotlin
class SafeFragment : Fragment() {
    private class H(f: SafeFragment) : Handler(Looper.getMainLooper())  // 仅弱引用语义见下
    private val h = object : android.os.Handler(Looper.getMainLooper()) {}
    override fun onDestroyView() {
        h.removeCallbacksAndMessages()   // 清掉队列里攥着自己的消息
        super.onDestroyView()
    }
}

工程注意:检测只在 debug 构建跑;release 想带内存防线,用 OnLowMemory/trim 上报 + heapprofd 抽样,别上完整 LeakCanary。

六、低内存链:从 trim 回调到 LMKD ​

一个 cached 进程内存压力下的完整遭遇(与进程模型页衔接):

  1. 系统水位紧 → onTrimMemory(TRIM_MEMORY_UI_HIDDEN/RUNNING_CRITICAL/COMPLETE):应用侧主动弃缓存(图片池、预热资源)——这是免费的生命权;
  2. 继续恶化 → LMKD 按 PSI 颠簸度选杀(先高 adj、综合占用与 thrashing),写事件日志;
  3. 再恶化 → 内核 OOM killer 兜底;
  4. 应用退出原因可查:ActivityManager#getHistoricalProcessExitReasons(API 30+)区分 LMK/崩溃/ANR/用户划掉;
  5. 设计启示:把「会被杀」写进架构假设——可重建、可恢复、可丢缓存,才是 Android 内存模型下的好公民。
kotlin
override fun onTrimMemory(level: Int) {
    when (level) {
        TRIM_MEMORY_UI_HIDDEN -> imageCache.trimTo(0.5f)   // 让出一半
        TRIM_MEMORY_RUNNING_CRITICAL -> imageCache.clear() // 全力自保
        else -> {}
    }
}

七、常见误区问答 ​

Q1:System.gc() 到底有没有用? 没有直接有用:平台限频且可能被合并;它的正确替身是「给 GC 制造机会」——LeakCanary 也是跑多轮 + 等 ReferenceQueue,不是指望一次回收一切。

Q2:adb shell dumpsys meminfo 的 PSS 和 Java 堆对不上? 正常:PSS 含 native、图形、代码映射与共享摊薄;查 Java 泄漏看 Debug MemoryInfo 的 Java Heap 与 hprof,别拿 PSS 直接归因 Java。

Q3:内存泄漏一定会 OOM 吗? 不一定,更常见结局是被 LMK 提前杀掉——泄漏的真实成本是「存活时间短 + 冷启动多」,线上要用退出原因统计衡量,不能只盯崩溃率。

版本注记:基于 Android 16 / API 36;Android 5 差异:CC GC 登场;8/9 差异:region 分配 + Bitmap 像素入 native;10/11 差异:GC record-replay 与 app image、Scudo 默认化;12+ 差异:MTE/heapprofd 工具面成熟;13~16 差异:LMK 与 freezer 策略持续演进(以 AOSP 与设备为准)。

本页小结:对象以 8 字节头 + 压缩引用精打细算;GC 用读屏障与转场实现「边搬边跑」的并发拷贝;分配走 TLAB 快路径、OOM 多是堆上限;native 内存交给硬化过的 Scudo,用 heapprofd 看它;泄漏靠引用链思维 + LeakCanary 的自动化审判;低内存链上,主动 trim 是应用唯一能主动出牌的一手。

参考链接 ​

基于 Android 16 / API 36 · 内容为原创整理,转载请注明出处