Android 性能优化入门(二)—— 内存优化

1、概述

1.1 Java 对象的生命周期

各状态含义:

  • 创建:分配内存空间并调用构造方法
  • 应用:使用中,处于被强引用持有(至少一个)的状态
  • 不可见:不被强引用持有,应用程序已经不再使用该对象了,但是它仍然保存在内存中
  • 不可达:GC 运行时检测到(可达性分析)了该对象不再被任何强引用持有,即根不可达
  • 收集:被 GC 标记收集准备回收
  • 终结:调用该对象的 finalized(),如果 finalized() 内部没有拯救该对象的措施(即便拯救也只能躲过一次 GC),就会执行回收过程
  • 对象空间重新分配:对象已经被回收,其占用空间会被重新分配

1.2 JVM 的堆区内存划分示意图

对象在内存区域中流动的大致步骤:

  1. 对象创建后在 Eden 区
  2. 执行 GC 后,如果对象仍然存活,则复制到 S0 区
  3. 当 S0 区满时,该区域存活对象将复制到 S1 区,然后 S0 清空,接下来 S0 和 S1 角色互换
  4. 当第 3 步达到一定次数(系统版本不同会有差异)后,存活对象将被复制到 Old Generation
  5. 当这个对象在 Old Generation 区域停留的时间达到一定程度时,最后会移动到 Permanent Generation 区域

Android 系统使用的虚拟机在 JVM 的基础上又会多出几个区域。Dalvik 虚拟机多出:

  • Linear Alloc:匿名共享内存
  • Zygote Space:Zygote 相关信息
  • Alloc Space:每个进程独占

而 ART 虚拟机多出:

  • NonMoving Space
  • Zygote Space
  • Alloc Space
  • Image Space:预加载的类信息(预加载是 Zygote 启动过程中执行的任务)
  • Large Obj Space:分配大对象的区域,如 Bitmap。

此外还需回忆前面讲过的:

  1. 可回收对象的判定:不被 GC roots 直接或间接持有的对象是可回收的,GC roots 包括静态变量、线程栈变量、常量池和 JNI(指针)
  2. Java 的四种引用:强 > 软(内存不足时回收)> 弱(GC 时回收)> 虚
  3. 垃圾回收算法(面试必问):
    • 标记清除算法:位置不连续(有内存碎片)、效率略低、两次扫描(第一次标记,第二次回收)
    • 复制算法:实现简单、运行高效、没有内存碎片但空间利用率只有一半
    • 标记整理算法:没有内存碎片、效率偏低、两次扫描(第一次标记,第二次整理)、指针需要调整
    • 分代收集算法:未创建新的算法,只是在不同的内存区域使用以上不同的算法

1.3 app 内存组成与限制

Android 系统给每个 app 分配一个虚拟机 Dalvik/ART,让 app 运行在虚拟机上,即便 app 崩溃也不会影响到系统。系统给虚拟机分配了一定的内存大小,app 可以申请使用的内存大小不能超过此硬性逻辑限制,就算物理内存富余,如果应用超出虚拟机的最大内存就会发生内存溢出。

由程序控制操作的内存空间在堆上,分为 java heapsize 和 native heapsize。Java 申请的内存在 java heapsize 上,如果超过虚拟机的逻辑内存大小就会发生内存溢出的异常;而 native 层的内存申请不受到这个虚拟机的逻辑大小限制,而是受 native process 对内存大小的限制。

通常手机的 RAM 为 4G、8G 甚至 12G,但是每个 app 并不会有太大的内存,通过 adb shell cat /system/build.prop 命令可以看到,虚拟机堆的初始大小为 16M,最大堆内存为 128M:

这个初始大小和最大值,各个手机厂商会自行修改,不同的系统和机型都有可能不同。只不过 Android 系统源码设置的是 16M,在 AndroidRuntime.cpp 中:

cpp 复制代码
    int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv, bool zygote)
    {
    /*
     * The default starting and maximum size of the heap.  Larger
     * values should be specified in a product property override.
     */
       parseRuntimeOption("dalvik.vm.heapstartsize", heapstartsizeOptsBuf, "-Xms", "4m");
       parseRuntimeOption("dalvik.vm.heapsize", heapsizeOptsBuf, "-Xmx", "16m"); //修改这里
     }

可以看到给 "dalvik.vm.heapsize" 设置的大小为 16M,可以通过修改这个值改变初始的虚拟机堆大小,也可以通过修改 platform/dalvik/+/eclair-release/vm/Init.c 文件:

cpp 复制代码
    gDvm.heapSizeStart = 2 * 1024 * 1024;   // Spec says 16MB; too big for us.
    gDvm.heapSizeMax = 16 * 1024 * 1024;    // Spec says 75% physical mem

要获取这个值的话,可以在代码中通过 AMS 获取:

java 复制代码
    ActivityManager activityManager = (ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)
	activityManager.getMemoryClass(); // 以 m 为单位

其实 AMS 是通过在 AMS.setSystemProcess() 内注册的 meminfobinder 获取到内存信息的,另外 ActivityManager 中还有 MemoryInfo 这个成员。

此外还可以通过 adb shell cat /proc/meminfo 命令查看内存信息:

1.4 Android 的低内存杀进程机制

oom_adj 在讲 AMS 源码时有讲过,可以去复习一下。

AMS 中的 oom_adj 会对应用分级,值为 [-16,15],值越小越不容易被杀,这是一个粗粒度的。此外还有一个 oom_score_adj 评分 [-1000,1000],分越高越容易被杀掉。

通过 adb shell cat /proc/pid/oom_adj 查看 pid 对应的 app 的值,在前台时为 0,按 home 键让其退到后台这个值就会变成 11。如果有两个应用都是 11,那么谁占用的内存大就杀谁,因此【降低应用进入后台后所占用的内存】也是一种保活方法。

1.5 内存三大问题

内存抖动:内存波动图形呈锯齿状,频繁 GC 导致卡顿。

内存泄漏:在当前应用周期内不再使用的对象被 GC Roots 引用,导致不能回收,使实际可使用内存变小。

内存溢出:即 OOM,OOM 时会导致程序异常。Android 设备出厂以后,Java 虚拟机对单个应用的最大内存分配就确定下来了,超出这个值就会 OOM。OOM 可以分为如下基几类:

  • Java 堆内存溢出
  • 无足够连续内存
  • FD 数量超出限制
  • 线程数量超出限制
  • 虚拟内存不足

2、常见分析内存的命令

2.1 adb shell dumpsys meminfo

输出系统内各个应用占用内存信息,以及分类的内存信息:

按照 oom_adj 排序的信息:

按照文件类型分类的信息:

Total RAM 是总的运行内存,Free RAM 是当前可用内存,Used RAM 是当前已使用的内存。

上图的 PSS 是内存指标概念,与之类似的还有几个,如下表:

Item 全称 含义 等价
USS Unique Set Size 物理内存 进程独占的内存
PSS Proportional Set Size 物理内存 PSS= USS+ 按比例包含共享库
RSS Resident Set Size 物理内存 RSS= USS+ 包含共享库
VSS Virtual Set Size 虚拟内存 VSS= RSS+ 未分配实际物理内存

其中 VSS >= RSS >= PSS >= USS,但 /dev/kgsl-3d0 部份必须考虑 VSS。

此外还可以通过加 --package 参数查询某个应用的内存情况,如 adb shell dumpsys meminfo --package packageName:

内存优化时可能会用到这个命令(只是大概判断,不是精准判断),比如说在复现前先打印一次内存信息 -> 复现可能的 OOM 操作 -> 再打印一次。

3、常见分析工具

3.1 MAT

按包分类,然后右击选择一个类的 incoming references 和 outgoing references,前者表示持有该类实例的对象,后者表示该类持有哪些类的对象。

浅堆(Shallow Heap)与深堆(Retained Heap):前者只计算自身占用的空间,后者则计算本身以及它所引用的对象那一条链上的所有对象占用的空间。

比如说下图:

A~G 每个对象都占 10 个内存单位,它们的浅堆都是 10,但是 B、C 的深堆就要计算上各自分支上的对象总和,即 30,而 A 要计算分支上所有对象的内存总和,即 70。

此时假如新来一个 H 引用 B,那么这时 A 的深堆变为 40,因为假如把 A 干掉的话,能释放的是 A、C、F、G 这 4 个。新加入的 H 深堆为 10,因为上图中将 H 干掉就只能释放它自己一个对象,不会连带其他对象一起被回收。但是假如在 A 释放后再看 H 的深堆,那么就是 40,因为这个时候释放 H 会实际释放 H、B、D、E 这 4 个对象。

3.2 AS memory profile

看官网连接,介绍的十分详细:

Inspect your app's memory usage with Memory Profiler

3.3 LeakCanary

LeakCanary 会找出有泄漏嫌疑的对象,并通过 haha 这个开源库进行可达性分析确定是否发生了泄漏。它的使用非常简单,仅需要添加如下依赖:

groovy 复制代码
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'

然后如果在应用运行时发生了内存泄漏就会在 UI 上提示我们,还可以保存成 hprof 文件交给 MAT 作进一步分析。

LeakCanary 是如何做到仅添加了一个依赖就帮助应用定位内存泄漏问题的呢?

初始化

首先,在 LeakCanary 的部分模块的 AndroidManifest.xml 中会声明一些 Provider,这些 Provider 在 apk 打包时会汇入 mergeAndroidManifest.xml,最后体现在 app 的 AndroidManifest.xml 文件中。

由于在 AMS 启动过程中,会先执行 ContentProvider 的 onCreate(),后执行 Application 的 onCreate():

LeakCanary 正是利用这一点,在 MainProcessAppWatcherInstaller 中初始化:

kotlin 复制代码
/**
 * Content providers are loaded before the application class is created. [MainProcessAppWatcherInstaller] is
 * used to install [leakcanary.AppWatcher] on application start.
 *
 * [MainProcessAppWatcherInstaller] automatically sets up the LeakCanary code that runs in the main
 * app process.
 */
internal class MainProcessAppWatcherInstaller : ContentProvider() {

  override fun onCreate(): Boolean {
    val application = context!!.applicationContext as Application
    AppWatcher.manualInstall(application)
    return true
  }
}

监听生命周期

注册 Application.ActivityLifecycleCallbacks 这个生命周期回调来监听 Activity 何时被销毁:

kotlin 复制代码
class ActivityWatcher(
  private val application: Application,
  private val reachabilityWatcher: ReachabilityWatcher
) : InstallableWatcher {

  private val lifecycleCallbacks =
    object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
      override fun onActivityDestroyed(activity: Activity) {
        reachabilityWatcher.expectWeaklyReachable(
          activity, "${activity::class.java.name} received Activity#onDestroy() callback"
        )
      }
    }

  override fun install() {
    application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
  }

  override fun uninstall() {
    application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
  }
}

ReferenceQueue

LeakCanary 的核心原理是:一个 Reference 对象(一般使用 WeakReference)所引用的对象被 GC 回收时,这个 Reference 对象会被添加到与之关联的(一般通过构造方法关联)引用队列 ReferenceQueue 的队列末尾。以下面代码为例:

java 复制代码
    public static void main(String[] args) {
        Object obj = new Object();
        ReferenceQueue<Object> queue = new ReferenceQueue<>();
        // 与 ReferenceQueue 关联
        WeakReference<Object> weakReference = new WeakReference<>(obj, queue);

        System.out.println("weakReference: " + weakReference);
        System.out.println("ReferenceQueue: " + queue.poll());

        obj = null;
        // Runtime.gc()一定会执行 GC;而 System.gc() 优先级低,调用后也不知何时执行,
        // 仅仅是通知系统在合适的时间 GC,并不保证一定执行
        Runtime.getRuntime().gc();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("weakReference: " + weakReference);
        System.out.println("ReferenceQueue: " + queue.poll());
    }

输出为:

cmd 复制代码
weakReference: java.lang.ref.WeakReference@15db9742
ReferenceQueue: null
weakReference: java.lang.ref.WeakReference@15db9742
ReferenceQueue: java.lang.ref.WeakReference@15db9742

可以证明 WeakReference 持有的对象被回收后,该 WeakReference 对象被添加到了与之关联的 ReferenceQueue 的队尾。

LeakCanary 利用这一点去检测可能的内存泄漏,具体步骤为:

  1. 给每个对象生成一个 uuid,放到观察列表 watchedReferences 中,观察 5 秒
  2. 5 秒后,调用一次 GC,去 ReferenceQueue 中查找是否有 WeakReference,有说明对象已经被回收,从 watchedReferences 中将其移除。否则该对象没有被回收,则有可能发生内存泄漏,将其添加到怀疑列表 retainedReferences 中
  3. 当怀疑列表 retainedReferences 中的元素数量大于 5 个时,就交给开源库 haha 去做可达性分析

4、Bitmap 使用

Bitmap 的内存问题解决好就解决了 90% 的 OOM 问题。基本上加载图片都用的 Glide。不用的话可以参考官网的资料:缓存位图 等等。

图片在不同分辨率的设备上的内存中大小可能不一样。当然这是图片放在 xxx-xdpi 中的情况,需要根据公式去算 density。但如果图片来源于网络或者其他不是 xxx-xdpi 文件夹中的情况,density 就是 1。

gradle 可以控制只打包一个维度的 xxx-hdpi 包,不打其他密度的。

解析 Bitmap 的一个技巧:

java 复制代码
    try {
        decode bitmap
    } catch(OutOfMemoryError e) {
        对 bitmap 进行质量压缩
    }

加入解析 Bitmap 时发生了 OOM,可以在 catch 中通过质量压缩的方式重新解析该 Bitmap。

第三方开源库 epic 可以 hook ImageView 设置图片的过程,检测图片的质量。

相关推荐
用户74589002079544 分钟前
线程池
android
专注前端30年13 分钟前
【PHP开发与安全防护实战】性能调优手册
android·安全·php
王正南2 小时前
安卓逆向之LSposed开发(一)
android·xposed·lsposed
爱吃奶酪的松鼠丶2 小时前
React长列表,性能优化。关于循环遍历的时候,key是用对象数据中的ID还是用索引
javascript·react.js·性能优化
sophie旭2 小时前
内存泄露排查之我的微感受
前端·javascript·性能优化
YIN_尹3 小时前
【MySQL】数据类型(上)
android·mysql·adb
robotx4 小时前
AOSP设备节点权限添加相关
android
顾林海4 小时前
Android文件系统安全与权限控制:给应用数据上把“安全锁”
android·面试·操作系统
青莲8434 小时前
Android 动画机制完整详解
android·前端·面试
城东米粉儿4 小时前
android 离屏预渲染 笔记
android