Android性能优化之多线程管理优化方案详解

背景

在我们日常开发中,多线程管理一直是非常头疼的问题之一,尤其在历史性长,结构复杂的app中,线程数会达到好几百个甚至更多,然而过多的线程不仅仅带来了内存上的消耗同时也降低了cpu调度的效率,过多的cpu调度带来的消耗的坏处甚至超过了多线程带来的好处。

在我们日常开发中,通常会遇到以下几个问题:

  • 某个场景会创造过多的线程,最终导致oom。
  • 线程池过多问题,比如三方库有一套线程池,自己项目也有一套线程池,随着三方/二方业务接入,导致了不相兼容的线程池数越多,降低了全体线程池数的调度效率,比如多个okhttp的调用。
  • 历史原因导致,new Thread横行,又或者是各种线程使用不规范,导致工程混乱。
  • 即使是空闲时候,依旧有线程在不断Waiting。
  • 各种线程死锁问题。

最终种种原因导致,我们的项目在上线过程中,会遇到各种线程不明的情况,对排查问题或者解决问题带来极大的考验。

常规解决方案

对于上述问题的解决,许多团队通过codeview去限制代码准入,比如定制Thread的规范,又或者是定义项目统一的线程池,在项目中去使用。

这个方案优点就是可操作性强,便于团队去实施,但是这比较依靠review(或者其他代码扫描插件),对于历史项目来说比较容易出现疏漏,而且后期也依旧需要维护,对于大型团队来说,需要兼顾所有人代码,且三方库无法处理。同时Thread的衍生物也有很多,比如Android中的HandlerThread等等,也是线程。

现在比较流行的方案是通过字节码插桩的方式,统一做线程监控亦或进行线程统一,比如监控处理的matrix,还有优化相关的booster等。线程统一这个依靠项目的情况,会有全统一线程池的情况(所以共用一个线程池),也有统一某单一业务的线程池的情况(比如只收口项目okhttp的线程池)下面我们围绕这两个主题,分别进行探讨

线程监控

当前线程统计

对线程的监控,首先我们要统计当前的信息对不对,可以直接通过

Thread.getAllStackTraces()

获取到当前所有thread的信息与堆栈情况,其返回值是一个map对象,

Map<Thread, StackTraceElement[]>

获取结果例子如下

[
Thread[Binder:30506_2,5,main],
 Thread[FinalizerWatchdogDaemon,5,system], 
 Thread[Binder:30506_3,5,main], 
 Thread[Jit thread pool worker thread 0,5,system], 
 Thread[ReferenceQueueDaemon,5,system], 
 Thread[Profile Saver,5,system], 
 Thread[main,5,main], 
 Thread[Binder:30506_1,5,main], 
 Thread[RenderThread,7,main], 
 Thread[pika_thread,5,main], 
 Thread[vivo.PerfThread,5,main], 
 Thread[Signal Catcher,10,system], 
 Thread[FinalizerDaemon,5,system], 
 Thread[HeapTaskDaemon,5,system]
 ]

我们可以看到key是一个thread对象,如果我们要设计一个自己的apm的话可以通过遍历key拿到一个Thread对象,然后再通过该Thread对象拿到自身的信息即可,比如获取thread的名称。

Thread.getAllStackTraces().keys.map {    it.name}

线程信息具体化

通过上述,我们可以拿到了当前所有的线程信息,但是很遗憾的是,其中有一些线程信息几乎是“不可用”的,比如我们用new Thread构建出来的线程,如果不给它指定的名字的话,默认就会出现类似这种情,比如Thread-1,这种名称的线程对我们来说几乎是没有任何意义的,我们暂且把它称为“匿名线程”,解决匿名线程的手段有很多,之前在学完ASM Tree api,再也不怕hook了这篇我们可以看到,我们可以用asm对调用thread进行插桩,通过改变指令调用函数,把普通的空参数Thread()方法变成带有name的构造方法Thread(String)进行hook处理,把调用者名称的信息放到前置的ldc指令,从而到达一个转化的效果。

737008951c21ac8fb01d378349b69475.png

asm 代码实例如下:

method.instructions.insertBefore(
        node, new LdcInsnNode(klass.name))
                def r = node.desc.lastIndexOf(')')
                把构造函数描述变成了带有string name的构造函数描述
                def desc = "${node.desc.substring(0, r)}Ljava/lang/String;${node.desc.substring(r)}"println(" * ${node.owner}.${node.name}${node.desc} => ${node.owner}.${node.name}$desc: ${klass.name}.${method.name}${method.desc}")node.desc = desc

当然,Thread还有很多构造函数,我们就不一一举例子去适配,相关的操作也是类似的,涉及到Executors等其他创建线程的方式,我们也可以通过这种指令替换的方式去进行Thread的命名操作。

线程统一

线程的统一可以依靠项目统一的线程池,但是这个约束不到第三方,我们可以利用ASM等工具进行线程的统一,线程统一包括全模块统一跟单模块统一(特定模块),由于单模块统一涉及具体业务,比如对okhttpclient的调度线程统一,由于不具备通用性,需要根据模块具体实现去统一,我们这里就不讨论了,单模块统一有个好处就是风险低,只影响单一模块的线程调度。我们讨论一下全模块的统一。

在项目中,我们有各种各样的线程调度api,直接new Thread,Executors,ThreadPoolExecutor等等,它们公共点就是都用到了Thread,最终都是靠着Thread去运行,但是想要把它们统一起来,我们要兼顾更上一层的api,那么适配工作量可是不少!!那么我们有没有一种黑科技,能够简单点就把线程统一到一个特定的线程池,作为收口呢?(注意这里讨论的是把全项目的线程统一,包括三方库),为了找到突破点,我们先看一下最基本的Thread是怎么创建出来的

Thread创建

最常用的Thread创建肯定是最简单的,我们举个例子:

var thread = Thread{    Log.i("hello","this is my thread ${Thread.currentThread().name}")}

那么这段代码它做了什么呢?我们要从字节码的角度去分析,才能找到突破点。

NEW java/lang/ThreadDUPINVOKEDYNAMIC run()Ljava/lang/Runnable; [ 
 // handle kind 0x6 : INVOKESTATIC
   java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
     // arguments:
       ()V,
          // handle kind 0x6 : INVOKESTATIC  
          com/example/spider/MainActivity.onCreate$lambda-0()V,   ()V]INVOKESPECIAL java/lang/Thread.<init> (Ljava/lang/Runnable;)VASTORE 2

我们来一一说明下调用的指令:

  1. NEW 创建一个java/lang/Thread对象,此时只是引用被创建,所引用的对象还没有创建,并加入操作数栈顶部。
8b759f94e51dacdd69c29b00d12cb38a.jpg
  1. DUP 将操作数栈顶部的参数复制一份,并加入操作数栈。
de8af0d76ab683d3bc730c1dbed5e7d0.jpg

3.INVOKEDYNAMIC lambad用到的函数调用指令,运行时绑定信息,()Ljava/lang/Runnable,由于入参为null,所以不消耗操作数栈的参数,返回值是Runnable,所以会在操作数栈上新加入一个Runnable对象。


2533c1a3c1e47d40fc0870237fb9c302.jpg

4.INVOKESPECIAL 构造函数能调用到的特殊指令,即创建一个对象,(Ljava/lang/Runnable;)V,我们看到入参只有一个Runnable对象,但是实际上调用INVOKESPECIAL的构造函数隐藏了一个条件,就是需要一个被创建对象对应的引用对象,这就是dup存在的原因,因为需要消耗一个Thread引用对象!这点需要注意。

25308b0434ac8f064569af20e1dc6836.jpg

5.ASTORE 2,就是把操作数栈顶部的变量放到了局部变量表index为2的地方,这里为什么是2呢,是由当前运行环境决定的,静态方法中index为0的就是参数1,而普通方法index为0的地方却是this指针,这点是需要注意的,除了index = 0 的地方有这个约定,其他index下标其实就是函数环境的决定的。(这也侧面说明,存在AStore,ALoad这些指令的时候,我们很难去做通用性插桩,因为这里依赖了局部变量表的具体实现)

524bf0eb969ec653ccb6942b05f82549.jpg

看到这里,我们就能够明白了一个Thread创建的字节码是怎么样的了。

那么我们想想看,怎么达到我们统一线程池的目的。看到Thread的创建过程我们就知道,Thread会依赖局部变量表(第5条),所以我们如果直接对Thread进行操作的话,是不行的,因为局部变量表的存储index是依靠当前环境的!其实我们统一线程池,想要统一的也不一定是要统一Thread,而是统一Runnable执行的线程环境对吧!突破点就来了,我们对Runnable进行操作,把其原本依赖执行的Thread变成我们自己线程池的Thread是不是就可以了!

目标明确了,但是我们也需要为此做一些特定的处理,因为这种自定义指令集的处理,用其他ASM工具也是无法生成的,所以我们才具体解释相关的指令集。最终这边的方案就是,进行Thread调用替换,即把new Thread这个指令,替换为我们自己的MyThread的指令进行定制化处理。步骤如下:

  1. 替换原本的INVOKESPECIAL指令调用为我们自己的MyThread调用,这里给出MyThread实现。
class MyThread(private val runnable: Runnable) : Thread(runnable) {
   // 调用到自己的start
   override fun start() { 
      Log.i("hello", "MyThread")
       // runnable 在定义的统一线程池执行
       ThreadHelper.runInCustomPool(runnable)
   }
}
f55fb501593d1e84d10645646458bf41.jpg
  1. 原本指令返回的是Thread,由于我们替换为了MyThread,那么原本跟Thread强绑定的NEW指令,DUP指令就也需要变更跟MyThread类型相关的指令,我们这里就不采用替换,采取新加的方式。(替换也可以,这里选择方便处理,因为操作数只对栈顶元素生效)
daec00ddb2a48a817047b0ae4aadeac9.jpg
  1. 到了这一步,还不行,因为我们原本要返回的是Thread对象,现在变成了MyThread对象,所以我们需要一个转化指令CHECKCAST。
c2c16b463de2af82339db642c76a13c9.jpg

我们给出具体的ASM代码:

class MyThreadHookUtils {
    static THREAD = "java/lang/Thread"    static void transform(ClassNode klass) {
            // 我们自定义的MyThread类不需要参加转化
            if (klass.name.equals("com/example/spider/MyThread")) {
                return
            }
            klass.methods?.forEach { methodNode ->            methodNode.instructions.each {
                    if (it.opcode == Opcodes.INVOKESPECIAL) {
                        transformInvokeSpecial((MethodInsnNode) it, klass, methodNode)
                    }
                }
            }
        }
        private static void transformInvokeSpecial(MethodInsnNode node, ClassNode klass, MethodNode method) {
            // 如果不是构造函数,就直接退出
             if (node.owner != THREAD) {
                return
            }
            println("transformInvokeSpecial")
            transformThreadInvokeSpecial(node, klass, method)
        }
        private static void transformThreadInvokeSpecial( MethodInsnNode node,            ClassNode klass,            MethodNode method    ) { 
           println("init  ===>  " + node.desc + " " + node.owner) 
           if (node.desc.equals("(Ljava/lang/Runnable;)V")) {
                int index = method.instructions.indexOf(node) 
               def dyc = method.instructions[index - 1]
                InsnList insertNodes1 = new InsnList()
                TypeInsnNode newInsnNode = new TypeInsnNode(Opcodes.NEW, "com/example/spider/MyThread")
                InsnNode dupNode = new InsnNode(Opcodes.DUP)
                insertNodes1.add(newInsnNode)
                insertNodes1.add(dupNode) 
               method.instructions.insertBefore(dyc, insertNodes1) 
               MethodInsnNode methodHookNode = new MethodInsnNode(Opcodes.INVOKESPECIAL, "com/example/spider/MyThread","<init>",                  "(Ljava/lang/Runnable;)V", false)
                TypeInsnNode typeInsnNode = new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Thread")
                InsnList insertNodes = new InsnList() 
                insertNodes.add(methodHookNode)
                insertNodes.add(typeInsnNode)
                method.instructions.insertBefore(node, insertNodes) 
                method.instructions.remove(node)
                println("hook  ===>  " + node.name + " " + node.owner + " " + method.instructions.indexOf(node))
            } 
       }
    }

这个时候,任何Thread的start方法或者其他方法,都会调用到我们自定义的MyThread类的方法里面,在这里做线程池统一的处理,就非常方便了,因为我们有Runnable对象!同时所以方法我们都可以随意去玩了!

注意

注意的是,这种全局Thread插桩是有风险的,在实际项目中,我们会通过白名单的方式,选择性的去统一部分Thread,因为全局统一容易导致不可预期的问题。同时还有一个非常注意的点,我们可以看到上面关于指令的代码全部是基于index的去定位各种指令集的,NEW -> DUP ->INVOKEDYNAMIC ->INVOKESPECIAL 然而在真实项目中,这个指令集顺序不一定可靠,因为可能会被插入其他指令或者无关指令,所以我们还有一步就是指令顺序的校验,必须是满足NEW -> DUP ->INVOKEDYNAMIC ->INVOKESPECIAL这几个顺序的函数指令集才进行插桩,这部分内容比较简单,就不列举了,比较INSN指令的OpCode即可,校验规则按照项目实际需要。

总结

看到这里,我们对Thread应该有了足够的了解,同时本篇也介绍了ASM相关黑科技操作在Thread类的使用!同时也是android 性能优化系列的第一篇,在未来我会继续输出更多的文章,感谢大家观看!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345