尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【性能优化】大厂OOM优化和监控方案

【性能优化】大厂OOM优化和监控方案 一、前言随着项目不断壮大OOMOut Of Memory成为奔溃统计平台上的疑难杂症之一大部分业务开发人员对于线上OOM问题一般都是暂不处理一方面是因为OOM问题没有足够的log无法在短期内分析解决另一方面可能是忙于业务迭代、身心疲惫没有精力去研究OOM的解决方案。这篇文章将以线上OOM问题作为切入点介绍常见的OOM类型、OOM的原理、大厂OOM优化黑科技、以及主流的OOM监控方案。文章较长请备好小板凳~二、OOM问题分类很多人对于OOM的理解就是Java虚拟机内存不足但通过线上OOM问题分析OOM可以大致归为以下3类线程数太多打开太多文件内存不足接下来将分别围绕这三类问题进行展开分析~三、线程数太多3.1 报错信息pthread_create (1040KB stack) failed: Out of memory这个是典型的创建新线程触发的OOM问题3.2 源码分析pthread_create触发的OOM异常源码Android 9位置如下 androidxref.com/9.0.0_r3/xr…void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) { ... pthread_create_result pthread_create(...) //创建线程成功 if (pthread_create_result 0) { return; } //创建线程失败 ... { std::string msg(child_jni_env_ext.get() nullptr ? StringPrintf(Could not allocate JNI Env: %s, error_msg.c_str()) : StringPrintf(pthread_create (%s stack) failed: %s, PrettySize(stack_size).c_str(), strerror(pthread_create_result))); ScopedObjectAccess soa(env); soa.Self()-ThrowOutOfMemoryError(msg.c_str()); } }pthread_create里面会调用Linux内核创建线程那什么情况下会创建线程失败呢查看系统对每个进程的线程数限制cat /proc/sys/kernel/threads-max不同设备的threads-max限制是不一样的有些厂商的低端机型threads-max比较小容易出现此类OOM问题。查看当前进程运行的线程数cat proc/{pid}/status当线程数超过/proc/sys/kernel/threads-max中规定的上限时就会触发OOM。既然系统对每个进程的线程数有限制那么解决这个问题的关键就是尽可能降低线程数的峰值。3.3 线程优化3.3.1 禁用 new Thread解决线程过多问题传统的方案是禁止使用new Thread统一使用线程池但是一般很难人为控制 可以在代码提交之后触发自动检测有问题则通过邮件通知对应开发人员。不过这种方式存在两个问题无法解决老代码的new Thread对于第三方库无法控制。3.3.2 无侵入性的new Thread 优化Java层的Thread只是一个普通的对象只有调用了start方法才会调用native 层去创建线程所以理论上我们可以自定义Thread重写start方法不去启动线程而是将任务放到线程池中去执行为了做到无侵入性需要在编译期通过字节码插桩的方式将所有new Thread字节码都替换成new 自定义Thread。步骤如下1、创建一个Thread的子类叫ShadowThread吧重写start方法调用自定义的线程池CustomThreadPool来执行任务public class ShadowThread extends Thread { Override public synchronized void start() { Log.i(ShadowThread, start,name getName()); CustomThreadPool.THREAD_POOL_EXECUTOR.execute(new MyRunnable(getName())); } class MyRunnable implements Runnable { String name; public MyRunnable(String name){ this.name name; } Override public void run() { try { ShadowThread.this.run(); Log.d(ShadowThread,run namename); } catch (Exception e) { Log.w(ShadowThread,namename,exception: e.getMessage()); RuntimeException exception new RuntimeException(threadNamename,exception: e.getMessage()); exception.setStackTrace(e.getStackTrace()); throw exception; } } } }2、在编译期hook 所有new Thread字节码全部替换成我们自定义的ShadowThread这个难度应该不大按部就班我们先确认new Thread和new ShadowThread对应字节码差异可以安装一个ASM Bytecode Viewer插件如下所示通过字节码修改你可以简单理解为做如下替换3、由于将任务放到线程池去执行假如线程奔溃了我们不知道是哪个线程出问题所以自定义ShadowThread中的内部类MyRunnable的作用是在线程出现异常的时候将异常捕获还原它的名字重新抛出一个信息更全的异常。测试代码private fun testThreadCrash() { Thread { val i 9 / 0 }.apply { name testThreadCrash }.start() }开启一个线程然后触发奔溃堆栈信息如下可以看到原本的new Thread已经被优化成了CustomThreadPool线程池调用并且奔溃的时候不用担心找不到线程是哪里创建的会还原线程名。当然这种方式有一个小问题应用正常运行的情况下如果你想要收集所有线程信息那么线程名可能不太准确因为通过new Thread 去创建线程已经被替换成线程池调用了获取到的线程名是线程池中的线程的名字数据对比同个场景简单测试了一下new Thread优化前后线程数峰值对比线程数峰值优化前线程数峰值优化后降低最大线程数33731423对于不同App优化效果会有一些不同不过可以看到这个优化确实是有效的。3.3.3 无侵入的线程池优化随着项目引入的SDK越来越多绝大部分SDK内部都会使用自己的线程池做异步操作线程池的参数如果设置不对核心线程空闲的时候没有释放会使整体的线程数量处于较高位置。线程池几个参数public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable 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操作字节码跟new Thread的替换基本同理在编译期通过ASM做如下几个操作将调用Executors类的静态方法替换为自定义ShadowExecutors的静态方法设置executor.allowCoreThreadTimeOut(true)将调用ThreadPoolExecutor类的构造方法替换为自定义ShadowThreadPoolExecutor的静态方法设置executor.allowCoreThreadTimeOut(true)可以在 Application 类的 () 中调用我们自定义的静态方法ShadowAsyncTask.optimizeAsyncTaskExecutor()来修改 AsyncTask 的线程池参数调用executor.allowCoreThreadTimeOut(true)你可以简单理解为做如下替换3.4 线程监控假如线程优化后还存在创建线程OOM问题那我们就需要监控是否存在线程泄漏的情况。3.4.1 线程泄漏监控主要监控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函数。3.4.2 线程上报当监控到线程有异常的时候我们可以收集线程信息上报到后台进行分析。收集线程信息代码如下private fun dumpThreadIfNeed() { val threadNames runCatching { File(/proc/self/task).listFiles() } .getOrElse { returngetOrElse 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 ,)) }接下来介绍打开太多文件导致的OOM问题四、打开太多文件4.1 错误信息E/art: ashmem_create_region failed for indirect ref table: Too many open files Java.lang.OutOfMemoryError: Could not allocate JNI Env这个问题跟系统、厂商关系比较大4.2 系统限制Android是基于Linux内核/proc/pid/limits描述着linux系统对每个进程的一些资源限制如下图是一台Android 6.0的设备Max open files的限制是1024如果没有root权限可以通过ulimit -n命令查看Max open files结果是一样的ulimit -nLinux 系统一切皆文件进程每打开一个文件就会产生一个文件描述符fd记录在/proc/pid/fd下面cd /proc/10654/fdls这些fd文件都是链接文件通过ls -l可以查看其对应的真实文件路径当fd的数目达到Max open files规定的数目就会触发Too many open files的奔溃这种奔溃在低端机上比较容易复现。知道了文件描述符这玩意后看看怎么优化~4.2 文件描述符优化对于打开文件数太多的问题盲目优化其实无从下手总体的方案是监控为主。通过如下代码可以查看当前进程的fd信息private fun dumpFd() { val fdNames runCatching { File(/proc/self/fd).listFiles() } .getOrElse { returngetOrElse 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) }4.3 文件描述符监控监控策略当fd数大于1000个或者fd连续递增超过50个就触发fd收集将fd对应的文件路径上报到后台。这里模拟一个bug打开一个文件多次不关闭通过dumpFd可以看到很多重复的文件名进而大致定位到问题。当怀疑某个文件有问题之后我们还需要知道这个文件在哪创建是谁创建的这个就涉及到IO监控~4.4 IO监控4.4.1 监控内容监控完整的IO操作包括open、read、write、closeopen获取文件名、fd、文件大小、堆栈、线程read/write获取文件类型、读写次数、总大小使用buffer大小、读写总耗时close打开文件总耗时、最大连续读写时间4.4.2 Java监控方案以Android 6.0 源码为例FileInputStream的调用链如下java : FileInputStream - IoBridge.open - Libcore.os.open - BlockGuardOs.open - Posix.openLibcore.java是一个不错的hook点package libcore.io; public final class Libcore { private Libcore() { } public static Os os new BlockGuardOs(new Posix()); }我们可以通过反射获取到这个Os变量它是一个接口类型里面定义了open、read、write、close方法具体实现在BlockGuardOs里面。// 反射获得静态变量 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限制4.4.3 Native监控方案Native Hook方案的核心从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);我们需要选择一些有调用上面几个方法的 library例如选择libjavacore.so、libopenjdkjvm.so、libopenjdkjvm.so可以覆盖到所有的 Java 层的 I/O 调用。不同版本的 Android 系统实现有所不同在 Android 7.0 之后我们还需要替换下面这三个方法。open64 __read_chk __write_chknative hook 框架目前使用比较广泛的是爱奇艺的xhook 以及它的改进版字节跳动的bhook。具体的native IO监控代码可以参考 Matrix-IOCanary内部使用的是xhook框架。关于IO涉及到的知识非常多后面有时间可以单独整理一篇文章。接下来看看最后一种OOM类型~五、内存不足5.1 堆栈信息这种是最常见的OOMJava堆内存不足512M都不够玩~发生此问题的大部分设备都是Android 7.0高版本也有不过相对较少。5.2 重温JVM内存结构JVM在运行时将内存划分为以下5个部分方法区存放静态变量、常量、即时编译代码程序计数器线程私有记录当前执行的代码行数方便在cpu切换到其它线程再回来的时候能够不迷路Java虚拟机栈线程私有一个Java方法开始和结束对应一个栈帧的入栈和出栈栈帧里面有局部变量表、操作数栈、返回地址、符号引用等信息本地方法栈线程私有跟Java虚拟机栈的区别在于 这个是针对native方法堆绝大部分对象创建都在堆分配内存内存不足导致的OOM一般都是由于Java堆内存不足绝大部分对象都是在堆中分配内存除此之外大数组、以及Android3.0-7.0的Bitmap像素数据都是存放在堆中。基于这个结论关于Java堆内存不足导致的OOM问题优化方案主要是图片加载优化、内存泄漏监控。5.3 图片加载优化5.3.1 常规的图片优化方式分析了主流图片库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可以通过hookSingleRequest它里面有个requestListeners我们可以注册一个自己的监听图片加载完做一个大图检测。其它图片框架同理也是先找到hook点然后进行类似的hook操作就可以代码可以参考dokit-BigImgClassTransformer5.4.2 从ImageView侧监控5.4.1 是从图片加载框架侧监控大图假如项目中使用到的图片加载框架太多有些第三方SDK内部可能自己搞了图片加载这种情况下我们可以从ImageView控件侧做监控监听setImageDrawable等方法计算图片大小如果大于控件本身大小debug包可以弹窗提示需要修改。方案如下自定义ImageView重写setImageDrawable、setImageBitmap、setImageResource、setBackground、setBackgroundResource这几个方法在这些方法里面检测Drawable大小编译期修改字节码将所有ImageView的创建都替换成自定义的ImageView为了不影响主线程可以使用IdleHandler在主线程空闲的时候再检测最终是希望当检测到大图的时候debug环境能够弹窗提示开发进行修改release环境可以上报后台。debug如下效果[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qyrJo3Ih-1651225474031)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b9133dfddf514d6b8519a21c29044089~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image?)]当然这种方案有个缺点不能获取到图片url。图片优化告一段落接下来看看内存泄漏~5.5 内存泄漏监控演进LeakCanary关于内存泄漏大家可能都知道LeakCanary只要添加一个依赖debugImplementation com.squareup.leakcanary:leakcanary-android:2.8.1就能实现自动检测和分析内存泄漏并发出一个通知显示内存泄漏详情信息。LeakCanary只能在debug环境使用因为它是在当前进程dump内存快照Debug.dumpHprofData(path);会冻结当前进程一段时间整个 APP 会卡死约515s低端机上可能要几十秒的时间。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,pidpid); 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_(); } ... //2fork子进程,通过返回值可以判断是主进程还是子进程 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释放内存的函数freehook申请内存和释放内存的函数分配内存的时候收集堆栈、内存大小、地址、线程等信息存放到map中在释放内存的时候从map中移除。那怎么判断native内存泄漏呢周期性的使用mark-and-sweep分析整个进程 Native Heap获取不可达的内存块信息「地址、大小」获取到不可达的内存块的地址后可以从我们的Map中获取其堆栈、内存大小、地址、线程等信息。具体实现可以参考koom-native-leak总结本文从线上OOM问题入手介绍了OOM原理 以及OOM优化方案和监控方案基本上都是大厂开源出来的比较成熟的方案对于pthread_createOOM问题介绍了无侵入性的new Thread优化、无侵入性的线程池优化、以及线程泄漏监控对于文件描述符过多问题介绍了原理以及文件描述符监控方案、IO监控方案对于Java内存不足导致的OOM、介绍了无侵入性图片自动压缩方案、两种无侵入性的大图监控方案、Java内存泄漏监控的线下方案和线上方案、以及native内存泄漏监控方案。大厂对外开源的技术非常多但不一定最优我们在学习过程中可以多加思考 例如线程优化booster 对于new Thread的优化只是设置了线程名有助于分析问题而经过我的猜想和验证通过字节码插桩将new Thread无侵入性替换成线程池调用才是真正意义上的线程优化。
返回列表