Android 内存优化知识点梳理总结

来自:网络
时间:2022-12-26
阅读:
目录

前言:

Android 操作系统给每个进程都会分配指定额度的内存空间,App 使用内存来进行快速的文件访问交互。例如展示网络图片时,就是通过把网络图片下载到内存中展示,如果需要保存到本地,再从内存中保存到磁盘空间中。

RAM 和 ROM

手机一般有两种存储介质,一个是 RAM ,我们常说的内存,也称之为运行内存;另一个是 ROM ,即磁盘空间。 RAM 的访问速度一般会比 ROM 快,它是即插即用,断电会抹除所有数据,RAM 越大,可同时操作的数据就越多;ROM 是外部存储空间,相当于电脑的硬盘,主要是用来存储本地数据的。

App 运行时,会被加载到 RAM 中,又因为 App 所在进程会分配指定额度的空间,所以 App 的内存空间是有限的,内存的大小对 App 性能及正常运行都会有很大的影响。 当 App 所分配的内存空间不足时,会抛出 OOM 。所以对运行中的 App 的内存的优化就显得尤为重要。

常见内存问题

常见的内存问题包括:

  • 内存泄漏:因为 Java 对象无法被正常回收,如果长期运行程序,就会造成大量的无用对象占用内存空间,最终导致 OOM。
  • 内存抖动:频繁的创建对象,当对象数据到达一定程度会造成 GC ,如果短时间内频繁的 GC 就会造成 App 卡顿的现象,这个就叫内存抖动。
  • 内存溢出:当 App 申请内存空间时,没有足够的内存空间供其使用,就会导致内存溢出,即 Out Of Memory。

内存溢出

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时 App 就运行不了,系统会提示内存溢出,抛出异常。

所以避免 OOM 的办法就是解决内存泄漏问题,或尽量在代码中节约使用内存两种思路。

内存泄漏

内存泄漏在 Android 中就是在当前App 的生命周期内不再使用的对象被GC Roots引用,导致不能回收,使实际可使用内存变小。 需要注意的是,内存泄漏问题的出现,是和生命周期有关系的,从生命周期的角度考虑,就是生命周期短的对象被生命周期长的 GC Roots 对象持有引用,从而导致生命周期短的对象在该被回收的时候,无法被正确回收,该对象长期存活,但又毫无用处,白白地占用了内存空间。当这种对象过多时,就会造成 OOM 。

常见内存泄漏场景

无法回收无用对象的场景,可以统一理解为发生了内存泄漏,常见的 case 有:

  • 资源文件未关闭/回收
  • 注册对象未注销
  • 静态变量持有数据对象
  • 单例造成内存泄漏
  • 非静态内部类的实例持有外部类引用
  • Handler
  • 集合对象中的对象未释放
  • WebView 内存泄漏
  • View 的生命周期大于容器的生命周期

常见的诸如资源文件未关闭/为回收、注册对象未注销,导致观察者一致持有注册对象的引用,从而无法正常回收注册的对象。这里对其他几种场景进行详细的说明。

静态变量或单例持有对象

在 JVM 规范中,静态变量属于 GC Root 其中的一种,一般情况下它的生命周期都会比较长,所以如果一个对象的某个属性被静态变量持有了引用,就会导致该属性实例无法正常被回收。

以简单的示例代码说明:

class TestC {
    companion object {
        var leak: Any? = null
    }
}
class LeakCanaryActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent { LeakCanaryPage(actions()) }
    staticOOM()
	}
	private fun staticOOM() {
    Toast.makeText(this, "static own Context", Toast.LENGTH_SHORT).show()
    TestC.leak = this
	}
}

当我们打开这个LeakCanaryActivity后,返回上一个 Activity,此时查看 Profiler 排查内存泄漏的内容:

Android 内存优化知识点梳理总结

同样的道理,单例模式一般也是全局的生命周期且唯一的对象,如果被单例持有也会导致一样的问题。

object TestB {
    var leak: Any? = null
}
// 修改 LeakCanaryActivity 的 staticOOM 方法
	private fun staticOOM() {
    Toast.makeText(this, "static own Context", Toast.LENGTH_SHORT).show()
    TestB.leak = this
	}

Android 内存优化知识点梳理总结

非静态内部类的实例生命周期比外部类更长导致的内存泄漏

非静态内部类一般持有对外部类实例的引用,这个可以通过查看 class 文件发现,内部类的构造方法一般需要一个外部类类型的参数。所以如果一个内部类对象,生命周期更久的话就会造成内存泄漏。 这里一个比较明显的例子是多线程操作内部类对象时,外部类的生命周期已经结束时,因为内部类实例持有外部类的引用,导致外部类实例无法被正常回收:

class LeakCanaryActivity : ComponentActivity() {
	// ... 
	// 执行这个方法
	private fun innerClassOOM() {
    Toast.makeText(this, "inner leak", Toast.LENGTH_SHORT).show()
    val inner = InnerLeak()
    Thread(inner).start()
	  finish()
	}
	// 内部类
	inner class InnerLeak: Runnable {
    override fun run() {
        Thread.sleep(15000)
    }
	}
}

当我们打开一个 Activity 后,立刻创建一个新的线程执行内部类,然后立刻关闭自身,此时因为 InnerLeak 仍在子线程中,子线程在 sleep ,导致,外部类生命周期已经结束(调用了 finish),内部类对象 inner 仍持有外部类LeakCanaryActivity的引用。 除了这种内部类的形式,也可以用匿名内部类的形式来写,都会导致内存泄漏。 另一方面,不光是多线程的场景,如果内部类对象被静态变量持有引用也是一样的效果,因为他们都持有了内部类的引用,导致内部类的生命周期比外部类的生命周期更长。

Android 内存优化知识点梳理总结

Handler 导致的内存泄漏

通过 Handler 发送消息时,消息对象 Message 本身会持有 Handler 对象:

// Handler#sendMessage(Message) 会执行到 enqueueMessage 方法
private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
        long uptimeMillis) {
	  // 这里把 handler 自身保存到了 Message 的 target 属性中了
    msg.target = this;
    msg.workSourceUid = ThreadLocalWorkSource.getUid();

    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

sendMessage 方法内部调用到enqueueMessage(MessageQueue, Message, long)时,会把 Handler 对象自身赋值到 Message 的 target 上,这样 message 就知道去找哪个 Handler 执行handleMessage(msg: Message)方法。也是因为这个持有,导致了如果消息没有立刻被执行,就会一直持有 Handler 对象,此时如果关闭 Activity ,就会导致内存泄漏。

原因是 Handler 以匿名内部类或内部类的形式声明并创建的,会持有外部 Activity 的引用。从而导致持有关系是:

Message -> Handler -> Activity

实现 Handler 内存泄漏的代码:

// in LeakCanaryActivity
private fun handlerOOM() {
    val handler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            if (msg.what == 12)
            Toast.makeText(this@LeakCanaryActivity, "handler executed", Toast.LENGTH_SHORT).show()
        }
    }
    Thread {
        handler.sendMessageDelayed(Message().apply { what = 12 }, 10000)
    }.start()
}

操作逻辑是,在 LeakCanaryActivity 中调用这个方法后,立刻 finish LeakCanaryActivity ,然后查看内存泄漏情况:

Android 内存优化知识点梳理总结

postDelayed 导致的内存泄漏

postDelayed 实际上是把 Runnable 封装成了一个 Message 对象,传入的 Runnable 参数被赋值给了 Message 的 callback :

public final boolean postDelayed(@NonNull Runnable r, long delayMillis) {
    return sendMessageDelayed(getPostMessage(r), delayMillis);
}
private static Message getPostMessage(Runnable r) {
    Message m = Message.obtain();
    m.callback = r;
    return m;
}

而最终执行逻辑的方法都是 sendMessageDelayed(Message, long),所以和 sendMessage 一样都会导致内存泄漏。与之不同的是,postDelayed的泄漏会多一个Message#callback因为 在调用postDelayed时,第一个是个匿名内部类对象,多了一个引用。

handler.postDelayed(object : Runnable {
    override fun run() {
        Log.d(TAG, "postdelay done")
    }
}, 10000)

Android 内存优化知识点梳理总结

View 的生命周期大于 Activity 时导致的内存泄漏

一个极其简单的内存泄漏场景是,当我在一个 Activity 内多次弹出 Toast 时,立刻关闭当前 Activity ,就会导致内存泄漏的情况出现:

// in LeakCanaryActivity
private fun toastOOM() {
    Toast.makeText(this, "toast leak", Toast.LENGTH_SHORT).show()
}

操作步骤:将上面的方法设置在某个点击事件中,快速连续点击几次,然后立刻关闭当前 Activity ,查看 Profiler:

Android 内存优化知识点梳理总结

集合中的对象未释放导致内存泄漏

最常见的场景是观察者模式,观察者模式中注册一些观察者对象,一般是保存到一个全局的集合中,如果观察者对象在释放时不及时注销,就会造成内存泄漏:

object LeakCollection {
	val list = ArrayList<Any>()
}

class LeakCanaryActivity : ComponentActivity() {
	// ... 
	private fun collectionOOM() {
    LeakCollection.list.add(this)
	}
}

操作步骤:在 LeakCanaryActivity 内调用collectionOOM() ,然后立刻 finish 。

Android 内存优化知识点梳理总结

最常见的解决办法就是在 Activity 的 destroy 时,从 list 清除自身的引用。

WebView 导致的内存泄漏

网上都说 WebView 会导致内存泄漏。通过 Profiler 直接查看并没有明显的一个 Leaks 提示。那么如何排查这个内存泄露呢?

一个思路是参照对比实验:

  • 对照组 A :NoLeakActivity,一个空的 Activity,里面没有任何内容。
  • 对照组 B :LeakWebViewActivity, 一个包含 WebView 的 Activity 。

在同一个 Root Activity 中分别打开 A 和 B ,通过对比内存变化,来证明 WebView 是否真的造成了内存泄漏。

首先是打开了 NoLeakActivity, 并没有明显的内存变化。

Android 内存优化知识点梳理总结

然后返回到 LeakCannaryActivity ,内存还是没有变化。接着打开 LeakWebViewActivity ,发现内存明显上升,主要上升在 Native 、Others 和 Graphics 。 Graphics 可以理解,因为 loadUrl 失败了会显示一个失败页面,其中有个 icon 图片,所以主要分析的点是 Native 和 Others 。

Android 内存优化知识点梳理总结

然后返回到 LeakCanaryActivity, 内存基本没有变化。

Android 内存优化知识点梳理总结

为了证明,不是因为 NoLeakActivity 先打开,LeakWebViewActivity 后打开,所以内存中会有多余的 NoLeakActivity 相关的内存占用,我们再次打开 NoLeakActivity ,再返回,内存仍无明显变化。

Android 内存优化知识点梳理总结

所以,基本上可以证明,WebView 没有随着 Activity 的销毁而被回收。

但是如何解决这种情况呢?这个问题值得后续仔细研究一下。但目前网上的各种奇怪的解决方案(例如开启一个单独的进程)并不是合理的办法。 一个说法是,在 xml 里面是有 WebView 会出现内存泄漏,但是如果通过 addView 的形式去使用不会造成,以下是通过 addView 的形式添加 一个 WebView 对象的内存变化。

Android 内存优化知识点梳理总结

而这是通过 XML 的形式使用 WebView 的内存变化。

Android 内存优化知识点梳理总结

两种方法好像并没有什么区别,但有用的一点是,这里的内存变化,主要体现在 Native 上,证明 WebView 组件,会在 Native 层面生成一些内容。

这个部分的分析,后续可以再深入研究。从应用层面来看,WebView 并没有直接触发再 Java heap 上的内存泄漏。而是更底层的 Native heap 中。

另外需要注意的一点是,通过 LeakCanary 并不能精准的检测到内存泄漏,还是得用 Profiler。

内存抖动

短时间内频繁创建对象,导致虚拟机频繁触发GC操作,频繁的 GC 会导致画面卡顿。

解决方案

  • 尽量避免在循环体内创建对象,应该把对象创建移到循环体外。
  • 注意自定义 View 的 onDraw() 方法会被频繁调用,所以在这里面不应该频繁的创建对象。
  • 当需要大量使用 Bitmap 的时候,试着把它们缓存在数组中实现复用。
  • 对于能够复用的对象,同理可以使用对象池将它们缓存起来。

其他优化点

基本上减少内存优化的其他思路就是复用和压缩资源。

  • 图片资源过大,进行缩放处理。
  • 减少不必要的内存开销:一些基本数据类型的包装类,例如 Integer 占用 16 个字节,而 int 占用 4 个字节,所以尽量避免使用自动装箱的类。
  • 对象和资源进行复用。
  • 选择更合适的数据结构,避免数据结构分配过大导致的内存浪费。
  • 使用int 枚举或 String 枚举代替枚举类型 ,但枚举类型也会有比前者更好的特性,需要酌情使用。
  • 使用 LruCache 等缓存策略。
  • App 内存过低时主动清理。

App 内存过低时主动清理

实现 Application 中的 onTrimMemory/onLowMemory 方法去释放掉图片缓存、静态缓存来自保。

class BaseApplication: Application() {
    override fun onLowMemory() {
        super.onLowMemory()
    }
    override fun onTrimMemory(level: Int) {
        super.onTrimMemory(level)
    }
}
返回顶部
顶部