我们将从"为什么需要GC"开始,逐步深入到Android虚拟机中GC的具体实现和最佳实践。
一、 为什么要垃圾回收?
在程序运行时,我们会不断地创建对象(比如在Activity、Fragment中new一个对象),这些对象都占据着内存空间。如果只创建不销毁,内存迟早会被耗尽,导致程序崩溃(OutOfMemoryError)。
在C/C++中,内存需要程序员手动管理(malloc/free, new/delete),这很容易导致两种问题:
- 内存泄漏:忘记释放不再使用的内存。
- 悬空指针:释放了内存后,又去使用它。
GC就是为了解决这个问题而诞生的。它自动追踪和回收不再被使用的对象,释放其占用的内存,从而让程序员从繁琐的内存管理工作中解放出来,专注于业务逻辑。
二、 GC的核心基本原理:可达性分析
GC如何判断一个对象是否"存活"?
核心思想 :从一系列被称为 "GC Roots" 的对象出发,向下搜索,所走过的路径称为 "引用链"。如果一个对象到GC Roots没有任何引用链相连,则说明此对象是不可用的,可以被回收。
GC Roots 通常包括以下几种:
- 虚拟机栈中的局部变量:正在执行的各个线程的方法栈帧中的局部变量表所引用的对象。
- 本地方法栈中JNI引用的对象。
- 方法区中静态属性引用的对象:类的静态变量。
- 方法区中常量引用的对象:比如字符串常量池里的引用。
- Java虚拟机内部的引用:如基本数据类型对应的Class对象,常驻的异常对象等。
- 被同步锁持有的对象。
简单来说:如果一个对象,从任何"根"开始都找不到它,它就是垃圾。
三、 Android虚拟机中的GC
Android主要经历了两个虚拟机时代:Dalvik 和 ART。它们的GC机制有显著不同。
1. Dalvik虚拟机(Android 5.0之前)的GC
Dalvik的GC相对简单粗暴,是应用卡顿的一个重要原因。
-
标记-清除算法
- 标记 :暂停所有用户线程(这被称为 "Stop-The-World"),从GC Roots开始,遍历所有存活的对象并打上标记。
- 清除:遍历整个堆,回收所有未被标记的对象所占用的内存。
- 缺点 :会产生内存碎片。回收后,内存空间是不连续的,当需要分配一个较大对象时,可能无法找到足够的连续内存,从而触发另一次GC。
-
GC类型:
- GC_FOR_MALLOC: 当堆上分配内存失败时触发的GC。
- GC_CONCURRENT: 当堆内存达到一定阈值时,尝试在后台并发执行的GC,以减少应用停顿。
- GC_EXPLICIT : 显式调用
System.gc()触发的GC(强烈不建议,因为它会打乱虚拟机的GC计划)。
Dalvik GC的痛点 :Stop-The-World 时间较长,尤其是在GC_CONCURRENT的最后阶段,也会暂停所有线程进行清理工作,导致应用卡顿、掉帧。
2. ART虚拟机(Android 5.0及之后)的GC
ART在安装时就将字节码预编译成本地机器码,其GC策略也更加先进和高效,主要目标是减少停顿时间。
- 主要改进 :
- 多阶段GC:将GC过程拆分成多个阶段,很多阶段可以与用户线程并发执行。
- 不同种类的GC :针对不同情况使用不同的回收策略。
- 并发标记-清除:大部分标记和清除工作与用户线程并发进行,大大减少了停顿时间。
- 压缩GC :为了解决内存碎片问题,ART会不定期地执行压缩GC 。它会移动存活的对象,将它们紧凑地排列在内存的一端,从而释放出大块的连续空闲内存。这个过程的
Stop-The-World时间较长,但ART会尽量在后台或应用在后台时进行。
- 分代收集:这是现代GC算法的核心思想,ART也采用了。
四、 深入理解:分代收集理论
这是理解现代GC(包括ART)的关键。根据对象的存活周期,将Java堆划分为新生代 和老年代。
1. 新生代
-
存放对象:绝大多数新创建的对象。
-
特点:"朝生夕死",大部分对象很快变得不可达。
-
区域划分:
- Eden区:新对象基本都分配在这里。
- Survivor区(两个) :
From Survivor和To Survivor。用于存放每次Minor GC后存活下来的对象。
-
回收过程(Minor GC):
- 新对象在Eden区分配。
- 当Eden区满时,触发一次 Minor GC。
- 将Eden区和
From Survivor中仍然存活的对象,一次性复制到To Survivor区。 - 同时,为这些存活的对象"年龄"+1(每熬过一次Minor GC,年龄就加1)。
- 清空Eden区和刚使用的
From Survivor区。 - 最后,
From Survivor和To Survivor的角色互换。
- 优点:速度非常快,因为只处理一小块区域。
- 晋升 :当一个对象的年龄增长到一定程度(默认15),就会被移动到老年代。
2. 老年代
- 存放对象:经过多次Minor GC仍然存活的对象(即长时间存活的对象),以及大对象(可能直接进入老年代)。
- 特点:对象存活率高。
- 回收过程(Major GC / Full GC) :
- 当老年代空间不足时,会触发 Major GC ,通常会伴随一次 Minor GC ,因此也常被称为 Full GC。
- Full GC 会对整个堆(新生代 + 老年代)进行回收,停顿时间最长 ,是应用卡顿的主要元凶之一。我们的优化目标就是尽量避免或减少Full GC。
五、 对Android开发的指导意义与实践
理解了GC原理,我们就能写出对GC更友好的代码,提升应用性能。
1. 内存泄漏是头号大敌
内存泄漏会导致对象永远无法被GC回收,最终引发OOM。
- 常见场景 :
- 非静态内部类/匿名类持有外部类引用:如Handler、Runnable等。如果它们在Activity销毁后仍被系统(如消息队列)持有,就会导致Activity泄漏。
- 静态变量持有Context/View引用。
- 集合类未及时清理。
- 第三方库使用后未正确释放(如监听器、广播)。
- 排查工具 :Android Profiler , LeakCanary。
2. 避免创建不必要的对象
对象的创建和销毁都是有成本的。在性能敏感的代码段(如onDraw、循环体),应尽量避免创建临时对象。
-
反面教材 :
java// 在onDraw中每次循环都创建新对象,会瞬间产生大量垃圾,频繁触发GC for (int i = 0; i < 1000; i++) { String temp = "Item " + i; // 不要这样做! canvas.drawText(temp, x, y, paint); } -
优化方案:将对象提升为成员变量,或使用对象池。
3. 谨慎使用 System.gc()
如前所述,显式调用GC会打乱虚拟机的优化策略,可能导致不必要的、耗时的Full GC。把内存管理的决策权交给虚拟机。
4. 关注 onTrimMemory()
当系统内存不足时,会回调此方法。我们可以在这里释放一些非核心资源(如缓存图片),帮助系统减轻内存压力,从而降低自身进程被杀死和触发GC的概率。
总结
| 特性 | Dalvik GC | ART GC |
|---|---|---|
| 核心目标 | 功能实现 | 减少停顿,提升性能 |
| 主要算法 | 标记-清除(为主) | 并发标记-清除 + 分代收集 + 压缩 |
| 停顿时间 | 较长,易引起卡顿 | 显著缩短,更流畅 |
| 处理碎片 | 不处理,碎片化严重 | 通过压缩整理内存 |
作为Android开发者,理解GC原理的最终目的是:
- 写出高性能、低卡顿的代码:通过避免内存泄漏和减少不必要的对象分配。
- 快速定位和解决内存问题:当发生OOM或内存抖动时,能迅速找到根源。
- 建立良好的内存观:知道代码的每一行背后可能发生什么,做到心中有数。
希望这份详细的讲解能帮助你彻底理解Android GC!