前言
OOM(Out Of Memory)是一个比较常见的app异常。
OOM分类
OOM一般是JAVA虚拟机内存不足,可以大致分为3类:
- 线程数太多
- 打开文件太多
- 内存不足
线程数太多
错误信息:
pthread_create (1040KB stack) failed: Out of memory
pthread_create是调用Linux内核创建线程的。 查看系统对每个进程的限制数:
cat /proc/sys/kernel/threads-max
不同设备的threads-max是不一样的。游戏常见低端手机threads-max比较小,容易出现此类OOM。 查看当前线程数:
cat proc/{pid}/status
当前线程数超过threads-max中规定的上限就会触发OOM。所以我们尽量降低当前线程数的峰值。
禁用new Thread
统一使用线程池。
- 无法解决老代码的new Thread
- 对于第三方库无法控制
无侵入对new Thread优化
Thread只是普通对象,调用start后才会调用native去创建线程。可以自定义Thread重写start方法,不去启动线程,将任务放到线程池中执行。在编译期通过字节码插桩方式,将new Thread字节码替换为自定义的Thread。
无侵入线程池优化
大部分SDK内部都会使用自己的线程池异步操作。 核心线程空闲时候没有释放,整体的线程数量处于较高位置。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}
- corePoolSize:核心线程数。默认情况空闲也不会释放,除非设置allowCoreThreadTimeOut为true
- maximumPoolSize:最大线程数。任务数量超过核心线程数,会将任务放到队列中,队列满了,就会启动非核心线程执行任务,线程数量超过这个线程会走拒绝策略
- keepAliveTime:空闲线程存活时间
- unit:时间单位
- workQueue:队列。任务数量超过核心线程数,将任务放到队列中,直到队列满,就开启新线程,执行任务队列第一个任务
- threadFactory:线程工厂。实现new Thread方法创建线程
优化点:
- 限制空闲线程存活时间,
keepAliveTime设置小点,例如1-3s; - 允许核心线程在空闲时自动销毁
executor.allowCoreThreadTimeOut(true)
通过ASM操作
- 将调用Executors类静态方法替换为自定义的静态方法,设置executor.allowCoreThreadTimeOut(true)
- 将调用ThreadPoolExecutor类的构造方法替换为自定义的静态方法,设置executor.allowCoreThreadTimeOut(true)
- 可以在Application类的
<init>()中调用我们自定义的静态方法executor.allowCoreThreadTimeOut(true)
线程池泄漏监控
监控native线程的几个生命周期方法:pthread_create pthread_detach pthread_join pthread_exit
- hook以上几个方法,用于记录线程的生命周期和堆栈,名称等信息;
- 当发现一个joinable的线程在没有detach或join的情况下,执行了pthread_exit,则记录下泄漏线程信息
- 在合适的时机,上报线程泄漏信息
linux线程中,pthread有两种状态joinable状态和unjoinable状态。joinable状态下,当线程函数返回退出时或pthread_exit时都不会释放线程所占用堆栈和线程描述符。只有当你调用了pthread_join之后这些资源才会被释放,需要main函数或其他线程去调用pthread_join函数
线程上报
当监控到线程有异常,则收集线程信息,上报到后台进行分析
private fun dumpThreadIfNeed() {
val threadNames = runCatching { File("/proc/self/task").listFiles() }
.getOrElse {
return@getOrElse emptyArray()
}
?.map {
runCatching { File(it, "comm").readText() }.getOrElse { "failed to read $it/comm" }
}
?.map {
if (it.endsWith("\n")) it.substring(0, it.length - 1) else it
}
?: emptyList()
Log.d("TAG", "dumpThread = " + threadNames.joinToString(separator = ","))
}
打开太多文件
E/art: ashmem_create_region failed for 'indirect ref table': Too many open files
Java.lang.OutOfMemoryError: Could not allocate JNI Env
系统限制,/proc/pid/limits描述linux系统对每个进程的一些资源的限制
Max opne files限制1024
如果没有root权限,通过ulimit -n查看Max open files
在Linux系统一切皆文件,进程每打开一个文件就会产生一个文件描述符fd(记录在/proc/pid/fd下面)
这些fd文件都是链接文件,通过ls -l可以查看对应的真实文件路径。当fd的数目达到Max open files规定的数目,就会触发Too many open files崩溃,在低端机上容易复现。
文件描述符优化
通过代码查看当前进程的fd信息
private fun dumpFd() {
val fdNames = runCatching { File("/proc/self/fd").listFiles() }
.getOrElse {
return@getOrElse emptyArray()
}
?.map { file ->
runCatching { Os.readlink(file.path) }.getOrElse { "failed to read link ${file.path}" }
}
?: emptyList()
Log.d("TAG", "dumpFd: size=${fdNames.size},fdNames=$fdNames")
}
文件描述符的监控
当fd数大于1000个,或fd连续递增超过50个,就触发fd收集,上报到后台。打开一个文件多次不关闭,可以看到重复的文件名,定位到问题。某个文件有问题,需要直到文件在哪创建,谁创建,涉及到IO监控
IO监控
完整的IO操作:open read write close
- open:获取文件名、fd、文件大小、堆栈、线程
- read/write:获取文件类型、读写次数、总大小、使用buffer大小、读写总耗时
- close:打开文件总耗时、最大连续读写时间
Java监控方案
FileInputStream调用链
java : FileInputStream -> IoBridge.open -> Libcore.os.open ->
BlockGuardOs.open -> Posix.open
LibCore.java是hook点
package libcore.io;
public final class Libcore {
private Libcore() { }
public static Os os = new BlockGuardOs(new Posix());
}
反射获取Os变量,是一个接口类型,定义了open read write close方法
// 反射获得静态变量
Class<?> clibcore = Class.forName("libcore.io.Libcore");
Field fos = clibcore.getDeclaredField("os");
在IO方法前后加入插桩代码来统计IO信息
// 动态代理对象
Proxy.newProxyInstance(cPosix.getClassLoader(), getAllInterfaces(cPosix), this);
beforeInvoke(method, args, throwable);
result = method.invoke(mPosixOs, args);
afterInvoke(method, args, result);
缺点:
- 性能差,IO调用频繁,使用动态代理和Java字符串操作,导致性能差,无法达到线上使用标准
- 无法监控Native代码
- 兼容性差:需要根据Android版本做适配,特别Android P的非公开API限制
Native监控方案
从libc.so中几个函数选定Hook
int open(const char *pathname, int flags, mode_t mode);
ssize_t read(int fd, void *buf, size_t size);
ssize_t write(int fd, const void *buf, size_t size); write_cuk
int close(int fd);
可参考Matrix-IOCanary,内部使用的xhook框架
内存不足
Java堆内存不足512M,大部分是Android 7.0 高版本较少。 JVM将内存分为5部分
- 方法区:静态变量、常量、即时编译代码
- 程序计数器:线程私有,记录当前执行的代码行数,方便再cpu切换到其它线程再回来的时候能够不迷路
- Java虚拟机栈:线程私有,一个Java方法开始和结束,对应一个栈帧的入栈和出栈,栈帧里面有局部变量表、操作数栈、返回地址、符号引用等信息
- 本地方法栈:线程私有,和Java虚拟机栈的区别在于针对naive方法
- 堆:绝大部分对象创建都在堆分配内存
内存不足导致OOM一般在Java堆内存不足,绝大部分对象都是在堆中分配内存,大数组、以及Android3.0-7.0的Bitmap像素数据,都存放在堆中
图片加载优化
防止图片占用内存过多导致OOM:软引用、onLowMemory、Bitmap像素存储位置 无侵入性自动压缩图片,利用Gradle的task在编译过程,mergeResourcesTask这个任务将aar、module的资源进行合并,在mergeResourcesTask之后可以拿到所有资源文件
- 在mergeResourcesTask任务后,添加图片处理的Task,拿到所有资源文件
- 判断如果是图片,则通过压缩工具进行压缩,压缩后图片变小则替换掉原图 可以参考MCImage库
大图监控
dokit-BigImgClassTransformer库。对ImageView监听setImageDrawable等方法
- 自定义ImageView,重写setImageDrawable setImageBitmap setImageResource setBackground setBackgroundResource等方法检测Drawable大小
- 编译期,修改字节码,将ImageView船舰换成自定义的ImageView
- 为了不影响主线程,使用Idlehandler在线程空闲时再检测
内存泄漏
可参考LeakCanary,适合debug环境,Debug.dumpHprofdata(path)会冻结当前进程5-15s,低端机器可能几十秒。 微信对Leakcanary改造为ResourceCanary将检测和分析分离,只能在线下环境使用。 线上环境可以用KOOM。
- 间隔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%
内存镜像采集
KOOM监控,dump内存镜像需要单独开一个进程 策略:
虚拟机supend->fork虚拟机进程->虚拟机resume->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;
}
总结
线程优化,booster对于new Thread设置线程名,有助于分析问题,通过字节码插装,将new Thread无侵入替换成线程池调用,才是真正意义的线程优化。