在写今天这篇文章前,我又翻了翻三年前我在WeMobileDev公众号写过的《Android内存优化杂谈》,今天再看,对里面的一句话更有感触:“我们并不能将内存优化中用到的所有技巧都一一说明,而且随着Android版本的更替,可能很多方法都会变的过时”。

三年过去了,4GB内存的手机都变成了主流。那内存优化是不是变得不重要了?如今有哪些技巧已经淘汰,而我们又要升级什么技能呢?

今天在4GB内存时代下,我就再来谈谈“内存优化”这个话题。

移动设备发展

Facebook有一个叫device-year-class的开源库,它会用年份来区分设备的性能。可以看到,2008年的手机只有可怜的140MB内存,而今年的华为Mate 20 Pro手机的内存已经达到了8GB。

内存看起来好像是我们都非常熟悉的概念,那请问问自己,手机内存和PC内存有哪什么差异呢?8GB内存是不是就一定会比4GB内存更好?我想可能很多人都不一定能回答正确。

手机运行内存(RAM)其实相当于我们的PC中的内存,是手机中作为App运行过程中临时性数据暂时存储的内存介质。不过考虑到体积和功耗,手机不使用PC的DDR内存,采用的是LPDDR RAM,全称是“低功耗双倍数据速率内存”,其中LP就是“Lower Power”低功耗的意思。

以LPDDR4为例,带宽 = 时钟频率 × 内存总线位数 ÷ 8,即1600 × 64 ÷ 8 = 12.8GB/s,因为是DDR内存是双倍速率,所以最后的带宽是12.8 × 2 = 25.6GB/s。

目前市面上的手机,主流的运行内存有LPDDR3、LPDDR4以及LPDDR4X。可以看出LPDDR4的性能要比LPDDR3高出一倍,而LPDDR4X相比LPDDR4工作电压更低,所以也比LPDDR4省电20%~40%。当然图中的数据是标准数据,不同的生成厂商会有一些低频或者高频的版本,性能方面高频要好于低频。

那手机内存是否越大越好呢?

如果一个手机使用的是4GB的LPDDR4X内存,另外一个使用的是6GB的LPDDR3内存,那么无疑选择4GB的运行内存手机要更加实用一些。

但是内存并不是一个孤立的概念,它跟操作系统、应用生态这些因素都有关。同样是1GB内存,使用Android 9.0系统会比Android 4.0系统流畅,使用更加封闭、规范的iOS系统也会比“狂野”的Android系统更好。今年发布的iPhone XR和iPhone XS使用的都是LPDDR4X的内存,不过它们分别只有3GB和4GB的大小。

内存问题

在前面所讲的崩溃分析中,我提到过“内存优化”是崩溃优化工作中非常重要的一部分。类似OOM,很多的“异常退出”其实都是由内存问题引起。那么内存究竟能引发什么样的问题呢?

1.两个问题

内存造成的第一个问题是异常。在前面的崩溃分析我提到过“异常率”,异常包括OOM、内存分配失败这些崩溃,也包括因为整体内存不足导致应用被杀死、设备重启等问题。不知道你平时是否在工作中注意过,如果我们把用户设备的内存分成2GB以下和2GB以上两部分,你可以试试分别计算他们的异常率或者崩溃率,看看差距会有多大。

内存造成的第二个问题是卡顿。Java内存不足会导致频繁GC,这个问题在Dalvik虚拟机会更加明显。而ART虚拟机在内存管理跟回收策略上都做大量优化,内存分配和GC效率相比提升了5~10倍。如果想具体测试GC的性能,例如暂停挂起时间、总耗时、GC吞吐量,我们可以通过发送SIGQUIT信号获得ANR日志

adb shell kill -S QUIT PID
adb pull /data/anr/traces.txt

它包含一些ANR转储信息以及GC的详细性能信息。

sticky concurrent mark sweep paused:	Sum: 5.491ms 99% C.I. 1.464ms-2.133ms Avg: 1.830ms Max: 2.133ms     // GC 暂停时间

Total time spent in GC: 502.251ms     // GC 总耗时
Mean GC size throughput: 92MB/s       // GC 吞吐量
Mean GC object throughput: 1.54702e+06 objects/s 

另外我们还可以使用systrace来观察GC的性能耗时,这部分内容在专栏后面会详细讲到。

除了频繁GC造成卡顿之外,物理内存不足时系统会触发low memory killer机制,系统负载过高是造成卡顿的另外一个原因。

2.两个误区

除了内存引起的异常和卡顿,在日常做内存优化和架构设计时,很多同学还非常容易陷入两个误区之中。

误区一:内存占用越少越好

VSS、PSS、Java堆内存不足都可能会引起异常和卡顿。有些同学认为内存是洪水猛兽,占用越少应用的性能越好,这种认识在具体的优化过程中很容易“用力过猛”。

应用是否占用了过多的内存,跟设备、系统和当时情况有关,而不是300MB、400MB这样一个绝对的数值。当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到“用时分配,及时释放”,就像下面这张图一样,当系统内存出现压力时,能够迅速释放各种缓存来减少系统压力。

现在手机已经有6GB和8GB的内存出现了,Android系统也希望去提升内存的利用率,因此我们有必要简单回顾一下Android Bitmap内存分配的变化。

误区二:Native内存不用管

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

答案当然是否定的。正如前面所说当系统物理内存不足时,lmk开始杀进程,从后台、桌面、服务、前台,直到手机重启。系统构想的场景就像下面这张图描述的一样,大家有条不絮的按照优先级排队等着被kill。

low memory killer的设计,是假定我们都遵守Android规范,但并没有考虑到中国国情。国内很多应用就像是打不死的小强,杀死一个拉起五个。频繁的杀死、拉起进程,又会导致system server卡死。当然在Android 8.0以后应用保活变得困难很多,但依然有一些方法可以突破。

既然讲到了将图片的内存放到Native中,我们比较熟悉的是Fresco图片库在Dalvik会把图片放到Native内存中。事实上在Android 5.0~Android 7.0,也能做到相同的效果,只是流程相对复杂一些。

步骤一:通过直接调用libandroid_runtime.so中Bitmap的构造函数,可以得到一张空的Bitmap对象,而它的内存是放到Native堆中。但是不同Android版本的实现有那么一点差异,这里都需要适配。

步骤二:通过系统的方法创建一张普通的Java Bitmap。

步骤三:将Java Bitmap的内容绘制到之前申请的空的Native Bitmap中。

步骤四:将申请的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;

虽然最终图片的内存的确是放到Native中了,不过这个“黑科技”有两个主要问题,一个是兼容性问题,另外一个是频繁申请释放Java Bitmap容易导致内存抖动。

测量方法

在日常开发中,有时候我们需要去排查应用程序中的内存问题。对于系统内存和应用内存的使用情况,你可以参考Android Developer中 《调查RAM使用情况》。

adb shell dumpsys meminfo <package_name|pid> [-d]

1. Java内存分配

有些时候我们希望跟踪Java堆内存的使用情况,这个时候最常用的有Allocation Tracker和MAT这两个工具。

在我曾经写过的《Android内存申请分析》里,提到过Allocation Tracker的三个缺点。

因此我们希望可以做到脱离Android Studio,实现一个自定义的“Allocation Tracker”,实现对象内存的自动化分析。通过这个工具可以获取所有对象的申请信息(大小、类型、堆栈等),可以找到一段时间内哪些对象占用了大量的内存。

但是这个方法需要考虑的兼容性问题会比较多,在Dalvik和ART中,Allocation Tracker的处理流程差异就非常大。下面是在Dalvik和ART中,Allocation Tacker的开启方式。

// dalvik
bool dvmEnableAllocTracker()
// art
void setAllocTrackingEnabled()

我们可以用自定义的“Allocation Tracker”来监控Java内存的监控,也可以拓展成实时监控Java内存泄漏。这方面经验不多的同学也不用担心,我在今天的“课后作业”提供了一个自定义的“Allocation Tracker”供你参考。不过任何一个工具如果只需要做到线下自动化测试,实现起来会相对简单,但想要移植到线上使用,那就要更加关注兼容性、稳定性和性能,付出的努力要远远高于实验室方案。

在课后作业中我们会提供一个简单的例子,在熟悉Android Studio中Profiler各种工具的实现原理后,我们就可以做各种各样的自定义改造,在后面的文章中也会有大量的例子供你参考和练习。

2. Native内存分配

Android的Native内存分析是一直做得非常不好,当然Google在近几个版本也做了大量努力,让整个过程更加简单。

首先Google之前将Valgrind弃用,建议我们使用Chromium的AddressSanitize 。遵循“谁最痛,谁最需要,谁优化”,所以Chromium出品了一大堆Native相关的工具。Android之前对AddressSanitize支持的不太好,需要root和一大堆的操作,但在Android 8.0之后,我们可以根据这个指南来使用AddressSanitize。目前AddressSanitize内存泄漏检测只支持x86_64 Linux和OS X系统,不过相信Google很快就可以支持直接在Android上进行检测了。

那我们有没有类似Allocation Tracker那样的Native内存分配工具呢?在这方面,Android目前的支持还不是太好,但Android Developer近来也补充了一些相关的文档,你可以参考《调试本地内存使用》。关于Native内存的问题,有两种方法,分别是Malloc调试Malloc钩子

Malloc调试可以帮助我们去调试Native内存的一些使用问题,例如堆破坏、内存泄漏、非法地址等。Android 8.0之后支持在非root的设备做Native内存调试,不过跟AddressSanitize一样,需要通过wrap.sh做包装。

adb shell setprop wrap.<APP> '"LIBC_DEBUG_MALLOC_OPTIONS=backtrace logwrapper"'

Malloc钩子是在Android P之后,Android的libc支持拦截在程序执行期间发生的所有分配/释放调用,这样我们就可以构建出自定义的内存检测工具。

adb shell setprop wrap.<APP> '"LIBC_HOOKS_ENABLE=1"'

但是在使用“Malloc调试”时,感觉整个App都会变卡,有时候还会产生ANR。如何在Android上对应用Native内存分配和泄漏做自动化分析,也是我最近想做的事情。据我了解,微信最近几个月在Native内存泄漏监控上也做了一些尝试,我会在专栏下一期具体讲讲。

总结

LPDDR5将在明年进入量产阶段,移动内存一直向着更大容量、更低功耗、更高带宽的方向发展。伴随内存的发展,内存优化的挑战和解决方案也不断变化。而内存优化又是性能优化重要的一部分,今天我讲到了很多的异常和卡顿都是因为内存不足引起的,并在最后讲述了如何在日常开发中分析和测量内存的使用情况。

一个好的开发者并不满足于做完需求,我们在设计方案的时候,还需要考虑要使用多少的内存,应该怎么去管理这些内存。在需求完成之后,我们也应该去回归需求的内存情况,是否存在使用不当的地方,是否出现内存泄漏。

课后作业

内存优化是一个非常“古老”的话题,大家在工作中也会遇到各种各样内存相关的问题。今天的课后作业是分享一下你在工作中遇到的内存问题,总结一下通过Sample的练习有什么收获。

在今天文章里我提到,希望可以脱离Android Studio实现一个自定义的Allocation Tracker,这样就可以将它用到自动化分析中。本期的Sample就提供了一个自定义的Allocation Tracker实现的示例,目前已经兼容到Android 8.1。你可以用它练习实现自动化的内存分析,有哪些对象占用了大量内存,以及它们是如何导致GC等。

欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

评论