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] 

常见内存问题

内存泄露
一、泄露的常见原因

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";
二、问题定位

堆内存分配失败,通常说明进程中大部分的内存已经被占用了,且不能被垃圾回收器回收,一般来说此时内存占用都存在一些问题,例如内存泄漏等。要想定位到问题所在,就需要知道进程中的内存都被哪些对象占用,以及这些对象的引用链路。而这些信息都可以在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;

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
}

需要注意的是,先将 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)
        }
    }
缓存管理

我们可以使用 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();
        }
    }
    

关于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

你可能感兴趣的:(数据结构,Android,性能优化,android)