再聊聊pthread oom 问题 | 性能优化

2,651 阅读5分钟

前言

前文提要 Thread也会OOM吗?

之前和大家聊过一次pthread oom问题。基于当时的场景以及对Rxjava的分析,只能说解决了一小部分问题。但是实际上只要我们滥用了线程,特别是华为设备,还是有可能发生对应的问题的。

所以这次打算再展开下,顺便把自己最近做的一些这方面相关的给大家做一次简单的分享。

这一次我们从两方面入手,看看能不能有效的解决这部分问题。

  1. 通过debug工具hook所有DefaultThreadFactory创建的无名线程
  2. 通过plugin+asm进行线程池替换,把违法乱纪人员逮捕起来

正文

如果你的线上已经开始出现了这部分问题,从哪里开始下手其实是非常头疼的,因为光从线上的堆栈上来看,你很难分析出问题,同时因为是偶发线上,所以也没办法稳定复现这部分问题。

插句嘴,这篇文章没法帮你解决native端的线程溢出问题

这种对对开发来说,就是一个非常棘手的问题了。

我的看法是先能把当前的未命名的线程池都抓出来,然后将每个线程池都进行命名,这样当我们再次碰到类似的问题的时候就可以通过线程名来计数,看看谁是开启线程最多的人。之后看看这群大佬能不能优化下自己的代码。

Epic Hook

我在线上通过bugly排查过线程oom问题,这种问题并不能孤立起来看,最后一个堆栈只是压死骆驼的最后一根稻草而已。我看了下其他相邻线程的情况,并罗列了下发现其中有很多pool-x-thread-x这种相关的,这些就是默认的线程池构造中的ThreadFactory导致的创建的线程。

之前在iocanary文章内和大家介绍过一部分过于动态hook的能力,我们这次的调试工具也是基于Epic,当然和xhook还是有点差别的。

Android IO监控 | 性能监控系列

Epic提供了hook构造函数和方法的能力,这里我们主要要用的就是hook函数构造。DexposedBridge.hookAllConstructors也就是这个方法了。

DexposedBridge.hookAllConstructors(Executors.defaultThreadFactory().javaClass, object : XC_MethodHook() {
    @Throws(Throwable::class)
    override fun beforeHookedMethod(param: MethodHookParam) {
        super.beforeHookedMethod(param)
    }
})

我们hook的目标是Executors.defaultThreadFactory().javaClass的构造函数。通过DexposedBridge.hookAllConstructors方法,我们就可以获取到所有需要hook的class的构造函数调用。

因为DefaultThreadFactory的构造函数是私有的,所以比较麻烦

然后我们需要做的是什么呢?如果能获取到构造函数调用前的堆栈,是不是就很完美了。但是如何获取到堆栈呢,我第一时间想到的就是抛出一个异常打印。那么堆栈是在哪里被持有的呢????

private static List<StackTraceElement> stackTraceInCurrentThread() {
  return newArrayList(Thread.currentThread().getStackTrace());
}

这部分我们只要跟踪下异常的打印函数其实就能知道个大概了。这个是在Throwables里面获取到的。从这里我们其实可以看出来,堆栈信息是保存在线程上的。

这么说起来线程被作为gcroot就可以理解了。因为虚拟机持有了所有存活的线程实例和堆栈。

DexposedBridge.hookAllConstructors(Executors.defaultThreadFactory().javaClass, object : XC_MethodHook() {
              @Throws(Throwable::class)
              override fun beforeHookedMethod(param: MethodHookParam) {
                  super.beforeHookedMethod(param)
                  val thread = Thread.currentThread()
                  val stackTraceElements = thread.stackTrace
                  if (checkLegalStack(stackTraceElements)) {
                      instance.addStack(stackTraceElements)
                      Log.i(TAG, "stack: ${stackTraceElements.toString()}")
                  }
              }
          })

这里我们先hook了ThreadFactory,然后将这部分没有命名的线程池的调用堆栈记录下来,之后将堆栈信息写入文件。

然后让测试同学配合执行monkey,之后我们只要导出这份文件,就可以把当前项目内违规使用的线程池给罗列出来。

之后我们只需要将这部分无命名的线程池更换有命名规则的线程池,那么之后线上就能把这部分干扰我们排查问题的帮凶给搞定了。

魔改三方sdk

github demo 链接 ,虽然我已经看透了大家白嫖的本质了,但是我还是想说,真的不来个三连吗 兄弟

当我们执行完上述方法之后,我们基本可以确保我们自己可控范围内所有ThreadFactory都已经被我们修改完了。但是这个时候如果这部分线程池构造是第三方sdk内的呢?如何将这部分不讲武德的三方sdk的线程池构造也调整了呢?

这次也还是使用asm吧,之前我们在使用asm的时候大部分场景都是采取新增一个函数调用的方式。这次我们将采取类替换的规则。

简单的说,我们会进行类扫描,当发现当前行执行的是线程池构造的init函数的时候,将其替换成我们安全合法的线程池构造。这样我们就能对第三方sdk的代码进行修正了。

这部分代码我用ClassVisitor完全写不出来,还是要依靠ClassNode了。

class ThreadAsmHelper : AsmHelper {
    @Throws(IOException::class)
    override fun modifyClass(srcClass: ByteArray?): ByteArray {
        val classNode = ClassNode(Opcodes.ASM5)
        val classReader = ClassReader(srcClass)
        //1 将读入的字节转为classNode
        classReader.accept(classNode, 0)
        //2 对classNode的处理逻辑
        val iterator: Iterator<MethodNode> = classNode.methods.iterator()
        while (iterator.hasNext()) {
            val method = iterator.next()
            method.instructions?.iterator()?.forEach {
                if (it.opcode == Opcodes.INVOKESTATIC) {
                    if (it is MethodInsnNode) {
                        it.hookExecutors()
                    }
                }
            }
        }
        val classWriter = ClassWriter(0)
        //3  将classNode转为字节数组
        classNode.accept(classWriter)
        return classWriter.toByteArray()
    }

    private fun MethodInsnNode.hookExecutors() {
        when (this.owner) {
            EXECUTORS_OWNER -> {
                info("owner:${this.owner}  name:${this.name} ")
                ThreadPoolCreator.poolList.forEach {
                    if (it.name == this.name && this.name == it.name && this.owner == it.owner) {
                        this.owner = Owner
                        this.name = it.methodName
                        this.desc = it.replaceDesc()
                        info("owner:${this.owner}  name:${this.name} desc:${this.desc} ")
                    }
                }

            }
        }
    }
}

首先我们简单的分析下,线程池的默认构造都是基于Executors的静态方法。那么从bytecode上来说,我们能确定第一个操作符必然是INVOKESTATIC

这部分就是所有基于asm扫描插入的代码了。其实逻辑非常的简单哦,首先获取到ClassNode,然后遍历所有的方法,然后开始逐行读取,之后判断操作符是否符合INVOKESTATIC,之后我们只要判断Desc,methodName,name,owner这几个是否符合我们所需要魔改的规则,如果是的话则对其进行替换。这样就完成了这部分功能了。

具体的代码大家可以去看下我写的AndroidAutoTrack,里面有这部分代码的操作。

接入Apm系统

这部分内容现在停留在我的设想中,并没有投入实际的开发中,有时间我应该会尝试下这部分能力。

Apm作为一个app性能分析工具,主要的目的是辅助开发童靴快速定位问题,同时能帮助大家优化代码。

我个人感觉这部分也完全能作为APM的一小部分能力。我们可以基于Activity维度收集页面当前的线程总数。

另外我们需要做的就是设置几个阈值,当线程数到达低危,中危,高危之后进行线程名上报的操作。

同时因为收集了Activity维度的线程数据,我们就可以根据页面的状况进行评估,看看是不是哪个特定页面的操作问题,导致线程数量直线上升。

说句题外话,之前面阿里的时候被问过这样一道APM的阈值设计题,当时的告警设计我就完全没考虑到啊,菜狗虾正式在下。

总结

我觉得开发每过一段时间就要对之前你做的事情进行一次总结。比方说之前我们做的是不是够好了,还有没有优化的空间,同时有没有可能有技术手段进行监控,防范于未然。

之前听一位大佬说回头看看你三个月之前写的代码,如果你觉得之前写的很完美,证明你划了三个月的水。