内存优化①OOM和内存泄漏

作者: zackyG | 来源:发表于2020-12-08 23:45 被阅读0次

    在Android开发中,做内存优化的目的,从本质上讲,就是减少异常(OOM)和卡顿。不同的App在运行过程中,占用内存多少也不同,但是大家也肯定清楚,并不是内存占用较多的App出现OOM和卡顿的频率就一定更高。

    OOM

    OutOfMemoryError,内存溢出。出现这个异常最常见的原因是,创建对象时,堆内存中没有足够的空间可以分配。App运行过程中,出现OOM会直接导致程序退出。

    内存不足导致的卡顿,根本原因是,虚拟机给对象分配内存,由于内存不足会触发GC,执行GC会导致主线程阻塞,从而导致卡顿。

    在开发中要避免OOM,本质上来说,就是要在让占用的内存不超过可用的内存上限。由此,就可以分析出两种优化思路:

    • 合理使用内存,尽量减少不必要的内存占用
    • 提高可用内存的上限

    以上两个思路中,前者才是内存优化的核心价值观,需要花费较长的篇幅。后者相对简单,但是容易被忽视。所以这里先介绍提高App可用内存上限的方式。

    android:largeHeap

    largeHeap是在manifest文件中,application节点的一个配置项。它的作用是向系统请求,为App进程的虚拟机分配更大的堆内存空间。

    <application android:icon="@drawable/icon"
      android:allowBackup="false"
      android:label="@string/app_name"
      android:debuggable="true"
      android:theme="@android:style/Theme.Black"
      android:largeHeap="true"
    >
    

    设置android:largeHeap的属性为true之后,系统具体能为虚拟机分配多大的堆内存,取决于当前设备的相关配置。这个配置可以/system/build.prop文件中查看。

    dalvik.vm.heapstartsize=8m                //堆内存的初始大小
    dalvik.vm.heapgrowthlimit=192m            //largeHeap为false时,App的最大堆内存大小
    dalvik.vm.heapsize=512m                   //largeHeap为true时,App的最大堆内存大小
    dalvik.vm.heaptargetutilization=0.75      //内存使用率。GC完成之后,系统根据已使用内存和内存使用率来调整堆内存的大小
    dalvik.vm.heapminfree=2m                  //单次堆内存调整的最小值
    dalvik.vm.heapmaxfree=8m                  //单次堆内存调整的最大值
    

    通过设置largeHeap,向系统请求更大内存的方法。原则上说,应该结合App的实际需要才使用。如果App的内存使用没那么多,也就没必要开启这个设置,因为这个设置也会带来其他的影响,这个后面会提到。另外一点是要结合用户当前设备的配置。比如现在主流的手机配置,内存都是6G和8G,但是也有些老人机或者低端机的内存只有2G。针对6G以上内存的手机,我们可能就不需要开启largeHeap配置,而针对低配手机,则需要考虑开启这个配置。
    通过以下方法,可以在App运行时获取到系统为App进程分配的内存大小

    Runtime rt = Runtime.getRuntime();
    long maxMemory = rt.maxMemory();
    Log.e("MaxMemory:", Long.toString(maxMemory/(1024*1024)));
    ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
    Log.e("MemoryClass:", Long.toString(activityManager.getMemoryClass()));
    Log.e("LargeMemoryClass:", Long.toString(activityManager.getLargeMemoryClass()));
    

    获取到分配的内存大小,主要是用来设置图片缓存之类时,结合实际情况去配置。这个后面会再提到。

    Low Memory Killer

    Low Memory Killer(LMK)机制,简单来讲就是Android系统中有一个单独的守护进程,它会监控系统运行时的内存状态,并通过终止最不必要的进程来应对内存压力较高这一问题,使系统以可接受的水平运行。
    上面提到的内存压力,并不是针对App进程的内存,而是系统总内存,而且是物理内存。即通常所说的内存6G的手机,物理内存即为6G。当系统监控的内存压力之后,会通过回调Application的onTrimMemory()方法来通知App。由此,我们可以利用onTrimMemory()方法,针对不同级别的系统内存压力,采取不同的措施。比如,当内存压力到达一定级别,就需要考虑,释放掉部分缓存的对象,或者转为IO存储等之类,以达到减少内存占用,提高App的存活几率,保证必要的数据不丢失等等之类的目的。以下代码和注释是Android官方提供的,关于不同级别内存压力的演示和说明。

        import android.content.ComponentCallbacks2;
        // Other import statements ...
    
        public class MainActivity extends AppCompatActivity
            implements ComponentCallbacks2 {
    
            // Other activity code ...
    
            /**
             * Release memory when the UI becomes hidden or when system resources become low.
             * @param level the memory-related event that was raised.
             */
            public void onTrimMemory(int level) {
    
                // Determine which lifecycle or system event was raised.
                switch (level) {
    
                    case ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN:
    
                        /*
                           Release any UI objects that currently hold memory.
    
                           The user interface has moved to the background.
                        */
    
                        break;
    
                    case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE:
                    case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW:
                    case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL:
    
                        /*
                           Release any memory that your app doesn't need to run.
    
                           The device is running low on memory while the app is running.
                           The event raised indicates the severity of the memory-related event.
                           If the event is TRIM_MEMORY_RUNNING_CRITICAL, then the system will
                           begin killing background processes.
                        */
    
                        break;
    
                    case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND:
                    case ComponentCallbacks2.TRIM_MEMORY_MODERATE:
                    case ComponentCallbacks2.TRIM_MEMORY_COMPLETE:
    
                        /*
                           Release as much memory as the process can.
    
                           The app is on the LRU list and the system is running low on memory.
                           The event raised indicates where the app sits within the LRU list.
                           If the event is TRIM_MEMORY_COMPLETE, the process will be one of
                           the first to be terminated.
                        */
    
                        break;
    
                    default:
                        /*
                          Release any non-critical data structures.
    
                          The app received an unrecognized memory level value
                          from the system. Treat this as a generic low-memory message.
                        */
                        break;
                }
            }
        }
    

    值得注意的是,有些同学可能还接触过onLowMemory()方法。实际测试中,你会发现 onTrimMemory() 的 ComponentCallbacks2.TRIM_MEMORY_COMPLETE 并不等价于 onLowMemory(),因此推荐仍然要监听 onLowMemory() 回调。

    LMK采用oom_adj_score分数(可以理解为“内存压力分数”),来确定正在运行的进程的优先级,得分最高的进程最先被终止。 image.png

    以下是对上表各种类别的说明:

    • 后台应用——之前运行过且当前不处于活动状态的应用。LMK将首先从具有最高oom_adj_score分数的应用开始终止后台应用。
    • 上一个应用——即最近使用过的后台应用。上一个应用比后台应用的优先级更高(即oom_adj_score分数更低)。因为相比某个后台应用,用户更有可能切换到上一个应用。
    • 主屏幕应用——Launcher启动器应用,也就是俗称的桌面。终止该应用会导致壁纸消失。
    • 服务——服务由应用启动,例如上传和同步数据的服务之类的。
    • 可见应用——用户可以通过某种方式察觉到的非前台应用,例如运行一个显示小窗口界面的搜索应用或者音乐播放器应用等。
    • 前台应用——当前正在与用户交互的应用,终止前台应用看起来就像应用崩溃了,可能会向用户提示设备出了问题。
    • 持久性服务——这些是设备的核心服务,例如电话和WLAN。
    • 系统进程——系统进程被终止后,手机可能看起来即将重新启动。
    • Native进程——系统使用的极低级别的进程,如kswapd

    另外关于LMK需要注意的是:

    • 一个App冷启动时,系统会为其创建一个进程。当用户退出App或者切换到其他App,该进程并不会立即被终止,系统会将该进程保留在缓存中。如果一个App进程中保留了不必要的内存数据,那么即使用户没有使用此App,它也会占用系统内存,影响系统的整体性能。
    • 当系统内存不足时,缓存中的App进程占用的内存越少,就越有可能免于被终止并得以快速恢复。但是系统也可能根据当下的需求,不考虑缓存进程的内存占用情况而随时将其终止。
    • 手机制造厂商可以更改LMK的默认行为。
    设备分级

    很多同学都应该遇到过,同一个App,在6G内存的手机上运行如丝般顺滑,但是在1G内存的手机上就会有明显的卡顿,甚至直接运行不了。造成这种现象的主要原因,App进程的内存不足。如上面章节中提到,系统为App进程分配的堆内存大小,根据设备的实际配置而定。很明显,6G内存的手机上,系统分配给App进程的堆内存,肯定要比1G内存的手机要多不少。因为可用内存的限制,在低配手机上,同样的程序运行,发生OOM和卡顿的概率和频率也会更高。
    所以,设备分级的实质就是针对低端机的内存优化。首先我们既可以根据手机的内存、CPU核心数和频率等配置信息,可以根据上面提到的获取App进程内存信息的方式,来识别当前程序运行的设备是否属于低端机。针对低端机的优化技巧有以下这些:

    • 可以关闭复杂的动画,或者某些功能
    • 图片加载时,可以采用RGB_565格式,以减少Bitmap对象的内存占用
    • 设置相对较小的内存缓存
    • 减少子进程的启动。即使是一个空进程也会占用10MB的内存。在低端设备上,可以减少保活进程和常驻进程的创建。
    • 控制安装包大小。安装包的代码、资源、图片以及so文件的体积,跟它们占用的内存有很大的关系。例如,一个安装包为80MB的应用很难在内存只有512MB的手机上流畅运行。今日头条、Facebook等大厂的应用,都针对低端机用户,推出了像今日头条极速版、Facebook Lite这样的轻装版。这些轻装版的安装包大小相比于标准版,都要浓缩了很多。
    image.png

    上图展示了安装包中的代码、资源、图片和so文件的大小跟内存的关系。

    内存泄漏

    内存泄漏简单来说就是不再使用的对象引用没有释放,导致GC不能及时将该对象回收。在实际开发中,内存泄漏难以避免的原因就在于,总有不合理的代码设计导致了,一些不易察觉的对象引用没有被释放。

    Activity的内存泄漏

    之所以把Actiivty的内存泄漏单独拿出来讲,是因为在Android开发中,Activity是用得最多的组件,没有之一。出现内存泄漏的概率和频率更高。同时更重要的原因是,Activity承担了应用程序展示界面、与用户交互的职责,每一个Activity对象内部需要持有大量的资源引用以及与系统交互的Context对象的引用,因此Activity对象非常容易意外地被系统或者其他业务逻辑当做一个普通对象而长期持有。这会导致一个Activity对象的retained size特别大。一旦Activity对象出现了内存泄漏,被牵连导致其他对象的内存泄漏也非常多。

    关于retained size和shadow size
    shallow size就是对象本身占用的内存大小,不包含其引用的对象。常规对象(非数组)的shallow size由其成员变量的数量和类型决定。数组的shallow size由数组元素的类型(对象类型、基本类型)和数组长度决定。
    retained size包含该对象本身的shallow size,加上从该对象能直接或间接访问到的对象的shallow size之和。换句话说,retained size是该对象在GC之后所能会受到的内存总和。

    关于本文中会涉及到的,Java中的引用类型和对象引用的GC Root算法相关介绍,可以参考笔者之前的文章关于Java虚拟机,你需要了解的

    造成Activity内存泄漏的常见场景有以下几种:

    1. 将Context或者View对象设置为static
      每一个View对象默认都会持有一个Context的引用,即在Activity的布局文件中包含的视图,经过解析后生成的View对象会默认持有该Activity对象的引用,可以从View源码的getContext()方法和注释中得知。如果将View对象设置成static,将造成View对象在方法区无法被快速回收,从而导致Activity对象泄漏。
    /**
    * Returns the context the view is running in, through which it can
    * access the current theme, resources, etc.
    *
    * @return The view's Context.
    */
    @ViewDebug.CapturedViewProperty
    public final Context getContext() {
        return mContext;
    }
    
    1. 注册的各种listener和BroadcastReceiver没有注销
      在Activity中注册各种事件监听器和系统监听器,比如广播。但是在退出Activity时,未注销这些监听器。
    2. 非静态的Handler内部类导致Activity内存泄漏
      在Java中,非静态内部类会默认持有外部类对象的引用,常见的Handler正确用法是将Handler定义为静态内部类来使用,如果其内部需要用到外部的Activity对象,则将Activity对象封装在弱引用中使用,这样在GC发生时可以避免Activity对象出现内存泄漏。
    3. 第三方库使用Context
      在项目中使用第三方库时,有些三方库的初始化需要传入Context对象,但是我们可能不清楚三方库对Context对象的持有周期是多长时间。所以如果将Activity对象作为Context传入,在退出Acitvity时,就有可能因为Activity对象依然被引用持有而导致其内存泄漏。通常,如果没有限制必须传入Activity对象作为Context,我们都应该讲Application对象作为Context传入。
      由此延伸,我们自己在开发SDK时,为了避免传入的Context对象内存泄漏,应该主动使用Application对象作为内部的Context对象使用。
    4. 匿名内部类隐式持有外部类的引用
    public class ChattingActivity extends AppCompatActivity{
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              super.onCreate(savedInstanceState);
              setContentView(R.layout.activity_chatting);
              EventCenter.addEventListener(new IListener<IEvent>() {
                  // 这个 IListener 内部类里有个隐藏成员 this$ 持有了外部的 ChattingActivity 
                  @Override
                  public void onEvent() {
                      // ...
                  }
              });
          }
      }
      public class EventCenter {
          // 此 ArrayList 实例的生命周期为 App 的生命周期
          private static List<IListener> sListeners = new ArrayList();
          public  static void addEventListener(IListener cb) {
              // ArrayList 对象持有 cb,cb.this$ 持有 ChattingActivity ,导致 ChattingActivity  泄漏
              sListeners.add(cb);
          }
      }
    
    1. 系统组件导致的Activity泄漏,如果LeakCanary(一个监控内存泄漏的框架,下文中会介绍)中提到的SensorManager和InputMethodManager导致的泄漏。
    2. 耗时的Runnable持有Activity对象,或者Runnable本身的执行并不耗时,但在它前面有其他耗时的Runnable阻塞了线程导致此Runnable一直没有机会执行,导致它持有的Activity对象泄漏。任何能够长期持有Activity的强引用的场景都有可能导致Activity对象泄漏,从而导致其持有的大量View和其他对象也出现内存泄漏。

    以上是几种常见的导致Activity内存泄漏的场景。接下来介绍下几种常见的内存泄漏场景

    1. 资源性对象未关闭
      对于InputStream和OutputStream等资源性对象不再使用时,应该及时调用close()方法将其关闭,然后将引用置为null。对于Bitmap对象,在确定不再使用时,及时调用recycle()方法将其回收,避免内存泄漏。
    2. 类的静态变量持有大型数据对象
      尽量避免使用静态变量存储数据,对于大型数据对象,建议使用数据库或者文件存储。
    3. 集合容器内的对象没有清理
      当确定集合容器内的对象不再使用时,应该先将集合清空(如调用clear()方法),然后将集合的引用置为null。
    4. WebView
      WebView都存在内存泄漏的问题。在程序中只要使用过一次WebView,该对象的内存就不会被释放掉。我们可以为WebView单独开启一个子进程,使用AIDL与引用的主进程进行通信,WebView所在的进程可以根据需要选择合适的时机销毁,达到正常释放内存的目的。

    优化内存空间

    实际开发中要避免OOM,除了要防止内存泄漏之外,我们还要尽量优化内存的使用。不合理的使用内存,即使没有内存泄漏,也会导致可用内存越来越少,最终造成OOM。

    减少不必要的内存开销
    1. AutoBoxing(自动装箱)
      在Java中,自动装箱的核心是将基础数据类型的变量封装成对应的包装类对象。每一次自动装箱时,都会产生一个新的对象。这样就会产生额外的内存和性能消耗。比如int类型数据只占4个字节,Integer对象则要占用16个字节。特别是像List和HashMap这种集合类,对它们进行增删改查操作时,都会伴随大量的自动装箱操作。在做内存优化时,可以通过TraceView或者CPU Profiler,查看方法调用,如果发现调用了大量的类似Integer.valueOf()方法,就表明发生了自动装箱。
    2. 内存复用
      常见的内存复用有以下几种方式:
      资源复用——通用的字符串、颜色定义、页面布局的复用
      视图复用——比如使用ListView时通过ViewHolder对convertView的复用
      对象复用——显式创建对象池,实现复用逻辑,对相同类型的对象使用同一块内存空间
      Bitmap复用——使用inBitmap属性可以让Bitmap解码器尝试使用已经存在的内存空间,新解码的bitmap可以尝试复用此内存空间存放像素数据。前提是新创建的Bitmap对象需要的内存不超过已存在的这块内存空间
      线程复用——在多个模块中,使用统一的线程池管理和复用线程。默认情况下,虚拟机会为新创建的线程分配1Mb左右的内存。而系统分配的App进程的可用内存是有限的。如果无节制的创建线程。也会导致如下所示的OOM。
    java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
    
    使用最优的数据类型

    比如用ArrayMap、SparseArray代替HashMap。ArrayMap和SparseArray是Android系统优化过的数据集合类,能在很多场景下和HashMap的用途一样。

    ArrayMap的原理

    ArrayMap的内部主要包含两个数组mArray和mHashes。mArray是一个Object类型的数组,用来存放所有的key和Value。其中每一个key分别存放在偶数下标的位置,如0、2、4等,而每一个value分别存放在奇数下标的位置,如1、3、5等。mHashes是一个int型的数组,其中存放的是每一个key的hash值。mHashes中的hash值按大小排序。
    查找元素时,先计算出目标key的hash值,然后在mHashes中通过二分查找,得到该hash值对应的下标index,再根据index计算出目标key在mArray数组中的位置。计算的方式是index<<1,即将index乘以2,即为key在mArray数组中的位置,而key对应的value所在的位置为index<<1+1。
    添加元素时,如果key已经在mArray中存在,就用新的value覆盖旧的value。如果key不存在,就先通过key的hash值,与mHashes中的元素进行排序,得出key的hash值存放的位置下标index,然后创建一个新的数组,其中前index<<1-1个元素由mArray复制它的前index<<1-1个元素,第index<<1个元素为新添加的key,第index<<1+1个元素为新添加的value,最后将原mArray数组的第index<<1个元素到最后一个元素,陆续复制到index<<1+2之后的位置,从而组成新的mArray数组。
    从原理上看,ArrayMap不适合用来存储大量的数据,因为存储的key-value越多,涉及到数组搬迁的成本会越大。

    SparseArray的原理

    SparseArray,使用int[]数组存放key,避免了HashMap中基本数据类型需要装箱的步骤,其次不使用额外的结构体(Entry),单个元素的存储成本下降。其内部包含两个数组:mKeys和mValues。mKeys为int类型的数组,存放每一个键值对的key,且其中的int型元素按大小排序。mValues是Object类型的数组,存放每个键值对的value。
    查找元素时,首先通过二分查找,得到目标key在mKeys中的索引index。而mValues数组中,索引为index的元素即为目标key对应的value。
    添加元素的过程可以分为以下几个步骤:

    1. 通过二分查找待插入的目标key,得到一个下标i。
    2. 如果i >= 0,则表示key已存在,就直接在mValues数组中,用心的value覆盖原来的value。
    3. 否则表示key不存在,就将i取反(i=~i),用来表示目标key带插入的位置。
    4. 如果待插入位置 i 没有超过mKeys数组的长度,则先判断mValues[i]是否已经标记为DELETED,如果是则直接更新mValues[i]。否则将带插入的key和value分别插入到mKeys和mValues数组的相应位置。
    5. 如果带插入位置 i 超过了mKeys数组的长度,则会先将mValues中标记为DELETED的元素和mKeys中对应的key移除,整理mKeys和mValues数组,然后再次通过二分查找和取反计算,重新得到新的待插入位置 i 。然后将key、value插入到mKeys和mValues的相应位置。插入时还需要判断如果数组已满,则需要先进行数组扩容。

    删除元素时,SparseArray并不是立即从数组中移除元素,而是先在mValues中将待删除的元素标记为DELETED,这个待删除元素的位置可在之后添加相同key的元素时复用,或者调用gc()方法时将标记为DELETED的元素统一移除。
    gc()方法的职责就是遍历数组的每一个元素,将标记为DELETED的元素移除,之后的所有元素前移。
    多说一句,从SparseArray的原理来看,其中的一些设计思路还是很值得借鉴和学习的。

    使用typeDef注解替代枚举类型

    枚举最大的优点是类型安全。但在Android平台上,枚举的内存开销是直接定义常量的3倍以上。所以Android提供了注解的方式检查类型安全。目前提供了int型和String型两种注解方式:IntDef和StringDef。用来提供编译时的类型检查。

    关于Java枚举的原理介绍
    枚举本质上是通过普通的类实现的,只是编译器为我们进行了处理。每个枚举类型都继承自java.lang.Enum。并自动添加了values和valueOf方法,而枚举中定义的每一个常量都是这个类中的静态常量字段,使用内部类实现,该内部类继承自枚举类Enum。所有的枚举常量都是通过静态代码块来初始化,即在类加载期间就初始化。另外通过把clone、readObject、writeObject这三个方法定义为final,同时实现是抛出相应的异常,这样保证了每个枚举类型以及枚举常量都是不可变的。

    总结

    本文作为内存优化系列的第一篇,先介绍了内存优化要解决的问题:OOM和卡顿。这也是为什么要做内存优化的原因和目的。主要介绍了如何避免OOM,提到了两个优化的思路。一是提高可用内存上限,即android:largeHeap设置。二是合理使用内存,这其中涉及到常见的内存泄漏问题介绍和优化内存使用的相关技巧。
    后续还会继续介绍内存优化的相关工具、框架使用和Bitmap相关的内存优化。

    本文参考
    Java堆:Shallow Size和RetainedSize
    SparseArray原理分析
    Android中不使用枚举类(enum)替代为@IntDef @StringDef
    Android开发高手课之内存优化

    相关文章

      网友评论

        本文标题:内存优化①OOM和内存泄漏

        本文链接:https://www.haomeiwen.com/subject/ylyowktx.html