Android 变量生命周期、内存释放机制与 GC 触发时机研究
Android 的垃圾回收(GC)机制基于可达性分析算法,即通过一系列名为 GC Roots 的对象作为起始节点,向下搜索走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明该对象是不可用的。本文重点分析 Android 系统何时会触发 GC,如何监听对象被回收的时机,以及针对变量生命周期的内存优化建议。
全局变量与局部变量的内存行为
在 Android 开发中,理解变量作用域对内存管理至关重要。以下代码示例展示了 Activity 中全局变量与局部变量的定义区别:
class DetailActivity : AppCompatActivity() {
// 这个 house 就是全局变量(成员变量)
private var house: House? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_detail)
// 这个 person 就是局部变量
val person = Person()
}
findViewById<TextView>(R.id.button).setOnClickListener { v ->
// 这个 person2 也是局部变量,尽管在闭包内
val person2 = Person()
Log.e("dq", "create Person " + person2.hashCode())
}
}
1. 全局变量生命周期
- 默认情况:Activity 中定义的全局变量,如果不为 null,其生命周期通常跟随 Activity 实例。只有当 Activity 销毁后,经过一定的缓冲时间(通常为 5 秒左右),才会被 GC 彻底释放。
- XML View 机制:xml 里的 View 的内存机制类似,无论你是否把 View 设置为全局变量,只要它被 Activity 持有,其生命周期就受限于 Activity。
- 主动置空:如果将全局变量显式设置为
null,那么它的生命周期和局部变量是一样的,都是在触发 GC 的时候释放内存。这常用于手动干预内存回收。
2. 局部变量生命周期
- 局部变量存储在栈帧中,当方法执行结束或超出作用域后,理论上可被回收。但在 Android 中,如果局部变量被匿名内部类或 Lambda 捕获,它们可能变成堆上的对象,受 GC 策略影响。
- System.gc() 无效性:很多手机厂商定制 ROM 后,用代码主动调用
System.gc()往往毫无效果,系统会根据自身策略决定是否执行 GC。
3. GC 触发时机
- 非紧张状态:如果内存不紧张,系统会在当前 Activity 走了生命周期方法(如 onPause、onDestroy)后再过 5、6 秒触发 GC。例如,从 home 回到桌面会走 onPause,再过 5 秒也会 GC;但再回来走 onResume 就不 GC 了。
- 强制回收:onDestroy 的 5 秒后触发了 GC,Activity 和全局变量才彻底被回收(即
WeakReference.get() == null)。 - 日志特征:系统触发 GC 有时候会在 logcat 里打印,有时候不会。虽然 GC 会 stop 所有线程,但是简单的 GC 的 pause 时长只有 5ms 左右。
com.dq.qkotlin: Background young concurrent copying GC freed 58306(1766KB) AllocSpace objects, 4(68KB) LOS objects, 32% free, 3959KB/5892KB, paused 5.216ms total 23.590ms
由此可见,GC 比你想象中频繁得多。如果你的 Object 比较简单,里面只包含几个 String、int,那么这个 Object 占用的内存非常少。即使创建了 10000 个 Object 只占用 2M 的内存,当时轮询创建了 1000 多个局部变量 Object,都没触发系统 GC。这意味着这些局部变量 Object 都在内存里没释放。即便如此,还是不建议疯狂创建局部变量,因为可能出现无连续可用内存而导致 OOM。
如果你创建的局部变量非常大,就会"惊动"系统,系统会主动触发 GC 来回收局部变量。
意外发现:UI 交互触发 GC
如果来回频繁的上下滚动 RecyclerView 或 ListView(即便滚动的距离非常短,没触发 ViewHolder 的复用)。仅仅是手指在屏幕上快速的上下戳动 10 秒左右,也会触发 GC。而且当时并没有 new Object()。
这是唯一发现的 Activity 生命周期没改变且内存占用不紧张的情况下也会触发 GC 的场景,推测可能是 Android 底层为了处理 UI 渲染压力而进行的自动清理。
数据结构内存占用对比
存储同样的数据,HashMap 的内存占用约等于 Model 的 3 倍,原本以为会是 17 倍左右,毕竟底层是个 int[16]。但是实际测试只是 3 倍左右。
// 每调用一次创建十万个对象,检测内存变化
for (int i = 0; i < 100000; i++) {
linklist.add(new Object()); // 内存变化:73M - 76M - 79M - 82M
linklist.add(new HashMap<String,String>()); // 内存变化:79M - 85M - 91M
GiftBean bean = new GiftBean();
bean.setTitle("1");
linklist.add(bean); // 71M - 77M - 83M - 88M - 94M = 每次差额为 6M
HashMap<String,String> map = new HashMap<String,String>();
map.put("title","1");
linklist.add(map); // 72M - 88M - 105M - 121M = 每次差额为 17M
}
如何监听变量生命周期?
可以通过重写 finalize 方法来监听对象被回收的时机,但请注意,finalize 方法在现代 Java/Android 版本中已被标记为废弃(Deprecated),推荐使用 PhantomReference 或 Cleaner API 替代,但在旧版本或特定场景下仍有参考价值。
class Person : Object() {
var name: String? = null
// 走了 finalize 方法就说明该 Object 被回收了
@Throws(Throwable::class)
override fun finalize() {
Log.e("dq", "Person 被回收 " + hashCode())
}
}
如何监听系统 GC?
可以通过实现自定义的 GC Watcher 来监控 GC 触发时机。核心原理是利用 WeakReference 注册回调,当 GC 发生时,弱引用对象会被回收从而触发 finalize 或相关逻辑。
public class GcWatcherInternal {
private static WeakReference<GcWatcher> sGcWatcher;
private static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
private static Object lock = new Object();
private static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
sLastGcTime = SystemClock.uptimeMillis();
ArrayList<Runnable> sTmpWatchers;
synchronized (lock) {
sTmpWatchers = sGcWatchers;
try {
for (int i = 0; i < sTmpWatchers.size(); i++) {
if (sTmpWatchers.get(i) != null) {
sTmpWatchers.get(i).run();
}
}
} catch (Throwable e){
e.printStackTrace();
}
sGcWatcher = new WeakReference<>(new GcWatcher());
}
}
}
public static void addGcWatcher(Runnable watcher) {
(lock) {
sGcWatchers.add(watcher);
(sGcWatcher==)
sGcWatcher = <>( ());
}
}
}
使用方式:
// MainActivity 中写一次就好
GcWatcherInternal.addGcWatcher { Log.e("dq", "触发 GC!!!") }
怎么优化内存
1. 变量作用域选择
在 Activity\Fragment 中,无论你是否把 View 设为全局变量,View 的生命周期都是一样的。所以性能和内存占用是一样的。
在 Activity\Fragment 中,没必要设为全局变量的,尽量使用局部变量,不要设为全局变量:
- 主观原因:全局变量会让逻辑混乱,Activity 代码看起来脏,别人接手你的代码容易改错。
- 客观原因:只要 Activity 不死,全局变量不会被 GC 回收内存。如果你用局部变量,那么 Activity 只要走了任何生命周期(比如 onCreate、onPause、onResume),这个局部变量的内存就被释放了。
确定不再需要用的全局变量,可以用代码设置为 = null,这样也会被及时 GC 回收。同理,全局变量中的全局变量,如果不需要用到了,也可以 = null。比如 this.house.image = null。
2. 复杂对象缓存策略
有一些 ListView\RecyclerView,为了更好的显示,不得不在 model 里新加你自己定义的对象。最典型的比如聊天界面的表情 SpannableString,但是要注意到如果你把 SpannableString 放到 model 中,他就不会被 GC 释放,特别是 SpannableString 里带 ImageSpan 的(一般是表情),就会比较占内存且不会被 GC。
建议的解决办法:
- 可以用 LruCache,或者新建一个 SpareArray,只缓存最后 20 条左右的 SpannableString。
- 也可以最简单粗暴地把过早的信息的 Model 里的 spannableString
= null。万一用户手动翻到最早的聊天记录,你再重新拼接 spannableString。 - 要是实在觉得无所谓,觉得你们 app 用户量不大,可以用空间换时间,不处理这些问题倒也没太大问题。但是依然要注意 ImageSpan(一般是表情)需要做成单一变量,不要每条聊天消息都 new ImageSpan()。
3. 常见内存泄漏场景:Thread 持有 Activity
在无限循环的 Thread 里访问全局变量:由于你的 Thread 在无限循环,所以 Thread 无法被回收这是正常的,可你在 Thread 里调用了 Activity 里的变量,会导致整个 Activity 无法被 GC 回收(包括 Activity 的全局变量也都无法回收)。然后关闭 Activity,Activity 走了 onDestroy。这时候按道理 5 秒后会正常触发 GC 回收 Activity。这时候严重的来了:由于系统这次 GC 无法回收 onDestroy(因为她被 thread 引用了)。系统会每 2.1 秒 GC 一次,无限的尝试去释放这个 Activity。
代码如下:
// 错误示范:Thread 强引用 Activity
Thread(Runnable {
while(true) {
Thread.sleep(2000)
Log.e("dz", "给 activity 扔 start " + house.hashCode() + " Activity = " + this@DetailActivity.hashCode())
handler.sendEmptyMessageDelayed(1, 400)
}
}).start()
补充说明:
- 把第 148 行换成最简单的
this@DetailActivity,也一样会导致内存泄漏:Activity 和她的所有全局变量都无法释放。 - 每 2.1 秒 GC 一次,是冲着想释放 Activity 来的(并不是因为截图中的 sleep 两秒,这个 2.1 秒是系统固定的)。
- 如果 Activity 还没 onDestroy 5 秒,那么上面代码不会触发 GC。
- 如果 Thread 里的 Runnable 里不调用全局变量,只打纯粹的 Log,那么不会触发 GC。大家都可以释放。
- 所以以上代码会导致:Activity 无法释放 + 每 2.1 秒就 GC 一次。
- 事实上只要 Activity 无法被释放,无论处于什么原因。系统都会在她 onDestroy 5 秒后,每 2.1 秒就 GC 一次。
4. 正确用法:Handler 弱引用
以下代码是正常使用的情况,他可以正常释放:
private val handler = MyHandler(this)
private class MyHandler(context: Context) : Handler(Looper.getMainLooper()) {
private val reference: WeakReference<Context> = WeakReference(context)
override fun handleMessage(msg: Message) {
val activity = reference.get() as DetailActivity?
// 经过测试:onDestroy 后 5 秒触发 GC,然后就 Activity = 0。且全局变量 house 也被释放
Log.e("dz", "MyHandler 收到消息 Activity = " + System.identityHashCode(activity))
if (activity == null){
return
}
when (msg.what) {
1 -> {
Log.e("dz", "MyHandler 收到消息 且 Activity 没死 " + activity.house)
// 每 0.4 秒打印一次,直到 onDestroy 的 5 秒后触发了 GC,就会被上面的 activity == null 拦截
activity.handler.sendEmptyMessageDelayed(1, 400)
}
}
}
}
// 启动线程
Thread(Runnable {
Thread.sleep(2000)
Log.e("dz", "给 activity 扔 start " + house.hashCode() + " Activity = " + this@DetailActivity.hashCode())
handler.sendEmptyMessageDelayed(1, 400)
}).start()
深入理解 GC 根与内存分配
为了更好地优化内存,我们需要深入理解 JVM 层面的内存分配机制。Android 应用运行在 ART 虚拟机上,内存分为堆(Heap)和非堆(Non-Heap)区域。堆又细分为新生代(Young Generation)和老年代(Old Generation)。
1. GC Roots 类型
GC Roots 主要包括以下几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
只要对象到 GC Roots 没有引用链,就可以被回收。这也是为什么局部变量在方法结束后可以被回收的原因,因为它们不再存在于栈帧的本地变量表中。
2. 内存分配策略
- Eden 区:大部分对象首先在这里分配。Eden 区满了之后,触发 Minor GC。
- Survivor 区:存活下来的对象进入 Survivor 区。如果在 Survivor 区多次复制后仍然存活,则晋升到老年代。
- 老年代:存放生命周期较长的对象。老年代满了之后,触发 Major GC 或 Full GC,耗时较长。
3. 避免频繁创建大对象
在 RecyclerView 等列表控件中,频繁创建大对象(如 Bitmap、复杂的 Drawable)会导致 Eden 区迅速填满,触发频繁的 Young GC。建议在 Adapter 中复用对象,或者使用池化技术(Object Pool)来减少对象创建开销。
总结与建议
- 优先使用局部变量:除非必要,不要在 Activity 中定义过多的全局变量,以减少内存驻留时间。
- 及时清理引用:对于不再需要的全局变量,务必手动置为
null,帮助 GC 尽早回收。 - 警惕长生命周期对象:Thread、Handler、静态单例等长生命周期对象持有 Activity 引用是导致内存泄漏的主要原因,务必使用 WeakReference。
- 关注 UI 组件内存:Bitmap、SpannableString 等图片资源消耗巨大,应严格控制其加载范围和缓存策略。
- 利用工具分析:定期使用 Android Studio Profiler 或 MAT 工具分析内存快照,定位潜在的泄漏点。
通过遵循上述原则,可以有效降低 Android 应用的内存占用,提升用户体验,避免因内存不足导致的崩溃问题。


