1.3 内存优化方法论

通过前文可知,内存分为虚拟内存和物理内存两个部分,虚拟内存是通过mmap函数申请的内存,并没有真正写入数据,物理内存是写入数据后才会消耗的内存。虚拟内存或物理内存的任何一部分的消耗超过阈值,都会导致OOM发生,所以我们做内存优化时首先要明确优化的是虚拟内存还是物理内存。如果优化的是物理内存,那么优化方向又可以分为Native内存和Java堆内存。不管是虚拟内存还是物理内存,优化方法论都是一样的,主要包括以下3个方向:

❑及时清理数据;

❑减少数据的加载;

❑增加内存大小。

1.3.1 及时清理数据

根据及时清理数据这一方法论设计出来的优化方案往往都是应用层的优化方案,这些方案一般都比较容易落地而且有较好的效果。大多数情况下,我们只需要在业务结束时和内存不足时进行数据清理。

1.业务结束时

业务结束时,有些数据需要手动进行清理,比如全局的缓存和资源。有些数据会自动清理,比如Activity及其成员变量。对于需要手动清理的数据,我们要避免清理后因为还有业务使用该数据导致空异常。因为清理这类数据很容易发生异常,所以一定要谨慎,或者尽量将需要手动清理的全局数据放入Activity中,将其转为Activity成员数据。

对于Activity来说,当它被执行销毁操作后,只要这个Activity不被其他地方的某个对象长期持有,那么当虚拟机执行GC时,这个Activity及其成员变量就会被释放掉。在现实中,对于这类业务结束时自动清理的数据,优化工作更多集中在内存泄漏的排查和治理上。但是我们依然可以在排查和治理之外增加一些防御型的优化策略,比如我们可以把持有Activity的上下文(context)代码改成持有Application的上下文代码,如果不能持有Application的上下文,也应该以弱引用持有该Activity的上下文。

2.内存不足时

在内存不足时,我们也需要主动清除非必要的对象和数据,比如在Java堆内存不足时,对应用中的缓存进行清理。那如何才能知道Java堆内存不足呢?这就需要增加一个检测机制了。我们可以开启一个独立的子线程,然后按照一定的频率进行检测以获取Java堆信息,可以采用通过AMS获取memoryInfo的方式,也可以通过Runtime.getRuntime()接口来获取。一般来说,用Runtime.getRuntime()是合适的,因为这种方式对性能的影响最小,并且我们只需要知道Java堆的最大内存和已经使用的内存即可。

当得到最大可使用内存和已经使用的Java堆内存后,我们便能判定内存的使用是否超过了设定的阈值,如果超过了就通过回调通知各个业务、缓存、单例对象等进行缓存的清理工作。

1.3.2 减少数据的加载

想要减少加载进Java堆的数据,我们可以通过减小缓存大小、按需加载数据、转移数据这几种方式来实现。

1.减小缓存大小

业务开发中不可避免地需要用到很多缓存,缓存是一种用空间换时间的方案,可有效地提升系统性能。缓存使用得多,内存占用就多,减少缓存的使用,自然也能减少内存的占用。但是减少缓存的使用会降低用户体验,所以在减少缓存的使用时需要综合评估业务的体验、OOM、业务使用频率等多方面因素。具体该怎么操作呢?就拿LruCache(Least Recently Used Cache,最近最少被使用缓存)来说,它是我们使用最多的缓存之一,要优化LruCache这类缓存,我们需要考虑如下两点:

❑缓存的大小是多少?

❑缓存中的数据何时清理?

先看第一个问题。我们需要在LruCache构造函数中设置LruCache的容量,网上很多文章都提到默认传入最大可用堆内存的12.5%,这样设置其实并不太准确。我们需要评估业务的重要性和业务使用频率。如果是重要并且使用频率高的业务缓存,这里的容量多设置一些也能接受。同时,我们还需要评估当前的机型,如果是只有256MB可用堆内存的低端机,这里设置为12.5%(即32MB)就有点多了,可能会对整个应用的稳定性产生影响。那么到底应该设置为多少?建议综合机型、业务并充分考虑后再设置,这里没有绝对的标准,需要应用的开发者结合实际场景和业务进行评估。

再来看第二个问题。缓存中的数据何时清理呢?LruCache自带了缓存清理的策略,这个缓存的容量满了之后,就会清理最后一个最近未被使用的数据。除了这个清理策略之外,我们可以再多增加一些策略,比如当Java堆内存的使用达到阈值(如80%)时就清理LruCache的数据。

除了LruCache之外,常用的缓存还有List、Map等。在做内存优化时,我们需要考虑如下问题:

❑应用运行时所占用的内存会有多大?

❑是否会因为缓存过大导致内存异常?

❑如何及时清理缓存?

2.按需加载数据

按需加载数据指的是只有真正需要用到的时候才去加载数据。Android系统中用到大量按需加载的策略。比如前面提到的mmap函数申请的其实是虚拟内存,只有真正需要存放数据时才会去分配并映射物理内存。在应用开发中,使用按需加载数据的策略能节约不少Java堆内存。

在项目开发中,也有很多场景用到该策略,比如我们通常会在项目中将各种全局服务注册到一个服务容器中,再通过服务的接口将各个业务的能力暴露出去,达到解耦的目的。很多情况下,我们会在程序启动或者业务初始化的时候进行注册。但如果采用按需加载数据的策略,将注册逻辑延迟到真正需要使用该服务的时候再进行,便能实现对系统性能的优化。除此之外,对于应用启动时的各种预加载,我们也可以思考是否可以在真正使用时再进行加载。按需加载数据的案例有很多,这里就不一一列举了。

3.转移数据

我们知道,Java堆的大小是有限制的,主流机型下的可用大小只有512MB。那如果我们将需要放入Java堆的数据转移到其他地方,是不是就可以突破512MB的限制了呢?实际上确实可以这样做,转移数据的方式主要有以下两种。

将Java堆中的数据转移到Native中:针对这一优化方案,Bitmap是一个很经典的案例。在Android 8以前的版本中,Bitmap是算入Java堆的空间的,Android 8及之后的版本却将Bitmap放入了Native中。这一策略极大地增加了Java堆的可用空间。在Android 8之前,Fresco这款图片加载工具也采用过将Bitmap的创建放在Ashmem匿名共享内存中的方案来优化Java堆内存。可以看到,Android系统或者Fresco框架都是基于将原本存放在Java堆中的数据转移到Native中这一思路来优化Java堆内存的,所以我们在做Java堆内存优化时也可以采用这样的思路。比如说,我们可以将需要读取大数据的业务下沉到Native层去做,包括网络库、业务的数据处理等。即使是Bitmap,在Android 8以前的版本中,也是可以通过Native Hook等技术手段转移到Native中的。

将当前进程中Java堆的数据转移到其他进程中:每个进程的Java堆都是固定的,但是我们可以将应用设计成多进程模型,这样就有多个可用的Java堆空间了。我们可以选择将比较独立的业务放在子进程中,如需要小程序、Flutter、RN、Webview等容器承载的业务,当我们把这些业务放在独立的子进程中后,不仅可以减小主进程中Java堆的大小,还能降低主进程中因为这些业务导致的内存泄漏、Crash等性能问题的出现概率。

1.3.3 增加内存大小

针对“增加内存大小”这个优化方法论,读者可能会有疑问:内存大小是设备决定的,要怎样增加内存大小呢?通过前面的基础知识我们可以知道,虽然物理内存以及系统为进程创建的虚拟内存的大小都是固定的,但是还有很多其他的内存空间是通过虚拟机或者系统库来创建的,比如默认为512MB的Java堆空间是虚拟机来创建和管理的,默认为1024 KB的线程栈空间是libc系统库来创建的,所以我们可以通过Native Hook技术来改变系统库或者虚拟机的逻辑,从而实现“改变这些空间大小,增加内存空间”的优化方案。

但是通过Native Hook改变系统库的逻辑,从而增加内存大小并不是很常规的优化方案,因为想要落地这个优化方案,我们不仅需要熟悉底层的逻辑和源码,还要熟悉Native Hook技术。比较经典的案例如字节跳动的mSponse内存优化方案,便是通过Native Hook技术将LargeObjectSpace从原有的空间中分离出来,并为其分配了512MB的独立空间。

除了扩大可用的内存空间,我们还可以通过减少内存空间中被系统所占用却并不会使用的空间,来间接提升可用的内存大小,这一方法在虚拟内存的优化中经常会用到,后文会通过实战案例来进一步讲解。