跳转至

03 内存优化(上):4GB内存时代,再谈内存优化

在写今天这篇文章前,我又翻了翻三年前我在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内存分配的变化。

  • 在Android 3.0之前,Bitmap对象放在Java堆,而像素数据是放在Native内存中。如果不手动调用recycle,Bitmap Native内存的回收完全依赖finalize函数回调,熟悉Java的同学应该知道,这个时机不太可控。
  • Android 3.0~Android 7.0将Bitmap对象和像素数据统一放到Java堆中,这样就算我们不调用recycle,Bitmap内存也会随着对象一起被回收。不过Bitmap是内存消耗的大户,把它的内存放到Java堆中似乎不是那么美妙。即使是最新的华为Mate 20,最大的Java堆限制也才到512MB,可能我的物理内存还有5GB,但是应用还是会因为Java堆内存不足导致OOM。Bitmap放到Java堆的另外一个问题会引起大量的GC,对系统内存也没有完全利用起来。
  • 有没有一种实现,可以将Bitmap内存放到Native中,也可以做到和对象一起快速释放,同时GC的时候也能考虑这些内存防止被滥用?NativeAllocationRegistry可以一次满足你这三个要求,Android 8.0正是使用这个辅助回收Native内存的机制,来实现像素数据放到Native内存中。Android 8.0还新增了硬件位图Hardware 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的三个缺点。

  • 获取的信息过于分散,中间夹杂着不少其他的信息,很多信息不是应用申请的,可能需要进行不少查找才能定位到具体的问题。
  • 跟Traceview一样,无法做到自动化分析,每次都需要开发者手工开始/结束,这对于某些问题的分析可能会造成不便,而且对于批量分析来说也比较困难。
  • 虽然在Allocation Tracking的时候,不会对手机本身的运行造成过多的性能影响,但是在停止的时候,直到把数据dump出来之前,经常会把手机完全卡死,如果时间过长甚至会直接ANR。

因此我们希望可以做到脱离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等。

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

精选留言(15)
  • 孙鹏飞 👍(38) 💬(1)

    更新了例子,完善了操作方式,支持了x86平台的编译运行,兼容7.1到9.0的手机和模拟器,支持x86和armv7a,已经相对稳定

    2018-12-07

  • 旁友💊有伐🤔 👍(19) 💬(1)

    兴致勃勃地去把sample下载下来想要拜读一下,发现是JNI里面看的我一脸懵逼

    2018-12-06

  • 李华 👍(17) 💬(1)

    我在电脑前,你也在电脑前,我沉默,你也不说话,但你用这篇专栏嘲笑了我。

    2019-10-31

  • 小鹏 👍(4) 💬(1)

    大佬,越讲越难了

    2018-12-06

  • 👍(2) 💬(1)

    diff --git a/alloctrackSample/src/main/cpp/allocTracker.cpp b/alloctrackSample/src/main/cp p/allocTracker.cpp index b5f4bee..a90dd1c 100755 --- a/alloctrackSample/src/main/cpp/allocTracker.cpp +++ b/alloctrackSample/src/main/cpp/allocTracker.cpp @@ -90,7 +90,7 @@ void hookFunc() { } else if (hookRecordAllocation24 != nullptr) { LOGI("Finish get symbol24"); // ZzWrap((void *) hookRecordAllocation24, beforeRecordAllocation, nullptr); - MSHookFunction(hookRecordAllocation26, (void *) &newArtRecordAllocation26, + MSHookFunction(hookRecordAllocation24, (void *) &newArtRecordAllocation26, (void **) &oldArtRecordAllocation26); } else if (hookRecordAllocation23 != NULL) { @@ -181,8 +181,7 @@ JNI_METHOD_DECL(void, setSaveDataDirectory) static void startARTAllocationTracker() { - LOGI(ALLOC_TRACKER_TAG, - "art, startAllocationTracker, func==NULL: %s, artEnvSetCheckJniEnabled==NULL: %s ", + LOGI("art, startAllocationTracker, func==NULL: %s, artEnvSetCheckJniEnabled==NULL: %s ", artEnvSetCheckJniEnabled == NULL ? "true" : "false", artVmSetCheckJniEnabled == NULL ? "true" : "false"); 修改了两点了,第一点改了之后android版本7.1.2的手机才能运行,第二点修改后log正常输出

    2019-07-10

  • 镜像 👍(2) 💬(1)

    说一下今天作业的遇到的问题和解决,希望可以帮助到大家。 1.界面中的输出内存DUMP到日志,说的是把日志写入到Logcat的中并不是输入。 2.java代码中 new File(Environment.getExternalStorageDirectory(), "crashDump"); 是日志输出的文字,我们看Logcat中 saveARTAllocationData write file to XXXX 就能找到对应的日志文件,和git上说的路径可能不一致。 3.评论中有同学说看不到日志的输出。代码中 【tracker.initForArt(BuildConfig.VERSION_CODE, 5000);//从 start 开始触发到5000的数据就 dump 到文件中】 有说明开始后到达5000的数据才会写入文件。 大家设备内存情况不一样,GC回收的频率也不一致,在你不停生产1000个对象的时候,GC不断的跟随回收,导致无法达到 5000的数据量,所以一直没有日志的写入。 可以尝试修改对象的创建数量改成10000。

    2019-05-07

  • 王洛民 👍(2) 💬(1)

    学习起来很吃力,文章好多都看不懂,现在在处理内存相关的问题,可以告知下都需要掌握哪些基础的知识吗?系统学习的,多谢多谢

    2019-01-19

  • $$$ 👍(2) 💬(1)

    老师好,adb shell kill -S QUIT PID 这个命令,执行了一下,有几点实践记录如下: 1、其中“-S”文中是大写,实际执行是小写才行“-s”,查看adb shell kill的命令帮助如下: usage: kill [-s signame | -signum | -signame] { job | pid | pgrp } ... kill -l [exit_status ...] 2、我用了一台 小米5 Android 7.0 一台 三星S8 Android 8.0 和一台华为荣耀3 Android 4.4.2 没root的三台机器执行这个命令会提示kill: PID : Operation not permitted; 3、用了一个模拟器执行就可以

    2018-12-19

  • 无知 👍(2) 💬(1)

    这是第三个了,没有一个能跑起来的。各种懵呀。

    2018-12-14

  • Lea 👍(1) 💬(1)

    "Android 8.0 以后应用保活变得困难很多,但依然有...",是使用Jobscheduler?

    2018-12-11

  • Billy.Q.S 👍(1) 💬(2)

    请教类似这样的函数入口是怎么查找出来的??_ZN3art3Dbg19alloc_record_count_E、_ZN3art2gc20AllocRecordObjectMap16RecordAllocationEPNS_6ThreadEPNS_6ObjPtrINS_6mirror6ObjectEEEj ???有什么工具吗?怎么对应上功能的?

    2018-12-09

  • elephant 👍(1) 💬(1)

    fresco 在5.0上废弃了ashmem是因为考虑了兼容性问题,java bitmap频繁创建造成抖动,以及5.0以上GC的优化,这些方面才废弃掉的么?

    2018-12-07

  • young 👍(1) 💬(1)

    12月7日更新后,手机和模拟器还是运行不了。手机是7.1.2系统,错误日志如下: 2018-12-07 11:41:31.739 6200-6200/com.dodola.alloctrack A/libc: Fatal signal 7 (SIGBUS), code 1, fault addr 0xbeb79b04 in tid 6200 (dola.alloctrack) 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: MK Version: 'MK71.2-bacon-201708090305-NIGHTLY' 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: Build fingerprint: 'oneplus/bacon/A0001:6.0.1/MHC19Q/ZNH2KAS1KN:user/release-keys' 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: Revision: '0' 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: ABI: 'arm' 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: pid: 6200, tid: 6200, name: dola.alloctrack >>> com.dodola.alloctrack <<< 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0xbeb79b04 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: r0 b3c0d000 r1 00000003 r2 beb79b04 r3 00000000 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: r4 00000003 r5 00000000 r6 b39e9111 r7 b6b03e10 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: r8 b3cc7609 r9 b6fb1590 sl b6fb11d0 fp 00000000 2018-12-07 11:41:31.805 6218-6218/? A/DEBUG: ip 004f3000 sp beb79a64 lr b6f44e61 pc b6f46fac cpsr 60070030 2018-12-07 11:41:31.809 6218-6218/? A/DEBUG: backtrace: 2018-12-07 11:41:31.809 6218-6218/? A/DEBUG: #00 pc 00004fac /system/bin/linker (__dl__Z9do_dlopenPKciPK17android_dlextinfoPv+475) 2018-12-07 11:41:31.809 6218-6218/? A/DEBUG: #01 pc 00002e5d /system/bin/linker (__dl__ZL10dlopen_extPKciPK17android_dlextinfoPv+28) 2018-12-07 11:41:31.810 6218-6218/? A/DEBUG: #02 pc 000009f3 /system/lib/libart.so (offset 0xb9000)

    2018-12-07

  • louis 👍(0) 💬(1)

    张老师,你好,我在文中看到你对保活也一定地了解,请问针对不同的手机有什么好的方案提高保活的概率呢?

    2019-09-22

  • Epic 👍(0) 💬(1)

    nativeCreateBitmap方法是调的这个so里面的方法吧。libandroid_runtime.so是需要另外导入吗,百度之后没有找到这个库,求助

    2019-07-09