- 五、内存不足
- 5.1 堆栈信息
- 5.2 重温JVM内存结构
- 5.3 图片加载优化
- 5.4 大图监控
- 5.5 内存泄漏监控演进
- 5.6 线上内存泄漏监控方案
- 5.7 native内存泄漏监控
- 总结
五、内存不足
5.1 堆栈信息
这种是最常见的OOM,Java堆内存不足,512M都不够玩~
发生此问题的大部分设备都是Android 7.0,高版本也有,不过相对较少。
5.2 重温JVM内存结构
JVM在运行时,将内存划分为以下5个部分
- 方法区:存放静态变量、常量、即时编译代码;
- 程序计数器:线程私有,记录当前执行的代码行数,方便在cpu切换到其它线程再回来的时候能够不迷路;
- Java虚拟机栈:线程私有,一个Java方法开始和结束,对应一个栈帧的入栈和出栈,栈帧里面有局部变量表、操作数栈、返回地址、符号引用等信息;
- 本地方法栈:线程私有,跟Java虚拟机栈的区别在于 这个是针对native方法;
- 堆:绝大部分对象创建都在堆分配内存
内存不足导致的OOM,一般都是由于Java堆内存不足,绝大部分对象都是在堆中分配内存,除此之外,大数组、以及Android3.0-7.0的Bitmap像素数据,都是存放在堆中。
Java堆内存不足导致的OOM问题,线上难以复现,往往比较难定位到问题,绝大部分设备都是8.0以下的,主要也是由于Android 3.0-7.0 Bitmap像素内存是存放在堆中 导致的。(可以参考之前一篇文章分析过其源码《面试官:简历上最好不要写Glide,不是问源码那么简单》)
基于这个结论,关于Java堆内存不足导致的OOM问题,优化方案主要是图片加载优化、内存泄漏监控 。
5.3 图片加载优化
5.3.1 常规的图片优化方式
常规的图片加载优化,依然可以参考两年前的一篇文章《面试官:简历上最好不要写Glide,不是问源码那么简单》, 文章核心内容大概如下:
- 分析了主流图片库Glide和Fresco的优缺点,以及使用场景;
- 分析了设计一个图片加载框架需要考虑的问题;
- 防止图片占用内存过多导致OOM的三个方式:软引用、onLowMemory、Bitmap 像素存储位置
这篇文章现在来看还是有点意义的,其中的原理部分还没过时,不过技术更新迭代,常规的优化方式已经不太够了,长远考虑,可以做图片自动压缩、大图自动检测和告警 。
5.3.2 无侵入性自动压缩图片
针对图片资源,设计师往往会追求高清效果,忽略图片大小,一般的做法是拿到图后手动压缩一下,这种手动的操作完全看个人修养。
无侵入性自动压缩图片,主流的方案是利用Gradle 的Task原理,在编译过程中,mergeResourcesTask
这个任务是将所以aar、module的资源进行合并,我们可以在mergeResourcesTask
之后可以拿到所有资源文件,具体做法:
- 在
mergeResourcesTask
这个任务后面,增加一个图片处理的Task,拿到所有资源文件; - 拿到所有资源文件后,判断如果是图片文件,则通过压缩工具进行压缩,压缩后如果图片有变小,就将压缩过的图片替换掉原图。
可以简单理解如下:
具体代码可以参考 McImage 这个库。
5.4 大图监控
5.3.2 自动压缩图片只是针对本地资源,而对于网络图片,如果加载的时候没有压缩,那么内存占用会比较大,这种情况就需要监控了。
5.4.1 从图片框架侧监控
很多App内部可能使用了多个图片库,例如Glide、Picasso、Fresco、ImageLoader、Coil
,如果想监控某个图片框架, 那么我们需要熟读源码,找到hook点。
对于Glide,可以通过hook SingleRequest
,它里面有个requestListeners
,我们可以注册一个自己的监听,图片加载完做一个大图检测。
其它图片框架,同理也是先找到hook点,然后进行类似的hook操作就可以,代码可以参考:dokit-BigImgClassTransformer
5.4.2 从ImageView侧监控
5.4.1 是从图片加载框架侧监控大图,假如项目中使用到的图片加载框架太多,有些第三方SDK内部可能自己搞了图片加载,
这种情况下我们可以从ImageView
控件侧做监控,监听setImageDrawable
等方法,计算图片大小如果大于控件本身大小,debug包可以弹窗提示需要修改。
方案如下:
- 自定义ImageView,重写
setImageDrawable、setImageBitmap、setImageResource、setBackground、setBackgroundResource
这几个方法,在这些方法里面,检测Drawable大小; - 编译期,修改字节码,将所有
ImageView
的创建都替换成自定义的ImageView
; - 为了不影响主线程,可以使用
IdleHandler
,在主线程空闲的时候再检测;
最终是希望当检测到大图的时候,debug环境能够弹窗提示开发进行修改,release环境可以上报后台。
debug如下效果:
当然这种方案有个缺点:不能获取到图片url。
图片优化告一段落,接下来看看内存泄漏~
5.5 内存泄漏监控演进
LeakCanary
关于内存泄漏,大家可能都知道LeakCanary,只要添加一个依赖
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
,
就能实现自动检测和分析内存泄漏,并发出一个通知显示内存泄漏详情信息。
LeakCanary只能在debug环境使用,因为它是在当前进程dump内存快照,Debug.dumpHprofData(path);
会冻结当前进程一段时间,整个 APP 会卡死约5~15s,低端机上可能要几十秒的时间。
ResourceCanary
微信对LeakCanary做了一些改造,将检测和分析分离,客户端只负责检测和dump内存镜像文件,文件裁剪后上报到服务端进行分析。
具体可以看这篇文章Matrix ResourceCanary -- Activity 泄漏及Bitmap冗余检测
KOOM
不管是LeakCanary 还是 ResourceCanary,他们都只能在线下使用,而线上内存泄漏监控方案,目前KOOM的方案比较完善,下面我将基于KOOM分析线上内存泄漏监控方案的核心流程。
5.6 线上内存泄漏监控方案
基于KOOM源码分析
5.6.1 检测时机
- 间隔5s检测一次
- 触发内存镜像采集的条件:
- 当内存使用率达到80%以上
//->OOMMonitorConfig private val DEFAULT_HEAP_THRESHOLD by lazy { val maxMem = SizeUnit.BYTE.toMB(Runtime.getRuntime().maxMemory()) when { maxMem >= 512 - 10 -> 0.8f maxMem >= 256 - 10 -> 0.85f else -> 0.9f } }
- 两次检测时间内(例如5s内),内存使用率增加5%
5.6.2 内存镜像采集
我们知道LeakCanary检测内存泄漏,不能用于线上,是因为它dump内存镜像是在当前进程进行操作,会冻结App一段时间。
所以,作为线上OOM监控,dump内存镜像需要单独开一个进程。
整体的策略是:
虚拟机supend->fork虚拟机进程->虚拟机resume->dump内存镜像
的策略。
dump内存镜像的源码如下:
//->ForkJvmHeapDumper public boolean dump(String path) { ... boolean dumpRes = false; try { //1、通过fork函数创建子进程,会返回两次,通过pid判断是父进程还是子进程 int pid = suspendAndFork(); MonitorLog.i(TAG, "suspendAndFork,pid="+pid); if (pid == 0) { //2、子进程返回,dump内存操作,dump内存完成,退出子进程 Debug.dumpHprofData(path); exitProcess(); } else if (pid > 0) { // 3、父进程返回,恢复虚拟机,将子进程的pid传过去,阻塞等待子进程结束 dumpRes = resumeAndWait(pid); MonitorLog.i(TAG, "notify from pid " + pid); } } return dumpRes; }
注释1:父进程调用native方法挂起虚拟机,并且创建子进程;注释2:子进程创建成功,执行Debug.dumpHprofData
,执行完后退出子进程;注释3:得知子进程创建成功后,父进程恢复虚拟机,解除冻结,并且当前线程等待子进程结束。
注释1源码如下:
// ->native_bridge.cpp pid_t HprofDump::SuspendAndFork() { //1、暂停VM,不同Android版本兼容 if (android_api_ < __ANDROID_API_R__) { suspend_vm_fnc_(); } ... //2,fork子进程,通过返回值可以判断是主进程还是子进程 pid_t pid = fork(); if (pid == 0) { // Set timeout for child process alarm(60); prctl(PR_SET_NAME, "forked-dump-process"); } return pid; }
注释3源码如下:
//->hprof_dump.cpp bool HprofDump::ResumeAndWait(pid_t pid) { //1、恢复虚拟机,兼容不同Android版本 if (android_api_ < __ANDROID_API_R__) { resume_vm_fnc_(); } ... int status; for (;;) { //2、waitpid,等待子进程结束 if (waitpid(pid, &status, 0) != -1 || errno != EINTR) { //进程异常退出 if (!WIFEXITED(status)) { ALOGE("Child process %d exited with status %d, terminated by signal %d", pid, WEXITSTATUS(status), WTERMSIG(status)); return false; } return true; } return false; } }
这里主要是利用Linux的waitpid
函数,主进程可以等待子进程dump结束,然后再返回执行内存镜像文件分析操作。
5.6.3 内存镜像分析
前面一步已经通过Debug.dumpHprofData(path)
拿到内存镜像文件,接下来就开启一个后台服务来处理
//->HeapAnalysisService override fun onHandleIntent(intent: Intent?) { ... kotlin.runCatching { //1、通过shark将hprof文件转换成HeapGraph对象 buildIndex(hprofFile) } ... //2、将设备信息封装成json buildJson(intent) kotlin.runCatching { //3、过滤泄漏对象,有几个规制 filterLeakingObjects() } ... kotlin.runCatching { // 4、gcRoot是否可达,判断内存泄漏 findPathsToGcRoot() } ... //5、泄漏信息填充到json中,然后结束了 fillJsonFile(jsonFile) //通知主进程内存泄漏分析成功 resultReceiver?.send(AnalysisReceiver.RESULT_CODE_OK, null) //这个服务是在单独进程,分析完就退出 System.exit(0); }
内存镜像分析的流程如下:
- 通过
shark
这个开源库将hprof文件转换成HeapGraph
对象 - 收集设备信息,封装成json,现场信息很重要
filterLeakingObjects
:过滤出泄漏的对象,有一些规制,例如已经destroyed和finished的activity、fragment manager为空的fragment、已经destroyed的window等。findPathsToGcRoot
:内存泄漏的对象,查找其到GcRoot
的路径,通过这一步就可以揪出内存泄漏的原因fillJsonFile
:格式化输出内存泄漏信息
小结
线上Java内存泄漏监控方案分析,这里小结一下:
- 挂起当前进程,然后通过
fork
创建子进程; fork
会返回两次,一次是子进程,一次是父进程,通过返回的pid可以判断是子进程还是父进程;- 如果是父进程返回,则通过
resumeAndWait
恢复进程,然后当前线程阻塞等待子进程结束; - 如果子进程返回,通过
Debug.dumpHprofData(path)
读取内存镜像信息,这个会比较耗时,执行结束就退出子进程; - 子进程退出,父进程的
resumeAndWait
就会返回,这时候就可以开启一个服务,后台分析内存泄漏情况,这块跟LeakCanary
的分析内存泄漏原理基本差不多。
不画图了,结合源码看应该可以理解。
5.7 native内存泄漏监控
对于Java内存泄漏监控,线下我们可以使用LeakCanary
、线上可以使用KOOM
,而对于native内存泄漏应该如何监控呢?
方案如下:
首先要了解native层 申请内存的函数:malloc、realloc、calloc、memalign、posix_memalign
释放内存的函数:free
- hook申请内存和释放内存的函数
分配内存的时候,收集堆栈、内存大小、地址、线程等信息,存放到map中,在释放内存的时候从map中移除。
那怎么判断native内存泄漏呢?
- 周期性的使用
mark-and-sweep
分析整个进程 Native Heap,获取不可达的内存块信息「地址、大小」 - 获取到不可达的内存块的地址后,可以从我们的Map中获取其堆栈、内存大小、地址、线程等信息。
具体实现可以参考:koom-native-leak
总结
本文从线上OOM问题入手,介绍了OOM原理, 以及OOM优化方案和监控方案,基本上都是大厂开源出来的比较成熟的方案:
- 对于
pthread_create
OOM问题,介绍了无侵入性的new Thread
优化、无侵入性的线程池优化、以及线程泄漏监控; - 对于文件描述符过多问题,介绍了原理以及文件描述符监控方案、IO监控方案;
- 对于Java内存不足导致的OOM、介绍了无侵入性图片自动压缩方案、两种无侵入性的大图监控方案、Java内存泄漏监控的线下方案和线上方案、以及native内存泄漏监控方案。
大厂对外开源的技术非常多,但不一定最优,我们在学习过程中可以多加思考, 例如线程优化,booster 对于new Thread
的优化只是设置了线程名,有助于分析问题,而经过我的猜想和验证,通过字节码插桩,将new Thread
无侵入性替换成线程池调用,才是真正意义上的线程优化。