性能优化OOM

333 阅读7分钟

前言

OOM(Out Of Memory)是一个比较常见的app异常。

OOM分类

OOM一般是JAVA虚拟机内存不足,可以大致分为3类:

  1. 线程数太多
  2. 打开文件太多
  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

统一使用线程池。

  1. 无法解决老代码的new Thread
  2. 对于第三方库无法控制

无侵入对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);
}
  1. corePoolSize:核心线程数。默认情况空闲也不会释放,除非设置allowCoreThreadTimeOut为true
  2. maximumPoolSize:最大线程数。任务数量超过核心线程数,会将任务放到队列中,队列满了,就会启动非核心线程执行任务,线程数量超过这个线程会走拒绝策略
  3. keepAliveTime:空闲线程存活时间
  4. unit:时间单位
  5. workQueue:队列。任务数量超过核心线程数,将任务放到队列中,直到队列满,就开启新线程,执行任务队列第一个任务
  6. threadFactory:线程工厂。实现new Thread方法创建线程

优化点:

  1. 限制空闲线程存活时间,keepAliveTime设置小点,例如1-3s;
  2. 允许核心线程在空闲时自动销毁
executor.allowCoreThreadTimeOut(true)

通过ASM操作

  1. 将调用Executors类静态方法替换为自定义的静态方法,设置executor.allowCoreThreadTimeOut(true)
  2. 将调用ThreadPoolExecutor类的构造方法替换为自定义的静态方法,设置executor.allowCoreThreadTimeOut(true)
  3. 可以在Application类的<init>()中调用我们自定义的静态方法executor.allowCoreThreadTimeOut(true)

线程池泄漏监控

监控native线程的几个生命周期方法:pthread_create pthread_detach pthread_join pthread_exit

  1. hook以上几个方法,用于记录线程的生命周期和堆栈,名称等信息;
  2. 当发现一个joinable的线程在没有detach或join的情况下,执行了pthread_exit,则记录下泄漏线程信息
  3. 在合适的时机,上报线程泄漏信息

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);

缺点:

  1. 性能差,IO调用频繁,使用动态代理和Java字符串操作,导致性能差,无法达到线上使用标准
  2. 无法监控Native代码
  3. 兼容性差:需要根据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部分

  1. 方法区:静态变量、常量、即时编译代码
  2. 程序计数器:线程私有,记录当前执行的代码行数,方便再cpu切换到其它线程再回来的时候能够不迷路
  3. Java虚拟机栈:线程私有,一个Java方法开始和结束,对应一个栈帧的入栈和出栈,栈帧里面有局部变量表、操作数栈、返回地址、符号引用等信息
  4. 本地方法栈:线程私有,和Java虚拟机栈的区别在于针对naive方法
  5. 堆:绝大部分对象创建都在堆分配内存

内存不足导致OOM一般在Java堆内存不足,绝大部分对象都是在堆中分配内存,大数组、以及Android3.0-7.0的Bitmap像素数据,都存放在堆中

图片加载优化

防止图片占用内存过多导致OOM:软引用、onLowMemory、Bitmap像素存储位置 无侵入性自动压缩图片,利用Gradle的task在编译过程,mergeResourcesTask这个任务将aar、module的资源进行合并,在mergeResourcesTask之后可以拿到所有资源文件

  1. 在mergeResourcesTask任务后,添加图片处理的Task,拿到所有资源文件
  2. 判断如果是图片,则通过压缩工具进行压缩,压缩后图片变小则替换掉原图 可以参考MCImage库

大图监控

dokit-BigImgClassTransformer库。对ImageView监听setImageDrawable等方法

  1. 自定义ImageView,重写setImageDrawable setImageBitmap setImageResource setBackground setBackgroundResource等方法检测Drawable大小
  2. 编译期,修改字节码,将ImageView船舰换成自定义的ImageView
  3. 为了不影响主线程,使用Idlehandler在线程空闲时再检测

内存泄漏

可参考LeakCanary,适合debug环境,Debug.dumpHprofdata(path)会冻结当前进程5-15s,低端机器可能几十秒。 微信对Leakcanary改造为ResourceCanary将检测和分析分离,只能在线下环境使用。 线上环境可以用KOOM。

  1. 间隔5s检测一次
  2. 触发内存镜像采集的条件

当内存使用率达到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无侵入替换成线程池调用,才是真正意义的线程优化。