Java内存回收机制(GC)完整详解

219 阅读38分钟

第一部分:垃圾回收基础

1.1 什么是垃圾回收

定义: 垃圾回收(Garbage Collection,GC)是自动管理内存的机制,能自动识别并回收不再使用的对象,释放其占用的内存。

作用:

作用说明
自动释放内存无需手动释放,降低忘记释放导致泄漏的风险
避免内存泄漏回收不可达对象,减缓内存被无效占用
简化开发开发者更关注业务,少关心释放细节
提升效率减少因内存管理不当产生的 Bug

在 Android 中的重要性:

设备内存有限、GC 影响流畅度、频繁 GC 易卡顿、内存不足时应用可能被系统回收。

public void createObjects() {
    Object obj1 = new Object();
    Object obj2 = new Object();
    // 不再被引用后,GC 会自动回收,无需手动释放
}

1.2 如何判断对象已死

1.2.1 引用计数法(Android 未采用)

  • 做法: 对象被引用时计数 +1,取消引用时 -1,计数为 0 则可回收。
  • 问题: 无法处理循环引用,两个对象互相引用时计数永不为 0,导致泄漏。
  • 结论: Android 使用可达性分析,不用引用计数。

1.2.2 可达性分析(Android 采用)

思路: 从一组「GC Roots」出发,能到达的对象视为存活,不可达的视为垃圾,可被回收。

flowchart LR
    R1["GC Roots: 栈/静态/常量/锁"] --> R2["可达: 对象1-对象2"]
    R3["不可达: 对象3-对象4"] -.->|垃圾| R4["可回收"]

常见 GC Roots:

类型示例
虚拟机栈中的引用局部变量、方法参数
方法区静态属性static 变量
方法区常量字符串常量等
本地方法栈引用JNI 引用
同步锁持有对象synchronized(lock) 的 lock
内部引用Class、异常对象等

循环引用在可达性分析下可被正确回收:只要从 GC Roots 不可达,即使对象之间互相引用也会被当作垃圾。


1.2.3 引用类型(简要)

类型特点典型用法
强引用存在则不会被回收普通对象、Activity 等
软引用内存紧张时才回收缓存(Android 更推荐 LruCache)
弱引用下次 GC 就可能回收Handler 持 Activity、WeakHashMap
虚引用最弱,多用于回收前清理少见,如堆外内存清理

第二部分:垃圾回收算法

2.1 三种基础算法对比

算法思路优点缺点Android 使用
标记-清除先标记再清除未标记对象实现简单、不移动对象易产生碎片非主流
标记-复制存活对象复制到另一块,再清空当前块无碎片、适合年轻代占用双倍空间(可优化)年轻代主用
标记-整理标记后把存活对象向一端紧凑移动,再清理边界外的内存无碎片、不浪费整块空间移动对象、更新引用成本高老年代

2.1.1 标记-清除(Mark-Sweep)

两阶段: ① 从 GC Roots 遍历,标记所有存活对象 → ② 遍历整块内存,清除所有未标记的对象。

问题: 清除后存活对象之间会留下许多“空洞”,即内存碎片,后续分配大对象时可能找不到连续空间。

flowchart LR
    A1["回收前: A活 B垃圾 C活 D垃圾 E活"] --> A2["标记: A留 B除 C留 D除 E留"] --> A3["清除后: A 空 C 空 E"]

小结: 存活对象位置不变,只把“垃圾”清掉 → 中间出现空洞,形成碎片。


2.1.2 标记-复制(Mark-Copy)

思路: 内存一分为二(From 区 / To 区),平时只在 From 区分配。GC 时:标记 From 区存活对象 → 把存活对象复制到 To 区 → 整块清空 From 区。下次 GC 时 From/To 角色互换。

优点: 复制后 To 区一侧全是存活对象,没有空洞,无碎片;分配时顺序往后挪即可。

flowchart LR
    B1["From区: A活 B垃圾 C活 D垃圾"] --> B2["复制存活到To"]
    B2 --> B3["To区: A C | From区: 清空"]

小结: 只复制存活对象到另一块,原块整块清空 → 新块连续无碎片,但需要预留一半空间(Appel 式用 Eden+2个 Survivor 可只浪费约 10%)。


2.1.3 标记-整理(Mark-Compact)

三阶段: ① 标记所有存活对象 → ② 紧凑移动:把存活对象往内存一端(如左侧)挨个挪动,中间不留空 → ③ 清边界:在“最后一个存活对象”后面画一条边界,边界外的整块内存统一回收。

优点: 存活对象集中在一端,另一侧是一大块连续空闲,无碎片,且不需要双倍内存。

flowchart LR
    C1["回收前: A活 空 B活 空 空 C活 空"] --> C2["紧凑移动"]
    C2 --> C3["一端存活ABC 一端可回收"]

“清边界”含义: 不逐个清每个空洞,而是把「最后一个存活对象」右边的整块区域视为空闲,一次性用于后续分配。

小结: 标记 → 紧凑移动(无空洞)→ 清边界 → 得到一端连续存活、一端连续空闲,无碎片。


2.2 分代收集(Appel 式):年轻代 + 老年代是一套设计

年轻代和老年代是同一套分代策略的两块区域:年轻代用 Appel 式(Eden + 两块 Survivor)+ 标记-复制,老年代用标记-整理,两者配合成一套。

2.2.1 为什么用分代

大部分对象创建后很快不用,少数长期存活;把新对象和长期存活对象分开存放、用不同算法回收,省时、少碎片。年轻代 + 老年代即这一思路下的同一套布局。

2.2.2 新老代总览(谁是谁、怎么收)

堆分两大块

包含区域存什么回收算法何时回收
年轻代Eden + Survivor0 + Survivor1(三块)新对象、上轮存活对象(S0 与 S1 轮流当 From/To标记-复制Eden 满 → Minor GC
老年代独立一块(≠ Survivor,不是 S0/S1)从年轻代晋升来的对象、大对象标记-整理老年代满/堆不足等 → Full GC

要点: 老年代 ≠ Survivor1,是堆里单独的一块。Minor GC 时,Eden 与当前 From 的存活对象都复制到当前 To(S0、S1 之一);To 放不下则多出的晋升。晋升条件详见 2.2.3

2.2.3 年轻代:Eden + S0/S1 + 晋升条件(重要)

年轻代三块各干什么:

区域作用
Eden新对象都在这里分配;约占年轻代 80%(Appel 式 8:1:1)。
Survivor0 / Survivor1(S0、S1)两块大小相同(各约 10%),轮流当 From / To,存上一轮 Minor GC 后仍存活的对象。

Minor GC 触发与“往哪放”:

  • 何时触发:一般是 Eden 满了、新对象没地方分配时触发 Minor GC。只要发生了 Minor GC,就会回收年轻代(Eden + 当前 From),并把存活对象复制走。
  • Eden 和 From 的存活对象往哪放:每次 Minor GC 时,S0、S1 里一块是 From(源)、一块是 To(目标)Eden 的存活对象当前 From 的存活对象都复制到当前作为 To 的那一块里。下次 Minor GC 时 From 与 To 互换。能否全部放进 To,按下面表格判断:
To 是否放得下判断结果
放得下Eden + From 的存活对象总量 ≤ To 可用空间全部复制到 To,留在年轻代,年龄 +1
放不下Eden + From 的存活对象总量 > To 可用空间能放进 To 的进 To;多出的直接晋升到老年代

从年轻代晋升到老年代的条件(重要): 老年代是独立的一块,不是 Survivor。满足任一即晋升:

条件说明
Survivor 放不下即上表「To 放不下」:本次存活对象太多,To 装不下,多出的直接进老年代。
对象年龄达阈值在年轻代经历多次 Minor GC 仍存活(如年龄 ≥ 15),晋升到老年代。

一次 Minor GC 的流程:

flowchart LR
    D1["Eden满+S0+S1"] --> D2["标记"] --> D3["复制到To"] --> D4["清空From"] --> D5["互换角色"]

小结: 年轻代 = Eden(分配)+ 两块 Survivor(轮流 From/To);Minor GC 时 Eden + From 的存活对象都进 To,To 放不下则多出的晋升;晋升条件见上表。

2.2.4 老年代

  • 存什么: 从年轻代晋升来的对象(晋升时可能在 S0 或 S1)、部分大对象。
  • 算法: 标记-整理(无碎片、不双倍空间)。
  • 何时回收: 仅在 Full GC 时(老年代满 / 堆不足 / 显式 System.gc());Full GC 时年轻代也参与,暂停明显长于 Minor GC。

2.2.5 新老代关系(一张图)

flowchart LR
    Y1["年轻代 Eden+S0+S1"] -->|晋升| Y2["老年代"]
    Y2 -.->|Full GC回收| Y2

对象在年轻代 S0/S1 间轮流复制,满足晋升条件(见 2.2.3)则进入老年代;老年代仅 Full GC 时回收。


2.3 分代收集策略小结

对象从创建到回收的流转与对比如下;晋升条件见 2.2.3

flowchart LR
    E1["新对象"] --> E2["Eden"] --> E3["Minor GC"]
    E3 --> E4{"存活"}
    E4 -->|否| E5["回收"]
    E4 -->|是| E6["Survivor"] --> E7{"年龄"}
    E7 -->|否| E6
    E7 -->|是| E8["晋升"] --> E9["Full GC"]
存什么算法触发回收类型暂停
年轻代新对象、短期对象标记-复制(Eden+S0+S1)Eden 满Minor GC
老年代晋升对象、大对象标记-整理老年代满/堆不足/System.gc()Full GC

第三部分:Android ART 垃圾回收器

ART(Android Runtime) 是 Android 5.0 起使用的运行时,替代了早期的 Dalvik。ART 的 GC 与 JVM 不同:只有一种收集器(并发复制收集器),面向低延迟、少卡顿,适合手机上的交互式应用。

3.1 ART 与 JVM 的差异

ART 不是 JVM:编译方式、GC 机制、适用场景都不同。

维度ARTJVM
是什么Android 运行时,替代 DalvikJava 虚拟机,常见于服务端/桌面
编译AOT(安装或安装后编译为机器码)多为 JIT(运行时编译),部分支持 AOT
收集器仅一种:并发复制(CC),无需选择多种可选(Serial、Parallel、CMS、G1、ZGC 等)
目标低延迟、减少卡顿、适合交互吞吐量/延迟等可按场景配置

为什么 ART 只有一种收集器? 移动端场景统一(交互式、内存有限、对卡顿敏感),ART 针对这类场景做好一种即可,应用开发者无需、也无法选择收集器;JVM 则要兼顾服务端、批处理等多种场景,所以提供多种收集器。

3.2 并发复制收集器(CC)的特性

ART 的 GC 就是这一种:并发复制收集器(Concurrent Copying,CC)

本质:还是分代回收,只不过加了并发。 CC 用的仍是 2 节的分代模型(年轻代 Eden+S0/S1 标记-复制,老年代标记-整理);区别在于执行方式:尽量在后台线程做标记、复制等耗时工作,减少主线程暂停。可理解为「分代回收 + 并发执行」。

下面都是 CC 的特性(不是多种回收方式,而是同一套收集器的不同侧面):

特性含义说明
分代回收年轻代 + 老年代,不同代用不同算法与 2 节一致:年轻代标记-复制,老年代标记-整理
并发回收尽量在后台线程做标记/复制在分代基础上把耗时操作放后台,减少主线程暂停
复制算法年轻代用复制存活对象复制到另一块,无碎片,即 2 节 Appel 式 Eden+S0/S1
增量回收把一次大回收拆成多小步单次暂停更短,多次短暂停代替一次长暂停
压缩回收老年代整理内存回收时移动存活对象、减少碎片,即 2 节老年代标记-整理

串行 vs 并发(直观对比): 串行回收时主线程要停下来等 GC;ART 的并发回收尽量在后台做,主线程可继续跑,从而减少卡顿。

flowchart LR
    F1["串行: 主线程-暂停GC-主线程"] --> F2["并发: 主线程继续 + 后台GC"]

3.3 GC 触发条件

ART 根据堆使用情况自动触发不同范围的 GC,与 2 节的 Minor GC / Full GC 对应。三种类型如下。

类型(英文)中文名称触发条件回收范围耗时与影响
Partial GC部分回收(年轻代回收)年轻代空间不足,如 Eden 区满了仅年轻代(Eden + 当前 Survivor),不碰老年代暂停短(通常几毫秒),即 Minor GC,对流畅度影响小
Full GC完全回收(整堆回收)堆整体紧张、老年代满了、或代码里调用了 System.gc()整堆(年轻代 + 老年代)一起回收暂停时间长(可能几十到几百毫秒),容易造成卡顿,应尽量避免
Sticky GC粘性回收(局部年轻代回收)频繁分配“刚用就丢”的对象,例如列表快速滑动、动画帧、临时对象大量创建只清理最近新分配的那一小块区域(年轻代里刚分配不久的对象)暂停极短,几乎无感,专门为“分配很多、死得很快”的场景优化

简要对照:

  • Partial GC:只收年轻代,即 Minor GC,最常见。
  • Full GC:收整堆,老年代满或堆不足时触发,耗时长、易卡顿;避免主动调 System.gc()
  • Sticky GC:只收“刚分配不久”的区域,用于列表滑动、动画等高频分配,停顿极短。

建议: 不要主动调用 System.gc(),易引发 Full GC,增加卡顿风险。

3.4 内存分配策略(详细)

ART 的对象分配与 2 节的分代、晋升一致:新对象先进年轻代,大对象或熬过多次 GC 的会进老年代;堆有上限,分配失败可能触发 GC 或 OOM。

3.4.1 新对象:优先在年轻代(Eden)分配

  • 规则new 出来的普通对象,默认在年轻代的 Eden 区分配。
  • 原因:大多数对象生命周期很短,放在年轻代便于用 Minor GC 快速回收,减少对老年代的压力。
  • 流程:分配时先看 Eden 是否有足够连续空间;有则直接分配;没有则触发 Minor GC(回收年轻代),再尝试分配;若年轻代仍不足,可能再触发 Full GC;若 Full GC 后仍不足,则抛出 OutOfMemoryError
  • 小结:日常分配都发生在 Eden,Eden 满会触发 Minor GC,是应用中最常见的 GC 来源。

3.4.2 大对象:可能直接进老年代

  • 什么是大对象:占用的内存超过一定阈值(具体由 ART 决定,可理解为“比一块 Survivor 还大”或类似量级),例如大数组、大 Bitmap。
  • 为什么可能直接进老年代:若在年轻代分配,每次 Minor GC 都要在 Eden 与两块 Survivor 之间复制,成本高且可能装不下;直接放进老年代可避免在年轻代反复复制。
  • 对开发的影响:大对象会占用老年代、增加 Full GC 压力;应尽量控制大小、及时释放(如 Bitmap 用后 recycle()),避免一次分配过多或长期不释放。

3.4.3 晋升:从年轻代进入老年代

  • 规则:在年轻代(Eden、S0/S1)里经历多次 Minor GC 仍存活的对象,满足晋升条件时会进入老年代(详见 2.2.3:Survivor 放不下 或 对象年龄达阈值)。
  • 年龄:ART 会为对象维护“在年轻代存活的次数”(年龄);超过阈值(如 15)即晋升。
  • 意义:长期存活的对象集中在老年代,减少年轻代 GC 的扫描与复制开销;老年代只在 Full GC 时回收,频率低,适合“活很久”的对象。

3.4.4 堆上限与分配失败

  • 堆有上限:每个进程的 Java 堆大小由系统限制,不同设备、不同 API 级别上限不同(几十 MB 到几百 MB 甚至更大)。应用无法超过该上限。
  • 分配失败时:当堆中无法再划出足够连续空间时,会先触发 GC(Minor / Full)尝试回收;若回收后仍无法满足分配,则抛出 OutOfMemoryError
  • onLowMemory():系统整体内存紧张时,可能回调 ComponentCallbacks2.onLowMemory(),应用应在此释放非必要缓存、大对象等,减轻压力。
  • largeHeap:在 AndroidManifest 中为 Application 设置 android:largeHeap="true" 可申请更大堆上限,但只是缓解手段,仍应优化内存使用、避免泄漏与过度分配。

小结表(与 2 节对应):

规则说明
新对象优先在 Eden 分配;Eden 满 → Minor GC → 再分配;仍不足可能 Full GC 或 OOM
大对象可能直接进老年代,避免在年轻代复制;注意及时释放、控制大小
晋升年轻代存活多次(年龄达阈值)或 Survivor 放不下 → 进老年代(见 2.2.3)
堆上限设备有堆上限;分配失败先 GC,再 OOM;可关注 onLowMemory()、合理使用 largeHeap

3.5 与 2 节的关系(小结)

  • 2 节:GC 的算法与分代模型(标记-清除/复制/整理,年轻代/老年代划分、晋升条件),通用原理,不限于 Android。
  • 3 节ART 上的具体实现——收集器(CC)、触发类型(Partial/Full/Sticky)、分配与晋升。
  • 关系:2 节是“规则和模型”,3 节是“Android 里怎么用”;CC 仍用 2 节的分代回收,执行上加了并发。

第四部分:内存问题与优化

4.1 内存泄漏

4.1.1 定义与危害

定义: 对象在业务上已经不再使用,但仍有强引用指向它,GC 无法回收,占用的内存一直不释放,导致可用堆逐渐变小

与内存溢出的区别:

内存泄漏内存溢出(OOM)
本质该回收的没回收,可用内存被无效占用需要分配的内存超过堆上限,分配失败
过程随时间累积,可用内存越来越少某次分配时发现堆不够用
结果最终可能引发 OOM、卡顿、ANR直接崩溃(OutOfMemoryError)

危害: 可用堆变小 → Minor/Full GC 更频繁、暂停时间变长 → 卡顿;严重时老年代被占满 → 频繁 Full GC 或最终 OOM 崩溃;若主线程被阻塞还可能 ANR

4.1.2 为什么会产生泄漏

  • 对象要被 GC 回收,必须从 GC Roots 出发不可达。若某处一直持有它的强引用(如静态变量、单例、未取消的监听、未关闭的集合),它就始终可达,不会被回收。
  • 典型情况:生命周期长的(Application、单例、静态)持有了生命周期短的(Activity、Fragment、临时对象),且没有在合适的时机(如 onDestroy)释放引用或取消注册。

4.1.3 常见场景与应对

场景典型错误为什么算泄漏正确做法
Activity/Context静态变量或单例持有 Activity/Context静态/单例与进程同寿,Activity 销毁后仍被引用,无法回收用 Application 的 Context;或必须持有时用 WeakReference,并在 onDestroy 清空
内部类 / 匿名类非静态内部类、匿名 Runnable/Listener 持有外部 Activity内部类隐式持有外部类引用,若被静态或长生命周期对象引用,Activity 无法回收静态内部类 + WeakReference(Activity);或在 onDestroy 中移除回调、清空引用
监听器 / 广播EventBus、BroadcastReceiver、自定义 Listener 注册后不反注册订阅表/系统持有对 Activity 的引用,Activity 销毁后仍被引用在 onDestroy 中 unregister、unregisterReceiver、removeListener
集合全局 List/Map 只增不减,把 Activity 或大对象放进去了集合被静态或长生命周期持有,其中的对象一直可达及时 remove/clear;或用有界缓存(LruCache),在 onDestroy 或合适时机清理
线程 / 异步匿名 Runnable、AsyncTask 内部持有 Activity,且任务长时间运行线程/任务持有 Activity,若在后台一直不结束,Activity 无法回收静态内部类 + 弱引用持 Activity;在 onDestroy 里 cancel 任务、中断线程
资源未关闭文件流、Cursor、Socket、连接未 close资源对象可能间接持有 Context 或大块 native 内存,未关闭则一直占用try-with-resources 或 finally 中 close;Cursor 及时 close,避免在 Activity 里长期持有

Handler 示例(防泄漏): Handler 若由非静态内部类或匿名类实现,会持有外部 Activity;若消息延迟发送或消息队列未清空,Activity 销毁后仍被 Handler 引用。做法:静态内部类 + WeakReference(Activity),并在 onDestroy 中 removeCallbacksAndMessages(null)

private static class MyHandler extends Handler {
    private final WeakReference<Activity> ref;
    MyHandler(Activity a) { ref = new WeakReference<>(a); }
    @Override
    public void handleMessage(Message msg) {
        Activity a = ref.get();
        if (a != null) { /* 使用 a */ }
    }
}
// onDestroy 中:handler.removeCallbacksAndMessages(null);

4.1.4 如何检测与避免

  • 检测:开发期用 LeakCanary 自动检测 Activity/Fragment 等泄漏;或通过 Profiler 看内存曲线只升不降、生成堆转储后用 MAT 看 Path to GC Roots,定位谁在持有。
  • 避免:生命周期内成对“注册/反注册、持有/释放”;长生命周期不持短生命周期强引用,必要时用 WeakReference;集合、缓存有界并在合适时机清理;资源用 try-with-resources 或 finally 关闭。

4.2 内存溢出(OOM)

4.2.1 定义与危害

定义: 当次申请的内存超过堆上限(或当前无足够连续空间),无法完成分配,虚拟机抛出 OutOfMemoryError。(Android 上由 ART 抛出。)

危害: 应用崩溃,当前操作中断,用户体验差;若发生在启动或关键流程,可能造成数据未保存、流程无法完成。

4.2.2 常见原因

原因说明
内存泄漏累积泄漏导致可用堆越来越小,最终某次正常分配时触发 OOM(泄漏是 OOM 的常见诱因)
单次分配过大一次加载过大的图片、大数组、大列表到内存,超过堆剩余或堆上限
大对象 / 大数组过多多个大 Bitmap、大数组同时存在,占满老年代或整堆
堆上限本身较小低端设备或系统给应用的堆上限较低,同样代码更容易 OOM

4.2.3 OOM 的常见类型

  • java.lang.OutOfMemoryError: Java heap space:堆空间不足,无法分配新对象,最常见。
  • java.lang.OutOfMemoryError: Failed to allocate a ... byte allocation:某次具体分配失败,可看出申请了多少字节、当前堆情况(若日志完整)。

4.2.4 解决与排查思路

解决思路:

方向做法
查泄漏用 LeakCanary、Profiler、MAT 排查是否存在泄漏,修复后可用堆恢复,减少 OOM
控制单次分配图片用 inSampleSize、合适宽高解码;大列表分页、按需加载;避免一次性把大批数据加载进内存
大对象与缓存大 Bitmap 用后 recycle;缓存用 LruCache 等有界缓存,避免无限增长
分批与回收大批数据分批处理;不再使用的对象及时置空、清集合,便于 GC 回收
堆与系统回调必要时 android:largeHeap="true" 申请更大堆(仅缓解);在 onLowMemory() 中释放非必要缓存,减轻压力

排查思路: OOM 时若有堆转储(hprof),用 MAT 打开,看 Histogram / Dominator Tree 找出占用最大的对象,再用 Path to GC Roots 看是否被不该持有的引用链持有(即泄漏);若无转储,可在复现路径上提前用 Profiler 抓 heap dump,或加 try-catch 在 OOM 前后打日志、缩小范围。

4.2.5 与内存泄漏的关系

  • 泄漏会让可用堆越来越小,更容易在后续某次分配时触发 OOM;很多 OOM 背后是长期泄漏。
  • 因此:防泄漏(4.1)本身就是防 OOM 的重要手段;同时还要控制单次分配大小、大对象和缓存,避免“瞬时占用”或“无界缓存”直接撑爆堆。

4.3 Android 开发中的 GC 优化策略

优化目标: 降低 GC 暂停时间(尤其避免 Full GC)、减少内存占用与泄漏、提升流畅度并避免 ANR。

4.3.1 减少对象分配

  • 对象复用 / 对象池:对频繁创建、生命周期短的对象(如列表项、临时 DTO)做池化,用完归还而非每次 new,减少年轻代压力和 Minor GC。
  • 避免不必要的 new:循环里能用同一变量复用的不要每次 new;字符串拼接用 StringBuilder 代替多次 "a" + "b";能用基本类型 intboolean 就不用包装类 IntegerBoolean,减少装箱对象。
  • 列表与 RecyclerView:依赖系统自带的 ViewHolder 复用,不在 onBindViewHolder 里 new 新对象(如每次 new String、new 匿名 listener);数据用已有字段或轻量结构绑定。

4.3.2 合理使用缓存

  • 有界缓存:用 LruCache 等带容量上限的缓存,避免无限增长的 Map 导致老年代膨胀和 Full GC;按业务设定合理 maxSize(如按条目数或总字节数)。
  • 及时清理:在合适的生命周期(如 onDestroy、退出页面、内存紧张时)清理不再需要的缓存,避免长期占用堆。

4.3.3 典型场景优化

场景常见问题优化做法
列表滑动频繁创建 ViewHolder 内对象、临时 String,触发 Partial GC 甚至卡顿ViewHolder 复用;onBindViewHolder 内不 new;大列表考虑分页、预加载
动画每帧或每次动画 new 对象复用 Animator、ValueAnimator 等,或使用静态/单例配置,避免在 onDraw/每帧里 new
图片加载大图、多图同时解码,占满年轻代/老年代采样率 inSampleSize、合适尺寸;用 Glide/Coil 等库的缓存与生命周期绑定;及时 recycle 不再用的 Bitmap
大对象大数组、大 Bitmap 直接进老年代,加剧 Full GC控制单次分配大小;用完及时置空、recycle;必要时分批加载或流式处理

4.3.4 避免内存泄漏(减少 Full GC 与 OOM)

  • 泄漏会导致可用堆越来越小,进而频繁 Full GC 或 OOM;防泄漏本身也是重要的“GC 优化”。
  • 生命周期:在 onDestroy 中取消监听、移除回调、清空对 Activity/Fragment 的引用、关闭 Cursor/流等。
  • 引用方式:单例、静态变量不持 Activity/Context;若必须持有,用 Application 的 ContextWeakReference;Handler、异步任务用静态内部类 + 弱引用,并在 onDestroy 里 removeCallbacks。
  • 结合 LeakCanaryProfiler 定期排查泄漏,避免“看不见的强引用”长期占用堆。

小结: GC 优化策略 = 少分配(对象池、复用、基本类型)+ 有界缓存与及时清理 + 列表/动画/图片/大对象等场景的具体写法 + 防泄漏(生命周期、弱引用、取消注册)。目标都是减少年轻代压力、避免老年代被占满,从而减少 GC 次数与暂停时间,提升流畅度。


第五部分:检测与诊断工具

常用于排查 GC、内存泄漏与 OOM 的工具:各自能看什么、怎么用、何时用。日常开发可优先用 Profiler + LeakCanary;精确定位引用链用 MAT;看 GC 频率与暂停用 logcat;分析卡顿是否与 GC 有关用 Systrace

5.1 工具一览与选用

工具主要用途典型场景
Android Studio Profiler实时看堆占用、GC 事件、生成堆转储、跟踪一段时间内的对象分配看内存是否只升不降(泄漏嫌疑)、GC 是否频繁、谁在分配对象、导出 hprof 给 MAT
logcat看 GC 相关日志(类型、暂停时间、回收量)确认 Partial/Full GC 频率、pause 是否过长,配合优化前后对比
Systrace看各线程时间线,含 GC 与主线程的关系判断卡顿是否由 GC 引起、主线程在 GC 时是否被阻塞
MAT(Memory Analyzer Tool)分析 hprof,看对象数量、占用、引用链定位谁持有泄漏对象、大对象、OOM 时堆里什么占得多
LeakCanary自动检测 Activity/Fragment 等泄漏并给出引用链开发期自动发现泄漏,无需手动抓 dump,适合日常回归

何时用哪个: 怀疑泄漏 → LeakCanary 或 Profiler 看曲线 + 抓 dump;要查“谁持有了谁” → MAT 分析 hprof;看 GC 频率/暂停 → logcat;看卡顿是否和 GC 重叠 → Systrace。


5.2 Android Studio Profiler(内存与 GC)

5.2.1 打开与基本用法

  • 菜单 View → Tool Windows → Profiler,或底部 Profiler 标签。
  • 选择已运行的应用进程(Debug 构建),点击 Memory 行进入内存视图。
  • 上方为堆占用曲线(可看 Java 堆、Native、Graphics 等),曲线下方会出现 GC 事件图标,点击可看该次 GC 的类型与耗时。

5.2.2 看什么、怎么判断

关注点怎么看说明
内存是否泄漏曲线是否只升不降(操作后多次 GC 仍不回落)若反复操作同一流程后堆持续升高,有泄漏嫌疑
GC 频率与类型下方 GC 图标的密度与类型(Partial/Full 等)Full GC 多或 GC 很密,需结合代码减少分配或查泄漏
单次 GC 耗时点击 GC 图标看详情中的 paused / total 时间暂停时间过长会直接导致卡顿

5.2.3 Heap Dump(堆转储)

  • 在 Memory 视图中点击 Heap Dump(或工具栏对应按钮),等待生成完成。
  • 可查看当前堆中按类/按实例的对象数量与大小,排查大对象、重复创建过多的类。
  • Export 可导出为 .hprof 文件,用 MAT 打开做引用链分析(需先用 hprof-conv 转换,见 5.4)。

5.2.4 Allocation Tracking(分配跟踪)

  • 点击 Record allocations,操作应用一段时间(如滑动列表、进入某页),再 Stop
  • 可看到这段时间内哪些类被分配了多少次调用栈在哪里,用于定位“谁在频繁 new 对象”、配合优化减少分配。

5.3 logcat 看 GC 日志

5.3.1 常用命令

# 过滤 GC 相关
adb logcat | grep -E "GC|dalvikvm|art"

# 仅当前应用(替换为你的包名)
adb logcat --pid=$(adb shell pidof -s 你的包名) | grep -E "GC|art"

在 Android Studio 的 Logcat 窗口里也可用过滤框输入 GCart 查看。

5.3.2 日志里看什么

  • GC 类型:Partial GC(年轻代)、Full GC(整堆)、Sticky GC 等,对应 3.3 节的三种类型。
  • paused / total:本次 GC 的暂停时间与总耗时,paused 过长(如几十毫秒以上)容易造成卡顿。
  • 回收量:freed 等字段表示本次回收了多少内存,可粗略判断回收是否有效。

若一段时间内 Full GC 非常频繁paused 经常很大,需从泄漏、大对象、分配量等方面优化(见 4 节)。


5.4 MAT(Memory Analyzer Tool)分析 hprof

用于离线分析堆转储文件,精确定位大对象、引用链与泄漏路径。堆转储可从 Profiler 导出,或 OOM 时自动/手动抓取。

5.4.1 获取与转换 hprof

  • 从 Profiler 导出:Memory 视图 → Heap Dump → Export 保存为 heap.hprof
  • Android 的 hprof 格式与标准 Java 略有不同,需用 SDK 自带的 hprof-conv 转换后 MAT 才能正确解析:
# 在 Android SDK 的 platform-tools 目录下执行
hprof-conv heap.hprof heap-converted.hprof

再用 MAT 打开 heap-converted.hprof

5.4.2 常用视图与用法

视图作用典型用法
Histogram统计实例数量与总占用(Retained Heap)按 Retained Heap 排序,找占用最大的类;双击类可看该类的所有实例
Dominator Tree对象看占用及“谁支配了谁”按 Retained Heap 排序,找占用最大的对象,展开看其引用的子对象
Path to GC Roots从某对象反推到 GC Roots 的引用链右键可疑对象 → Path to GC Roots → exclude weak/soft references,看是在强引用它(即泄漏路径)
Leak SuspectsMAT 自动生成的泄漏嫌疑报告打开报告可快速看可能泄漏的点,再结合 Path to GC Roots 确认

5.4.3 分析泄漏的典型步骤

  1. 打开转换后的 hprof,等 MAT 解析完成。
  2. Leak Suspects(若有),了解自动检测出的嫌疑。
  3. Histogram 中搜索关心的类(如 Activity、Fragment、大业务对象),按 Retained Heap 排序,看实例数是否异常多(如 Activity 已销毁却仍存在多份)。
  4. 对可疑对象右键 → Path to GC Rootsexclude weak/soft references,得到从该对象到 GC Roots 的强引用链,即“谁持有了它”。
  5. 根据引用链回到代码:取消注册、清空静态/单例引用、改用 WeakReference 或在合适生命周期释放。

5.5 Systrace 看 GC 与卡顿

  • 作用:录制各线程时间线(含主线程、GC 线程),看 GC 与主线程是否重叠、卡顿/掉帧是否与 GC 暂停重合。
  • 用法:Android Studio Profile 或命令行抓取 System Trace,在 Perfetto/Chrome 中打开;关注主线程上的 GC 片段与 Choreographer/Frame 的对应关系。若卡顿帧与 GC 重叠,需减少 GC(少分配、避免 Full GC、查泄漏)。

5.6 LeakCanary 原理简述与使用

使用说明: 在模块的 build.gradle 中添加 debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'仅 debug 构建会生效。无需额外代码,LeakCanary 会自动监控 Activity、Fragment 等;若检测到泄漏,会弹出通知并生成泄漏报告,报告中会给出从泄漏对象到 GC Roots 的引用链,便于直接定位到代码中的持有者。

核心思路

WeakReference(弱引用)+ ReferenceQueue(引用队列) 判断「本该被回收的对象是否真的被 GC 回收」。若没被回收,说明还有别的强引用在持有它 → 存在泄漏。

为什么弱引用能用来“测泄漏”

  • 弱引用不阻止 GC:只要没有强引用指向对象,GC 时对象就会被回收。
  • 对象被回收时,指向它的 WeakReference 会被 JVM 自动放进你传入的 ReferenceQueue
  • 因此:若一段时间后、且触发过 GC,发现这个 WeakReference 没进队列,且 ref.get() 还不为 null → 说明对象没被回收 → 一定还有强引用在引用它 → 发生了泄漏

引用队列(ReferenceQueue)是什么、怎么用

引用队列是什么

  • ReferenceQueue 是 JDK 里的一个队列,专门和 WeakReferenceSoftReference 等配合用。
  • 约定:创建 WeakReference(对象, queue) 时绑上这个 queue,当那个对象被 GC 回收后,JVM 会把这个 WeakReference 本身(注意:不是被回收的对象)放进 queue。
  • 也就是说:入队的是“引用对象”(WeakReference),不是 Activity。Activity 被回收后已经没了,我们只是通过「引用有没有入队」来推断「它指向的对象有没有被回收」。

谁入队、何时入队

  • 入队的是 WeakReference 实例(我们创建的那个 ref)。
  • 时机:在 GC 回收掉 Activity 之后,由 JVM 自动把 ref 放入 queue。我们不需要也不应该手动往 queue 里放东西。
  • 因此:若某个 ref 在 queue 里出现,说明 这个 ref 曾经指向的对象已经被回收了

怎么用队列判断“有没有泄漏”

  • 我们只创建一个 WeakReference(activity, queue),并记住这个 ref
  • 触发 GC 后,用 queue.poll()(或轮询 queue)看队列里有没有出现我们这个 ref
    • 出现了 → 说明 JVM 把我们的 ref 放进去了 → ref 指向的 Activity 已被回收 → 无泄漏
    • 没出现,并且 ref.get() != null → Activity 还在 → 一定有强引用在别处持有它 → 泄漏

简单代码理解

ReferenceQueue<Activity> queue = new ReferenceQueue<>();
WeakReference<Activity> ref = new WeakReference<>(activity, queue);
// 此后不再持有 activity 的强引用

// ... 等待几秒,触发 GC ...

// 若 activity 被回收了,ref 会被 JVM 放入 queue
Reference<?> polled = queue.poll();
if (polled != null && polled == ref) {
    // 我们的 ref 入队了 → activity 已被回收 → 无泄漏
} else if (ref.get() != null) {
    // ref 没入队,且 activity 还在 → 有强引用持有 → 泄漏
}

小结(引用队列): 队列里存的是「哪个 Reference 指向的对象已经被回收了」。我们的 ref 入队 = 对应对象已回收;ref 没入队且对象还在 = 泄漏。

LeakCanary 的检测流程(分步)

  1. 监控时机:通过 Application.registerActivityLifecycleCallbacksActivity.onDestroy() 时认为「这个 Activity 不该再被用了」。
  2. 创建弱引用 + 队列:用 WeakReference(activity, queue) 包装该 Activity,不保留强引用。
  3. 等待 + 触发 GC:等几秒(给业务代码释放引用),再调 Runtime.getRuntime().gc() 建议 GC。
  4. 检查队列queue.poll() 看刚才的 WeakReference 是否入队。
    • 入队了 → Activity 已被回收 → 无泄漏
    • 没入队ref.get() != null → Activity 还在 → 有强引用持有 → 判定泄漏。
  5. 确认泄漏后:生成堆转储(heap dump),用 Shark 等分析「谁还在引用这个 Activity」(Path to GC Roots),得到泄漏引用链并通知开发者。

流程简图

sequenceDiagram
    participant App
    participant LC as LeakCanary
    participant Q as RefQueue
    participant GC

    App->>LC: onDestroy
    LC->>LC: WeakRef+Queue 不持强引用
    LC->>LC: 等待后 gc
    LC->>GC: gc
    GC->>Q: 回收则Ref入队
    LC->>Q: poll检查
    alt 已入队
        LC->>App: 无泄漏
    else 未入队且get非空
        LC->>LC: dump分析
        LC->>App: 泄漏路径
    end

小结: 弱引用不阻止回收;对象被回收后其 WeakReference 会入队;未入队且对象仍在 = 有强引用持有 = 泄漏。LeakCanary 据此判定泄漏并分析引用链。


5.7 常见问题排查思路

现象排查方向工具与手段
GC 频繁看谁在频繁分配、是否泄漏logcat 看 GC 频率;Profiler 分配跟踪
暂停时间长是否多为 Full GC、堆是否过大logcat/Systrace;减少 Full GC、查泄漏
内存只升不降泄漏或缓存过大Profiler 曲线;LeakCanary;MAT 看引用链
OOM谁占得多、为何不回收堆转储 + MAT Histogram/Dominator/Path to Roots
卡顿是否与 GC 重叠Systrace 看主线程与 GC;减少分配与 Full GC

第六部分:面试题精选

基础概念、算法与分代、实践与优化、工具与原理、设计题整理,答案可与前文对应章节对照。回答时可先说结论,再按点展开。


6.1 基础概念

Q:什么是 GC?为什么 Android 里特别重要?

答: GC(Garbage Collection)是自动识别并回收不可达对象、释放内存的机制。作用包括:减轻遗漏释放与泄漏、简化开发。在 Android 上特别重要是因为:设备内存有限、GC 会暂停用户线程,频繁或长时间 GC 会导致卡顿甚至 ANR,内存不足时应用可能被系统杀进程。

Q:Android 用什么方式判断对象可回收?为什么不用引用计数?

答: 使用可达性分析:从一组 GC Roots(栈、静态变量、常量、锁、JNI 等)出发,能到达的对象视为存活,不可达的视为可回收。不用引用计数是因为无法处理循环引用:两个对象互相引用时计数永不为 0,无法回收,而可达性分析下只要从 Roots 不可达就会被正确回收。

Q:GC Roots 具体有哪些?

答: 常见有:虚拟机栈帧中的局部变量与参数引用、方法区中静态变量与常量引用、本地方法栈中的 JNI 引用、被 synchronized 持有的对象、以及虚拟机内部引用(如 Class 对象、异常对象等)。面试时答“栈、静态、常量、锁、JNI”即可。

Q:强引用、软引用、弱引用、虚引用的区别?各用在什么场景?

答: 按强度从强到弱:强引用—存在就不会被回收,普通 new 出来的都是;软引用—内存紧张时才会被回收,可做缓存(Android 更常用 LruCache);弱引用—下次 GC 就可能被回收,适合“不阻止回收但需要时还能拿到”的场景,如 Handler 持 Activity、LeakCanary 检测泄漏;虚引用—最弱,多用于回收前做清理(如堆外内存),日常开发少见。回答时可以说:强度递减,弱引用常用来避免泄漏(如用 WeakReference 包装 Activity)。

Q:ART 和 JVM 在 GC 上有什么不同?

答: ART 只有一种收集器(并发复制 CC),面向低延迟、移动端;JVM 有多种收集器可选(如 G1、ZGC),可侧重吞吐或延迟。ART 采用 AOT 编译,JVM 多为 JIT。可以概括为:ART 单一收集器、为移动端优化;JVM 多方案、可配置。

Q:Partial GC、Full GC、Sticky GC 分别是什么?什么时候触发?

答: Partial GC(部分/年轻代回收):只收年轻代,Eden 满等触发,暂停短,对应前文的 Minor GC。Full GC(整堆回收):年轻代+老年代一起收,老年代满或 System.gc() 等触发,暂停长,应尽量避免。Sticky GC(粘性/局部年轻代):只清理最近新分配的那块,针对“分配很多、死得很快”的场景,暂停极短。面试重点:能说出三种的触发范围和暂停差异即可。

Q:年轻代和老年代是什么关系?晋升条件是什么?

答: 同属分代收集设计:年轻代包含 Eden + 两个 Survivor(S0/S1),用标记-复制;老年代独立一块,用标记-整理。Minor GC 时 Eden + 当前 From 的存活对象都进当前 To(S0/S1 之一),To 放不下则多出的直接晋升。晋升条件:Survivor(To)放不下,或对象年龄达阈值。老年代 ≠ Survivor,是单独区域。


6.2 算法与分代

Q:标记-清除、标记-复制、标记-整理各有什么特点?Android 里谁用在哪儿?

答: 标记-清除:先标记存活,再清除未标记;实现简单但易产生碎片。标记-复制:把存活对象复制到另一块再清空当前块;无碎片但占双倍空间,适合年轻代(对象朝生夕死,复制成本低)。标记-整理:标记后把存活对象向一端紧凑移动再清边界;无碎片、不浪费整块,但移动和更新引用成本高,适合老年代。Android 年轻代主用标记-复制,老年代用标记-整理。

Q:分代收集的思想是什么?为什么能减少停顿?

答: 思想是多数对象活不久:新对象放年轻代,用复制算法快速回收;熬过多次 GC 的进老年代,少动。这样大部分 GC 只扫年轻代(Partial GC),暂停短;只有老年代紧张时才 Full GC。通过“把很少变化的老对象和频繁生死的新对象分开处理”,整体减少停顿。


6.3 实践与优化

Q:如何减少 GC 频率、减轻卡顿?

答: 减少分配:对象池、复用、StringBuilder、基本类型代替包装类;列表用 ViewHolder 复用;避免在 onDraw、onBind、循环里大量 new。减少** Full GC**:防泄漏、及时释放、有界缓存(LruCache)、不主动调 System.gc()。卡顿多与 Full GC 或频繁 Partial GC 有关,所以“少分配 + 防泄漏”是核心。

Q:如何检测和解决内存泄漏?

答: 检测:LeakCanary 自动在 onDestroy 后判断对象是否被回收,并给出引用链;或 Profiler 抓堆转储,用 MAT 的 Path to GC Roots(排除弱/软引用)看谁在强引用。解决:单例/静态不持 Activity,用 Application 或 WeakReference;Handler、异步任务用静态内部类 + 弱引用,在 onDestroy 里 removeCallbacks/cancel;监听、广播、注册在适当时机解绑并置空引用。

Q:LeakCanary 是怎么检测泄漏的?(弱引用 + ReferenceQueue)

答:WeakReference + ReferenceQueue:在 Activity onDestroy 时用 WeakReference(activity, queue) 包装,不保留强引用;等几秒后触发 GC;若对象该被回收,其 WeakReference 会被 JVM 放入 queue。若 ref 没入队且 ref.get() 还不为 null,说明还有强引用持有 → 判定泄漏;再 dump 堆、分析引用链给出报告。

Q:OOM 怎么排查?和内存泄漏有什么关系?

答: 排查:抓堆转储(OOM 时或 Profiler 主动抓)→ 用 hprof-conv 转成 MAT 可读格式 → MAT 里看 Histogram/Dominator Tree 找谁占得多,Path to GC Roots 看是否被强引用长期持有(泄漏)。关系:泄漏会导致对象无法回收,堆持续增长,最终容易 OOM;但 OOM 也可能只是瞬时分配过大(如大图、大数组),不一定有泄漏。要结合 dump 看是“谁占得多”和“是否该释放却没释放”。

Q:大对象、大图导致 OOM 怎么处理?

答: 大对象尽量少创建、复用或池化;大图用采样压缩(inSampleSize)、按需解码(BitmapRegionDecoder)、及时 recycle;列表里用缩略图 + 点击再加载大图。可配合 LruCache 控制缓存数量与总大小,避免无限堆积。


6.4 工具与原理

Q:Profiler、MAT、LeakCanary、logcat、Systrace 分别用来干什么?什么时候用哪个?

答: Profiler:看内存曲线、GC 事件、抓堆转储、Allocation Tracking 看谁在分配。MAT:分析 hprof,看对象数量、占用、引用链,精确定位泄漏路径。LeakCanary:自动检测 Activity/Fragment 等泄漏并给引用链,开发期首选。logcat:过滤 GC 日志看频率、暂停时间。Systrace:看主线程与 GC 时间线,判断卡顿是否和 GC 重叠。怀疑泄漏 → LeakCanary 或 Profiler + dump;要查“谁持有了谁” → MAT;看 GC 频率/暂停 → logcat;看卡顿是否和 GC 有关 → Systrace。

Q:MAT 里 Histogram、Dominator Tree、Path to GC Roots 怎么用?

答: Histogram:按类看实例数和 Retained Heap,找占用大的类。Dominator Tree:按对象看占用和支配关系,找大对象。Path to GC Roots:对可疑对象右键 → exclude weak/soft references,看从该对象到 GC Roots 的强引用链,即“谁在持有它”,用于定位泄漏。典型流程:Histogram/Dominator 找大或异常多的对象 → Path to GC Roots 看引用链 → 回代码改持有关系。


6.5 设计题

Q:对象池怎么设计?

答: 维护一个池(如 Queue 或 List),提供 acquire():池空则 new,否则从池里取;release(obj):把对象重置到可复用状态后放回池;并设最大池大小,超过则丢弃,防止泄漏或无限增长。注意线程安全(若多线程用)和对象重置是否彻底(避免残留上次状态)。

Q:如何设计一个高内存效率的应用?

答: 少分配:池化、复用、基本类型、ViewHolder、避免在热点路径大量 new。防泄漏:生命周期内注册/反注册、单例/静态不持 Activity、Handler/异步用弱引用并在 onDestroy 取消。有界缓存:LruCache 控制图片等缓存大小。大对象与资源:大图采样、及时 recycle、流/连接及时关闭。可观测:用 Profiler、LeakCanary、MAT 定期看内存与引用链,问题早发现早修。

Q:如果让你实现一个“带弱引用的缓存”,思路是什么?

答:WeakReference 包装缓存对象,并配合 ReferenceQueue:当被缓存对象被 GC 回收时,对应 WeakReference 会入队,我们轮询或监听队列,把已入队的条目从缓存 Map 里移除,避免 Map 里堆积无效 key。这样既能在内存紧张时被回收,又能在未回收时命中缓存;需要强引用时由调用方在用时临时持有。


总结

  • 基础: GC 自动回收不可达对象;Android 用可达性分析;强/软/弱/虚引用强度递减。
  • 算法: 年轻代以标记-复制为主(Eden + 2 Survivor,S0/S1 轮流 From/To);Minor GC 时 Eden+From 存活进 To,To 放不下则多出的晋升;老年代标记-整理;整体为分代收集。
  • ART: 仅一种并发复制收集器,分代、并发、增量、压缩等均为其特性;Partial/Full/Sticky 为触发类型。
  • 问题: 泄漏导致无法回收;OOM 为堆超限;优化重点是少分配、防泄漏、控缓存与资源。
  • 工具: Profiler 看内存与 GC;logcat 看日志;Systrace 看卡顿;MAT 分析 hprof;LeakCanary 自动查泄漏。