• Android内存优化的知识梳理


    JVM内存管理基础知识

    了解JVM内存管理的基础内容,对我们理解内存分配有很大的帮助:比如Java堆的原理,JVM如何判断对象的存活、几种垃圾回收算法:

    关于这部分,可以参考笔者之前写的JVM|翻越内存管理的墙

    Android内存管理

    LMK(Low Memory Killer)

    Android中有个机制叫 Low Memory Killer,当 Cached Pages太少时,就会被触发。它的工作方式是根据进程的优先级,选择性地杀死某个进程,释放该进程占用的所有资源以满足内存分配需要。

    如果 LMK 杀掉的是用户正在交互或可以感知的进程,将会导致非常不友好的用户体验。所以 Android的SystemServer 进程维护了一张进程优先级列表,LMK 根据这张表来决定先杀死哪个进程。从后台、桌面、服务、前台,直到手机重启。这些按照优先级排队等着被kill

    评估内存使用情况

    设备的物理内存被分为很多页(Page),Linux Kernel将会持续跟踪每个进程使用的Pages,所以只要对进程使用的Pages进行计数即可,但实际情况远比这要复杂的多,因为有些 Pages 是进程间共享的。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-etCevIcP-1659968097701)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0b82a94cd8bf48249929fac420d4c2ee~tplv-k3u1fbpfcp-watermark.image?)]

    因此共享内存页计数的方法有多种:

    1. RSS(Resident Set Size):App 完全负责
    1. PSS(Proportional Set Size):App 按比例负责。如果两个进程共享,那就负责一半。如果三个进程共享,那就负责三分之一
    1. USS(Unique Set Size):App 无责

    实际上,至少需要系统级别的上下文才能知道识别 RSSUSS。所以通常都是使用 PSS来计算,可以使用以下命令查看一个进程的 PSS 使用情况:

    adb shell dumpsys meminfo -s [process] 
    
    • 1

    常见内存问题

    内存泄露

    一、泄露的常见原因

    Activity对象被生命周期更长的对象通过强引用持有,使Activity生命周期结束后仍无法被GC机制回收,从而泄漏Activity 持有的大量View 和其他对象。导致其占用的内存空间无法得到释放。

    内存泄漏主要分两种情况,一种是同一个对象泄漏,还有一种情况更加糟糕,就是每次都会泄漏新的对象,可能会出现几百上千个无用的对象。

    从来源上这类例子是举不完的,比如:

    • 不正确使用单例模式是引起内存泄露的一个常见问题。
    • 各种原因导致的反注册函数未按预期被调用导致的Activity泄漏。BraodcastReceiverContentObserverFileObserverCursorCallback等在Activity onDestroy或者某类生命周期结束没有unregister 或者close
    • 特别耗时的Runnable 持有Activity,或者此Runnable 本身并不耗时,但在它前面有个耗时的 Runnable 堵塞了执行线程导致此 Runnable 一直没机会从等待队列里移除,也会引发 Activity泄漏等等。

    更多的泄露场景可参考:

    内存泄露从入门到精通三部曲之常见原因与用户实践

    二、检测工具

    LeakCanary 和 ResourceCanary

    LeakCanary能给出可读性非常好的检测结果,在 Activity 泄漏检测、分析上完全可以代替人力。美中不足的是LeakCanary 把检测和分析报告都放到了一起,流程上更符合开发和测试是同一人的情况,对批量自动化测试和事后分析就不太友好了。

    ResourceCanary 是腾讯质量监控平台 Matrix 的一部分,参与到每天的自动化测试流程中。ResourceCanary 做了以下改进:

    1.分离检测和分析两部分逻辑

    事实上这两部分本来就可以独立运作,检测部分负责检测和产生 Hprof 及一些必要的附加信息,分析部分处理这些产物即可得到引发泄漏的强引用链。这样一来检测部分就不再和分析、故障解决相耦合,自动化测试由测试平台进行,分析则由监控平台的服务端离线完成,再通知相关开发同学解决问题。三者互不打断对方进程,保证了自动化流程的连贯性。

    2.裁剪 Hprof 文件,降低后台存档 Hprof 的开销。

    Activity 泄漏分析而言,我们只需要 Hprof 中类和对象的描述和这些描述所需的字符串信息,其他数据都可以在客户端就地裁剪。由于 Hprof 中这些数据比重很低,这样处理之后能把 Hprof 的大小降至原来的 1/10 左右,极大降低了传输和存储开销。

    MAT(Memory Analyzer Tool)

    MAT工具全称为Memory Analyzer Tool,一款详细分析Java堆内存的工具,该工具非常强大,为了使用该工具,我们需要hprof文件。

    hprof文件可以通过Android Profiler获取,使用方式可参考Android Profiler 工具常用功能

    但是hprof文件文件不能直接被MAT使用,需要进行一步转化,可以使用hprof-conv命令来转化,Android Studio也可以直接转化。

    MAT中有个Leaks suspects视图,可以非常方便的帮助我们定位到内存泄露的地方。MAT使用方式可以参考:

    Android 内存泄漏MAT使用详解

    三、避免泄露的优秀实践
    • Activity 等组件的引用应该控制在 Activity 的生命周期之内; 如果不能就考虑使用 getApplicationContext 或者 getApplication,以避免Activity 被外部长生命周期的对象引用而泄露。
    • 尽量不要在静态变量或者静态内部类中使用非静态外部成员变量(包括context ),即使要使用,也要考虑适时把外部成员变量置空;也可以在内部类中使用弱引用来引用外部类的变量。
    • Handler 持有的引用对象最好使用弱引用,资源释放时清空Handler 里面的消息。比如在Activity onStop 或者 onDestroy的时候,取消掉该 Handler 对象的 MessageRunnable
    • 线程 Runnable执行耗时操作,注意在页面返回时及时取消或者把 Runnable 写成静态类。如果线程类是内部类,改为静态内部类。线程内如果需要引用外部类对象如context,需要使用弱引用。

    有时候会遇到一些难以立即解决的泄漏,可以采取一些措施规避:

    • 主动切断 Activity View的引用、回收 View中的 Drawable,降低 Activity泄漏带来的影响
    • 尽量用Application Context 获取某些系统服务实例,规避系统带来的内存泄漏
    • 来自系统的内存泄漏,参考LeakCanary 给出的建议进行规避

    内存抖动

    一、理解概念的背后

    在程序里每创建一个对象,就会有一块内存分配给它。每分配一块内存,程序的可用内存也就少一块。当程序被占用的内存达到一定临界程度,GC 也就是垃圾回收器(Garbage Collector)就会出动,来释放掉一部分不再被使用的内存。

    如果在短时间频繁创建出一大批只被使用一次的对象(比如在onDraw() 里写了创建对象的代码,当界面频繁刷新的时候),这就会导致内存占用的迅速攀升。然后很快,可能就会触发 GC 的回收动作。

    频繁创建这些对象会造成内存不断地攀升,在刚回收了之后又迅速涨起来,那么紧接着就是又一次的回收。这么往复下来,最终导致一种循环,一种在短时间内反复地发生内存增长和回收的循环。这种循环往复的状态,专业称呼叫 Memory ChurnAndroid 的官方文档里把它翻译成内存抖动。

    一句话概括,内存抖动就是指,短时间内不断发生内存增长和回收的循环往复状态。

    (PS:要关注概念背后的东西,而不是概念这个词本身)

    关于内存抖动,推荐看下凯哥的这个视频/文章,:

    「内存抖动」?别再吓唬面试者们了行吗

    在实践中,我们在 onDraw() 里创建的对象往往是绘制相关的对象,而这些对象又经常会包含通往系统下层的 Native 对象的引用,这就导致在 onDraw() 里创建对象所导致的内存回收的耗时往往会更高,直白地说就是—界面更卡顿。

    二、问题定位

    内存抖动的解决方法一般都很简单。关键是如何发现抖动,定位到具体位置?

    内存抖动的定位可直接使用Memory Profiler,发生内存抖动时,Memory Profiler会显示明显的锯齿状效果,我们选择内存变化锯齿状的区域。

    然后点击Allocations进行对象分配数量排序(如果发生了内存抖动,大概率的是在对象数量多的地方出现了问题,因此先进行对象数量排序)

    找到排在前几位的对象,查看其调用栈。

    上述文字不够清晰的话,可以参照这个图文并茂的例子:

    Android 内存优化一 内存抖动的定位及优化

    OOM

    一、理解OOM

    开发过程中,基本都会遇到java.lang.OutOfMemoryError,该错误就是常说的OOM引发的,OOM就是OutOfMemory,内存溢出。这种错误解决起来相对于一般的Exception或者Error都要难一些,因为错误产生的根因不是很明显,所以定位OOM问题,依旧是重中之中。

    要定位OOM问题,首先需要弄明白Android中有哪些原因会导致OOMAndroid中导致OOM的原因主要可以划分为以下几个类型:

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

    通过Runtime.getRuntime.MaxMemory()可以得到Android中每个进程被系统分配的内存上限,当进程占用内存达到这个上限时就会发生OOM,这也是Android中最常见的OOM类型:为对象分配内存时达到进程的内存上限。

    此时就回出现常见的错误的日志打印格式:

    oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free  << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM";
    
    • 1
    二、问题定位

    堆内存分配失败,通常说明进程中大部分的内存已经被占用了,且不能被垃圾回收器回收,一般来说此时内存占用都存在一些问题,例如内存泄漏等。要想定位到问题所在,就需要知道进程中的内存都被哪些对象占用,以及这些对象的引用链路。而这些信息都可以在Java内存快照文件中得到,调用Debug.dumpHprofData(String fileName)函数就可以得到当前进程的Java内存快照文件,即HPROF文件。

    线下的定位方法,和内存泄露的定位方式一样,可以通过Android Profiler获取hprof文件`,再通过MAT(Memory Analyzer Tool) 查看具体的内存被哪些对象占用了。

    三、监控

    内存的监控方案,可以学习下KOOM(Kwai OOM, Kill OOM),是快手性能优化团队在处理移动端OOM问题的过程中沉淀出的一套完整解决方案。

    KOOM——高性能线上内存监控方案

    美团也有一个用于快速定位线上OOM问题的组件—Probe,项目虽没有开源,但也分享了实现的思路:

    Probe:Android线上OOM问题定位组件

    优化措施

    应用是否占用了过多的内存,跟设备、系统和当时情况有关。不能根据具体的数值来决定性能。最理想的状态是,当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到“用时分配,及时释放”。

    Bitmap优化

    Bitmap 内存一般占应用总内存很大一部分,所以做内存优化没法避开的就是Bitmap的优化。

    一、Bitmap Native

    随着Android系统版本的演进,Bitmap也被官方不断的折腾,对Bitmap存放的位置做了好几次变换。

    1. Android 3.0之前,Bitmap 对象放在Java 堆,而像素数据是放在Native 内存中。如果不手动调用 recycleBitmap Native 内存的回收完全依赖finalize函数回调,这个时机不太可控的。
    1. Android 3.0-Android 7.0Bitmap对象和像素数据统一放到 Java 堆中,这样就算不调用 recycleBitmap内存也会随着对象一起被回收。不过Bitmap是内存消耗的大户,而手机分配给Java堆内存也不多,即使手机内存还剩很多,依然会出现Java堆内存不足出现OOM。(Bitmap放到 Java 堆的另外一个问题会引起大量的 GC,对系统内存也没有完全利用起来。)
    1. NativeAllocationRegistryAndroid 8.0(API 27)引入的一种辅助回收native内存的机制。它会将 Bitmap内存放到 Native中,但可以做到和对象一起快速释放,同时 GC的时候也能考虑这些内存防止被滥用。

    在 Android 3.0~Android 7.0,其实也是有方法将图片的内存放到 Native中的:

    Java Bitmap 的内容绘制到申请的空的 Native Bitmap 中。再将申请的Java Bitmap释放,实现图片内存的“偷龙转凤”。(PS:虽然最终图片的内存的确是放到 Native 中了,不过与此同时带来了两个问题,一个是兼容性问题,另外一个是频繁申请释放Java Bitmap容易导致内存抖动。)

    // 步骤一:申请一张空的 Native Bitmap
    Bitmap nativeBitmap = nativeCreateBitmap(dstWidth, dstHeight, nativeConfig, 22);
    ​
    // 步骤二:申请一张普通的 Java Bitmap
    Bitmap srcBitmap = BitmapFactory.decodeResource(res, id);
    ​
    // 步骤三:使用 Java Bitmap 将内容绘制到 Native Bitmap 中
    mNativeCanvas.setBitmap(nativeBitmap);
    mNativeCanvas.drawBitmap(srcBitmap, mSrcRect, mDstRect, mPaint);
    ​
    // 步骤四:释放 Java Bitmap 内存
    srcBitmap.recycle();
    srcBitmap = null;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    Android 8.0 重新将Bitmap内存放回到Native内存中,那么我们是不是就可以随心所欲地使用图片呢?

    答案肯定是不行的。随心所欲的使用,也会很容易导致Native内存不够,引发LMK。还由于一些保活机制,违反了Android规范,各个进程的互相拉起,导致system server卡死。

    把所有的Bitmap 都放到 Native 内存,并不代表图片内存问题就完全解决了,这样做只是提升了系统内存利用率,减少了 GC 带来的一些问题而已。

    二、收拢图片调用

    图片内存优化的有个很关键步骤是收拢图片的调用,这样我们可以做整体的控制策略。

    例如可以针对低端机统一使用 565 格式、更加严格的缩放算法。

    还可以根据不同的情况使用 GlideFresco 或者采取自研的图片框架,同时可以根据业务需要无痛的切换框架。

    进一步的也要将所有Bitmap.createBitmapBitmapFactory 相关的接口也一并收拢。

    三、图片按需加载

    即图片的大小不应该超过view的大小。在把图片载入内存之前,我们需要先计算出一个合适的inSampleSize缩放比例,避免不必要的大图载入。

    千万不要去加载不需要的分辨率。在一个很小的ImageView上显示一张高分辨率的图片不会带来任何视觉上的好处,但却会占用相当多宝贵的内存。需要注意的是,将一张图片解析成一个Bitmap对象时所占用的内存并不是这个图片在硬盘中的大小,可能一张图片只有100k你觉得它并不大,但是读取到内存当中是按照像素点来算的,比如这张图片是1920x1080 像素,使用的ARGB_8888颜色类型,那么每个像素点就会占用4个字节,总内存就是1920x1080x4字节,接近8M,这就相当浪费内存了。

    如何加载的高效加载大型位图,关键还是计算采样率:

    fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
        // Raw height and width of image
        val (height: Int, width: Int) = options.run { outHeight to outWidth }
        var inSampleSize = 1
    ​
        if (height > reqHeight || width > reqWidth) {
            val halfHeight: Int = height / 2
            val halfWidth: Int = width / 2
            // Calculate the largest inSampleSize value that is a power of 2 and keeps both
            // height and width larger than the requested height and width.
            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    需要注意的是,先将 inJustDecodeBounds 设为 true 进行解码,传递选项,然后使用新的 inSampleSize 值并将 inJustDecodeBounds 设为 false 再次进行解码:

        fun decodeSampledBitmapFromResource(
                res: Resources,
                resId: Int,
                reqWidth: Int,
                reqHeight: Int
        ): Bitmap {
            // First decode with inJustDecodeBounds=true to check dimensions
            return BitmapFactory.Options().run {
                inJustDecodeBounds = true
                BitmapFactory.decodeResource(res, resId, this)
                // Calculate inSampleSize
                inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
                // Decode bitmap with inSampleSize set
                inJustDecodeBounds = false
                BitmapFactory.decodeResource(res, resId, this)
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    缓存管理

    我们可以使用 OnTrimMemory回调,根据不同的状态决定释放哪些内存。一般情况以下资源可以按需释放:

    • 缓存:缓存包括一些文件缓存,图片缓存等,当应用程序UI不可见的时候,这些缓存就可以被清除以减少内存的使用,比如第三方图片库的缓存。

    • 一些动态添加的View。这些动态生成和添加的View且少数情况下才使用到的View,这时候可以被释放,下次使用的时候再进行动态生成即可。比如原生桌面中,会在OnTrimMemoryTRIM_MEMORY_MODERATE等级中,释放所有AppsCustomizePagedView的资源,来保证在低内存的时候,桌面不会轻易被杀掉。

      @Override
      public void onTrimMemory(int level) {
          super.onTrimMemory(level);
          if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
              mAppsCustomizeTabHost.onTrimMemory();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7

    关于OnTrimMemory优化更多细节可以参考:

    Android 代码内存优化建议 - OnTrimMemory 优化

    进程管理

    为了让应用有更大的可使用内存,常常会另外开辟一个新的进程去处理业务。需要注意的是:一个空的进程也会占用 10MB 的内存。

    当然,并不是说开新进程的方式不好,这里想说的还是要谨慎使用,如果使用不当,它会显著增加内存的使用,而不是减少。

    减少应用启动的进程数、减少常驻进程对低端机内存优化是非常重要的。

    设备分级

    使用类似device-year-class的策略对设备分级,device-year-class会根据手机的内存、CPU 核心数和频率等信息决定设备属于哪一个年份。

    对于低端机用户可以关闭复杂的动画,或者是某些吃内存的功能,选择使用 565 格式的图片,使用更小的缓存内存等。

    最后

    随着对性能优化的理解,发现优化的方法并不是重难点,关键是在于去主动、及时的发现问题所在。

    要想实现主动和及时,代码采用优化--埋坑--优化--埋坑的方式并不能帮我们做到。

    发力点应该在于去建立一套合理的框架与监控体系,能及时的发现诸如bitmap过大、像素浪费、内存占用过大、应用OOM等问题。好在现在已经有很多大厂开源了它们的方案,我们可以使用它们的方案或借鉴学习它们的实现思路。

    参考

    分析并优化 Android 应用内存占用

    Android OOM案例分析

    Android 代码内存优化建议 - OnTrimMemory 优化

    如何加载的高效加载大型位图

    Probe:Android线上OOM问题定位组件

    android-performance-optimization

  • 相关阅读:
    【组成原理-处理器】微程序控制器
    PyTorch C++ 前端:张量
    spring transaction propagation 02 isolation
    Redis/Mysql知识概述
    Borland编辑器DOS系统快捷键应用
    c++小知识
    SpringBoot整合RabbitMQ实战附加死信交换机
    基于51单片机16×16点阵广告牌的滚动显示仿真设计
    Python:实现djb2哈希算法(附完整源码)
    关键性进展! 小米造车露真容 预计明年上市
  • 原文地址:https://blog.csdn.net/KING_GUOGUO/article/details/126237402